445 lines
18 KiB
JavaScript
445 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
const root = path.resolve(__dirname, '..');
|
|
const sourceRoot = path.join(root, 'prototype-src');
|
|
const generalHtml = fs.readFileSync(path.join(sourceRoot, 'modules', '10-general.html'), 'utf8');
|
|
const shellHtml = fs.readFileSync(path.join(sourceRoot, 'shell.html'), 'utf8');
|
|
const overlaysHtml = fs.readFileSync(path.join(sourceRoot, 'overlays.html'), 'utf8');
|
|
const appJs = fs.readFileSync(path.join(sourceRoot, 'app.js'), 'utf8');
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifest.json'), 'utf8'));
|
|
|
|
function maskNonCode(source) {
|
|
let output = '';
|
|
let state = 'code';
|
|
let escaped = false;
|
|
const regexCanStartAfter = /[([{,:;=!?&|+\-*%^~<>]/;
|
|
const controlKeywords = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);
|
|
const parenthesisStack = [];
|
|
let previousSignificant = '';
|
|
let previousWord = '';
|
|
let regexAllowedAfterControlParen = false;
|
|
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
const char = source[index];
|
|
const next = source[index + 1];
|
|
|
|
if (state === 'line-comment') {
|
|
output += char === '\n' || char === '\r' ? char : ' ';
|
|
if (char === '\n' || char === '\r') state = 'code';
|
|
continue;
|
|
}
|
|
|
|
if (state === 'block-comment') {
|
|
output += char === '\n' || char === '\r' ? char : ' ';
|
|
if (char === '*' && next === '/') {
|
|
output += ' ';
|
|
index += 1;
|
|
state = 'code';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (state === 'regex' || state === 'regex-class') {
|
|
output += char === '\n' || char === '\r' ? char : ' ';
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === '\\') {
|
|
escaped = true;
|
|
} else if (state === 'regex' && char === '[') {
|
|
state = 'regex-class';
|
|
} else if (state === 'regex-class' && char === ']') {
|
|
state = 'regex';
|
|
} else if (state === 'regex' && char === '/') {
|
|
state = 'code';
|
|
previousSignificant = '/';
|
|
previousWord = '';
|
|
regexAllowedAfterControlParen = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (state !== 'code') {
|
|
output += char === '\n' || char === '\r' ? char : ' ';
|
|
if (escaped) {
|
|
escaped = false;
|
|
} else if (char === '\\') {
|
|
escaped = true;
|
|
} else if (
|
|
(state === 'single-quote' && char === "'")
|
|
|| (state === 'double-quote' && char === '"')
|
|
|| (state === 'template' && char === '`')
|
|
) {
|
|
state = 'code';
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (char === '/' && next === '/') {
|
|
output += ' ';
|
|
index += 1;
|
|
state = 'line-comment';
|
|
} else if (char === '/' && next === '*') {
|
|
output += ' ';
|
|
index += 1;
|
|
state = 'block-comment';
|
|
} else if (char === "'") {
|
|
output += ' ';
|
|
state = 'single-quote';
|
|
} else if (char === '"') {
|
|
output += ' ';
|
|
state = 'double-quote';
|
|
} else if (char === '`') {
|
|
output += ' ';
|
|
state = 'template';
|
|
} else if (char === '(') {
|
|
output += char;
|
|
parenthesisStack.push(controlKeywords.has(previousWord));
|
|
previousSignificant = char;
|
|
previousWord = '';
|
|
regexAllowedAfterControlParen = false;
|
|
} else if (char === ')') {
|
|
output += char;
|
|
regexAllowedAfterControlParen = parenthesisStack.pop() === true;
|
|
previousSignificant = char;
|
|
previousWord = '';
|
|
} else if (char === '/') {
|
|
const startsRegex = !previousSignificant
|
|
|| regexCanStartAfter.test(previousSignificant)
|
|
|| /^(return|case|throw|typeof|void|delete|yield|await)$/.test(previousWord)
|
|
|| regexAllowedAfterControlParen;
|
|
output += startsRegex ? ' ' : char;
|
|
if (startsRegex) state = 'regex';
|
|
else {
|
|
previousSignificant = char;
|
|
previousWord = '';
|
|
}
|
|
regexAllowedAfterControlParen = false;
|
|
} else {
|
|
output += char;
|
|
if (!/\s/.test(char)) {
|
|
previousSignificant = char;
|
|
previousWord = /[A-Za-z0-9_$]/.test(char) ? `${previousWord}${char}` : '';
|
|
regexAllowedAfterControlParen = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function extractFunction(name, source) {
|
|
const code = maskNonCode(source);
|
|
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const declaration = new RegExp(`function\\s+${escapedName}\\s*\\(`).exec(code);
|
|
if (!declaration) return '';
|
|
|
|
const bodyStart = code.indexOf('{', declaration.index + declaration[0].length);
|
|
if (bodyStart < 0) return '';
|
|
|
|
let depth = 0;
|
|
for (let index = bodyStart; index < code.length; index += 1) {
|
|
if (code[index] === '{') depth += 1;
|
|
if (code[index] === '}') {
|
|
depth -= 1;
|
|
if (depth === 0) return source.slice(declaration.index, index + 1);
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function extractPage(source, pageId) {
|
|
const opening = new RegExp(`<div\\b[^>]*\\bid=["']page-${pageId}["'][^>]*>`, 'i').exec(source);
|
|
if (!opening) return '';
|
|
const rest = source.slice(opening.index + opening[0].length);
|
|
const nextPage = /<div\b[^>]*\bclass=["'][^"']*\bpage\b[^"']*["'][^>]*\bid=["']page-/i.exec(rest);
|
|
return source.slice(opening.index, nextPage ? opening.index + opening[0].length + nextPage.index : source.length);
|
|
}
|
|
|
|
function extractDivById(source, id) {
|
|
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const opening = new RegExp(`<div\\b[^>]*\\bid=["']${escapedId}["'][^>]*>`, 'i').exec(source);
|
|
if (!opening) return '';
|
|
|
|
const divTags = /<\/?div\b[^>]*>/gi;
|
|
divTags.lastIndex = opening.index;
|
|
let depth = 0;
|
|
let tag;
|
|
while ((tag = divTags.exec(source))) {
|
|
depth += /^<\/div\b/i.test(tag[0]) ? -1 : 1;
|
|
if (depth === 0) return source.slice(opening.index, divTags.lastIndex);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function openingTagById(source, id) {
|
|
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
return (new RegExp(`<[^>]+\\bid=["']${escapedId}["'][^>]*>`, 'i').exec(source) || [''])[0];
|
|
}
|
|
|
|
function textContent(fragment) {
|
|
return fragment.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
const extractorProbe = [
|
|
'// function ignoredLine() {}',
|
|
'/* function ignoredBlock() {} */',
|
|
'const ignoredString = "function ignoredString() {}";',
|
|
'const ignoredTemplate = `function ignoredTemplate() {}`;',
|
|
'if (ready) /function ignoredRegex() {}/.test(value);',
|
|
'function liveProbe() {',
|
|
' const nested = {outer: {inner: true}};',
|
|
' const markup = `<div data-value="${JSON.stringify({closing: "}"})}">{text}</div>`;',
|
|
' return {nested, markup};',
|
|
'}',
|
|
'function afterProbe() { return true; }',
|
|
].join('\n');
|
|
assert.equal(extractFunction('ignoredLine', extractorProbe), '', '函数提取器误识别行注释');
|
|
assert.equal(extractFunction('ignoredBlock', extractorProbe), '', '函数提取器误识别块注释');
|
|
assert.equal(extractFunction('ignoredString', extractorProbe), '', '函数提取器误识别字符串内容');
|
|
assert.equal(extractFunction('ignoredTemplate', extractorProbe), '', '函数提取器误识别模板字符串内容');
|
|
assert.equal(extractFunction('ignoredRegex', extractorProbe), '', '函数提取器误识别正则内容');
|
|
assert.match(extractFunction('liveProbe', extractorProbe), /return \{nested, markup\};/, '函数提取器被对象或模板字符串截断');
|
|
assert.match(extractFunction('afterProbe', extractorProbe), /return true;/, '函数提取器未能继续定位后续函数');
|
|
|
|
const generalModule = manifest.modules.find((item) => item.file === 'modules/10-general.html');
|
|
assert.ok(generalModule && generalModule.pages.includes('gen-exchange-rate'), '通用模块未登记汇率管理页');
|
|
assert.equal(
|
|
manifest.pageOrder.filter((page) => page === 'gen-exchange-rate').length,
|
|
1,
|
|
'pageOrder 应唯一登记 gen-exchange-rate',
|
|
);
|
|
|
|
assert.match(
|
|
shellHtml,
|
|
/<div\b[^>]*\bclass=["'][^"']*\bsub-menu-item\b[^"']*["'][^>]*\bonclick=["']switchPage\('gen-exchange-rate',this\)["'][^>]*>\s*汇率管理\s*<\/div>/,
|
|
'通用导航缺少“汇率管理”入口',
|
|
);
|
|
|
|
const exchangeRatePage = extractPage(generalHtml, 'gen-exchange-rate');
|
|
assert.ok(exchangeRatePage, '通用模块缺少 page-gen-exchange-rate');
|
|
const monthFilterTag = openingTagById(exchangeRatePage, 'exchange-rate-month-filter');
|
|
assert.ok(monthFilterTag, '汇率管理页缺少月份筛选 exchange-rate-month-filter');
|
|
assert.match(monthFilterTag, /\btype=["']month["']/, '汇率查询必须使用原生 month 控件');
|
|
assert.match(monthFilterTag, /\baria-label=["']查询月份["']/, '月份筛选缺少明确可访问名称');
|
|
assert.doesNotMatch(exchangeRatePage, /\bid=["']exchange-rate-year["']/, '不应保留旧的年份筛选控件');
|
|
assert.match(exchangeRatePage, /\bid=["']tbody-exchange-rate["']/, '汇率管理页缺少表体 tbody-exchange-rate');
|
|
assert.match(exchangeRatePage, /class=["'][^"']*exchange-rate-summary/, '汇率概览缺少独立响应式网格类');
|
|
assert.doesNotMatch(exchangeRatePage, /class=["'][^"']*exchange-rate-summary[^>]*style=["'][^"']*grid-template-columns/, '汇率概览不应以内联列数覆盖响应式规则');
|
|
assert.match(shellHtml, /#page-gen-exchange-rate \.exchange-rate-summary\{grid-template-columns:repeat\(4,minmax\(0,1fr\)\)\}/, '桌面端汇率概览应使用四列');
|
|
assert.match(shellHtml, /max-width:1200px[^}]*#page-gen-exchange-rate \.exchange-rate-summary\{grid-template-columns:repeat\(2,minmax\(0,1fr\)\)\}/, '中等视口汇率概览应降为两列');
|
|
assert.match(shellHtml, /max-width:640px[^}]*#page-gen-exchange-rate \.exchange-rate-summary\{grid-template-columns:1fr\}/, '移动端汇率概览应降为单列');
|
|
assert.match(shellHtml, /#page-gen-exchange-rate \.exchange-rate-summary \.value\{[^}]*font-size:20px[^}]*overflow-wrap:anywhere/, '汇率概览长文本应使用紧凑字号并允许换行');
|
|
|
|
const exchangeRateTable = (/<table\b[^>]*>[\s\S]*?<\/table>/i.exec(exchangeRatePage) || [''])[0];
|
|
assert.ok(exchangeRateTable, '汇率管理页缺少汇率表格');
|
|
const headers = [...exchangeRateTable.matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)]
|
|
.map((match) => textContent(match[1]));
|
|
assert.deepEqual(
|
|
headers,
|
|
['汇率月份', '平均汇率', '换算说明', '状态', '更新人', '更新时间', '备注', '操作'],
|
|
'汇率管理表头顺序不符合契约',
|
|
);
|
|
|
|
const editModal = extractDivById(overlaysHtml, 'modal-exchange-rate-edit');
|
|
assert.ok(editModal, '缺少 modal-exchange-rate-edit');
|
|
const monthInput = openingTagById(editModal, 'exchange-rate-month');
|
|
assert.ok(monthInput, '汇率编辑弹窗缺少 exchange-rate-month');
|
|
assert.match(monthInput, /\btype=["']month["']/, 'exchange-rate-month 必须使用 month 输入类型');
|
|
const valueInput = openingTagById(editModal, 'exchange-rate-value');
|
|
assert.ok(valueInput, '汇率编辑弹窗缺少 exchange-rate-value');
|
|
assert.match(valueInput, /\bstep=["']0\.0001["']/, 'exchange-rate-value 必须支持四位小数');
|
|
assert.match(editModal, /1\s*USD\s*=\s*X\s*CNY/, '汇率编辑弹窗缺少换算说明“1 USD = X CNY”');
|
|
|
|
const getPreviousMonthKeySource = extractFunction('getPreviousMonthKey', appJs);
|
|
assert.ok(getPreviousMonthKeySource, 'app.js 缺少 getPreviousMonthKey 函数');
|
|
const getPreviousMonthAverageRateSource = extractFunction('getPreviousMonthAverageRate', appJs);
|
|
assert.ok(getPreviousMonthAverageRateSource, 'app.js 缺少 getPreviousMonthAverageRate 函数');
|
|
|
|
const rateLookupContext = {
|
|
exchangeRateData: [
|
|
{month: '2026-07', averageRate: 7.1846, status: '生效'},
|
|
],
|
|
};
|
|
vm.runInNewContext(
|
|
`${getPreviousMonthKeySource}\n${getPreviousMonthAverageRateSource}\n`
|
|
+ 'this.api={getPreviousMonthKey,getPreviousMonthAverageRate};',
|
|
rateLookupContext,
|
|
{filename: 'monthly-exchange-rate-helpers.js', timeout: 1000},
|
|
);
|
|
assert.equal(rateLookupContext.api.getPreviousMonthKey('2026-08-13'), '2026-07');
|
|
assert.equal(rateLookupContext.api.getPreviousMonthKey('2026-01-15'), '2025-12');
|
|
assert.equal(rateLookupContext.api.getPreviousMonthKey(''), '');
|
|
assert.equal(rateLookupContext.api.getPreviousMonthAverageRate('2026-08-13'), 7.1846);
|
|
assert.equal(rateLookupContext.api.getPreviousMonthAverageRate('2026-10-01'), null);
|
|
|
|
const filterExchangeRatesByMonthSource = extractFunction('filterExchangeRatesByMonth', appJs);
|
|
assert.ok(filterExchangeRatesByMonthSource, 'app.js 缺少 filterExchangeRatesByMonth 函数');
|
|
const monthFilterContext = {};
|
|
vm.runInNewContext(
|
|
`${filterExchangeRatesByMonthSource}\nthis.filterExchangeRatesByMonth=filterExchangeRatesByMonth;`,
|
|
monthFilterContext,
|
|
{filename: 'filter-exchange-rates-by-month.js', timeout: 1000},
|
|
);
|
|
const filterRows = [
|
|
{month: '2026-07', averageRate: 7.1846},
|
|
{month: '2026-06', averageRate: 7.1768},
|
|
{month: '2025-07', averageRate: 7.1522},
|
|
];
|
|
assert.equal(monthFilterContext.filterExchangeRatesByMonth(filterRows, '').length, 3, '空月份应展示全部记录');
|
|
assert.deepEqual(
|
|
JSON.parse(JSON.stringify(monthFilterContext.filterExchangeRatesByMonth(filterRows, '2026-07'))),
|
|
[{month: '2026-07', averageRate: 7.1846}],
|
|
'月份筛选必须按 YYYY-MM 精确匹配',
|
|
);
|
|
assert.equal(monthFilterContext.filterExchangeRatesByMonth(filterRows, '2026-08').length, 0, '不存在月份应返回空结果');
|
|
|
|
const renderExchangeRatesSource = extractFunction('renderExchangeRates', appJs);
|
|
assert.match(renderExchangeRatesSource, /exchange-rate-month-filter/, '渲染函数未读取月份筛选控件');
|
|
assert.match(renderExchangeRatesSource, /filterExchangeRatesByMonth/, '渲染函数未使用月份精确筛选函数');
|
|
const resetExchangeRateFiltersSource = extractFunction('resetExchangeRateFilters', appJs);
|
|
assert.match(
|
|
resetExchangeRateFiltersSource,
|
|
/exchange-rate-month-filter[^]*value\s*=\s*["']["']/,
|
|
'重置函数未清空月份筛选',
|
|
);
|
|
|
|
const saveExchangeRateSource = extractFunction('saveExchangeRate', appJs);
|
|
assert.ok(saveExchangeRateSource, 'app.js 缺少 saveExchangeRate 函数');
|
|
const formElements = {
|
|
'exchange-rate-month': {value: '2026-07'},
|
|
'exchange-rate-value': {value: '7.2'},
|
|
'exchange-rate-remark': {value: '月末更新'},
|
|
'exchange-rate-status': {value: '生效'},
|
|
'exchange-rate-edit-id': {value: ''},
|
|
};
|
|
const saveContext = {
|
|
exchangeRateData: [
|
|
{
|
|
month: '2026-07',
|
|
averageRate: 7.1846,
|
|
status: '生效',
|
|
updatedBy: '张经理',
|
|
updatedAt: '2026-08-01 09:00',
|
|
remark: '初始值',
|
|
},
|
|
],
|
|
exchangeRateEditingMonth: '2026-07',
|
|
exchangeRateEditingId: '2026-07',
|
|
editingExchangeRateMonth: '2026-07',
|
|
currentExchangeRateMonth: '2026-07',
|
|
document: {
|
|
getElementById(id) {
|
|
return formElements[id] || {value: '', textContent: '', innerHTML: ''};
|
|
},
|
|
querySelector() {
|
|
return null;
|
|
},
|
|
},
|
|
renderExchangeRates() {},
|
|
hideModal() {},
|
|
showToast() {},
|
|
alert() {},
|
|
confirm() {
|
|
return true;
|
|
},
|
|
window: {
|
|
confirm() {
|
|
return true;
|
|
},
|
|
},
|
|
};
|
|
vm.runInNewContext(
|
|
`${saveExchangeRateSource}\nthis.saveExchangeRate=saveExchangeRate;`,
|
|
saveContext,
|
|
{filename: 'save-monthly-exchange-rate.js', timeout: 1000},
|
|
);
|
|
saveContext.saveExchangeRate();
|
|
assert.equal(saveContext.exchangeRateData.length, 1, '保存已有月份时不得新增重复记录');
|
|
assert.equal(saveContext.exchangeRateData[0].averageRate, 7.2, '保存已有月份时应覆盖平均汇率');
|
|
|
|
let validationToast = '';
|
|
formElements['exchange-rate-value'].value = '0';
|
|
saveContext.showToast = (message) => { validationToast = message; };
|
|
saveContext.saveExchangeRate();
|
|
assert.equal(saveContext.exchangeRateData[0].averageRate, 7.2, '非正数汇率不得覆盖已有记录');
|
|
assert.match(validationToast, /大于\s*0/, '非正数汇率应显示明确校验提示');
|
|
|
|
const applyPreviousMonthRateSource = extractFunction('applyPreviousMonthRate', appJs);
|
|
assert.ok(applyPreviousMonthRateSource, 'app.js 缺少 applyPreviousMonthRate 函数');
|
|
const rateInput = {value: '6.9000'};
|
|
const rateSource = {textContent: '', style: {color: ''}};
|
|
const applyContext = {
|
|
document: {
|
|
getElementById(id) {
|
|
if (id === 'cost-rate') return rateInput;
|
|
if (id === 'cost-rate-source') return rateSource;
|
|
return null;
|
|
},
|
|
},
|
|
getPreviousMonthKey: rateLookupContext.api.getPreviousMonthKey,
|
|
getPreviousMonthAverageRate: rateLookupContext.api.getPreviousMonthAverageRate,
|
|
};
|
|
vm.runInNewContext(
|
|
`${applyPreviousMonthRateSource}\nthis.applyPreviousMonthRate=applyPreviousMonthRate;`,
|
|
applyContext,
|
|
{filename: 'apply-previous-month-rate.js', timeout: 1000},
|
|
);
|
|
applyContext.applyPreviousMonthRate('2026-08-13');
|
|
assert.equal(rateInput.value, '7.1846', '找到上月记录时应带入四位小数汇率');
|
|
assert.match(rateSource.textContent, /2026-07/, '汇率来源提示应显示上一个自然月');
|
|
rateInput.value = '7.3333';
|
|
applyContext.applyPreviousMonthRate('2026-10-01');
|
|
assert.equal(rateInput.value, '7.3333', '上月汇率缺失时不得覆盖当前成本汇率');
|
|
assert.match(rateSource.textContent, /未维护\s*2026-09/, '上月汇率缺失时应显示缺失月份');
|
|
|
|
const switchPageSource = extractFunction('switchPage', appJs);
|
|
assert.ok(switchPageSource, 'app.js 缺少 switchPage 函数');
|
|
assert.match(
|
|
switchPageSource,
|
|
/page\s*===\s*["']gen-exchange-rate["'][\s\S]{0,160}renderExchangeRates\s*\(\s*\)/,
|
|
'进入 gen-exchange-rate 页面时未调用 renderExchangeRates()',
|
|
);
|
|
|
|
const switchVoyageTabSource = extractFunction('switchVoyageTab', appJs);
|
|
assert.ok(switchVoyageTabSource, 'app.js 缺少 switchVoyageTab 函数');
|
|
let appliedPreviousMonthRate = 0;
|
|
const voyageContext = {
|
|
document: {
|
|
querySelectorAll() {
|
|
return [];
|
|
},
|
|
getElementById(id) {
|
|
return id === 'cost-voyage-date' ? {value: '2026-08-13'} : null;
|
|
},
|
|
},
|
|
renderGenVoyage() {},
|
|
renderCostCalcRows() {},
|
|
applyPreviousMonthRate() {
|
|
appliedPreviousMonthRate += 1;
|
|
},
|
|
};
|
|
vm.runInNewContext(
|
|
`${switchVoyageTabSource}\nthis.switchVoyageTab=switchVoyageTab;`,
|
|
voyageContext,
|
|
{filename: 'switch-voyage-tab.js', timeout: 1000},
|
|
);
|
|
voyageContext.switchVoyageTab('cost');
|
|
assert.equal(appliedPreviousMonthRate, 1, '切换到成本测算时应调用 applyPreviousMonthRate');
|
|
|
|
const voyagePage = extractPage(generalHtml, 'gen-voyage');
|
|
assert.ok(voyagePage, '通用模块缺少 page-gen-voyage');
|
|
const voyageDateInput = openingTagById(voyagePage, 'cost-voyage-date');
|
|
assert.match(
|
|
voyageDateInput,
|
|
/\bonchange=["']applyPreviousMonthRate\(this\.value\)["']/,
|
|
'船期日期变化时应调用 applyPreviousMonthRate(this.value)',
|
|
);
|
|
assert.match(voyagePage, /\bid=["']cost-rate-source["']/, '成本测算缺少汇率来源 cost-rate-source');
|
|
|
|
console.log('MONTHLY_EXCHANGE_RATE_CHECK_PASS');
|