// 英文文案与业务边界检查:不调用网络、设备或支付接口。 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.`);