Files
docs/悦/货代SAAS平台后台/tests/verify-monthly-exchange-rate.cjs
T
2026-08-21 09:20:26 +08:00

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');