update:翻译备份

This commit is contained in:
2026-11-05 18:25:03 +08:00
parent 53ac4473f9
commit d1c689cfda
9 changed files with 3947 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
// 英文文案与业务边界检查:不调用网络、设备或支付接口。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const { parse, compileScript } = require('@vue/compiler-sfc');
const { baseParse } = require('@vue/compiler-dom');
const { createI18n } = require('vue-i18n');
const root = path.resolve(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const en = JSON.parse(read('src/locales/en.json'));
const zh = JSON.parse(read('src/locales/zh-CN.json'));
const sourceKeys = JSON.parse(read('src/locales/source-keys.json'));
const han = /[\p{Script=Han}]/u;
const manifest=JSON.parse(read('src/manifest.json'));
assert.equal(manifest['mp-weixin'].permission['scope.userLocation'].desc,en.wifi_location_permission);
// 微信上传会校验权限用途说明,英文也最多允许 30 个字符。
for (const [scope, permission] of Object.entries(manifest['mp-weixin'].permission || {})) {
assert.equal(typeof permission.desc, 'string', `${scope}: permission description must be a string`);
assert(permission.desc.length > 0 && permission.desc.length <= 30,
`${scope}: permission description must contain 1–30 characters (got ${permission.desc.length})`);
}
const params = value => [...value.matchAll(/\{(p\d+)\}/g)].map(m=>m[1]).sort();
assert.deepEqual(Object.keys(en).sort(), Object.keys(zh).sort());
const localeCode = read('src/locales/index.js').replace(/^import .*;\r?\n/gm, '').replace(/export /g, '');
const api = new Function('createI18n','en','sourceKeys', localeCode+'\nreturn {t,translateStatic,formatBattleTip};')(createI18n,en,sourceKeys);
// 使用当前 uni-app 实际打包的消息编译器复现问题,避免只测 Node 完整版。
const miniRuntimePath = path.join(path.dirname(require.resolve('@dcloudio/uni-cli-shared/package.json')),
'lib/vue-i18n/dist/vue-i18n.runtime.esm-bundler.js');
const miniRuntimeCode = fs.readFileSync(miniRuntimePath, 'utf8');
let miniCompilerCode;
traverse(parser.parse(miniRuntimeCode, {sourceType: 'module'}), {
ObjectProperty(p) {
if (p.node.key.name === 'messageCompiler' && p.node.value.type === 'FunctionExpression') {
miniCompilerCode = miniRuntimeCode.slice(p.node.value.start, p.node.value.end);
}
},
});
assert(miniCompilerCode, 'Cannot locate uni-app message compiler; review runtime compatibility test');
const miniCompiler = new Function('return (' + miniCompilerCode + ');')();
const createMiniI18n = ({locale, messages}) => ({global: {t: (key, args = {}) => {
const message = messages[locale][key];
return message === undefined ? key : miniCompiler(message)({
normalize: values => values.join(''),
interpolate: value => String(value),
named: name => args[name],
});
}}});
const miniApi = new Function('createI18n','en','sourceKeys', localeCode+'\nreturn {t,translateStatic,formatBattleTip};')(createMiniI18n,en,sourceKeys);
const referenceI18n = createI18n({legacy: false, locale: 'en', fallbackLocale: 'en', messages: {en}});
const parameterized = Object.entries(en).filter(([,value]) => params(value).length);
for (const [key,value] of parameterized) {
for (const example of [0, 7, -2, '', false, 'Bronze', '$& {p1}']) {
const args = Object.fromEntries(params(value).map(name => [name, example]));
const expected = referenceI18n.global.t(key, args);
assert.equal(api.t(key, args), expected, key + ': full runtime interpolation');
assert.equal(miniApi.t(key, args), expected, key + ': mini runtime interpolation');
}
}
assert.equal(miniApi.t('value_value_matches', {p0: 'Bronze', p1: 0}), 'Bronze, 0 matches');
assert.equal(miniApi.t('value_value_matches', {p0: 'Bronze', p1: 12}), 'Bronze, 12 matches');
assert.equal(miniApi.t('value_value_matches', {p0: '{p1}', p1: 12}), '{p1}, 12 matches');
assert.equal(miniApi.t('value_value_matches', {p0: '', p1: 0}), ', 0 matches');
assert.equal(miniApi.t('value_value_matches', {p0: null, p1: 0}), ', 0 matches');
assert.equal(miniApi.t('value_value_matches', {p0: 'Bronze'}), 'Bronze, {p1} matches');
assert.equal(miniApi.t('value_value_matches', Object.create({p0: 'Inherited', p1: 12})), en.value_value_matches);
assert.equal(miniApi.formatBattleTip('请开始射箭第三轮'), 'Start shooting round 3');
console.log(`Interpolation compatibility passed: ${parameterized.length} messages, full and uni-app mini compilers, zero/empty/boolean/literal-brace values.`);
// 构建后检查真实产物模块;只替换平台启动依赖,沿用上方实际小程序编译器。
if (process.argv.includes('--built')) {
const builtExports = {};
vm.runInNewContext(read('dist/build/mp-weixin/locales/index.js'), {
exports: builtExports,
require: id => {
assert(/common\/vendor\.js$/.test(id), 'Unexpected built locale dependency: ' + id);
return {createI18n: createMiniI18n};
},
}, {filename: 'dist/build/mp-weixin/locales/index.js'});
for (const [key,value] of parameterized) {
for (const example of [0, 12, '', false, 'Bronze', '$& {p1}']) {
const args = Object.fromEntries(params(value).map(name => [name, example]));
assert.equal(builtExports.t(key, args), referenceI18n.global.t(key, args), key + ': built module interpolation');
}
}
console.log(`Built WeChat locale module passed: ${parameterized.length} parameterized messages.`);
}
for (const [key,value] of Object.entries(en)) {
assert(!han.test(value), 'English contains Chinese: '+key);
assert.deepEqual(params(value), params(zh[key]), 'Placeholder mismatch: '+key);
const args=Object.fromEntries(params(value).map(name=>[name, name.toUpperCase()]));
const output=api.t(key,args);
for (const name of params(value)) assert(output.includes(args[name]),'Interpolation failed: '+key);
}
for (const [source,key] of Object.entries(sourceKeys)) assert.equal(zh[key],source);
assert.equal(api.formatBattleTip('请红队射箭你重回'),'Red team, shoot');
assert.equal(api.formatBattleTip('上半场:请先射6箭'),'First half: shoot 6 arrows first');
assert.equal(api.formatBattleTip('请开始射箭第三轮'),'Start shooting round 3');
assert.equal(api.translateStatic('An unknown server value'),'An unknown server value');
let vueCount=0, references=0;
const files=[];
function walk(dir) {
for (const item of fs.readdirSync(path.join(root,dir),{withFileTypes:true})) {
const file=dir+'/'+item.name;
if (item.isDirectory()) {if (!['static','locales'].includes(item.name)) walk(file);}
else if (/\.(vue|js)$/.test(file) && !/match\.min\.js/.test(file)) files.push(file);
}
}
walk('src');
function checkJs(code,file,expression=false) {
let ast;
try {ast=parser.parse(expression?'('+code+')':code,{sourceType:'module'});} catch(error) {if (expression) return;throw error;}
traverse(ast,{CallExpression(p) {
const n=p.node;
if (!['t','translate'].includes(n.callee.name) || n.arguments[0]?.type!=='StringLiteral') return;
const binding=p.scope.getBinding(n.callee.name);
if (!expression) assert(binding && binding.path.node.type==='ImportSpecifier' && /locales/.test(binding.path.parent.source.value),file+': translation function shadowed');
const key=n.arguments[0].value;
assert(Object.hasOwn(en,key),file+': missing key '+key);references++;
const supplied=n.arguments[1]?.properties?.map(prop=>prop.key.name)||[];
assert.deepEqual(supplied.sort(),params(en[key]),file+': wrong parameters for '+key);
}});
}
for (const file of files) {
const code=read(file);
if (!file.endsWith('.vue')) {checkJs(code,file);continue;}
const {descriptor,errors}=parse(code,{filename:file});
assert.equal(errors.length,0,file+': SFC parse failed');vueCount++;
if (descriptor.script || descriptor.scriptSetup) {
compileScript(descriptor,{id:file});
for (const b of [descriptor.script,descriptor.scriptSetup]) if (b) checkJs(b.content,file);
}
if (!descriptor.template) continue;
function visit(n) {
if (n.type===2) assert(!han.test(n.content),file+': untranslated template text '+n.content);
if (n.type===5) checkJs(n.content.content,file,true);
for (const prop of n.props||[]) {
if (prop.exp) checkJs(prop.exp.content,file,true);
if (prop.type===6 && prop.value && !/^(src|class|id|style|key|name|value|tips|end-audio-key|practice-end-audio-key)$/.test(prop.name)) assert(!han.test(prop.value.content),file+': untranslated attribute '+prop.name);
}
for (const c of n.children||[]) visit(c);
}
visit(baseParse(descriptor.template.content));
}
// 固件与比赛继续使用中文语音键,英文仅用于展示。
const utilCode=read('src/util.js').replace(/^import .*;\r?\n/gm,'').replace(/export /g,'');
const uni={getMenuButtonBoundingClientRect:()=>({top:20}),getAccountInfoSync:()=>({miniProgram:{envVersion:'release'}}),showToast:()=>{}};
const util=new Function('t','translateStatic','uni',utilCode+'\nreturn {getDirectionText,getShootValidation,getDistanceCheckAudioKey,getDistanceCheckText,getInvalidShotAudioKey,getInvalidShotText,isLimitError,canEenter};')(api.t,api.translateStatic,uni);
for (const [angle,expected] of [[0,'下'],[45,'左下'],[90,'左'],[135,'左上'],[180,'上'],[225,'右上'],[270,'右'],[315,'右下']]) assert.equal(util.getDirectionText(angle),expected);
for (const shot of [null,{}, {distance:0}, {distance:-1}, {distance:499,targetOk:true}, {distance:500,targetOk:true}, {distance:600,targetOk:false}]) {
assert(han.test(util.getInvalidShotAudioKey(shot)));
assert(han.test(util.getDistanceCheckAudioKey(shot)));
assert(!han.test(util.getInvalidShotText(shot)));
assert(!han.test(util.getDistanceCheckText(shot)));
}
assert.equal(util.getShootValidation({distance:499,targetOk:true}).distanceOk,false);
assert.equal(util.getShootValidation({distance:500,targetOk:true}).distanceOk,true);
assert(util.isLimitError({message:'今日次数已达上限'}));
assert(!util.isLimitError({message:'other error'}));
assert.equal(util.canEenter({}, {}, true),false);
assert.equal(util.canEenter({trio:1},{deviceId:'test'},true),true);
assert.equal(util.canEenter({trio:1},{deviceId:'test'},false),false);
assert.equal(util.canEenter({trio:0},{deviceId:'test'},true),false);
// 对照 Git 基线检查协议资源未改动,只检查当前变更涉及的源码文件。
const urls=code=>[...code.matchAll(/https?:\/\/[^\s"'`<>]+/g)].map(m=>m[0]).sort();
function scriptShapes(code,file) {
const blocks=file.endsWith('.vue') ? ((d)=>[d.script,d.scriptSetup].filter(Boolean).map(b=>b.content))(parse(code).descriptor) : [code];
return blocks.map(block=>{
const ast=parser.parse(block,{sourceType:'module'});
function clean(value) {
if (Array.isArray(value)) return value.map(clean).filter(v=>v!==undefined);
if (!value || typeof value!=='object') return value;
if (value.type==='ImportDeclaration' && /locales/.test(value.source.value)) return undefined;
if (file==='src/pages/training/index.vue' && value.type==='ObjectProperty' && value.key.name==='staticLabel') return undefined;
if (file==='src/canvas.js' && value.type==='CallExpression' && value.callee.name==='renderText') {
const key=value.arguments[1]?.arguments?.[0]?.value;
if (key==='today_s_check_in') { assert.equal(value.arguments[2].value,9); value.arguments[2].value=14; }
if (key==='total') { assert.equal(value.arguments[4].value,102); value.arguments[4].value=122; }
}
if (value.type==='ExpressionStatement' && value.expression.callee?.property?.name==='use' && value.expression.arguments?.[0]?.name==='i18n') return undefined;
if (value.type==='CallExpression') {
if (value.callee.name==='translateStatic') return clean(value.arguments[0]);
if (['t','translate'].includes(value.callee.name) && value.arguments[0]?.type==='StringLiteral') {
const source=zh[value.arguments[0].value];
assert.notEqual(source,undefined,file+': unknown source');
const matches=[...source.matchAll(/\{(p\d+)\}/g)];
if (!matches.length) return {type:'StringLiteral',value:source};
const argumentsMap=Object.fromEntries((value.arguments[1]?.properties||[]).map(p=>[p.key.name,p.value]));
let cursor=0;
const quasis=matches.map(m=>{const text=source.slice(cursor,m.index);cursor=m.index+m[0].length;return {type:'TemplateElement',value:{cooked:text},tail:false};});
quasis.push({type:'TemplateElement',value:{cooked:source.slice(cursor)},tail:true});
return clean({type:'TemplateLiteral',quasis,expressions:matches.map(m=>argumentsMap[m[1]])});
}
}
if (value.type==='TemplateLiteral' && !value.expressions.length) return {type:'StringLiteral',value:value.quasis[0].value.cooked};
const result={};
for (const [key,item] of Object.entries(value)) {
if (['start','end','loc','extra','leadingComments','innerComments','trailingComments','comments','tokens','errors','raw'].includes(key)) continue;
const next=clean(item);
if (next!==undefined) result[key]=next;
}
// 唯一调整的脚本标点位于测距展示文本,不参与判断。
if (result.type==='TemplateElement' && result.value.cooked===',') result.value.cooked='; ';
return result;
}
return clean(ast.program);
});
}
const baselinePath=process.argv.slice(2).find(arg => arg !== '--built');
if (baselinePath) {
const baseline=JSON.parse(fs.readFileSync(baselinePath,'utf8').replace(/^\uFEFF/,''));
for (const [file,original] of Object.entries(baseline)) {
assert.deepEqual(urls(read(file)),urls(original),file+': URL changed');
assert.deepEqual(scriptShapes(read(file),file),scriptShapes(original,file),file+': non-localization script change');
if (file==='src/audioManager.js') assert.equal(read(file).replace(/\r\n/g,'\n'),original.replace(/\r\n/g,'\n'),'Audio resources or keys changed');
}
console.log('Git baseline media URLs and audio resources verified.');
}
console.log(`Localization checks passed: ${Object.keys(en).length} entries, ${references} references, ${vueCount} Vue files; placeholders, static templates, audio keys, shooting validation and entry conditions verified.`);