This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
// tests/unit/i18n/locale-parity.test.ts
//
// 守卫:zh-CN.json / en-US.json 顶层键结构必须严格一致。
// 行为:把两文件扁平化到 dot 键(递归 object),两侧集合不同就 fail。
// 目的:防止漏译 / 拼错键名 —— 双 locale 同时被引,i18next 在缺的 key 上抛
// 警告并把 zh 当 fallback,renderer 看似正常但英文版回退中文,debug 极难。
//
// 已知 i18n stub:en-US 部分技术字面量(loadingDots '…')和 zh 相同是
// 预期 —— 不因此 fail。
//
// 历史:audit-r156 (2026-09-?) 起一直被守;r165-hotfix 收紧"两边 dict
// 完整性"语义后从 1 例失败拉到 1 例通过(去掉 en-US 9 个多余键)。
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { describe, expect, it } from 'vitest';
interface LocaleJson {
[key: string]: unknown;
}
const LOCALES_DIR = resolve(__dirname, '../../../src/renderer/src/i18n/locales');
function flatKeys(value: LocaleJson, prefix = ''): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...flatKeys(v as LocaleJson, key));
} else {
out.push(key);
}
}
return out;
}
describe('i18n locale parity', () => {
const zhPath = resolve(LOCALES_DIR, 'zh-CN.json');
const enPath = resolve(LOCALES_DIR, 'en-US.json');
const zh = JSON.parse(readFileSync(zhPath, 'utf8')) as LocaleJson;
const en = JSON.parse(readFileSync(enPath, 'utf8')) as LocaleJson;
const zhKeys = new Set(flatKeys(zh));
const enKeys = new Set(flatKeys(en));
it('zh-CN.json 和 en-US.json 顶层都读得到且是合法 JSON', () => {
expect(typeof zh).toBe('object');
expect(typeof en).toBe('object');
});
it('两 locale 的扁平键集合严格一致', () => {
const onlyZh = [...zhKeys].filter((k) => !enKeys.has(k)).sort();
const onlyEn = [...enKeys].filter((k) => !zhKeys.has(k)).sort();
expect({ onlyZh, onlyEn }).toEqual({ onlyZh: [], onlyEn: [] });
});
it('两 locale 都有 _meta.code 字段(自我描述)', () => {
const zhMeta = (zh as { _meta?: { code?: string } })._meta;
const enMeta = (en as { _meta?: { code?: string } })._meta;
expect(zhMeta?.code).toBe('zh-CN');
expect(enMeta?.code).toBe('en-US');
});
});

View File

