diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index 38a6918e8b..d9fc9a3352 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -214,7 +214,22 @@ func L(_ key: String, language: String) -> String { } func codexBarLocalizedLocale() -> Locale { - let language = resolvedAppLanguage() + codexBarLocale(forLanguage: resolvedAppLanguage()) +} + +/// Returns the locale of the resource bundle currently selected by `L`. +/// +/// This can differ from `Locale.current` when the app falls back to a supported language. Plural +/// formatting must use this locale so it follows the same language as the resolved strings. +func codexBarLocalizedResourceLocale() -> Locale { + let bundleURL = localizedBundle().bundleURL + guard bundleURL.pathExtension == "lproj" else { + return codexBarLocalizedLocale() + } + return codexBarLocale(forLanguage: bundleURL.deletingPathExtension().lastPathComponent) +} + +private func codexBarLocale(forLanguage language: String) -> Locale { guard !language.isEmpty else { return .current } let normalized = language.lowercased() if normalized == "ar" || normalized.hasPrefix("ar-") { diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 32823b6d22..bbe65498ec 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -39,19 +39,21 @@ enum UsagePaceText { static func sessionEquivalentDetail(forecast: SessionEquivalentForecast) -> SessionEquivalentDetail { let displayedEstimate = Self.boundedFullWindowCount(forecast.estimatedWindowsToExhaustWeekly) - let numberText = String.localizedStringWithFormat( - L("≈%d full 5h windows of weekly left · %d windows until reset"), - displayedEstimate, - forecast.windowsUntilReset) + let formattingLocale = codexBarLocalizedResourceLocale() + let numberText = String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: formattingLocale, + arguments: [displayedEstimate, forecast.windowsUntilReset]) let verdictText: String if forecast.estimatedWindowsToExhaustWeekly >= forecast.availableWindowsUntilReset { verdictText = L("Weekly cannot run out before reset at this pace") } else { let windowsEarly = Self.boundedWindowCount( forecast.availableWindowsUntilReset - forecast.estimatedWindowsToExhaustWeekly) - verdictText = String.localizedStringWithFormat( - L("Weekly can run out ≈%d windows early"), - max(1, windowsEarly)) + verdictText = String( + format: L("Weekly can run out ≈%d windows early"), + locale: formattingLocale, + arguments: [max(1, windowsEarly)]) } return SessionEquivalentDetail( verdictText: verdictText, diff --git a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift index 7a7d2572c2..1d4e2f7b98 100644 --- a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift +++ b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift @@ -54,6 +54,31 @@ struct LocalizationBundleCacheTests { #expect(bundle.bundleURL.lastPathComponent == "en.lproj") } + @Test + func `format locale follows the resolved resource bundle`() { + let english = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + codexBarLocalizedResourceLocale() + } + #expect(english.language.languageCode?.identifier == "en") + + let fallback = CodexBarLocalizationOverride.$appLanguage.withValue("zz-unknown") { + codexBarLocalizedResourceLocale() + } + #expect(fallback.language.languageCode?.identifier == "en") + } + + @Test + func `resource locale expands English stringsdict singular forms`() { + let rendered = CodexBarLocalizationOverride.$appLanguage.withValue("en") { + String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: codexBarLocalizedResourceLocale(), + arguments: [1, 1]) + } + + #expect(rendered == "≈1 full 5h window of weekly left · 1 window until reset") + } + @Test func `resolution survives an explicit cache reset`() { let first = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 17e398ce42..de61b8736f 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -4,6 +4,7 @@ import Testing @testable import CodexBar @MainActor +@Suite(.serialized) struct SpendDashboardControllerTests { @Test func `empty codex history loads as successful inactive source`() async { @@ -288,7 +289,7 @@ struct SpendDashboardControllerTests { let snapshot = Self.input(id: "claude", provider: .claude, cost: 3).snapshot store._setTokenSnapshotForTesting(snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in + let controller = SpendDashboardController(userDefaults: settings.userDefaults, requestBuilder: { mode in await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) }) @@ -408,9 +409,11 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 3).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) controller.update(configuration: firstConfiguration) @@ -431,9 +434,11 @@ struct SpendDashboardControllerTests { #expect(controller.failedSourceCount == 1) #expect(store.tokenSnapshot(for: .claude)?.last30DaysCostUSD == 3) - let reopenedController = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let reopenedController = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) reopenedController.update(configuration: replacementConfiguration) await Self.waitUntil { !reopenedController.isRefreshing } #expect(reopenedController.model.groups.isEmpty) @@ -487,9 +492,11 @@ struct SpendDashboardControllerTests { store._setTokenSnapshotForTesting(Self.input(provider: .mistral, cost: 3).snapshot, provider: .mistral) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: selectedBackupConfiguration) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 3) @@ -527,9 +534,11 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 4).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 4) @@ -557,9 +566,11 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) let firstConfiguration = SpendDashboardSource.configuration(settings: settings, store: store) controller.update(configuration: firstConfiguration) await Self.waitUntil { !controller.isRefreshing } @@ -626,9 +637,11 @@ struct SpendDashboardControllerTests { environmentBase: [:]) store._setTokenSnapshotForTesting(Self.input(provider: .claude, cost: 5).snapshot, provider: .claude) store._test_tokenUsageRefreshOverride = { _, _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 5) @@ -644,9 +657,11 @@ struct SpendDashboardControllerTests { #expect(controller.model.groups.isEmpty) #expect(controller.failedSourceCount == 1) - let reopenedController = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let reopenedController = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) reopenedController.update(configuration: reenabledConfiguration) await Self.waitUntil { !reopenedController.isRefreshing } #expect(reopenedController.model.groups.isEmpty) diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift index 88e594eca4..23902d4d79 100644 --- a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -124,9 +124,11 @@ struct SpendDashboardTokenProvenanceTests { store.activateCachedTokenAccountSnapshot(provider: .mistral, accountID: account.id) #expect(store.tokenSnapshotPublicationRevision(for: .mistral) == baselineRevision) store._test_providerRefreshOverride = { _ in } - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 3) @@ -148,9 +150,11 @@ struct SpendDashboardTokenProvenanceTests { return loadCount == 1 ? Self.tokenSnapshot(cost: 4) : Self.emptyTokenSnapshot() } await store.refreshTokenUsageNow(for: .bedrock, force: true) - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 4) @@ -177,9 +181,11 @@ struct SpendDashboardTokenProvenanceTests { } await store.refreshTokenUsageNow(for: .bedrock, force: true) let publicationRevision = store.tokenSnapshotPublicationRevision(for: .bedrock) - let controller = SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + let controller = SpendDashboardController( + userDefaults: settings.userDefaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing }