fix(locale): persist an explicit pick of the provisional locale

setLocale returned early when the id already matched the active locale, so
choosing the language already on screen wrote nothing. That value may be a
provisional browser-derived or fallback resolution nothing has stored, so a
different browser sharing the DSH home still resolved on its own. Write
unconditionally; keep the render publish conditional.

Broaden the dictionary parity gate to every workspace package, pair zh/en
across sibling files and inline registrations, and fail when a dictionary has
no counterpart. It previously scanned only packages/client and packages/
extensions, compared within a single module, and silently skipped unpaired
dictionaries -- so the split locales/zh.ts + en.ts common pair, the inline
directory-picker-browse dictionary, and session-log-export were unchecked.
Normalize paths at ingestion so the sweep does not narrow on Windows.

Regenerate the client API catalog and update the locale README pair: both
described the old zh fallback direction.

Add the English fallback dialog golden, and drop a dead afterEach plus the
blank lines left where the dead browser-language pins were removed.
This commit is contained in:
Chinesezjc
2026-08-18 12:18:03 +08:00
parent bf4cb507f1
commit 9de06952ab
17 changed files with 265 additions and 89 deletions
+8 -1
View File
@@ -24,6 +24,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md')
// The English fallback surface: a browser naming no shipped language.
const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md')
const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]'
const MODE = webSnapshotMode()
@@ -496,6 +498,11 @@ describe('web e2e: settings modal and General preferences', () => {
const dialog = frPage.getByRole('dialog', { name: 'Settings' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
// Golden of the English fallback dialog — the visible output this change
// produces. The zh golden above covers the detected-locale surface, so
// the pair pins both directions of the resolution.
const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd)
await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE)
expect(frTripwire.pageErrors).toEqual([])
expect(frTripwire.warnings).toEqual([])
} finally {
@@ -506,6 +513,6 @@ describe('web e2e: settings modal and General preferences', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog-en.expected.md', 'dialog.expected.md', 'plugins.expected.md'])
})
})
@@ -0,0 +1,45 @@
- dialog "Settings":
- navigation:
- text: Settings
- button "General":
- img
- text: General
- button "Models":
- img
- text: Models
- button "Plugins":
- img
- text: Plugins
- button "Agent presets":
- img
- text: Agent presets
- button "Open configuration file"
- button "Close":
- img
- text: Close
- text: Agent preset Applies to sessions you start from now on. Running sessions keep the preset they began with.
- button "Standard mode":
- text: Standard mode
- img
- text: Permission Choose the default permission mode for new sessions
- button "Workspace Write":
- text: Workspace Write
- img
- text: Language
- button "English":
- text: English
- img
- text: Appearance
- button "Light":
- img
- text: Light
- button "Dark":
- img
- text: Dark
- button "System" [pressed]:
- img
- text: System
- text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior
- button "Queue":
- text: Queue
- img
+2 -2
View File
@@ -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 packages/client/locale/README.md
README.md: a63807f093dc12831a41196e61008151868e3205
README.zh.md: b302e6055ba30d2db0948c0a6f1551c4a9dcb24c
README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9
README.zh.md: 4f08344d6f030e408e570ff0ad31d0b0d4de3ecc
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `<html lang>` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)``TranslateNS<ns>`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
## Model Experience
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
locale 插件:LocaleRuntime——`zh``en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
locale 插件:LocaleRuntime——`zh``en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `<html lang>` 指向当前 locale`zh-CN``en`。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})``LocaleNamespaceMap` 校验,`bind(ns)``TranslateNS<ns>`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate``TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
## 模型体验
+9 -2
View File
@@ -197,13 +197,20 @@ export class LocaleRuntime {
/**
* Switch the active locale — the only user preference write entry.
*
* The durable write happens even when the id already matches the active
* locale, because the active value may be a provisional browser-derived or
* fallback resolution that nothing has stored yet. Picking the language
* already on screen is still an explicit choice, and it must survive a
* different browser sharing the same DSH home. Only the render notification
* is conditional: republishing an unchanged locale would churn every
* subscriber for nothing.
* @param id - a registered locale id; unknown ids throw.
*/
setLocale(id: string): void {
const match = this.snapshot.locales.find(l => l.id === id)
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
if (this.snapshot.active === match.id) return
this.publish(match.id, true)
if (this.snapshot.active !== match.id) this.publish(match.id, true)
void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id)
}
@@ -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, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
@@ -78,10 +78,6 @@ describe('locale apply', () => {
// 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()
})
it('declares the slot service', () => {
expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope'])
})
@@ -138,7 +138,7 @@ describe('LocaleRuntime', () => {
expect(svc.getSnapshot().revision).toBe(before + 1)
})
it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => {
it('setLocale writes through the scope and republishes only on a real change', () => {
const host = stubSettingsScope<LocaleSettings>()
const { svc, events } = make(host)
svc.setLocale('en')
@@ -147,9 +147,27 @@ describe('LocaleRuntime', () => {
expect(events).toHaveLength(1)
expect(events[0]).toBe(svc.getLocale())
expect(events[0]!.revision).toBe(1)
// Re-selecting the active locale publishes nothing (no subscriber churn)
// but still writes: the active value may be a provisional browser-derived
// resolution nothing has stored, and picking it is an explicit choice that
// must outlive this browser.
svc.setLocale('en')
expect(events).toHaveLength(1)
expect(host.set).toHaveBeenCalledOnce()
expect(host.set).toHaveBeenCalledTimes(2)
expect(host.set).toHaveBeenLastCalledWith('preference', 'en')
})
it('persists an explicit pick of the provisional locale, so a shared DSH home agrees', () => {
// A browser naming no shipped language opens at FALLBACK_LOCALE with
// nothing stored. Choosing that same language in the menu must become
// durable, or a Chinese browser sharing the home still opens Chinese.
stubLanguages('fr-FR')
const host = stubSettingsScope<LocaleSettings>()
const { svc } = make(host)
expect(svc.getLocale().active).toBe('en')
expect(host.set).not.toHaveBeenCalled()
svc.setLocale('en')
expect(host.set).toHaveBeenCalledWith('preference', 'en')
})
it('setLocale without a host scope stays process-local', () => {
@@ -21,7 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
const ROSTER_ONE = {
rpcId: 'r',
result: {
@@ -12,7 +12,6 @@ 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'
const sid = (k: string): SessionId => k as SessionId
async function bench() {
@@ -10,7 +10,6 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx'
/** The seats this plugin fills for a loopback browser (slot name → expected component). */
const SEATS = [
['settings.trigger', TriggerContent],
@@ -11,7 +11,6 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx'
import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx'
import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
async function bench(isLoopback = true) {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
@@ -13,7 +13,6 @@ import type {
ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
/**
* @param served - namespaces the Host describes; omitted answers a failed read,
* which is what most of these specs want (no card has anything to render).
@@ -87,7 +86,6 @@ describe('ui-settings-plugins apply', () => {
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
})
it('injects a live tab projection, the card directory, and one business face per card', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
@@ -14,7 +14,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'
const SLOT = 'settings.general.item'
function deferred<T>() {
@@ -7,7 +7,6 @@ import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepsee
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
@@ -106,7 +106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'locale',
summary: 'Dictionary registry plus locale preference.',
description: '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 text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).',
description: 'Dictionary registry plus locale preference. Lookup chain per key: the 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 getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).',
methods: [
{
signature: 'getLocale(): LocaleSnapshot',
+177 -66
View File
@@ -9,83 +9,144 @@
* 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.
*
* Discovery is deliberately broad, because a gate that silently narrows is
* worse than no gate. It sweeps every workspace package (not just
* `packages/client`), reads dictionaries wherever they are declared —
* `locales.ts`, a `locales/` directory, or inline in the plugin body — and
* pairs `zh`/`en` across sibling files as well as within one module. A `zh`
* dictionary whose `en` counterpart cannot be found anywhere is an error, not
* a skip.
*/
import type { Dirent } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { dirname, 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[] {
/** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */
function relative(file: string): string {
return file.slice(root.length).replaceAll('\\', '/')
}
/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */
function sourceFiles(): 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)
const packagesRoot = resolve(root, 'packages')
for (const group of directories(packagesRoot)) {
for (const pkg of directories(resolve(packagesRoot, group))) {
walk(resolve(packagesRoot, group, pkg, 'src'), files)
}
}
return files.sort()
}
function walk(dir: string, out: string[]): void {
/** Immediate subdirectory names, or none when the path is not a directory. */
function directories(dir: string): string[] {
if (!existsSync(dir)) return []
let entries: Dirent[]
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
// Swallows only the race between existsSync and readdirSync (a package
// directory removed mid-sweep); readdirSync is the sole statement in the
// try, so no other failure can reach here.
return []
}
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name)
}
function walk(dir: string, out: string[]): void {
if (!existsSync(dir)) return
let entries: Dirent[]
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
// Same narrow race as `directories`: readdirSync is the only statement
// guarded, so this cannot mask a parse or assertion failure.
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)
}
if (entry.isDirectory()) walk(full, out)
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full)
}
}
/** One discovered dictionary: which file and export name declared it. */
interface Dictionary {
/** Repo-relative declaring file. */
file: string
/** Export name, or the registration site for an inline literal. */
name: string
/** Declared keys, sorted. */
keys: string[]
}
/**
* 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.
* Keys of every top-level `export const <name> = { ... }` object literal whose
* name identifies a locale dictionary, plus inline `register(ns, locale, {...})`
* literals. Read from the AST so the gate never executes package code.
* @param file - absolute path of a candidate module.
* @returns discovered dictionaries, keyed by locale-bearing name.
*/
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[]>()
function dictionariesIn(file: string): Dictionary[] {
const text = readFileSync(file, 'utf8')
// Cheap pre-filter: parsing every package source is wasteful, and a file
// with no locale token cannot declare a dictionary under any shape below.
if (!/\b(zh|en)\b/.test(text)) return []
const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true)
const found: Dictionary[] = []
const rel = relative(file)
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
if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) 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())
const literal = unwrap(decl.initializer)
if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
if (localeOf(decl.name.text) === undefined) continue
found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) })
}
}
// Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a
// registration loop in the plugin body. Both halves key off the enclosing
// array's line so they pair with each other and not across sites.
const visit = (node: ts.Node): void => {
if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) {
const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
for (const element of node.elements) {
if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue
const [tag, dict] = element.elements
const literal = unwrap(dict)
if (tag === undefined || !ts.isStringLiteral(tag)) continue
if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
if (tag.text !== 'zh' && tag.text !== 'en') continue
found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) })
}
}
ts.forEachChild(node, visit)
}
visit(source)
return found
}
/** Declared property names of an object literal, sorted. */
function keysOf(literal: ts.ObjectLiteralExpression): string[] {
const keys: string[] = []
for (const prop of literal.properties) {
if (!ts.isPropertyAssignment(prop)) continue
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text)
}
return keys.sort()
}
/** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */
function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
let current = node
@@ -98,40 +159,90 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
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`
/**
* The locale a dictionary name declares, and the namespace-ish remainder that
* identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`,
* and `settingsZh`/`settingsEn` are the shapes this repo uses.
* @param name - export name or synthetic inline name.
* @returns locale plus pair key, or undefined when the name names no locale.
*/
function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined {
for (const locale of ['zh', 'en'] as const) {
const other = locale === 'zh' ? 'Zh' : 'En'
if (name === locale) return { locale, pair: '' }
if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) }
if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) {
return { locale, pair: name.slice(2) }
}
if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) }
}
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 files = sourceFiles()
// Guard the discovery itself: an empty or narrowed sweep would pass every
// assertion below while checking nothing.
expect(files.length).toBeGreaterThan(500)
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(', ')}`)
// Pair within a file first; a dictionary whose counterpart is not in the
// same module then pairs with a sibling in the same directory. Both shapes
// ship here: `locales/settings.ts` exports zh+en together, while
// `locales/zh.ts` + `locales/en.ts` split the common pair across files.
const perFile = new Map<string, Dictionary[]>()
for (const file of files) {
const dicts = dictionariesIn(file)
if (dicts.length > 0) perFile.set(relative(file), dicts)
}
const groups = new Map<string, Map<'zh' | 'en', Dictionary>>()
const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => {
const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>()
if (slot.has(locale)) {
throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`)
}
slot.set(locale, dict)
groups.set(key, slot)
}
for (const [rel, dicts] of perFile) {
for (const dict of dicts) {
const parsed = localeOf(dict.name)
if (parsed === undefined) continue
const sameFileCounterpart = dicts.some((other) => {
const otherParsed = localeOf(other.name)
return otherParsed !== undefined
&& otherParsed.pair === parsed.pair
&& otherParsed.locale !== parsed.locale
})
// Same-file pairs key by file so two pairs in one directory stay
// distinct; split pairs key by directory so siblings meet.
const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}`
place(key, parsed.locale, dict)
}
}
expect(comparedPairs).toBeGreaterThan(20)
expect(mismatches).toEqual([])
const problems: string[] = []
let comparedPairs = 0
for (const [key, slot] of [...groups].sort()) {
const zh = slot.get('zh')
const en = slot.get('en')
if (zh === undefined || en === undefined) {
const present = zh ?? en
problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`)
continue
}
comparedPairs++
const zhOnly = zh.keys.filter(k => !en.keys.includes(k))
const enOnly = en.keys.filter(k => !zh.keys.includes(k))
if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`)
if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`)
}
// The shipped dictionary count only grows; a collapse means discovery or
// pairing broke, which would hide real asymmetry.
expect(comparedPairs).toBeGreaterThan(25)
expect(problems).toEqual([])
})
})