@@ -0,0 +1,113 @@
// tests/unit/i18n/no-dead-keys.test.ts
//
// 守 r233「locale 键集合不得有死键」这条卫生契约。
//
// ── 为什么值得守 ─────────────────────────────────────────────────
// renderer 的 t() 是**字符串寻址**,TS 永远不报错:功能下线时谁也不会
// 删 locale 键 ⇒ 键集合只涨不缩,r206 主进程字典清过一次(90→77),
// renderer 字典从没清过。死键的代价:
// ① 误导维护者 —— 看到 `modal.renameLiterature` 以为还有这个弹窗,
// 实际只有 `renameLiteratureConfirm` 活着(r233 实测);
// ② 误导占位符契约 / locale parity 的排查面 —— 集合越大噪声越多;
// ③ 翻译成本 —— 每加一个 locale 就要为死键多付一遍翻译。
// r233 清掉的两条:`modal.renameLiterature`(ConfirmDialog 化后只剩
// Confirm 键活着)、`folders.moveOutOfFolder`(「移出文件夹」功能自
// disk-folder 改造后整段下线,只剩注释提到)。
//
// ── 扫描口径(先剥注释再匹配,注释提到 key 不算使用)───────────────
// 一个键算「活着」当且仅当 src/renderer 去注释源码里命中以下任一形态:
// 1. 字面量:t('ns.key') / t("ns.key") / t(`ns.key`);
// 2. 复数基:`notes.listCount_one/_other` 的基 `notes.listCount` 命中
// 字面量(i18next 按 count 自动拼后缀);
// 3. 前缀模板:源码出现 `t(`ns.${…}`)` ⇒ `ns.*` 整族视同 used
// (settings.platform / startup.phase 靠这条);
// 4. tWithFallback('ns', …) 第一参是命名空间字面量 ⇒ `ns.*` 整族
// 视同 used(ErrorFallback 的 errorBoundary.* / toast.* 靠这条,
// 口径与组件头注的「调用形态不变式」一致)。
// `_meta.*` 是结构字段(locale-parity 直接 JSON 读,不走 t()),豁免。
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const LOCALES_DIR = join(ROOT, 'src/renderer/src/i18n/locales');
const RENDERER_SRC = join(ROOT, 'src/renderer/src');
interface LocaleJson {
[key: string]: unknown;
}
function flatKeys(value: LocaleJson, prefix = ''): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...flatKeys(v as LocaleJson, key));
} else {
out.push(key);
}
}
return out;
}
/** 收集 src/renderer 全部 .ts/.tsx(排除 locales 目录本身)。 */
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (p.includes('locales')) continue;
out.push(...collectSourceFiles(p));
} else if (/\.(ts|tsx)$/.test(e.name)) {
out.push(p);
}
}
return out;
}
/** 剥掉注释 —— 注释里提到 key 不算使用(否则死键靠死注释「续命」)。 */
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
}
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
/** 死键扫描:返回 src/renderer 里没有任何活引用的 locale 键。 */
export function findDeadLocaleKeys(locale: LocaleJson, strippedCorpus: string): string[] {
const keys = flatKeys(locale);
const hit = (s: string): boolean =>
strippedCorpus.includes(`'${s}'`) ||
strippedCorpus.includes(`"${s}"`) ||
strippedCorpus.includes('`' + s + '`');
const dead: string[] = [];
for (const k of keys) {
if (k.startsWith('_meta.')) continue;
const base = k.replace(PLURAL_SUFFIX, '');
if (hit(k)) continue;
if (base !== k && hit(base)) continue;
const ns = k.split('.').slice(0, -1).join('.');
if (ns && (strippedCorpus.includes('`' + ns + '.${') || strippedCorpus.includes(`'${ns}.\${`)))
continue;
if (ns && strippedCorpus.includes(`tWithFallback('${ns}'`)) continue;
dead.push(k);
}
return dead;
}
describe('renderer locale 无死键(r233)', () => {
const zh = JSON.parse(readFileSync(join(LOCALES_DIR, 'zh-CN.json'), 'utf8')) as LocaleJson;
const corpus = collectSourceFiles(RENDERER_SRC)
.map((f) => stripComments(readFileSync(f, 'utf8')))
.join('\n');
it('zh-CN 每个键都在 src/renderer 有活引用(死键 = 功能已下线但键没删)', () => {
const dead = findDeadLocaleKeys(zh, corpus);
expect(dead, `死键(功能下线后忘删):\n ${dead.join('\n ')}`).toEqual([]);
});
it('en-US 同样无死键(两 locale 键集合 parity 已有守卫,这里双保险)', () => {
const en = JSON.parse(readFileSync(join(LOCALES_DIR, 'en-US.json'), 'utf8')) as LocaleJson;
const dead = findDeadLocaleKeys(en, corpus);
expect(dead, `死键(功能下线后忘删):\n ${dead.join('\n ')}`).toEqual([]);
});
});

View File

