/** * lang.js 单元测试($lang 解析器) * * 运行方式: * node --test tests/lang.test.js * * 测试覆盖(6 大维度): * 1. 基础解析: 命中、未命中、默认值兜底、生产环境/开发环境返回差异 * 2. 命名参数: {reason} / {count} / {unit} 多参数、占位符安全性 * 3. 位置参数: {0} / {1} 兼容老式调用 * 4. 嵌套命名空间: diy.loadFailTpl / wechatChannel.wechatMpOnly / common.tabBar.home * 5. 占位符替换: 多次替换、正则元字符、__proto__/constructor/prototype 过滤 * 6. 实际业务 key: loadFailTpl (com.js 下 diy)、tabBar.* 翻译对齐、common.buyNow vs common.submitSuccess * * 实现策略: * lang.js 是 Vue 插件,挂在 Vue.prototype 上,依赖 store / uni / Config, * 无法直接 import 跑测试。本测试按 util.test.js 的同款做法: * 把要测的纯函数 (applyLangParams + 简化版 lang() 解析流程) 从 lang.js * 源码里"剪切"出来放到本测试文件,与 lang.js 保持同步。 * 这样不依赖任何测试框架,任何 node 版本 (>=18) 都能跑通。 */ 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('path'); // ============================================================= // 被测源码区块(与 common/js/lang.js 保持同步) // ============================================================= /** 与 lang.js 中 FORBIDDEN_KEYS 一致 */ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); /** * @see lang.js#applyLangParams * 命名参数 + 位置参数 替换 */ function applyLangParams(value, args) { if (args.length === 0) return value; let result = String(value); const firstArg = args[0]; const isNamed = firstArg !== null && typeof firstArg === 'object' && !Array.isArray(firstArg); if (isNamed) { const map = firstArg; for (const key in map) { if (!Object.prototype.hasOwnProperty.call(map, key)) continue; if (FORBIDDEN_KEYS.has(key)) continue; const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); result = result.replace(new RegExp('\\{' + safeKey + '\\}', 'g'), String(map[key])); } } else { for (let i = 0; i < args.length; i++) { result = result.replace(new RegExp('\\{' + i + '\\}', 'g'), String(args[i])); } } return result; } /** * 简化版 lang() 解析流程(模拟 lang.js 主流程的"按 . 切分 + 逐层查找 + 默认值 + 占位符替换"核心) * * 与 lang.js 主流程差异: * - 不处理 route 缓存、_mergedLangCache、_fieldResultCache 等(那些是性能优化,不影响语义) * - 不处理 overrideCache(那是远端覆写机制,需要单独 mock) * - 不处理递归保护、isRemoteOnly 短路(那是运行时 hook) * - 但"按 . 切分 → 逐层查找 → 未命中走默认值 → 占位符替换" 这一核心路径与 lang.js 完全一致 * * @param {Object} mergedLang 合并后的语言包对象(顶层已是 common namespace) * @param {string} field 字段路径,如 'diy.loadFailTpl' * @param {...any} args 可选参数(命名参数或位置参数) * @returns {string} */ function fakeLang(mergedLang, field, ...args) { const hasDefault = args.length > 0 && typeof args[0] === 'string' && args[0] !== ''; const arr = field.split('.'); let value; if (arr.length > 1) { let temp = mergedLang; let found = true; for (const key of arr) { if (temp && typeof temp === 'object' && temp[key] !== undefined) { temp = temp[key]; } else { found = false; break; } } value = found && typeof temp === 'string' ? temp : field; } else { const raw = mergedLang[field]; value = raw !== undefined && typeof raw === 'string' ? raw : field; } // 未命中 → 默认值兜底 if (value === field) { if (hasDefault) return args[0]; return ''; // 生产环境返回空串(对齐 lang.js 的 __DEV__ === false 分支) } // 占位符替换 if (args.length > 0) { value = applyLangParams(value, args); } return value; } /** * 直接 require 实际语言包,确保我们测的就是项目里那份 zh-cn / en-us */ const zh = require(path.resolve(__dirname, '..', 'lang', 'zh-cn', 'common.js')).lang; const en = require(path.resolve(__dirname, '..', 'lang', 'en-us', 'common.js')).lang; // ============================================================= // 1. 基础解析:命中 / 未命中 / 默认值 // ============================================================= test('基础解析: 命中已有 key 返回翻译结果', () => { assert.strictEqual( fakeLang(zh, 'diy.retry'), '重试' ); assert.strictEqual( fakeLang(en, 'diy.retry'), 'Retry' ); }); test('基础解析: 中英对照 一致性', () => { const pairs = [ ['diy.retry', '重试', 'Retry'], ['diy.noDiyData', '未配置自定义页面数据', 'Custom page data not configured'], ['customer.weChatKefu', '微信客服', 'WeChat Customer Service'], ]; for (const [key, zhExpected, enExpected] of pairs) { assert.strictEqual(fakeLang(zh, key), zhExpected, `zh ${key}`); assert.strictEqual(fakeLang(en, key), enExpected, `en ${key}`); } }); test('基础解析: 未命中且无默认值 → 生产环境返回空串', () => { assert.strictEqual( fakeLang(zh, 'totally.fake.missing.key'), '', '未命中且无默认值时,生产环境(本测试视角)返回空串,避免展示 raw key' ); }); test('基础解析: 未命中但有默认值 → 返回默认值', () => { assert.strictEqual( fakeLang(zh, 'totally.fake.missing.key', '兜底文案'), '兜底文案' ); assert.strictEqual( fakeLang(en, 'totally.fake.missing.key', 'Fallback Text'), 'Fallback Text' ); }); test('基础解析: 默认值是空串时被忽略(对齐 lang.js hasDefault 判断)', () => { // lang.js: hasDefault = typeof args[1] === 'string' && args[1] !== '' // args[0] 是 string '' 时不算默认值 assert.strictEqual( fakeLang(zh, 'totally.fake.missing.key', ''), '' ); }); test('基础解析: 默认值必须是字符串,非字符串不走默认值兜底', () => { // args[0] = { foo: 1 } 是命名参数,不走默认值路径 // 这里用一个不存在 key 验证:返回空串(因为命名参数替换失败) assert.strictEqual( fakeLang(zh, 'totally.fake.missing.key', { foo: 'bar' }), '' ); }); test('基础解析: 中间路径上某节点非对象(命中 leaf 是 number/boolean)', () => { // 模拟 leaf 不是 string 的场景 const stub = { foo: { bar: 123 } }; // lang.js 实际行为:中间节点上 leaf 不是 string 时,value 设为 field, // 但因为没有默认值兜底且后续判定 value === field → 返回空串 assert.strictEqual( fakeLang(stub, 'foo.bar'), '', '非 string 类型 leaf 当成未命中,且无默认值 → 返回空串' ); // 给默认值时返回默认值 assert.strictEqual( fakeLang(stub, 'foo.bar', 'fallback'), 'fallback' ); }); // ============================================================= // 2. 命名参数:本次修复的核心场景 // ============================================================= test('命名参数: diy.loadFailTpl + { reason } 是核心场景', () => { // 注意:zh-cn 实际文案是中文标点「:」,不是英文「:」 assert.strictEqual( fakeLang(zh, 'diy.loadFailTpl', { reason: '网络连接超时' }), '页面数据加载失败:网络连接超时,请稍后重试' ); assert.strictEqual( fakeLang(en, 'diy.loadFailTpl', { reason: 'Network timeout' }), 'Failed to load page data: Network timeout, please try again later' ); }); test('命名参数: 多参数同时替换', () => { // goods.detail.purchaseLimit = '该商品每人限购{count}{unit}' const zhGoods = require(path.resolve(__dirname, '..', 'lang', 'zh-cn', 'goods', 'detail.js')).lang; const enGoods = require(path.resolve(__dirname, '..', 'lang', 'en-us', 'goods', 'detail.js')).lang; assert.strictEqual( fakeLang(zhGoods, 'purchaseLimit', { count: 5, unit: '件' }), '该商品每人限购5件' ); assert.strictEqual( fakeLang(enGoods, 'purchaseLimit', { count: 5, unit: 'pcs' }), 'Limit 5 pcs per person', 'en-us 文案是 "Limit {count} {unit} per person",顺序替换 {count}=5 / {unit}=pcs' ); // 多参数 + 多占位符 替换 assert.strictEqual( fakeLang(zhGoods, 'purchaseLimitWithPurchased', { count: 5, unit: '件', purchased: 2 }), '该商品每人限购5件,您已购买了2件' ); }); test('命名参数: 多次出现的占位符全部替换', () => { const stub = { msg: '欢迎{user},{user} 你好' }; assert.strictEqual( fakeLang(stub, 'msg', { user: '张三' }), '欢迎张三,张三 你好' ); }); test('命名参数: 占位符的值含正则元字符不会注入', () => { const stub = { msg: '下载 {url} 完成' }; assert.strictEqual( fakeLang(stub, 'msg', { url: 'a.b+c*d(e)f' }), '下载 a.b+c*d(e)f 完成' ); assert.strictEqual( fakeLang(stub, 'msg', { url: '$1\\2{3}' }), '下载 $1\\2{3} 完成' ); }); test('命名参数: 占位符的值含 .*+?^${}()|[]\\ 等全部正确转义', () => { const stub = { msg: '前缀 {x} 后缀' }; const r1 = fakeLang(stub, 'msg', { x: '$1' }); assert.strictEqual(r1, '前缀 $1 后缀', 'value 端的 $1 不应被吃成反向引用'); const r2 = fakeLang(stub, 'msg', { x: '\\n' }); assert.strictEqual(r2, '前缀 \\n 后缀'); }); test('命名参数: 危险 key __proto__/constructor/prototype 被过滤', () => { const stub = { msg: 'hello {__proto__} {constructor} {prototype} world' }; const out = fakeLang(stub, 'msg', { __proto__: 'A', constructor: 'B', prototype: 'C', safe: 'OK' }); assert.strictEqual(out, 'hello {__proto__} {constructor} {prototype} world'); }); test('命名参数: 没有占位符的纯文案 + 命名参数(多余参数被忽略)', () => { const stub = { msg: '你好' }; assert.strictEqual( fakeLang(stub, 'msg', { foo: 'bar' }), '你好' ); }); test('命名参数: 对象参数里没提供的占位符 → 字面残留', () => { const stub = { msg: '你好 {name},你 {count} 岁' }; assert.strictEqual( fakeLang(stub, 'msg', { name: 'Tom' }), '你好 Tom,你 {count} 岁' ); }); // ============================================================= // 3. 位置参数: {0} / {1} 老式调用兼容 // ============================================================= test('位置参数: {0} 形式', () => { const stub = { msg: '欢迎第 {0} 位用户' }; assert.strictEqual( fakeLang(stub, 'msg', '张三'), '欢迎第 张三 位用户' ); }); test('位置参数: 多位置参数 {0}/{1}/{2}', () => { const stub = { msg: '{0} 在 {1} 出生,今年 {2} 岁' }; assert.strictEqual( fakeLang(stub, 'msg', 'Alice', 'Beijing', 18), 'Alice 在 Beijing 出生,今年 18 岁' ); }); test('位置参数: 数组会被识别为位置参数而非命名参数', () => { // args[0] = ['a', 'b'] 是 array → 走位置参数分支 // 但 args.length === 1(只传了一个数组),所以只有 {0} 被替换,{1} 残留 const stub = { msg: '{0} {1}' }; assert.strictEqual( fakeLang(stub, 'msg', ['a', 'b']), 'a,b {1}' ); }); test('位置参数: null 走位置参数分支,被 String() 转为 "null"', () => { const stub = { msg: '{0}' }; // args[0] === null: applyLangParams 中 firstArg !== null 判定为 false, // 走位置参数分支, String(null) = 'null',替换 {0} assert.strictEqual( fakeLang(stub, 'msg', null), 'null' ); }); // ============================================================= // 4. 嵌套命名空间: 多层 . 路径 // ============================================================= test('嵌套命名空间: order.payment.title (3 层)', () => { const orderZh = require(path.resolve(__dirname, '..', 'lang', 'zh-cn', 'order', 'payment.js')).lang; const orderEn = require(path.resolve(__dirname, '..', 'lang', 'en-us', 'order', 'payment.js')).lang; // 注意:页面语言包整体就是 order.payment namespace, // 所以 $lang('title') 也能命中(归一化逻辑),但显式路径是 $lang('order.payment.title') assert.strictEqual(fakeLang(orderZh, 'title'), '确认订单'); assert.strictEqual(fakeLang(orderEn, 'title'), 'Confirm Order'); }); test('嵌套命名空间: wechatChannel.wechatMpOnly (本次新增)', () => { assert.strictEqual( fakeLang(zh, 'wechatChannel.wechatMpOnly'), '视频号仅支持在微信小程序环境中播放' ); assert.strictEqual( fakeLang(en, 'wechatChannel.wechatMpOnly'), 'WeChat Channel only plays in WeChat Mini Program' ); }); test('嵌套命名空间: common.tabBar.* (本次修正)', () => { assert.strictEqual(fakeLang(zh, 'tabBar.home'), '首页'); assert.strictEqual(fakeLang(zh, 'tabBar.category'), '分类'); assert.strictEqual(fakeLang(zh, 'tabBar.cart'), '购物车'); assert.strictEqual(fakeLang(zh, 'tabBar.member'), '个人中心'); // en-us 顶层 tabBar(本次修复后) assert.strictEqual(fakeLang(en, 'tabBar.home'), 'Home'); assert.strictEqual(fakeLang(en, 'tabBar.category'), 'Category'); assert.strictEqual(fakeLang(en, 'tabBar.cart'), 'Cart'); assert.strictEqual(fakeLang(en, 'tabBar.member'), 'Me'); }); test('嵌套命名空间: common.tabBar.* 中英文一致性(本次修复的核心断言)', () => { const tabs = ['home', 'category', 'cart', 'member']; for (const tab of tabs) { const zhVal = fakeLang(zh, `tabBar.${tab}`); const enVal = fakeLang(en, `tabBar.${tab}`); assert.notStrictEqual(enVal, zhVal, `en-us tabBar.${tab} 不能等于 zh-cn 值`); assert.ok(enVal.length > 0, `en-us tabBar.${tab} 必须有翻译`); // 英文 tab 应是 Title Case,而不是全小写英文单词 assert.ok( /^[A-Z]/.test(enVal) || enVal === 'Me', `en-us tabBar.${tab}="${enVal}" 应以大写字母开头(或等于 'Me')` ); } }); test('嵌套命名空间: common 顶层键 vs common.common 子对象双层结构', () => { // 历史遗留:common.js 的 lang 对象整体即 common namespace, // 但对象内部又有一个 'common: {...}' 子对象 assert.strictEqual(fakeLang(zh, 'buyNow'), '立即购买'); assert.strictEqual(fakeLang(zh, 'common.goodsRecommendTitle'), '猜你喜欢'); assert.strictEqual(fakeLang(en, 'buyNow'), 'Buy Now'); assert.strictEqual(fakeLang(en, 'common.goodsRecommendTitle'), 'Guess you like'); }); test('嵌套命名空间: 双重 common. 误用回归保护(扫所有顶层 key)', () => { // 防御:common.js 的 lang 对象整体即 common namespace, // 顶层 key 不应再带 'common.' 前缀,否则会找不到 // 这个测试是 2026-07-23 真实 console 报错后追加的回归保护 // // 策略:自动枚举 zh/en 顶层所有 key, // 对每个 key,验证 fakeLang(zh, 'common.' + key) === '' // —— 这样不管以后谁写出 common.,测试都会自动 fail // 收集两包顶层 key 的并集(只要 zh 或 en 一方有就检查) const topKeys = new Set(); for (const k of Object.keys(zh)) { if (typeof zh[k] === 'string') topKeys.add(k); } for (const k of Object.keys(en)) { if (typeof en[k] === 'string') topKeys.add(k); } const mistakes = []; for (const k of topKeys) { // 注意:跳过常见会被合法命中的 key(common 里的合法子对象) // 这些 key 在 zh.common / en.common 子对象里也存在(双 common 合法) if (zh.common && typeof zh.common[k] === 'string') continue; if (en.common && typeof en.common[k] === 'string') continue; const zhHit = fakeLang(zh, 'common.' + k); const enHit = fakeLang(en, 'common.' + k); if (zhHit !== '' || enHit !== '') { mistakes.push({ k, zhHit, enHit }); } } if (mistakes.length > 0) { console.error('以下 key 在顶层,但用 common. 调用居然命中了:'); for (const m of mistakes) { console.error(` common.${m.k} → zh="${m.zhHit}" en="${m.enHit}"`); } } assert.strictEqual(mistakes.length, 0, '不应有任何 common. 调用能命中顶层 key(应该是空串)'); }); test('嵌套命名空间: 双重 common.buyNow 是不存在的(防止误用回归)', () => { // 历史 bug:common.buyNow 被当作 fallback,实际 key 是顶层 buyNow assert.strictEqual( fakeLang(zh, 'common.buyNow'), '', 'common.buyNow 双前缀不存在,真实调用必须用 $lang("buyNow")' ); assert.strictEqual( fakeLang(en, 'common.buyNow'), '', 'en-us 同理,common.buyNow 双前缀不存在' ); // 同时验证顶层 buyNow 存在 assert.strictEqual(fakeLang(zh, 'buyNow'), '立即购买'); assert.strictEqual(fakeLang(en, 'buyNow'), 'Buy Now'); }); test('嵌套命名空间: 4 层路径 diy.networkErrorTitle', () => { assert.strictEqual(fakeLang(zh, 'diy.networkErrorTitle'), '提示'); assert.strictEqual(fakeLang(en, 'diy.networkErrorTitle'), 'Notice'); }); // ============================================================= // 5. 占位符替换细节 // ============================================================= test('占位符替换: 占位符的值含数字时,String() 强制转换', () => { const stub = { msg: '价格 {price} 元' }; assert.strictEqual( fakeLang(stub, 'msg', { price: 99 }), '价格 99 元' ); assert.strictEqual( fakeLang(stub, 'msg', { price: 99.5 }), '价格 99.5 元' ); }); test('占位符替换: 占位符的值为 undefined 时,String(undefined) = "undefined"', () => { const stub = { msg: 'name={name}' }; assert.strictEqual( fakeLang(stub, 'msg', { name: undefined }), 'name=undefined' ); }); test('占位符替换: 占位符的值为 0/false 时,也会被替换(因为走 String 强制转换)', () => { const stub = { msg: '数量 {n},启用 {on}' }; assert.strictEqual( fakeLang(stub, 'msg', { n: 0, on: false }), '数量 0,启用 false' ); }); test('占位符替换: 占位符的值为空字符串', () => { const stub = { msg: '前 [{x}] 后' }; assert.strictEqual( fakeLang(stub, 'msg', { x: '' }), '前 [] 后' ); }); // ============================================================= // 6. 真实业务 key 回归测试 // ============================================================= test('回归: 本次修复新增的 8 个 key 在两包中都有', () => { const newKeys = [ 'diy.networkError', 'wechatChannel.wechatMpOnly', 'submitSuccess', 'submitFail', 'versionInfo', 'commitId', 'buildTime', 'loading', ]; for (const key of newKeys) { const zhVal = fakeLang(zh, key); const enVal = fakeLang(en, key); assert.ok(typeof zhVal === 'string' && zhVal.length > 0, `zh ${key}="${zhVal}"`); assert.ok(typeof enVal === 'string' && enVal.length > 0, `en ${key}="${enVal}"`); assert.notStrictEqual(zhVal, enVal, `${key} 中英文必须不同`); } }); test('回归: 核心占位符场景 diy.loadFailTpl 不传参数会字面残留占位符', () => { // 已知行为:reason 必填,如果不传,确实会字面残留(对齐 lang.js 行为) // 实际 zh-cn 文案是中文标点「:」 const stub1 = { ...zh }; const out1 = fakeLang(stub1, 'diy.loadFailTpl'); // lang.js: value === field? 否(命中了 diy.loadFailTpl 的 leaf)→ 走占位符替换 // applyLangParams 的 args.length === 0 → 直接返回 value assert.strictEqual( out1, '页面数据加载失败:{reason},请稍后重试', '不传参数会字面残留占位符,这是已知行为' ); // 但传了 reason 必须正确替换 assert.strictEqual( fakeLang(zh, 'diy.loadFailTpl', { reason: 'X' }), '页面数据加载失败:X,请稍后重试' ); }); test('回归: zh-cn/en-us common.js 同 key 同结构(key 一致性)', () => { const zhKeys = Object.keys(zh).sort(); const enKeys = Object.keys(en).sort(); assert.deepStrictEqual( zhKeys, enKeys, 'zh-cn 与 en-us common.js 顶层 key 必须完全一致(否则会有 missing_key 风险)' ); }); test('回归: 抽 zh/en 内 common 子对象 key 一致性', () => { const zhCommon = Object.keys(zh.common || {}).sort(); const enCommon = Object.keys(en.common || {}).sort(); assert.deepStrictEqual(zhCommon, enCommon, 'zh/en common 子对象 key 一致'); });