// 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`,传错 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 { const out = new Map(); 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 { const out = new Set(); 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 = /(?): 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(/(? { 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(); }); });