@@ -0,0 +1,143 @@
// tests/unit/i18n/no-missing-keys.test.ts
//
// 守「**代码里用的 i18n 键必须真的存在**」这条契约(no-missing-keys)。
//
// ── 为什么值得守(与 no-dead-keys 是镜像的一对)────────────────────
// renderer 的 t() 是**字符串寻址**:写错一个字母,TS 不报错、eslint 不
// 报错、build 不报错 —— 只有真正渲染到那一处时,i18next 找不到 key,
// **把 key 本身当文案印出来**。用户看到的是 `notes.svaeSucess` 这种
// 点号串,而不是「保存成功」。
//
// 与已有守卫的分工:
// · locale-parity.test.ts → zh 集合 == en 集合(漏译)
// · no-dead-keys.test.ts → locale 里的键都被代码用(死键)
// · **本测** → 代码里用的键都在 locale 里(缺键)
// 主进程侧有同款守卫:tests/unit/main/menu-label-i18n-keys.test.ts
// (「调用点引用的 key 必须在 T 里」)。renderer 侧此前**没有**这面镜子:
// r206 只在主进程字典上补过,renderer 的 JSON 侧一直裸奔。
//
// ── 扫描口径(与 no-dead-keys 对齐,免得两套口径互相打脸)───────────
// 先剥注释(注释里提到 key 不算使用),再扫 src/renderer/**/*.ts(x):
// 1. 字面量:t('ns.key') / t("ns.key") / t(`ns.key`) —— 键必须存在;
// 2. 复数基:代码写 'notes.listCount',locale 里是
// 'notes.listCount_one' / '_other' ⇒ 按 i18next 的复数后缀解析,
// 基键 + 任意一个复数变体存在即算命中;
// 3. 前缀模板:t(`ns.${…}`) ⇒ 要求 ns.* 至少有一个键存在
// (settings.platform / startup.phase 走这条);
// 4. tWithFallback('ns', …) ⇒ 要求 ns.* 至少有一个键存在
// (ErrorFallback 的 errorBoundary.* / toast.* 走这条)。
// `_meta.*` 是结构字段(locale-parity 直接 JSON 读,不走 t()),不参与。
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const LOCALES_DIR = join(ROOT, 'src/renderer/src/i18n/locales');
const RENDERER_SRC = join(ROOT, 'src/renderer/src');
interface LocaleJson {
[k: string]: unknown;
}
function flatKeys(value: LocaleJson, prefix = ''): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...flatKeys(v as LocaleJson, key));
} else {
out.push(key);
}
}
return out;
}
/** 收集 src/renderer 全部 .ts/.tsx(排除 locales 目录本身)。 */
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (p.includes('locales')) continue;
out.push(...collectSourceFiles(p));
} else if (/\.(ts|tsx)$/.test(e.name)) {
out.push(p);
}
}
return out;
}
/** 剥掉注释 —— 注释里提到 key 不算使用。 */
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
}
const PLURAL_SUFFIXES = ['_zero', '_one', '_two', '_few', '_many', '_other'];
/** 从源码里抽出「用到的字面量键」。 */
function findUsedKeys(corpus: string): string[] {
const out = new Set<string>();
// t('ns.key') / t("ns.key") / t(`ns.key`)
const lit = /\bt\(\s*['"`]([A-Za-z0-9_.-]+)['"`]/g;
let m: RegExpExecArray | null;
while ((m = lit.exec(corpus)) !== null) out.add(m[1]!);
return [...out];
}
/** 从源码里抽出「用到的模板命名空间」(t(`ns.${…}`) / tWithFallback('ns', …))。 */
function findUsedNamespaces(corpus: string): string[] {
const out = new Set<string>();
const tpl = /\bt\(\s*['"`]([A-Za-z0-9_-]+)\.\$\{/g;
let m: RegExpExecArray | null;
while ((m = tpl.exec(corpus)) !== null) out.add(m[1]!);
const fb = /\btWithFallback\(\s*['"`]([A-Za-z0-9_-]+)['"`]/g;
while ((m = fb.exec(corpus)) !== null) out.add(m[1]!);
return [...out];
}
/**
* 键是否存在 —— 复数基也算命中(i18next 按 count 拼 _one/_other 后缀)。
*/
function keyExists(keys: Set<string>, key: string): boolean {
if (keys.has(key)) return true;
return PLURAL_SUFFIXES.some((s) => keys.has(key + s));
}
export function findMissingKeys(locale: LocaleJson, usedKeys: string[], usedNamespaces: string[]): string[] {
const keys = new Set(flatKeys(locale));
const missing: string[] = [];
for (const k of usedKeys) {
if (k.startsWith('_meta.')) continue;
if (!keyExists(keys, k)) missing.push(k);
}
for (const ns of usedNamespaces) {
const hasAny = [...keys].some((k) => k.startsWith(`${ns}.`));
if (!hasAny) missing.push(`${ns}.* (模板命名空间无任何键)`);
}
return missing.sort();
}
describe('renderer i18n 无缺键(用了但 locale 里没有 ⇒ 界面印出裸 key)', () => {
const zh = JSON.parse(readFileSync(join(LOCALES_DIR, 'zh-CN.json'), 'utf8')) as LocaleJson;
const en = JSON.parse(readFileSync(join(LOCALES_DIR, 'en-US.json'), 'utf8')) as LocaleJson;
const corpus = collectSourceFiles(RENDERER_SRC)
.map((f) => stripComments(readFileSync(f, 'utf8')))
.join('\n');
const usedKeys = findUsedKeys(corpus);
const usedNamespaces = findUsedNamespaces(corpus);
it('扫描器非空转:至少扫到若干调用点与命名空间(防空转守卫)', () => {
expect(usedKeys.length).toBeGreaterThan(50);
expect(usedNamespaces.length).toBeGreaterThan(0);
});
it('zh-CN:代码里用到的每个键都存在(缺键 = 界面印出 key 本身)', () => {
const missing = findMissingKeys(zh, usedKeys, usedNamespaces);
expect(missing, `zh-CN 缺键:\n ${missing.join('\n ')}`).toEqual([]);
});
it('en-US:代码里用到的每个键都存在(缺键 = 英文界面印出 key 本身)', () => {
const missing = findMissingKeys(en, usedKeys, usedNamespaces);
expect(missing, `en-US 缺键:\n ${missing.join('\n ')}`).toEqual([]);
});
});

View File

@@ -0,0 +1,376 @@
// tests/unit/i18n/placeholder-contract.test.ts
//
// 守卫(r206,2026-10-20):**i18n 调用点 ↔ locale 模板的占位符契约**。
//
// 为什么这条是安全面(不是风格):占位符对不上时 i18next **不报错、不抛异常**
// —— 它把填不上的 `{xxx}` 原样留在渲染结果里。用户看到的是
// 「导入失败:{count} 个文件导入失败:
// {msgs}」这种半成品文案(2026-10-17 r73 的真实现场:
// 调用点传 `{ msg }`,而 locale 模板要 `{count}` / `{msgs}` —— 两个都没填上)。
// 而 TypeScript **完全看不见**这件事:react-i18next 的 t() 第二个参数是
// `Record<string, unknown>`,传错 key 名不报错;locale JSON 是普通字符串,
// 编译期也不校验。⇒ 只有一条**行为级**契约能拦。
//
// 三条不变式:
// 1. 同一 key 的 zh-CN / en-US 两份模板占位符集合**完全一致**
// (漏翻译一侧的占位符 = 切语言后文案少一段);
// 2. 同一 key 的复数变体(base / _one / _other)占位符集合完全一致
// (i18next 按 count 选变体,变体之间占位符不同 = 某些数量下少占位符);
// 3. 每个 t('key', {vars}) 调用点,vars 集合**恰好等于**模板占位符集合
// (少传 ⇒ 裸占位符露出;多传 ⇒ 填不进去,通常是 key 写错)。
//
// 判定方式:静态扫 src/renderer/src/** 的 t() 调用点 + 读两份 locale JSON。
// 扫之前先剥注释(注释里的 t('x') 不是调用点)。
//
// 覆盖边界(诚实声明):
// - 只认**字面量** key 与**字面量** vars 对象;`t(someVar)`、`t(`a.${b}`)`、
// `t('k', opts)`(opts 是变量)一律跳过 —— 静态分析看不见它们的 vars。
// - 只覆盖 src/renderer/src/**;主进程那套 T 字典由
// tests/unit/main/menu-label-i18n-keys.test.ts 守(结构不同:扁平的
// key → {zh-CN,en-US},不是嵌套 JSON)。
import { readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = process.cwd();
const LOCALES_DIR = join(ROOT, 'src/renderer/src/i18n/locales');
const RENDERER_DIR = join(ROOT, 'src/renderer/src');
interface LocaleJson {
[key: string]: unknown;
}
function readLocale(name: string): LocaleJson {
return JSON.parse(readFileSync(join(LOCALES_DIR, `${name}.json`), 'utf8')) as LocaleJson;
}
function flatKeys(value: LocaleJson, prefix = ''): string[] {
const out: string[] = [];
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
out.push(...flatKeys(v as LocaleJson, key));
} else {
out.push(key);
}
}
return out;
}
function flatValues(value: LocaleJson, prefix = ''): Map<string, string> {
const out = new Map<string, string>();
for (const [k, v] of Object.entries(value)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
for (const [k2, v2] of flatValues(v as LocaleJson, key)) out.set(k2, v2);
} else {
out.set(key, String(v));
}
}
return out;
}
const PLACEHOLDER_RE = /\{([A-Za-z0-9_]+)\}/g;
function placeholdersOf(text: string): Set<string> {
const out = new Set<string>();
PLACEHOLDER_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PLACEHOLDER_RE.exec(text)) !== null) out.add(m[1]);
return out;
}
// === 源码扫描 ===
function walkTs(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...walkTs(full));
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) out.push(full);
}
return out;
}
/**
* 剥掉注释(保留行数,便于报行号)。
*
* 自己写而不是用正则,是因为正则分不清 `//` 是注释还是字符串里的
* 'https://...'。这里做一个极小的词法扫描:单/双引号字符串整体跳过,
* 模板串按整段跳过(内部 ${} 的转义不深究 —— 仓库里没有含反引号的
* 嵌套模板串)。
*/
function stripComments(src: string): string {
const out: string[] = [];
let i = 0;
const n = src.length;
let inLine = false;
let inBlock = false;
let quote: string | null = null;
while (i < n) {
const c = src[i];
const c2 = src[i + 1];
if (inLine) {
if (c === '\n') {
inLine = false;
out.push(c);
} else {
out.push(' ');
}
i++;
continue;
}
if (inBlock) {
if (c === '*' && c2 === '/') {
inBlock = false;
out.push(' ');
i += 2;
continue;
}
out.push(c === '\n' ? '\n' : ' ');
i++;
continue;
}
if (quote) {
out.push(c);
if (c === '\\') {
if (i + 1 < n) out.push(src[i + 1]);
i += 2;
continue;
}
if (c === quote) quote = null;
i++;
continue;
}
if (c === '/' && c2 === '/') {
inLine = true;
out.push(' ');
i += 2;
continue;
}
if (c === '/' && c2 === '*') {
inBlock = true;
out.push(' ');
i += 2;
continue;
}
if (c === "'" || c === '"' || c === '`') {
quote = c;
out.push(c);
i++;
continue;
}
out.push(c);
i++;
}
return out.join('');
}
/** 从 openIdx(必须是 '(' 的位置)起取出成对括号内的内容 */
function argsOf(src: string, openIdx: number): string {
let depth = 0;
for (let i = openIdx; i < src.length; i++) {
const c = src[i];
if (c === '(') depth++;
else if (c === ')') {
depth--;
if (depth === 0) return src.slice(openIdx + 1, i);
}
}
return src.slice(openIdx + 1);
}
/** 顶层逗号切分(跳过括号/引号内部) */
function splitTopLevel(args: string): string[] {
const parts: string[] = [];
let depth = 0;
let quote: string | null = null;
let cur = '';
for (let i = 0; i < args.length; i++) {
const c = args[i];
if (quote) {
cur += c;
if (c === '\\') {
cur += args[i + 1] ?? '';
i++;
continue;
}
if (c === quote) quote = null;
continue;
}
if (c === "'" || c === '"' || c === '`') {
quote = c;
cur += c;
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
if (c === ',' && depth === 0) {
parts.push(cur.trim());
cur = '';
continue;
}
cur += c;
}
if (cur.trim()) parts.push(cur.trim());
return parts;
}
/** 对象字面量的键集合 —— 同时认 `k: v` 与简写 `{ k }` */
function objectKeys(obj: string): string[] | null {
const body = obj.trim();
if (!body.startsWith('{') || !body.endsWith('}')) return null;
const inner = body.slice(1, -1).trim();
if (!inner) return [];
const keys: string[] = [];
for (const part of splitTopLevel(inner)) {
const m = /^([A-Za-z_$][A-Za-z0-9_$]*)\s*(:|$)/.exec(part);
if (!m) return null; // 计算属性 / 展开 → 放弃分析这一处
keys.push(m[1]);
}
return keys;
}
interface CallSite {
file: string;
line: number;
key: string;
/** null = 调用点没传 vars(第二实参不是对象字面量) */
vars: string[] | null;
/** 第二实参存在但不是对象字面量(比如字符串默认值 / 变量) */
varsUnknown: boolean;
}
function collectCallSites(): CallSite[] {
const out: CallSite[] = [];
// 独立的标识符 t( —— 前一个字符不能是标识符字符/点(排除 .t( / obj.t( )
const re = /(?<![A-Za-z0-9_$.])t\s*\(/g;
for (const file of walkTs(RENDERER_DIR)) {
const rel = file.slice(ROOT.length + 1).replaceAll('\\', '/');
const raw = readFileSync(file, 'utf8');
const src = stripComments(raw);
for (const m of src.matchAll(re)) {
const openIdx = m.index + m[0].length - 1;
const args = splitTopLevel(argsOf(src, openIdx));
if (args.length === 0) continue;
const keyMatch = /^'([A-Za-z0-9_.]+)'$/.exec(args[0]);
if (!keyMatch) continue; // 动态 key / 模板串 → 跳过
const key = keyMatch[1];
const line = src.slice(0, m.index).split('\n').length;
if (args.length === 1) {
out.push({ file: rel, line, key, vars: null, varsUnknown: false });
continue;
}
const second = args[1];
const keys = objectKeys(second);
if (keys) {
out.push({ file: rel, line, key, vars: keys, varsUnknown: false });
} else {
out.push({ file: rel, line, key, vars: null, varsUnknown: true });
}
}
}
return out;
}
/** 复数变体:i18next 按 count 选 base / _one / _other */
function variantsOf(key: string, locale: Map<string, string>): string[] {
return [key, `${key}_one`, `${key}_other`].filter((k) => locale.has(k));
}
describe('i18n 占位符契约(r206)', () => {
const zh = readLocale('zh-CN');
const en = readLocale('en-US');
const zhVals = flatValues(zh);
const enVals = flatValues(en);
const callSites = collectCallSites();
it('同一 key 的 zh-CN / en-US 占位符集合一致', () => {
const bad: string[] = [];
for (const key of flatKeys(zh)) {
const a = placeholdersOf(zhVals.get(key) ?? '');
const b = placeholdersOf(enVals.get(key) ?? '');
if (a.size !== b.size || [...a].some((x) => !b.has(x))) {
bad.push(` ${key}: zh=[${[...a].sort()}] en=[${[...b].sort()}]`);
}
}
expect(bad.join('\n')).toBe('');
});
it('同一 key 的复数变体(base / _one / _other)占位符集合一致', () => {
const bad: string[] = [];
const all = new Set([...zhVals.keys(), ...enVals.keys()]);
for (const key of all) {
// 只看「某个复数变体存在」的 key
if (!key.endsWith('_one') && !key.endsWith('_other')) continue;
const base = key.replace(/_(one|other)$/, '');
const group = [base, `${base}_one`, `${base}_other`].filter((k) => zhVals.has(k));
const sets = group.map((k) => placeholdersOf(zhVals.get(k) ?? ''));
const first = sets[0];
if (sets.some((s) => s.size !== first.size || [...s].some((x) => !first.has(x)))) {
bad.push(
` ${base}: ` +
group.map((k) => `${k}=[${[...placeholdersOf(zhVals.get(k) ?? '')].sort()}]`).join(' '),
);
}
}
expect(bad.join('\n')).toBe('');
});
it('每个 t() 调用点传的 vars 恰好等于模板占位符集合', () => {
const bad: string[] = [];
for (const cs of callSites) {
if (cs.varsUnknown) continue; // 第二实参不是对象字面量 → 看不见
const variants = variantsOf(cs.key, zhVals);
if (variants.length === 0) {
bad.push(` ${cs.file}:${cs.line} → t('${cs.key}') 的 key 在 locale 里不存在`);
continue;
}
const need = placeholdersOf(zhVals.get(variants[0]) ?? '');
const given = new Set(cs.vars ?? []);
const missing = [...need].filter((x) => !given.has(x));
const extra = [...given].filter((x) => !need.has(x));
if (missing.length || extra.length) {
bad.push(
` ${cs.file}:${cs.line} → t('${cs.key}') 传=[${[...given].sort()}] ` +
`需=[${[...need].sort()}]` +
(missing.length ? ` 少传:${missing.join(',')}` : '') +
(extra.length ? ` 多传:${extra.join(',')}` : ''),
);
}
}
expect(bad.join('\n')).toBe('');
});
it('扫描器本身有效:能认出「少传 / 多传 / key 不存在」三种违规', () => {
// 探针:用真实的调用点解析器验证判定逻辑本身没写反。
const fake = [
"const a = t('k.one', { name: 1 });",
"const b = t('k.two', { wrong: 1 });",
"const c = t('k.missing', { name: 1 });",
].join('\n');
const parsed = [...fake.matchAll(/(?<![A-Za-z0-9_$.])t\s*\(/g)].map((m) => {
const openIdx = m.index + m[0].length - 1;
const args = splitTopLevel(argsOf(fake, openIdx));
return { key: /^'([A-Za-z0-9_.]+)'$/.exec(args[0])![1], vars: objectKeys(args[1]) };
});
expect(parsed).toEqual([
{ key: 'k.one', vars: ['name'] },
{ key: 'k.two', vars: ['wrong'] },
{ key: 'k.missing', vars: ['name'] },
]);
// 简写对象也要认出来(i18next 支持 `{ count }` 这种写法)
expect(objectKeys('{ count }')).toEqual(['count']);
expect(objectKeys('{ count: n, msgs: m }')).toEqual(['count', 'msgs']);
});
it('扫描器确实扫到了调用点(空集 = 扫描器坏了,不是契约没问题)', () => {
// 探针:防止上面几条因为「一个调用点都没扫到」而空过。
expect(callSites.length).toBeGreaterThan(20);
// 已知调用点:NotesList 的导入失败 toast(r73 的历史 bug 点)
const hit = callSites.find(
(c) => c.key === 'toast.importNoteFailed' && c.vars?.includes('msgs'),
);
expect(hit).toBeDefined();
});
});