codeburn/mac/Tests/CodeBurnMenubarTests/LocalizationSwitchTests.swift
Resham Joshi b8cc0edcbf
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Windows Menubar CI / check (windows-latest) (push) Waiting to run
Windows Menubar CI / check (ubuntu-latest) (push) Waiting to run
0.9.25 round 2: fixes found testing the built apps (#1517)
* fix: open menu bar Settings centered on the active screen

The window was positioned at a 520x380 placeholder while SwiftUI's own
minimum is 880x620, so the grown window kept its top-left corner and ran
off the bottom-right edge. Size the window from the view's minimum, place
it with a tested pure function, and honour a saved frame only while it
still fits on one of today's screens.

* fix: say where the telemetry toggle is managed when the desktop app owns it

A desktop telemetry.v1.json with enabled:false locks the menu bar toggle
off, so clicking it does nothing. Name the desktop app's Privacy & data
pane in the caption, and pin the four desktop/veto combinations plus the
standalone case in tests.

* fix(desktop): export the provider id, and never swallow a rejected setter

The export pane built its provider list from `current.providers`, which is
keyed on the lowercased display name. Five of the seventeen providers on a
real corpus ("cursor agent", "grok build", "kimi code", "hermes agent",
"grok bot") are not valid ids, so vProvider rejected them as bad-args.
`exportNow` had a try/finally and no catch, so the rejection produced no
toast and no file: the button did nothing. Use detectedProviders, which
already resolves ids from providerDetails, and toast on rejection.

The telemetry consent toggle swallowed its rejection the same way.

* fix(desktop): warm only the selected period, and only while visible

The post-paint sweep warmed all six standard periods x seven reports plus
the provider overviews: up to 48 serve-routed queries, ~18 CPU-seconds a
minute for five minutes and 2.79 GB peak RSS on a real corpus, and it ran
with the window hidden. It now warms the selected period's reports (its
own overview is already under that memo key from the visible poll) and
the other providers' headlines for that same period, holding before every
request while a user fetch is in flight or the window is hidden. Other
periods take the ordinary on-demand path.

Also corrects two comments that claimed background priority lets a click
jump ahead: spawnCli takes the serve fast path before priority is read,
and the resident child answers FIFO.

* fix(desktop): answer the click, report the write, tell the truth about the Claude login

Nine bridge setters in Settings attached only .then(), so a rejected
envelope produced no toast and the control read as if it had ignored the
click. One shared helper toasts the rejection, per pane fallback wording.

Telemetry save() swallowed write errors while setEnabled returned the
in-memory status, so the UI could show ON while the file the macOS menu
bar app reads still said OFF. The setters now carry persisted:boolean and
the renderer toasts when it is false; the write goes through the existing
atomic writeFileAtomic (temp file, rename) so a crash cannot tear it.

The Claude card called a logged-in Claude Code 2.x user "disconnected"
forever, because the credential lives only in the keychain and the first
poll never reads it. A new darwin-only keychainUnchecked state says we
have not looked yet and offers the forced read, keeping the login
instructions for the genuinely-logged-out case. ConnectAffordance's
toggle is renamed to what it does: it discloses instructions.

* fix: retry tax is the cost of retried edit turns, not a multiple of spend

* stop the change guard watching a Warp path it can never stat

* apply a menu bar language change in place instead of relaunching

* keep honoring WARP_DB_PATH in the change guard

* restart a menu bar too old to watch AppleLanguages

* one rebuild per language change, and no global bundle in tests

* fix(desktop): a forced quota refresh must not join the poll it just aborted

Check now / Refresh / ⌘R invalidated the quota service and then handed the
caller the background fetch it had just aborted, so every provider failed
its generation check and resolved disconnected: the click repainted every
card as logged out without ever reading the keychain, and Claude showed
"Not connected. Log in with the Claude CLI." A call now joins an in-flight
run only when that run is at least as privileged, so a keychain-allowed
call starts its own; a second forced click still joins the first, which
keeps the single macOS prompt. A superseded run hands its caller the run
that replaced it instead of its own abort artefacts.

retainOnFailure also holds accessDenied through a keychainUnchecked poll,
so the "Allow in keychain" guidance is not replaced by "Check now" one
cycle after the user denied the prompt.

* fix(desktop): stop the sweep churning, and never spend a query on an abandoned one

The prefetch effect depended on the detected-provider entries, whose costs
move on every overview poll, so it was torn down and restarted each cadence
tick: a warm slower than the interval was discarded and re-issued forever.
It now depends on the provider ids alone, and a finished warm is kept for
its own period/provider key even if the effect was torn down meanwhile —
only a config-generation change makes a result wrong.

Both loops re-test cancellation and the memo after the hold, which has no
bound, so an abandoned sweep issues nothing and a section the user visited
meanwhile is not parsed again. The hidden half of the hold waits on
visibilitychange instead of waking the renderer every two seconds for a
window nobody is looking at.

* fix(desktop): never offer a provider id the CLI will reject, and keep the keychain note

detectedProviders applied the round-trip filter only to the legacy
providers map; a providerDetails id with a space reached the picker and the
export as a --provider value the main process rejects.

The Plans settings row showed the "not read your login yet" line without
the note that macOS may ask once for keychain access, which the Plans card
does show.
2026-09-21 13:53:45 +02:00

127 lines
6.5 KiB
Swift

