Files
lucky_shop/tests/lang.test.js
ZF sun 70a01bfd2b fix: 修复多页面国际化文案调用路径错误
将多处误用的 common.前缀国际化调用改为顶层路径,同时新增语言调用错误的回归测试用例,防止后续再次出现类似的命名空间误用问题
2026-07-23 10:37:22 +08:00

553 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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> 误用回归保护(扫所有顶层 key)', () => {
// 防御:common.js 的 lang 对象整体即 common namespace,
// 顶层 key 不应再带 'common.' 前缀,否则会找不到
// 这个测试是 2026-07-23 真实 console 报错后追加的回归保护
//
// 策略:自动枚举 zh/en 顶层所有 key,
// 对每个 key,验证 fakeLang(zh, 'common.' + key) === ''
// —— 这样不管以后谁写出 common.<topLevelKey>,测试都会自动 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.<key> 调用居然命中了:');
for (const m of mistakes) {
console.error(` common.${m.k} → zh="${m.zhHit}" en="${m.enHit}"`);
}
}
assert.strictEqual(mistakes.length, 0,
'不应有任何 common.<key> 调用能命中顶层 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 一致');
});