CC Switch 的 i18n 测试守错了方向:参照系是 en 的代价
一个项目里有多语言完整性测试,是不是就等于翻译资产不会出问题?CC Switch 的 tests/config/localeCoverage.test.ts 提供了一个很干净的反例:这个文件专门校验翻译键的覆盖与插值一致性,三条断言按静态口径复算全都成立;而与此同时,源码里有两个键被 t() 调用,四种语言文件里一条都没有定义。测试不是写错了,是守的方向和问题发生的方向不是同一个。
先说清楚这篇的依据
本文对应仓库快照 3217f725(仓库内版本号 3.20.1),核对日 2026-08-31。所有结论都来自静态阅读源码与用同口径重新数一遍文件内容,我们没有编译、没有运行、也没有安装过这个桌面应用,因此下文不会出现任何关于界面长什么样、提示语实际显示成什么的描述。所谓「三条断言全绿」,也不是跑 vitest 得出的,而是把测试里的筛选与比对逻辑用同样的口径在四个 JSON 上重算了一遍——这个区别很重要,后面还会用到。
这个测试断言了三件事
tests/config/localeCoverage.test.ts 在 v3.20.1 快照里实读 94 行(行数属于会随版本变的量,这里只是给个体量感)。它先把四个语言文件 import 进来(:2-5,分别是 @/i18n/locales/ 下的 en / ja / zh-TW / zh),用 flattenStrings()(:8-22)把嵌套 JSON 压成 a.b.c → 字符串 的扁平 Map,再用 interpolationVariables()(:24-29)拿正则 /\{\{\s*([^}]+?)\s*\}\}/g 抽出一条文案里的全部插值变量名并 .sort()。
然后在 describe("locale coverage")(:55)下挂三个 it.each,遍历的语言列表是 zh / ja / zh-TW(:49-53)——en 不在被检查的列表里,它是基准。
covers every Pi translation key in %s(:56-63):被检查的语言不能缺任何一个 Pi 键。preserves every Pi interpolation variable in %s(:65-80):同一个键在译文和 en 原文里,{{变量}}的名字集合排序后必须逐字相同。比对方式是把排序后的变量名用\0连成字符串再比,用不可见字符当分隔符是为了避开变量名里含普通标点带来的拼接歧义。preserves explicit Pi product mentions in %s(:82-93):凡是 en 原文里出现独立单词Pi(正则/\bPi\b/,见:47与:88)的键,译文里也必须仍然出现Pi,防的是译者把产品名连同句子一起翻掉。
这三条的设计意图都很清楚,第二条尤其实用——插值变量被翻没了,运行时那个位置就是空的,比缺一整句更难发现。
参照系那一行,决定了它能看见什么
关键在 :31 这一行:
const reference = flattenStrings(en);
参照系是 en 文件,不是源码里被调用的键集合。往下 :41-45 把 reference 筛成 piReference,只保留 key.startsWith("pi.") 或落在硬编码白名单 piKeysOutsideNamespace(:32-40)里的键——第一、二条断言遍历的就是它。第三条走的是另一份 piProductReferences(:46-48),它从全量 reference 里按「值中出现独立单词 Pi」来筛,既不看 pi. 前缀也不看白名单。两份筛选的口径不同,但都派生自同一个 reference,所以下面这个方向上的问题,三条断言是一起中招的。
于是这个测试能回答的问题只有一个:「zh / ja / zh-TW 相对 en 缺不缺、变量对不对」。它回答不了另一个方向的问题:「代码要的这个键,en 自己有没有」。一个四种语言全都没有的键,在 reference 里根本不存在,进不了 piReference,自然也进不了 missing 数组,三条断言照样成立。
这不是钻牛角尖。v3.20.1 快照里就有两个这样的键:
| 键 | 调用点 | 四种语言中是否存在 |
|---|---|---|
common.collapse | src/components/providers/forms/PiProviderForm.tsx:1747 | 否 |
pi.form.providerKeyDuplicate | src/utils/errorUtils.ts:56 | 否 |
两个都不是拼错到面目全非,而是分组路径挂错了,所以特别难靠肉眼发现:
- 名为
collapse的键确实存在,完整路径是usage.collapse(en.json:1771)和env.actions.collapse(en.json:2448)——common分组下没有这一条。 - 名为
providerKeyDuplicate的键存在三个,分别挂在opencode.(en.json:1623)、openclaw.(en.json:2284)、hermes.form.(en.json:2390)下——唯独pi.那一条没有。
第二个键更值得注意,因为它正好落在这个测试守着的 pi. 命名空间里。测试专门为 Pi 这个特性写了三条断言,pi.* 那一批键一条不落地被检查了一遍,却漏掉了这一个——原因不在覆盖面,在方向。
后果只能这样推演,不能拍胸脯
按 i18next 的通用查找顺序,缺键时是「当前语言 → fallbackLng → 调用点给的 defaultValue → 键名本身」。src/i18n/index.ts:82 写的是 fallbackLng: "en",而这两处调用都是裸 t("..."),没有传 defaultValue(见 PiProviderForm.tsx:1745-1749 与 errorUtils.ts:56),所以按默认语义会一路落到最后一档。
必须给这段推演加两层限定:第一,这是读代码得出的推论,我们没有运行程序,不能断言实际会呈现成什么;第二,我们也没有逐条核实 i18next v25 / react-i18next v16 在缺键返回值上的具体行为变更,上面引用的是这套库的通用默认语义。
有一点倒是可以确定地说:common.collapse 的调用位置是一个 aria-label(同一属性的另一分支取的是 t("pi.form.customizeThinkingLevels")),也就是说受影响的是无障碍标签而不是正文可见文案。这解释了它为什么能一直躺着——它本来就不在人眼容易扫到的地方。
第二个洞:白名单写错一个键名,测试不会红
:32-40 那份 piKeysOutsideNamespace 白名单,处理的是「有些键不在 pi. 命名空间下,但属于这个特性」的例外,v3.20.1 快照里写了七条(这个条数会随 Pi 相关键的增减而变)。我们逐个回 en.json 核对,结果是其中 deeplink.api 并不存在:deeplink 分组下与之相近的是 apiKey 和 usageApiKey,没有裸 api;用 git log --oneline -S'"api"' -- src/i18n/locales/en.json 查历史也没有记录。
为什么它不会让测试失败?回看 :41-45:
const piReference = new Map(
[...reference].filter(
([key]) => key.startsWith("pi.") || piKeysOutsideNamespace.has(key),
),
);
piReference 是从 en 筛出来的。一个 en 里压根不存在的键,filter 阶段就没有对应元素可以留下,它根本进不了 piReference,后面三条断言自然也不会提到它。结果就是白名单少保护一项,而没有任何信号告诉你这件事。
这是「过滤式白名单」相对「查表式白名单」的固有差别:前者用配置去筛数据,配置里的多余项静默消失;后者先拿配置去查数据、查不到就报错,写错的第一天就会被抓出来。就这段代码而言,如果这七条是先断言「都存在于 en」再进入筛选,deeplink.api 不会活到今天。
三条断言不是正交的
还有一个结构性细节值得记一笔。第二条断言(插值变量一致性)在 :69-76 里有一句 actual !== undefined &&——译文缺这个键时直接跳过变量比对。
单看这句没有问题:缺键归第一条断言管,第二条不该重复报错。但它意味着第二条断言的有效性依赖第一条先失败——第一条要是因为参照系的缘故压根看不见某个键,第二条对这个键也就永远不会发声。第三条的写法不一样,:86-89 是 actual === undefined || !/\bPi\b/.test(actual),译文缺键会被直接算进 missingMentions,不走跳过那条路。真正做筛查时,这类「前置断言兜底、后置断言跳过」的写法需要单独确认前置那条确实在同一批数据上生效。
为什么开发期也没暴露
src/i18n/index.ts:88-89 是这样的:
// 开发模式下显示调试信息
debug: false,
注释写的是「开发模式下」显示,值却是硬编码的 false,没有读 import.meta.env.DEV,也没有读任何环境变量。这处注释描述的行为与代码不一致,两边各写各的。
这件事和上面那两个缺键是连着的:i18next 开着 debug 时会把每一次「键找不到」打到控制台。这个开关既然被写死成 false,那条本可以在开发期反复刷屏的信号就一直没有出现。一个自动化守卫看不见的问题,加上一个被关掉的运行期提示,剩下的就只能靠人肉纪律。
顺带说一句范围:前两条断言守的是 pi. 命名空间加白名单命中的那一小撮键,v3.20.1 快照实读是 132 条,而 en 展开成叶子字符串是 2846 条,也就是不到 5%(这两个数是快照值,会随版本快速变化,重点是那个量级关系)。第三条断言按值另筛出一批 en 原文里含 Pi 的键,同一快照实读 38 条,与前面那 132 条只是部分重叠,并不能把覆盖面拉到另一个量级上去。同一目录下另有几个按功能特性命名的 Locales 测试,各自守自己那一段——这套翻译校验是「按特性一块一块补」出来的,不是一张全局的表。没被任何一个特性测试覆盖到的键,就是完全裸奔的。
代价在这个仓库里有现成的例子:settings.oneClickInstall 这个键,主干在 2026-05-22 的提交 e3df8658 里随着「一键安装」按钮改造成工具管理面板被删掉了;五天后的 2026-05-27,繁体中文本地化 PR 5fd3ec0d 合入,而它的翻译基线停在删除之前,于是把这个已经死掉的键又带了回来。到 v3.20.1 它还躺在 zh-TW.json:824,grep -rn "oneClickInstall" src/ tests/ 显示代码里只有 settings.oneClickInstallHint 被调用(src/components/settings/AboutSection.tsx:1286),没有任何一处取 settings.oneClickInstall。它能一直躺着,和这一节讲的是同一件事,而且叠了一层:本文拆的这个测试从头到尾只看 pi. 那一撮键,settings.* 压根不在它的视野里;退一步讲,就算有断言覆盖到了 settings.*,这三条问的也都是「译文相对 en 缺不缺」,而 settings.oneClickInstall 是 zh-TW 多出一个 en 没有的键——方向上仍然不在被问的问题里。
你可以自己复核这两处
下面两个动作都是纯静态检查,不需要装这个应用,也不需要跑它的测试。命令在仓库根目录、git-bash 下执行;Windows 上没装 git-bash 的话,用 PowerShell 的 Select-String、或者任何能做正则搜索的工具都一样,只是路径分隔符要注意。
第一,确认白名单里的键是否都在基准语言文件里存在。 把 tests/config/localeCoverage.test.ts:32-40 那七条键名抄出来,逐个去 en.json 里查。快速定位某个末段键名可以用:
grep -n "\"collapse\"\|\"providerKeyDuplicate\"" src/i18n/locales/*.json
注意 grep 只能告诉你「这个末段键名存在」,不能告诉你它挂在哪个分组下——上面两个键的问题恰恰全在分组路径上。所以还得把 JSON 展平成完整键路径再比对,也就是测试里 flattenStrings() 做的那件事。
第二,反过来查「代码要的键,基准文件有没有」。 用正则 \bt\(\s*["']([A-Za-z0-9_.]+)["'] 扫全部 .ts / .tsx,把静态字面量键收集起来,与展平后的 en 键集合求差。这个方向正是 localeCoverage.test.ts 没有覆盖的那一半,v3.20.1 快照下就是靠它得到上面那两条。
以上两段是按仓库中已有的比对语义组合出来的检查动作,未经实测,具体写法以你自己环境里的工具行为为准。
还有一个必须同时说清的边界:这类静态扫描看不穿动态拼键。这个仓库里有十来处 t(\…`)的模板拼接,比如src/App.tsx:1304的apps.${sharedFeatureApp}、src/components/settings/LogConfigPanel.tsx:82的settings.advanced.logConfig.levels.${level}`,一处就可能覆盖十几条键。所以反向扫描得出的「en 里定义了但静态没命中」那一批,不能读成「这些是废键」——它只能说明「有相当比例的键无法被静态扫描确认在用」。给出这个方向的结论时,把它的失效边界一起给出来,比数字本身更重要。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、
路由指南与发布说明,以及 src/、src-tauri/、tests/ 的源码整理,
核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。
本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,
因此不涉及界面外观、操作手感与切换速度的任何描述。
文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。
该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。