import Foundation
import Testing
@testable import CodeBurnMenubar
/// The language switch is applied in-process (#1219 follow-up). It used to
/// restart the app, and macOS resets the "access data from other apps" consent
/// every time a process goes, so a Warp user was re-prompted on every change.
/// What these tests pin is the part that made the restart unnecessary: the
/// `.lproj` sub-bundle `L(_:)` reads from is chosen explicitly, for "System"
/// included, so nothing depends on CFBundle's launch-time pick.
///
/// Nothing here repoints the process-wide bundle at a language other than the
/// English every other suite asserts against: a table is checked by naming its
/// sub-bundle through `L10n.lookup(_:in:)`, which is exactly what `L(_:)` does
/// with `L10n.active`. Suites run in parallel, and a French global for the width
/// of one test is a CI flake waiting to happen.
@Suite("Runtime language switch")
struct LocalizationSwitchTests {
private static let shipped = L10n.supportedLocalizations
private static func table(_ preference: LanguagePreference) -> Bundle {
L10n.subbundle(for: preference, in: L10n.bundle, systemPreferred: [])
}
@Test("an explicit language names its own table")
func explicitLanguage() {
#expect(L10n.language(for: .french, available: Self.shipped, development: "en", systemPreferred: ["ja-JP"]) == "fr")
#expect(L10n.language(for: .chineseTraditional, available: Self.shipped, development: "en", systemPreferred: []) == "zh-Hant")
#expect(L10n.language(for: .english, available: Self.shipped, development: "en", systemPreferred: ["fr-FR"]) == "en")
}
@Test("a language the bundle does not ship falls back to the development language")
func unshippedLanguage() {
#expect(L10n.language(for: .korean, available: ["en", "fr"], development: "en", systemPreferred: ["ko-KR"]) == "en")
}
@Test("System follows the OS order", arguments: [
(["fr-FR", "en-US"], "fr"),
(["en-GB", "fr-FR"], "en"),
(["ja-JP"], "ja"),
(["ko-KR"], "ko"),
(["zh-Hans-CN"], "zh-Hans"),
(["zh-Hant-TW", "zh-Hans-CN"], "zh-Hant"),
// Nothing in common: the development language, which is the key text.
(["de-DE"], "en"),
([], "en"),
])
func systemFollowsTheOS(preferences: [String], expected: String) {
#expect(L10n.language(for: .system, available: Self.shipped, development: "en", systemPreferred: preferences) == expected)
}
@Test("every shipped language resolves to its own lproj")
func everyShippedLanguageResolves() throws {
for preference in LanguagePreference.allCases where preference != .system {
let resolved = Self.table(preference)
#expect(resolved != L10n.bundle, "\(preference.rawValue) did not resolve to a sub-bundle")
// SwiftPM lowercases the directory it writes, so compare that way.
#expect(resolved.bundlePath.lowercased().hasSuffix("/\(preference.rawValue.lowercased()).lproj"))
}
}
@Test("a bundle with no lproj at all still answers, in the key's own English")
func missingLprojFallsBackToTheBundle() throws {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("CodeBurnMenubarTests.\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let empty = try #require(Bundle(path: directory.path))
let resolved = L10n.subbundle(for: .french, in: empty, systemPreferred: ["fr-FR"])
#expect(resolved == empty)
#expect(L10n.lookup("Refresh Now", in: resolved) == "Refresh Now")
}
@Test("a key with no translation renders as its English self")
func missingKeyFallsBackToEnglish() {
let absent = "codeburn.test.key.that.is.not.in.any.table"
#expect(L10n.lookup(absent, in: Self.table(.french)) == absent)
}
/// The Chinese pair is what a case-mismatched lproj lookup drops to English
/// without saying anything: SwiftPM writes `zh-hans.lproj`, the preference
/// spells it `zh-Hans`, and `path(forResource:ofType:)` matches literally.
@Test("each table answers in its own language")
func eachTableAnswersInItsOwnLanguage() {
#expect(L10n.lookup("Refresh Now", in: Self.table(.french)) == "Actualiser maintenant")
#expect(L10n.lookup("Refresh Now", in: Self.table(.japanese)) == "今すぐ更新")
#expect(L10n.lookup("Refresh Now", in: Self.table(.chineseSimplified)) == "立即刷新")
#expect(L10n.lookup("Refresh Now", in: Self.table(.chineseTraditional)) == "立即重新整理")
// English is the development language, so the key is its own copy.
#expect(L10n.lookup("Refresh Now", in: Self.table(.english)) == "Refresh Now")
}
@Test("a format string switches with everything else")
func formatStringsSwitchToo() {
#expect(String(format: L10n.lookup("%lld%% left", in: Self.table(.french)), 42) == "42% restant")
#expect(String(format: L10n.lookup("%lld%% left", in: Self.table(.english)), 42) == "42% left")
}
/// `use(_:)` is what makes a change land without a restart, so what it writes
/// has to be what `L(_:)` reads. Asserted with English, which is what the
/// process already resolves to, so no other suite can see this happen.
@MainActor
@Test("use() repoints what L() reads")
func useRepointsTheActiveBundle() {
L10n.use(.english)
#expect(L10n.active.bundlePath == Self.table(.english).bundlePath)
#expect(L("Refresh Now") == L10n.lookup("Refresh Now", in: Self.table(.english)))
}
/// A bump tears the Settings window and the dock rail down and builds them
/// again, so a write that does not move the resolved language must not cause
/// one: the picker re-choosing what is in force, or a global-domain change
/// while an override is set.
@MainActor
@Test("re-applying the language already in force rebuilds nothing")
func redundantUseDoesNotRebuild() {
L10n.use(.english)
let generation = LanguageGeneration.shared.value
L10n.use(.english)
#expect(LanguageGeneration.shared.value == generation)
// .system resolves to English on a machine whose tests assert English,
// so this is the "same resolved language by another name" case.
L10n.use(.system)
#expect(LanguageGeneration.shared.value == generation)
}
}