diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index ce73a8267..6134d3583 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -200,6 +200,7 @@ struct L { // MARK: Usage recap var recapOpen: String { t("사용량 돌아보기", "Usage recap", "使用量のふりかえり", "Resumen de uso", "Récap d'utilisation", "Resumo de uso", "Nutzungsrückblick", "Итоги расхода") } + var recapDetails: String { t("상세", "Details", "詳細", "Detalle", "Détails", "Detalhes", "Details", "Детали") } var recapTokensUnit: String { t("토큰", "tokens", "トークン", "tokens", "tokens", "tokens", "Tokens", "токенов") } func recapScopeName(_ scope: RecapScope) -> String { switch scope { diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 1485929de..e24ee2b89 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -214,16 +214,16 @@ struct PopoverView: View { } // Keep period totals and saved-history navigation visible on quiet days too. - HStack(spacing: 14) { - periodLabel(l.thisWeek, tokens: store.weekTotalTokens, cost: store.showsCost ? store.weekUsageCost : nil) - periodLabel(l.thisMonth, tokens: store.monthTotalTokens, cost: store.showsCost ? store.monthUsageCost : nil) - Spacer() - Button { nav.showingRecap = true } label: { - Image(systemName: "chart.bar.xaxis") + ViewThatFits(in: .horizontal) { + HStack(spacing: 14) { + periodTotals.fixedSize(horizontal: true, vertical: false) + Spacer(minLength: 0) + recapButton + } + VStack(alignment: .trailing, spacing: 6) { + periodTotals.frame(maxWidth: .infinity, alignment: .leading) + recapButton } - .buttonStyle(.borderless) - .help(l.recapOpen) - .accessibilityLabel(l.recapOpen) } .padding(.top, 2) @@ -257,6 +257,39 @@ struct PopoverView: View { store.snapshot(preferring: nav.providerID) } + private var periodTotals: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 14) { periodLabels } + .fixedSize(horizontal: true, vertical: false) + VStack(alignment: .leading, spacing: 4) { periodLabels } + } + } + + private var periodLabels: some View { + Group { + periodLabel(l.thisWeek, tokens: store.weekTotalTokens, cost: store.showsCost ? store.weekUsageCost : nil) + .fixedSize(horizontal: true, vertical: false) + periodLabel(l.thisMonth, tokens: store.monthTotalTokens, cost: store.showsCost ? store.monthUsageCost : nil) + .fixedSize(horizontal: true, vertical: false) + } + } + + private var recapButton: some View { + Button { nav.showingRecap = true } label: { + HStack(spacing: 3) { + Image(systemName: "chart.bar.xaxis") + Text(l.recapDetails).font(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.primary.opacity(0.08), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .help(l.recapOpen) + .accessibilityLabel(l.recapOpen) + } + private var providerTabBar: some View { ProviderTabBar( snapshots: store.snapshots, diff --git a/Tests/PokeTokenBarTests/ZeroUsageHomeRenderingTests.swift b/Tests/PokeTokenBarTests/ZeroUsageHomeRenderingTests.swift index 31b4f8b8d..c788d44e6 100644 --- a/Tests/PokeTokenBarTests/ZeroUsageHomeRenderingTests.swift +++ b/Tests/PokeTokenBarTests/ZeroUsageHomeRenderingTests.swift @@ -7,7 +7,7 @@ import XCTest private struct QuietHomeProvider: UsageProvider { let id = "fixture" let displayName = "Fixture" - let reportsCost = false + var reportsCost = false let enrichment: ProviderEnrichment func fetchDaily() async throws -> DailyUsage? { nil } func fetchEnrichment() async -> ProviderEnrichment { enrichment } @@ -53,14 +53,24 @@ final class ZeroUsageHomeRenderingTests: XCTestCase { try await render(history: false) } - private func render(history: Bool) async throws { + func testRecapLabelKeepsPeriodCostsOnOneLine() async throws { + for language in [AppLanguage.en, .de] { + try await render(history: true, language: language, costs: .source) + } + } + + func testUnavailablePeriodCostsKeepGermanHomeWithinPopover() async throws { + try await render(history: true, language: .de, costs: .unavailable) + } + + private func render(history: Bool, language: AppLanguage = .ko, costs: CostCoverage? = nil) async throws { let suite = "QuietHome-\(UUID())" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } let file = FileManager.default.temporaryDirectory.appendingPathComponent("\(suite).json") defer { try? FileManager.default.removeItem(at: file) } var state = CompanionState() - state.language = .ko + state.language = language state.active = MonState(baseID: 129, pathIDs: [129, 130], stageIndex: 1, usedAtStage: 170_100_000, rarity: .common, totalForms: 2, nature: .hardy) try JSONEncoder().encode(state).write(to: file) @@ -79,10 +89,17 @@ final class ZeroUsageHomeRenderingTests: XCTestCase { } let weekKey = formatter.string(from: LocalUsageReader.startOfWeek(now)) let enrichment = ProviderEnrichment(blocksOK: true, - weekTotal: PeriodUsage(period: weekKey, daily: series.filter { $0.date >= weekKey }), - monthTotal: PeriodUsage(period: LocalUsageReader.monthKey(now), daily: series), + weekTotal: costs != nil + ? PeriodUsage(period: weekKey, totalTokens: 4_800_000, totalCost: costs == .unavailable ? 0 : 31.64, + costCoverage: costs ?? .source) + : PeriodUsage(period: weekKey, daily: series.filter { $0.date >= weekKey }), + monthTotal: costs != nil + ? PeriodUsage(period: LocalUsageReader.monthKey(now), totalTokens: 17_900_000, + totalCost: costs == .unavailable ? 0 : 121.30, + costCoverage: costs ?? .source) + : PeriodUsage(period: LocalUsageReader.monthKey(now), daily: series), monthDaily: series, periodsOK: true) - let store = UsageStore(providers: [QuietHomeProvider(enrichment: enrichment)], + let store = UsageStore(providers: [QuietHomeProvider(reportsCost: costs != nil, enrichment: enrichment)], claudeLimitsProvider: QuietHomeClaude(), discoverClaudeConfigDirs: { [] }, readDefaultIdentity: { nil }, claudeUsageEntries: { _ in [] }, codexLimitsProvider: QuietHomeCodex(), antigravityLimitsProvider: QuietHomeAntigravity(), @@ -90,7 +107,7 @@ final class ZeroUsageHomeRenderingTests: XCTestCase { autoRefresh: false, defaults: defaults) await store.refresh(scheduleEmptyRetry: false) XCTAssertEqual(store.todayTotalTokens, 0) - XCTAssertEqual(store.monthTotalTokens, series.reduce(0) { $0 + $1.totalTokens }) + XCTAssertEqual(store.monthTotalTokens, costs != nil ? 17_900_000 : series.reduce(0) { $0 + $1.totalTokens }) let height = 604.0 let host = NSHostingView(rootView: PopoverView() @@ -122,7 +139,11 @@ final class ZeroUsageHomeRenderingTests: XCTestCase { XCTAssertGreaterThanOrEqual(CGFloat(bitmap.pixelsHigh) / bounds.height, 2) host.cacheDisplay(in: bounds, to: bitmap) let request = VNRecognizeTextRequest() - request.recognitionLanguages = ["ko-KR", "en-US"] + switch language { + case .en: request.recognitionLanguages = ["en-US"] + case .de: request.recognitionLanguages = ["de-DE", "en-US"] + default: request.recognitionLanguages = ["ko-KR", "en-US"] + } request.recognitionLevel = .accurate try VNImageRequestHandler(cgImage: XCTUnwrap(bitmap.cgImage)).perform([request]) let text = request.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: " ") ?? "" @@ -149,17 +170,35 @@ final class ZeroUsageHomeRenderingTests: XCTestCase { XCTAssertTrue(normalizedText.contains(label.replacingOccurrences(of: " ", with: "")), "Missing visible \(label): \(text)\n\(diagnostics)") } - if history && day > 1 { - XCTAssertTrue(chartVisible, - "Historical chart missing: \(text)\n\(diagnostics)") - } else { - XCTAssertFalse(chartVisible, - "Empty history must not invent a chart\n\(diagnostics)") + if costs == .source { + // A split currency value creates separate OCR observations; do not join their digits. + for amount in ["$31.64", "$121.30"] { + XCTAssertTrue((request.results ?? []).contains { + $0.topCandidates(1).first?.string.replacingOccurrences(of: " ", with: "").contains(amount) == true + }, "Split or missing period cost \(amount) in \(language.rawValue): \(text)\n\(diagnostics)") + } + } + if costs == .unavailable { + let unavailable = companion.l.costUnavailable.replacingOccurrences(of: " ", with: "") + XCTAssertEqual((request.results ?? []).filter { + $0.topCandidates(1).first?.string.replacingOccurrences(of: " ", with: "").contains(unavailable) == true + }.count, 2, "Both unavailable period costs must stay visible\n\(diagnostics)") + } + if costs == nil { + // The original quiet-day fixtures own chart coverage; cost fixtures target the period row. + if history && day > 1 { + XCTAssertTrue(chartVisible, + "Historical chart missing: \(text)\n\(diagnostics)") + } else { + XCTAssertFalse(chartVisible, + "Empty history must not invent a chart\n\(diagnostics)") + } } if let output = ProcessInfo.processInfo.environment["PTB_ZERO_USAGE_SCREENSHOT_DIR"] { let directory = URL(fileURLWithPath: output) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let name = history ? "ko-zero-today-with-history.png" : "ko-zero-all-periods.png" + let name = costs != nil ? "\(language.rawValue)-period-costs-\(costs == .source ? "known" : "unavailable").png" + : (history ? "ko-zero-today-with-history.png" : "ko-zero-all-periods.png") try XCTUnwrap(bitmap.representation(using: .png, properties: [:])).write(to: directory.appendingPathComponent(name)) } } diff --git a/docs/pr-images/432/after-de-cost.png b/docs/pr-images/432/after-de-cost.png new file mode 100644 index 000000000..bc7a96216 Binary files /dev/null and b/docs/pr-images/432/after-de-cost.png differ diff --git a/docs/pr-images/432/after-de-unavailable.png b/docs/pr-images/432/after-de-unavailable.png new file mode 100644 index 000000000..a906acbe2 Binary files /dev/null and b/docs/pr-images/432/after-de-unavailable.png differ diff --git a/docs/pr-images/432/after-en-cost.png b/docs/pr-images/432/after-en-cost.png new file mode 100644 index 000000000..c644b20c8 Binary files /dev/null and b/docs/pr-images/432/after-en-cost.png differ diff --git a/docs/pr-images/432/before-de-cost.png b/docs/pr-images/432/before-de-cost.png new file mode 100644 index 000000000..d4ac95794 Binary files /dev/null and b/docs/pr-images/432/before-de-cost.png differ diff --git a/docs/pr-images/432/before-de-unavailable.png b/docs/pr-images/432/before-de-unavailable.png new file mode 100644 index 000000000..2101aec83 Binary files /dev/null and b/docs/pr-images/432/before-de-unavailable.png differ diff --git a/docs/pr-images/432/before-en-cost.png b/docs/pr-images/432/before-en-cost.png new file mode 100644 index 000000000..fbfb6b0c0 Binary files /dev/null and b/docs/pr-images/432/before-en-cost.png differ diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 137f86e63..feed3f522 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -1293,3 +1293,25 @@ read_when: fail the historical-chart assertion. Keep explicit pixel density, candidate diagnostics, and both CI fixture PNGs so recognition failures can be checked against rendered pixels. Other OCR harnesses passed the same CI runs; this repair is limited to the failing fixture. + +- **A wider navigation label must not squeeze numeric period summaries.** Adding the localized + recap capsule to the home's fixed-width summary row left too little space for its week/month + labels and costs: the real English and German popovers split `$31.64` and `$121.30` across + lines. The existing production render used Korean with costs disabled, so it never exercised + that combination. Preserve the period totals' intrinsic width and let `ViewThatFits` move + the recap button to a trailing row when the inline arrangement does not fit. If the two period + groups alone exceed the content width (German `Nicht verfügbar`), stack those groups too; + forcing their combined intrinsic width expands and clips the entire popover. The sibling + Claude account period row has no recap control and does not share this trigger. + `testRecapLabelKeepsPeriodCostsOnOneLine` renders the full production popover in English + and German with costs enabled and requires each complete amount in one top-ranked OCR + observation. Restoring the previous inline row makes both languages fail; joining OCR + observations would conceal the broken digits. `testUnavailablePeriodCostsKeepGermanHomeWithinPopover` + also requires the full leading home captions and both unavailable labels, so a clipped + popover cannot pass merely because its trailing recap button remains visible. + Match OCR recognition languages to each fixture: inheriting Korean for English made CI + read `This` as `Thls` despite correct pixels. The period-cost fixtures retain strict labels + and complete amounts; the original Korean quiet-day fixtures own the strict chart assertion. + On the same CI image, English-only recognition corrected the period label but still read + the unrelated chart caption `Daily` as `Dally`; do not teach OCR that misspelling or relax + the original chart guard to make a period-row regression pass.