553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
/**
|
||
* 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 一致');
|
||
}); |