433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|||
|
|
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const path = require('node:path');
|
||
|
|
const assert = require('node:assert/strict');
|
||
|
|
|
||
|
|
const root = path.resolve(__dirname, '..');
|
||
|
|
const moduleHtml = fs.readFileSync(
|
||
|
|
path.join(root, 'prototype-src', 'modules', '04-overseas-delivery.html'),
|
||
|
|
'utf8',
|
||
|
|
);
|
||
|
|
const overlays = fs.readFileSync(path.join(root, 'prototype-src', 'overlays.html'), 'utf8');
|
||
|
|
const tail = fs.readFileSync(path.join(root, 'prototype-src', 'tail.html'), 'utf8');
|
||
|
|
const app = fs.readFileSync(path.join(root, 'prototype-src', 'app.js'), 'utf8');
|
||
|
|
const manifest = JSON.parse(
|
||
|
|
fs.readFileSync(path.join(root, 'prototype-src', 'manifest.json'), 'utf8'),
|
||
|
|
);
|
||
|
|
const failures = [];
|
||
|
|
|
||
|
|
function expect(condition, message) {
|
||
|
|
if (!condition) failures.push(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
function codeOnly(source) {
|
||
|
|
let output = '';
|
||
|
|
let state = 'code';
|
||
|
|
let escaped = false;
|
||
|
|
const regexCanStartAfter = /[([{,:;=!?&|+\-*%^~<>]/;
|
||
|
|
const controlKeywords = new Set(['if', 'while', 'for', 'with', 'switch', 'catch']);
|
||
|
|
let previousSignificant = '';
|
||
|
|
let previousWord = '';
|
||
|
|
let regexAllowedAfterControlParen = false;
|
||
|
|
const parenthesisStack = [];
|
||
|
|
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 += ' ';
|
||
|
|
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, code = codeOnly(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) {
|
||
|
|
const char = code[index];
|
||
|
|
if (char === '{') {
|
||
|
|
depth += 1;
|
||
|
|
} else if (char === '}') {
|
||
|
|
depth -= 1;
|
||
|
|
if (depth === 0) return source.slice(declaration.index, index + 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
const extractorProbe = [
|
||
|
|
'// function ignoredLine() {}',
|
||
|
|
'/* function ignoredBlock() {} */',
|
||
|
|
'const ignoredString = "function ignoredString() {}";',
|
||
|
|
'const ignoredTemplate = `function ignoredTemplate() {}`;',
|
||
|
|
'const quotePattern = /["\']/;',
|
||
|
|
'if (ready) /function ignoredControlRegex() {}/.test(value);',
|
||
|
|
'function liveProbe() { return {ok: true}; }',
|
||
|
|
].join('\n');
|
||
|
|
expect(!extractFunction('ignoredLine', extractorProbe), '函数提取器误识别行注释声明');
|
||
|
|
expect(!extractFunction('ignoredBlock', extractorProbe), '函数提取器误识别块注释声明');
|
||
|
|
expect(!extractFunction('ignoredString', extractorProbe), '函数提取器误识别字符串声明');
|
||
|
|
expect(!extractFunction('ignoredTemplate', extractorProbe), '函数提取器误识别模板文本声明');
|
||
|
|
expect(!extractFunction('ignoredControlRegex', extractorProbe), '函数提取器误识别控制语句后的正则文本');
|
||
|
|
expect(Boolean(extractFunction('liveProbe', extractorProbe)), '函数提取器无法识别真实声明');
|
||
|
|
|
||
|
|
function extractModal(source, modalId) {
|
||
|
|
const escapedId = modalId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
|
|
const opening = new RegExp(`<div\\b[^>]*\\bid=["']${escapedId}["'][^>]*>`, 'i').exec(source);
|
||
|
|
if (!opening) return '';
|
||
|
|
|
||
|
|
const tagPattern = /<\/?div\b[^>]*>/gi;
|
||
|
|
tagPattern.lastIndex = opening.index;
|
||
|
|
let depth = 0;
|
||
|
|
let tag;
|
||
|
|
while ((tag = tagPattern.exec(source))) {
|
||
|
|
if (/^<\/div\b/i.test(tag[0])) depth -= 1;
|
||
|
|
else depth += 1;
|
||
|
|
if (depth === 0) return source.slice(opening.index, tagPattern.lastIndex);
|
||
|
|
}
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
function getSelectOptionValues(source, selectId) {
|
||
|
|
const escapedId = selectId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
|
|
const select = new RegExp(
|
||
|
|
`<select\\b(?=[^>]*\\bid=["']${escapedId}["'])[^>]*>([\\s\\S]*?)<\\/select>`,
|
||
|
|
'i',
|
||
|
|
).exec(source);
|
||
|
|
if (!select) return null;
|
||
|
|
|
||
|
|
const values = [];
|
||
|
|
const optionPattern = /<option\b([^>]*)>([\s\S]*?)<\/option>/gi;
|
||
|
|
let option;
|
||
|
|
while ((option = optionPattern.exec(select[1]))) {
|
||
|
|
const value = /\bvalue=["']([^"']*)["']/i.exec(option[1]);
|
||
|
|
const text = option[2].replace(/<[^>]*>/g, '').trim();
|
||
|
|
const optionValue = value ? value[1] : text;
|
||
|
|
if (optionValue) values.push(optionValue);
|
||
|
|
}
|
||
|
|
return values;
|
||
|
|
}
|
||
|
|
|
||
|
|
function expectExactOptionSet(source, selectId, expectedValues, label) {
|
||
|
|
const actualValues = getSelectOptionValues(source, selectId);
|
||
|
|
expect(Boolean(actualValues), `缺少 ${label}选择框`);
|
||
|
|
if (!actualValues) return;
|
||
|
|
const actual = [...actualValues].sort();
|
||
|
|
const expected = [...expectedValues].sort();
|
||
|
|
expect(
|
||
|
|
actual.length === expected.length && actual.every((value, index) => value === expected[index]),
|
||
|
|
`${label}选项应为“${expectedValues.join('、')}”`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(!moduleHtml.includes('id="page-outbound-vehicle-edit"'), '独立编辑页应移除');
|
||
|
|
expect(!manifest.pageOrder.includes('outbound-vehicle-edit'), 'pageOrder 应移除独立编辑页');
|
||
|
|
|
||
|
|
const deliveryModule = manifest.modules.find(
|
||
|
|
(item) => item.file === 'modules/04-overseas-delivery.html',
|
||
|
|
);
|
||
|
|
expect(Boolean(deliveryModule), 'manifest 缺少海外派送模块');
|
||
|
|
if (deliveryModule) {
|
||
|
|
expect(
|
||
|
|
!deliveryModule.pages.includes('outbound-vehicle-edit'),
|
||
|
|
'海外派送模块 pages 应移除独立编辑页',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(overlays.includes('id="modal-outbound-vehicle-edit"'), '缺少独立编辑排车弹框');
|
||
|
|
expect(
|
||
|
|
overlays.includes('id="modal-outbound-vehicle-order-select"'),
|
||
|
|
'缺少关联订单选择弹框',
|
||
|
|
);
|
||
|
|
const bolModal = extractModal(tail, 'modal-bol');
|
||
|
|
expect(Boolean(bolModal), '缺少独立转派送弹框');
|
||
|
|
expectExactOptionSet(
|
||
|
|
bolModal,
|
||
|
|
'bol-deliveryMethod',
|
||
|
|
['FBA自提', '客户自提', '卡派打托', '卡派地板', '快递', '外配'],
|
||
|
|
'派送方式',
|
||
|
|
);
|
||
|
|
expectExactOptionSet(bolModal, 'bol-pickupMethod', ['一提一卸', '一提多卸'], '提货方式');
|
||
|
|
expectExactOptionSet(bolModal, 'bol-loadType', ['托盘', '地板', '混装'], '装车类型');
|
||
|
|
expectExactOptionSet(bolModal, 'bol-bookAccount', ['4', '8'], '预约账号');
|
||
|
|
expectExactOptionSet(
|
||
|
|
bolModal,
|
||
|
|
'bol-vehicleSize',
|
||
|
|
['大车(30托/15托)', '小车(14托/12托)'],
|
||
|
|
'车辆大小',
|
||
|
|
);
|
||
|
|
expect(!bolModal.includes('卡派地版'), '转派送弹框不应保留错误文案“卡派地版”');
|
||
|
|
expect(bolModal.includes('<label>预约号</label>'), 'ISA号码应改为预约号');
|
||
|
|
expect(bolModal.includes('id="bol-appointmentNo"'), '预约号应使用独立字段');
|
||
|
|
expect(bolModal.includes('id="bol-courierCompanyId"'), '快递方式应保留快递公司选择');
|
||
|
|
expect(!bolModal.includes('车队邮箱'), '转派送弹框应移除车队邮箱');
|
||
|
|
expect(!bolModal.includes('ISA号码'), '转派送弹框不应保留 ISA号码 文案');
|
||
|
|
expect(!bolModal.includes('是否发送派送单'), '转派送弹框应移除是否发送派送单');
|
||
|
|
expect(!bolModal.includes('预估派送单费用'), '费用区应移除预估派送单费用');
|
||
|
|
expect(!bolModal.includes('快递费用'), '费用区应移除快递费用');
|
||
|
|
expect(!bolModal.includes('id="bol-driverEmail"'), '转派送弹框不应保留车队邮箱字段');
|
||
|
|
expect(!bolModal.includes('id="bol-sendDispatch"'), '转派送弹框不应保留发送派送单字段');
|
||
|
|
expect(!bolModal.includes('id="bol-estimatedDispatchFee"'), '转派送弹框不应保留预估派送费用字段');
|
||
|
|
expect(!bolModal.includes('id="bol-courierFee"'), '转派送弹框不应保留快递费字段');
|
||
|
|
expect(
|
||
|
|
/id="bol-dispatchFee"[^>]*placeholder="请输入派送费用"/.test(bolModal),
|
||
|
|
'派送费用输入框应提示“请输入派送费用”',
|
||
|
|
);
|
||
|
|
const vehicleTypeIndex = bolModal.indexOf('id="bol-vehicleType"');
|
||
|
|
const vehicleSizeIndex = bolModal.indexOf('id="bol-vehicleSize"');
|
||
|
|
const appointmentAccountIndex = bolModal.indexOf('id="bol-bookAccount"');
|
||
|
|
expect(
|
||
|
|
vehicleTypeIndex >= 0 && vehicleSizeIndex > vehicleTypeIndex && appointmentAccountIndex > vehicleSizeIndex,
|
||
|
|
'车辆大小应排在车辆类型后、预约账号前',
|
||
|
|
);
|
||
|
|
|
||
|
|
const functionNames = [
|
||
|
|
'editVehicle',
|
||
|
|
'switchOutboundVehicleEditTab',
|
||
|
|
'renderOutboundVehicleEditOrders',
|
||
|
|
'removeOutboundVehicleEditOrder',
|
||
|
|
'getAvailableOutboundVehicleOrders',
|
||
|
|
'openOutboundVehicleOrderSelect',
|
||
|
|
'renderOutboundVehicleOrderOptions',
|
||
|
|
'confirmOutboundVehicleOrderSelection',
|
||
|
|
'saveVehicleEdit',
|
||
|
|
'openBolModal',
|
||
|
|
'handleOutboundVehicleLoadTypeChange',
|
||
|
|
'validateOutboundVehicleDeliveryDraft',
|
||
|
|
'nextOutboundVehicleDeliveryNo',
|
||
|
|
'saveBol',
|
||
|
|
'openOutboundVehicleDelivery',
|
||
|
|
'getOutboundVehicleCourierFeeDetails',
|
||
|
|
];
|
||
|
|
const scriptSource = `${app}\n${tail}`;
|
||
|
|
const scriptCode = codeOnly(scriptSource);
|
||
|
|
const functionSources = Object.fromEntries(
|
||
|
|
functionNames.map((name) => [name, extractFunction(name, scriptSource, scriptCode)]),
|
||
|
|
);
|
||
|
|
functionNames.forEach((name) => {
|
||
|
|
expect(Boolean(functionSources[name]), `缺少 ${name} 函数`);
|
||
|
|
});
|
||
|
|
|
||
|
|
const saveBolSource = functionSources.saveBol || '';
|
||
|
|
expect(saveBolSource.includes("appointmentAccount:value('bol-bookAccount')"), '保存时应读取预约账号');
|
||
|
|
expect(saveBolSource.includes("appointmentNo:value('bol-appointmentNo')"), '保存时应读取预约号');
|
||
|
|
expect(saveBolSource.includes("vehicleSize:value('bol-vehicleSize')"), '保存时应读取车辆大小');
|
||
|
|
expect(saveBolSource.includes('appointmentAccount:draft.appointmentAccount'), '派送记录应保存预约账号');
|
||
|
|
expect(saveBolSource.includes('appointmentNo:draft.appointmentNo'), '派送记录应保存预约号');
|
||
|
|
expect(saveBolSource.includes('vehicleSize:draft.vehicleSize'), '派送记录应保存车辆大小');
|
||
|
|
|
||
|
|
function runPureFunctionCheck(name, callback) {
|
||
|
|
if (!functionSources[name]) return;
|
||
|
|
try {
|
||
|
|
const pureFunction = new Function(`${functionSources[name]}\nreturn ${name};`)();
|
||
|
|
callback(pureFunction);
|
||
|
|
} catch (error) {
|
||
|
|
failures.push(`${name} 运行时验证失败:${error.message}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
runPureFunctionCheck('getAvailableOutboundVehicleOrders', (getAvailableOutboundVehicleOrders) => {
|
||
|
|
const orders = [
|
||
|
|
{orderNo: 'ORDER-A', status: '待派送', destFba: 'ONT8'},
|
||
|
|
{orderNo: 'ORDER-B', status: '待派送', destFba: 'LAX9'},
|
||
|
|
{orderNo: 'ORDER-C', status: '派送中', destFba: 'ONT8'},
|
||
|
|
];
|
||
|
|
const vehicles = [
|
||
|
|
{vehicleNo: 'CAR-1', orders: [{orderNo: 'ORDER-A'}]},
|
||
|
|
{vehicleNo: 'CAR-2', orders: [{orderNo: 'ORDER-B'}]},
|
||
|
|
];
|
||
|
|
|
||
|
|
assert.deepEqual(
|
||
|
|
getAvailableOutboundVehicleOrders('CAR-1', [], orders, vehicles).map((item) => item.orderNo),
|
||
|
|
['ORDER-A'],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
runPureFunctionCheck('validateOutboundVehicleDeliveryDraft', (validateOutboundVehicleDeliveryDraft) => {
|
||
|
|
assert.equal(
|
||
|
|
validateOutboundVehicleDeliveryDraft({
|
||
|
|
deliveryDate: '2026-08-13',
|
||
|
|
deliveryMethod: '卡派地板',
|
||
|
|
pickupMethod: '一提一卸',
|
||
|
|
loadType: '地板',
|
||
|
|
dispatchFee: 200,
|
||
|
|
loadingFee: 150,
|
||
|
|
}),
|
||
|
|
'',
|
||
|
|
);
|
||
|
|
assert.match(
|
||
|
|
validateOutboundVehicleDeliveryDraft({
|
||
|
|
deliveryDate: '2026-08-13',
|
||
|
|
deliveryMethod: '卡派地板',
|
||
|
|
pickupMethod: '一提一卸',
|
||
|
|
loadType: '地板',
|
||
|
|
dispatchFee: -1,
|
||
|
|
loadingFee: 150,
|
||
|
|
}),
|
||
|
|
/派送费用/,
|
||
|
|
);
|
||
|
|
assert.match(
|
||
|
|
validateOutboundVehicleDeliveryDraft({
|
||
|
|
deliveryDate: '2026-08-13',
|
||
|
|
deliveryMethod: '快递',
|
||
|
|
pickupMethod: '一提一卸',
|
||
|
|
loadType: '托盘',
|
||
|
|
dispatchFee: 200,
|
||
|
|
courierCompanyId: '',
|
||
|
|
}, [{id: 'EXP-1', status: '合作中'}]),
|
||
|
|
/快递公司/,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
validateOutboundVehicleDeliveryDraft({
|
||
|
|
deliveryDate: '2026-08-13',
|
||
|
|
deliveryMethod: '快递',
|
||
|
|
pickupMethod: '一提一卸',
|
||
|
|
loadType: '托盘',
|
||
|
|
dispatchFee: 200,
|
||
|
|
courierCompanyId: 'EXP-1',
|
||
|
|
}, [{id: 'EXP-1', status: '合作中'}]),
|
||
|
|
'',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
runPureFunctionCheck('getOutboundVehicleCourierFeeDetails', (getOutboundVehicleCourierFeeDetails) => {
|
||
|
|
assert.deepEqual(
|
||
|
|
getOutboundVehicleCourierFeeDetails([
|
||
|
|
{
|
||
|
|
source: 'outboundVehicle',
|
||
|
|
deliveryMethod: '快递',
|
||
|
|
dispatchNo: 'PSD20260818001',
|
||
|
|
courierCompanyName: 'UPS',
|
||
|
|
appointmentDate: '2026-08-18',
|
||
|
|
dispatchFee: 88,
|
||
|
|
},
|
||
|
|
]),
|
||
|
|
[{
|
||
|
|
dispatchNo: 'PSD20260818001',
|
||
|
|
company: 'UPS',
|
||
|
|
businessDate: '2026-08-18',
|
||
|
|
currency: 'USD',
|
||
|
|
amount: 88,
|
||
|
|
}],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
runPureFunctionCheck('nextOutboundVehicleDeliveryNo', (nextOutboundVehicleDeliveryNo) => {
|
||
|
|
assert.equal(
|
||
|
|
nextOutboundVehicleDeliveryNo('2026-08-13', [
|
||
|
|
{dispatchNo: 'PSD20260813001'},
|
||
|
|
{dispatchNo: 'PSD20260813003'},
|
||
|
|
]),
|
||
|
|
'PSD20260813004',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
if (failures.length) {
|
||
|
|
console.error('OUTBOUND_VEHICLE_EDIT_DELIVERY_CHECK_FAIL');
|
||
|
|
failures.forEach((failure) => console.error(`- ${failure}`));
|
||
|
|
process.exitCode = 1;
|
||
|
|
} else {
|
||
|
|
console.log('OUTBOUND_VEHICLE_EDIT_DELIVERY_CHECK_PASS');
|
||
|
|
}
|