fix(locale): open in English when the browser names no shipped language

The provisional locale fell back to zh, so a browser asking for neither
zh nor en (fr, de) opened the product in Chinese. Resolve to en instead,
and use en as the dictionary fallback: the shipped zh/en dictionaries
declare identical key sets, so one constant serves both roles.

Add scripts/locale-dictionary-parity.spec.ts to gate that symmetry, and
set the asserted locale explicitly in specs that had relied on the old
zh fallback through a dead usePinnedBrowserLanguages call (those files
declare no jsdom environment, so browser detection never ran there).
This commit is contained in:
Chinesezjc
2026-08-17 18:55:28 +08:00
parent a9ce05a04c
commit 94135092a5
18 changed files with 306 additions and 79 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md
2026-07-31-browser-derived-initial-locale.md: 072f91b730cfc9eaeead7701d2b20d12b443acb3
2026-07-31-browser-derived-initial-locale.zh.md: 97f0f0007474c21fb618a085b506bc919586f624
2026-07-31-browser-derived-initial-locale.md: 94f32b136f20c7ab7fb8241a0ac9adf6249a4380
2026-07-31-browser-derived-initial-locale.zh.md: 8d879b9b11ad42feed9ffd2ec3e5a16d1dcd9b8c
@@ -8,29 +8,35 @@ English | [中文](2026-07-31-browser-derived-initial-locale.zh.md)
The Settings Language row opened every first visit in Chinese: `LocaleRuntime` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen.
Reading the browser fixed the readers whose browser names a language this app ships, but left the residual case wrong: a browser asking for neither `zh` nor `en` (`fr`, `de`) still fell back to `zh`. Those readers are the least likely to read Chinese.
## Decision
**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active.
**The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active.
**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`, the source language of the copy. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI.
**Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express.
**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`.
**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language, so gating on `navigator` would let a node boot of the client tree resolve to the machine's language instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`.
**An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question.
**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature.
**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback.
## Alternatives considered
- **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading.
- **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this".
- **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it.
- **A cordis config key for the default locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer.
- **A cordis config key for the fallback locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer.
- **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead.
- **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice.
- **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end.
## Consequences
- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction.
- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen".
- Tests that construct a `LocaleRuntime` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all).
- A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction.
- Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on.
- Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead.
- Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation.
@@ -8,29 +8,35 @@ Status: implemented
设置里的语言行在每一次首访时都以中文开场:`LocaleRuntime` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。
读取浏览器修好了那些浏览器声明了本应用所提供语言的读者,但残余情形依然是错的:既不请求 `zh` 也不请求 `en` 的浏览器(`fr``de`)仍会回落到 `zh`。这些读者恰恰最不可能阅读中文。
## Decision
**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。
**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE``en`解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。
**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh``en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`,也就是文案的源语言。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。
**浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN``zh-TW` 同归 `zh``en-GB``en`;而只请求本应用不提供的语言(`fr``de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。
**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言CI runner 上是 `en-US`,因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`
**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言,因此以 `navigator` 把关会让 node 启动客户端树时解析成机器语言,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`
**显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。
**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation``models-settings``onboarding-deepseek-config``settings-chrome`)以 `apps/web/tests/support.ts``locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US``settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据
**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation``models-settings``onboarding-deepseek-config``settings-chrome`)以 `apps/web/tests/support.ts``locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US``settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值
## Alternatives considered
- **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。
- **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。
- **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。
- **为默认 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。
- **为回落 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。
- **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。
- **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。
- **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。
## Consequences
- 来自英文浏览器的首访落在英文界面,语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。
- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」
- 在 jsdom 下构造 `LocaleRuntime` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`
- 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。
- 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提
- 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`套件级 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值
- 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
+27 -1
View File
@@ -455,7 +455,9 @@ describe('web e2e: settings modal and General preferences', () => {
it('opens an English browser in English without any stored preference', async () => {
// A fresh Host home has no locale preference, so its surface follows the
// browser rather than the product fallback.
// browser. English is also FALLBACK_LOCALE, so this scenario alone cannot
// distinguish detection from the default — the zh scenarios above supply
// the discriminating half (a Chinese browser must NOT land on the default).
const fresh = await launchWebScaffold({})
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
const enTripwire = watchConsole(enPage)
@@ -478,6 +480,30 @@ describe('web e2e: settings modal and General preferences', () => {
}
}, 90_000)
it('opens a browser asking for no shipped language in English', async () => {
// The product default for "no usable signal": a French browser ships
// neither zh nor en, so resolution falls to FALLBACK_LOCALE (en) rather
// than to Chinese.
const fresh = await launchWebScaffold({})
const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' })
const frTripwire = watchConsole(frPage)
onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language'))
try {
await frPage.goto(fresh.baseUrl, { waitUntil: 'load' })
await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
await frPage.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = frPage.getByRole('dialog', { name: 'Settings' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
expect(frTripwire.pageErrors).toEqual([])
expect(frTripwire.warnings).toEqual([])
} finally {
await frPage.close()
await fresh.close()
}
}, 90_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md'])
+10 -4
View File
@@ -86,8 +86,14 @@ declare module '@deepseek-ai/cordis' {
}
}
/** Fallback locale consulted after the active locale misses (also the last-resort initial locale). */
export const FALLBACK_LOCALE: LocaleId = 'zh'
/**
* English is both the locale the UI opens in when the browser names no shipped
* language (and for non-browser runs), and the dictionary consulted after the
* active locale misses a key. One constant serves both because the shipped
* `zh`/`en` dictionaries carry identical key sets, so neither direction can
* leave a key unresolved; English is the source language of the copy.
*/
export const FALLBACK_LOCALE: LocaleId = 'en'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
@@ -103,8 +109,8 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([
/**
* Dictionary registry plus locale preference. Lookup chain per key: the
* entry's namespace in the active locale -> that namespace's zh fallback ->
* the shared common namespace (active, then zh) -> the key itself (missing
* entry's namespace in the active locale -> that namespace's en fallback ->
* the shared common namespace (active, then en) -> the key itself (missing
* text stays visible, fail loud in the UI rather than blank). Reads go
* through {@link getLocale}; writes only through {@link setLocale};
* continuous sync through the `locale/change` event, or through the
@@ -2,7 +2,7 @@
* Language row registration, snapshot projection into the row store, and
* recovery after an HMR collapse of the declaring entry. */
import { Context } from '@deepseek-ai/cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
@@ -73,12 +73,10 @@ function faceOf(slots: SlotRegistry) {
}
describe('locale apply', () => {
// A fresh service opens in the browser's language, so these wiring specs
// pin one to keep their zh baseline independent of the test environment.
beforeEach(() => {
vi.stubGlobal('navigator', { languages: ['zh-CN'], language: 'zh-CN' })
})
// These are wiring specs, not default-language specs: each one that reads
// localized copy sets its locale explicitly via setLocale/Host preference
// rather than leaning on FALLBACK_LOCALE. This file has no jsdom environment,
// so there is no `window` and no browser-language detection to stub.
afterEach(() => {
vi.unstubAllGlobals()
})
@@ -95,6 +93,9 @@ describe('locale apply', () => {
// Base dictionaries are registered: the (ns, locale) seats are occupied.
expect(() => locale.register('common', 'zh', {})).toThrow('already has locale')
expect(() => locale.register('common', 'en', {})).toThrow('already has locale')
// Both dictionaries resolve; read each under its own active locale.
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
locale.setLocale('zh')
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)!
expect(entry.options).toMatchObject({ id: 'language', order: 0 })
@@ -110,9 +111,14 @@ describe('locale apply', () => {
it('projects service snapshots into the row store and routes face writes back', async () => {
const b = await bench()
// Open at zh so the pre-inject switch to en below is a real change: with
// FALLBACK_LOCALE = en it would otherwise be a no-op and never exercise
// the unbound-actions arm or persist.
b.setHostPreference('zh')
declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const locale = b.ctx.get('locale') as LocaleRuntime
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
// An event ahead of any inject hits the unbound-actions arm.
locale.setLocale('en')
@@ -133,17 +139,20 @@ describe('locale apply', () => {
it('loads and refreshes the explicit Host preference after nonblocking activation', async () => {
const b = await bench()
b.setHostPreference('en')
// Preference must differ from the provisional locale (FALLBACK_LOCALE = en
// with no window), or clearing it below would be unobservable.
b.setHostPreference('zh')
declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const locale = b.ctx.get('locale') as LocaleRuntime
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
// Cleared preference falls back to the provisional locale.
b.setHostPreference(undefined)
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
b.setHostPreference('en')
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
b.setHostPreference('zh')
b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0])
await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') })
expect(b.describe).toHaveBeenCalledTimes(3)
})
@@ -3,8 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { FALLBACK_LOCALE, LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
const make = (host?: StubSettingsScope<LocaleSettings>): {
ctx: Context
svc: LocaleRuntime
@@ -37,31 +36,34 @@ describe('LocaleRuntime', () => {
vi.unstubAllGlobals()
})
it('translates through the active-locale -> zh -> key chain', () => {
it('translates through the active-locale -> en -> key chain', () => {
const { svc } = make()
svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
svc.register('ns', 'en', { hello: 'Hello' })
svc.register('ns', 'zh', { hello: '你好' })
svc.register('ns', 'en', { hello: 'Hello', onlyEn: 'English only' })
const t = svc.bind('ns')
expect(svc.getLocale().active).toBe('zh')
expect(t('hello')).toBe('你好')
// The active locale misses this key; the en fallback supplies it.
expect(t('onlyEn')).toBe('English only')
svc.setLocale('en')
expect(t('hello')).toBe('Hello')
expect(t('onlyZh')).toBe('仅中文')
expect(t('missing.key')).toBe('missing.key')
})
it('falls through to the common vocabulary after the namespace misses (production keys)', () => {
const { svc } = make()
// The shipped common pair is registered by apply; the bench registers it
// directly to pin the production chain: ns -> common -> zh -> key.
// directly to pin the production chain: ns -> common -> en -> key.
svc.register('common', 'zh', { retry: '重试' })
svc.register('common', 'en', { retry: 'Retry' })
svc.register('ns', 'zh', { own: '自有' })
svc.register('ns', 'en', { own: 'Own' })
const t = svc.bind('ns')
expect(t('retry')).toBe('重试')
// zh is active and `ns` has no zh dictionary at all: the en fallback answers.
expect(t('own')).toBe('Own')
svc.setLocale('en')
expect(t('retry')).toBe('Retry')
expect(t('own')).toBe('自有')
expect(t('own')).toBe('Own')
// common itself must not recurse: a miss inside common echoes the key.
// (Wide-string ns hits the untyped bind overload — the typed one rejects
// unknown keys at compile time, which is the point of the typed registry contract.)
@@ -206,21 +208,21 @@ describe('LocaleRuntime', () => {
expect(make().svc.getLocale().active).toBe('en')
vi.stubGlobal('navigator', { language: 'en-US' })
expect(make().svc.getLocale().active).toBe('en')
// No shipped language anywhere in the browser's preferences: zh remains
// the product default rather than an arbitrary near-match.
// No shipped language anywhere in the browser's preferences: en is the
// product default rather than an arbitrary near-match.
stubLanguages('fr-FR', 'de')
expect(make().svc.getLocale().active).toBe('zh')
expect(make().svc.getLocale().active).toBe('en')
})
it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => {
it('runs outside a browser (node boots): the default decides and the machine language does not', () => {
vi.stubGlobal('window', undefined)
// Node exposes its own global navigator; without a window it must not
// reach the resolution at all.
stubLanguages('en-US')
stubLanguages('zh-CN')
const { svc } = make()
expect(svc.getLocale().active).toBe('zh')
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
svc.setLocale('zh')
expect(svc.getLocale().active).toBe('zh')
})
it('lets an explicit in-process preference replace the browser-derived value', () => {
@@ -230,6 +232,28 @@ describe('LocaleRuntime', () => {
expect(svc.getLocale().active).toBe('zh')
})
it('serves English as both the opening locale and the dictionary fallback', () => {
// One constant covers both jobs: the locale the UI opens in with no usable
// browser signal, and the dictionary backing a key the active locale
// misses. Safe to share only because the shipped zh/en dictionaries carry
// identical key sets (asserted below on a registered pair).
expect(FALLBACK_LOCALE).toBe('en')
vi.stubGlobal('window', undefined)
const { svc } = make()
// A key present only in en resolves for a zh reader through the fallback.
svc.register('ns', 'zh', {})
svc.register('ns', 'en', { onlyEn: 'English only' })
svc.setLocale('zh')
expect(svc.getLocale().active).toBe('zh')
expect(svc.bind('ns')('onlyEn')).toBe('English only')
// The reverse no longer resolves: a zh-only key is unreachable from en, so
// the key itself surfaces (fail loud) rather than silently rendering zh.
svc.register('ns2', 'zh', { onlyZh: '仅中文' })
svc.register('ns2', 'en', {})
svc.setLocale('en')
expect(svc.bind('ns2')('onlyZh')).toBe('onlyZh')
})
it('exposes the two shipped locales with self-described labels', () => {
const { svc } = make()
expect(svc.getLocale().locales).toEqual([
@@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
@@ -21,9 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const ROSTER_ONE = {
rpcId: 'r',
@@ -77,6 +74,10 @@ async function bench() {
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
// comes from FALLBACK_LOCALE (en): state the asserted locale explicitly.
locale.setLocale('zh')
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `$dispatch` handoff the connection sink makes.
@@ -7,15 +7,11 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const sid = (k: string): SessionId => k as SessionId
@@ -37,6 +33,10 @@ async function bench() {
scopeOf: (c: Context) => scopeOf(c),
})
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
// comes from FALLBACK_LOCALE (en): state the asserted locale explicitly.
locale.setLocale('zh')
ctx.provide('locale', locale)
return { ctx, slots, locale }
}
@@ -39,6 +39,10 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
ctx.provide('remote', { $on: () => () => {} } as never)
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
// These specs assert the shipped Chinese copy. There is no jsdom `window` in
// this lane, so browser-language detection never runs and the locale comes
// from FALLBACK_LOCALE (en): state the asserted locale explicitly.
ctx.locale.setLocale('zh')
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, fiber }
@@ -104,7 +104,12 @@ async function bench() {
return () => { seats.delete(options.name) }
},
})
ctx.provide('locale', new LocaleRuntime(ctx))
const localeRuntime = new LocaleRuntime(ctx)
// This spec asserts the shipped Chinese copy. There is no jsdom `window` in
// this lane, so browser-language detection never runs and the locale comes
// from FALLBACK_LOCALE (en): state the asserted locale explicitly.
localeRuntime.setLocale('zh')
ctx.provide('locale', localeRuntime)
const scopes = new Map<SessionId, Context>()
const addressed = new Set<SessionId>()
ctx.provide('sessions', {
@@ -4,16 +4,12 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
/** The seats this plugin fills for a loopback browser (slot name → expected component). */
const SEATS = [
@@ -28,6 +24,10 @@ async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
// comes from FALLBACK_LOCALE (en): state the asserted locale explicitly.
locale.setLocale('zh')
ctx.provide('locale', locale)
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
@@ -4,20 +4,21 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client'
import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
// comes from FALLBACK_LOCALE (en): state the asserted locale explicitly.
locale.setLocale('zh')
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `$dispatch` handoff the connection sink makes.
@@ -5,16 +5,13 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
import type {
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
/**
* @param served - namespaces the Host describes; omitted answers a failed read,
@@ -24,6 +21,10 @@ async function bench(served?: string[]) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
// comes from FALLBACK_LOCALE (en): state the asserted locale explicitly.
locale.setLocale('zh')
ctx.provide('locale', locale)
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
@@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
@@ -13,9 +13,6 @@ import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-sett
import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const SLOT = 'settings.general.item'
@@ -29,6 +26,10 @@ async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
// comes from FALLBACK_LOCALE (en): state the asserted locale explicitly.
locale.setLocale('zh')
ctx.provide('locale', locale)
let preference = 'system'
const namespace = () => ({
@@ -2,15 +2,11 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
async function bench() {
const ctx = new Context()
@@ -37,6 +33,10 @@ async function bench() {
} as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
const locale = new LocaleRuntime(ctx)
// These specs assert the shipped Chinese copy. There is no jsdom `window`
// in this lane, so browser-language detection never runs and the locale
// comes from FALLBACK_LOCALE (en): state the asserted locale explicitly.
locale.setLocale('zh')
ctx.provide('locale', locale)
return {
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename,
+137
View File
@@ -0,0 +1,137 @@
/**
* Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary
* declares the same keys in `zh` and `en`.
*
* The locale runtime resolves a key through the active locale, then through
* the single fallback locale (`en`), then surfaces the key itself. With
* symmetric dictionaries that middle step always resolves, so one constant can
* serve as both the opening locale and the dictionary fallback. A key added to
* only one side breaks that: a reader of the other language sees a bare key
* such as `list.aria` instead of text. This gate fails on the asymmetry rather
* than waiting for the bare key to reach a UI.
*/
import type { Dirent } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const root = fileURLToPath(new URL('..', import.meta.url))
/** Every `locales*.ts` module under a client package's `src/`. */
function dictionaryModules(): string[] {
const files: string[] = []
for (const group of ['client', 'extensions']) {
const groupRoot = resolve(root, 'packages', group)
let packages: string[]
try {
packages = readdirSync(groupRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
} catch {
continue
}
for (const pkg of packages) {
const srcRoot = resolve(groupRoot, pkg, 'src')
walk(srcRoot, files)
}
}
return files.sort()
}
function walk(dir: string, out: string[]): void {
let entries: Dirent[]
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) {
walk(full, out)
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full)
}
}
}
/**
* Keys of every top-level `export const <zh|en>...= { ... }` object literal,
* read from the AST so the gate never executes package code.
* @param file - absolute path of the dictionary module.
* @returns exported dictionary name mapped to its declared keys.
*/
function exportedDictionaries(file: string): Map<string, string[]> {
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true)
const found = new Map<string, string[]>()
for (const statement of source.statements) {
if (!ts.isVariableStatement(statement)) continue
const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true
if (!exported) continue
for (const decl of statement.declarationList.declarations) {
if (!ts.isIdentifier(decl.name)) continue
const initializer = unwrap(decl.initializer)
if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue
const keys: string[] = []
for (const prop of initializer.properties) {
if (!ts.isPropertyAssignment(prop)) continue
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text)
}
found.set(decl.name.text, keys.sort())
}
}
return found
}
/** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */
function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
let current = node
while (
current !== undefined
&& (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current))
) {
current = current.expression
}
return current
}
/** Pair a `zh` export with the `en` export covering the same namespace. */
function counterpart(name: string): string | undefined {
if (name === 'zh') return 'en'
if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}`
if (name.endsWith('Zh')) return `${name.slice(0, -2)}En`
return undefined
}
describe('shipped locale dictionaries', () => {
it('declares the same keys in zh and en, so the single fallback locale always resolves', () => {
const modules = dictionaryModules()
// Guard the discovery itself: an empty sweep would pass every assertion
// below while checking nothing.
expect(modules.length).toBeGreaterThan(20)
const mismatches: string[] = []
let comparedPairs = 0
for (const file of modules) {
const dicts = exportedDictionaries(file)
for (const [name, zhKeys] of dicts) {
const enName = counterpart(name)
if (enName === undefined) continue
const enKeys = dicts.get(enName)
if (enKeys === undefined) continue
comparedPairs++
const rel = file.slice(root.length)
const zhOnly = zhKeys.filter(key => !enKeys.includes(key))
const enOnly = enKeys.filter(key => !zhKeys.includes(key))
if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`)
if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`)
}
}
expect(comparedPairs).toBeGreaterThan(20)
expect(mismatches).toEqual([])
})
})