246 lines
16 KiB
JavaScript
246 lines
16 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const moduleSource = fs.readFileSync(path.join(__dirname, '..', 'prototype-src', 'modules', '03-overseas-warehouse.html'), 'utf8');
|
|
const appSource = fs.readFileSync(path.join(__dirname, '..', 'prototype-src', 'app.js'), 'utf8');
|
|
const failures = [];
|
|
|
|
function expect(condition, message) {
|
|
if (!condition) failures.push(message);
|
|
}
|
|
|
|
function section(source, startMarker, endMarker) {
|
|
const start = source.indexOf(startMarker);
|
|
if (start < 0) return '';
|
|
const end = source.indexOf(endMarker, start + startMarker.length);
|
|
return end < 0 ? source.slice(start) : source.slice(start, end);
|
|
}
|
|
|
|
function skipQuoted(source, index, quote) {
|
|
let cursor = index + 1;
|
|
while (cursor < source.length) {
|
|
if (source[cursor] === '\\') cursor += 2;
|
|
else if (source[cursor] === quote) return cursor + 1;
|
|
else cursor += 1;
|
|
}
|
|
return source.length;
|
|
}
|
|
|
|
function previousToken(source, index) {
|
|
let cursor = index - 1;
|
|
while (cursor >= 0 && /\s/.test(source[cursor])) cursor -= 1;
|
|
const end = cursor + 1;
|
|
while (cursor >= 0 && /[\w$]/.test(source[cursor])) cursor -= 1;
|
|
return source.slice(cursor + 1, end);
|
|
}
|
|
|
|
function isRegexStart(source, index) {
|
|
const previous = previousToken(source, index);
|
|
return !previous || /^(?:return|case|throw|else|do|typeof|void|delete|new|in|of|yield|await)$/.test(previous) || /[([{:;,=!?&|+\-*~%^<>]/.test(previous.slice(-1));
|
|
}
|
|
|
|
function skipRegex(source, index) {
|
|
let cursor = index + 1;
|
|
let inCharacterClass = false;
|
|
while (cursor < source.length) {
|
|
if (source[cursor] === '\\') cursor += 2;
|
|
else if (source[cursor] === '[') { inCharacterClass = true; cursor += 1; }
|
|
else if (source[cursor] === ']' && inCharacterClass) { inCharacterClass = false; cursor += 1; }
|
|
else if (source[cursor] === '/' && !inCharacterClass) {
|
|
cursor += 1;
|
|
while (/[a-z]/i.test(source[cursor] || '')) cursor += 1;
|
|
return cursor;
|
|
} else cursor += 1;
|
|
}
|
|
return source.length;
|
|
}
|
|
|
|
function skipTemplateExpression(source, index) {
|
|
let depth = 1;
|
|
let cursor = index;
|
|
while (cursor < source.length && depth > 0) {
|
|
const char = source[cursor];
|
|
if (char === "'" || char === '"') cursor = skipQuoted(source, cursor, char);
|
|
else if (char === '`') cursor = skipTemplate(source, cursor);
|
|
else if (char === '/' && source[cursor + 1] === '/') {
|
|
const newline = source.indexOf('\n', cursor + 2);
|
|
cursor = newline < 0 ? source.length : newline + 1;
|
|
} else if (char === '/' && source[cursor + 1] === '*') {
|
|
const close = source.indexOf('*/', cursor + 2);
|
|
cursor = close < 0 ? source.length : close + 2;
|
|
} else if (char === '/' && isRegexStart(source, cursor)) cursor = skipRegex(source, cursor);
|
|
else if (char === '{') { depth += 1; cursor += 1; }
|
|
else if (char === '}') { depth -= 1; cursor += 1; }
|
|
else cursor += 1;
|
|
}
|
|
return cursor;
|
|
}
|
|
|
|
function skipTemplate(source, index) {
|
|
let cursor = index + 1;
|
|
while (cursor < source.length) {
|
|
if (source[cursor] === '\\') cursor += 2;
|
|
else if (source[cursor] === '`') return cursor + 1;
|
|
else if (source[cursor] === '$' && source[cursor + 1] === '{') cursor = skipTemplateExpression(source, cursor + 2);
|
|
else cursor += 1;
|
|
}
|
|
return source.length;
|
|
}
|
|
|
|
function functionSource(name) {
|
|
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const declaration = new RegExp('(^|\\n)[\\t ]*function[\\t ]+' + escapedName + '[\\t ]*\\(', 'm').exec(appSource);
|
|
if (!declaration) return '';
|
|
const start = declaration.index + declaration[0].indexOf('function');
|
|
const openBrace = appSource.indexOf('{', start + declaration[0].length);
|
|
if (openBrace < 0) return '';
|
|
|
|
let depth = 1;
|
|
let cursor = openBrace + 1;
|
|
while (cursor < appSource.length && depth > 0) {
|
|
const char = appSource[cursor];
|
|
if (char === "'" || char === '"') cursor = skipQuoted(appSource, cursor, char);
|
|
else if (char === '`') cursor = skipTemplate(appSource, cursor);
|
|
else if (char === '/' && appSource[cursor + 1] === '/') {
|
|
const newline = appSource.indexOf('\n', cursor + 2);
|
|
cursor = newline < 0 ? appSource.length : newline + 1;
|
|
} else if (char === '/' && appSource[cursor + 1] === '*') {
|
|
const close = appSource.indexOf('*/', cursor + 2);
|
|
cursor = close < 0 ? appSource.length : close + 2;
|
|
} else if (char === '/' && isRegexStart(appSource, cursor)) cursor = skipRegex(appSource, cursor);
|
|
else if (char === '{') { depth += 1; cursor += 1; }
|
|
else if (char === '}') { depth -= 1; cursor += 1; }
|
|
else cursor += 1;
|
|
}
|
|
return depth === 0 ? appSource.slice(start, cursor) : '';
|
|
}
|
|
|
|
function expectExtractedFunction(name) {
|
|
const source = functionSource(name);
|
|
expect(Boolean(source), name + ' is defined');
|
|
if (source) {
|
|
try { new Function('return (' + source + ');'); }
|
|
catch (error) { expect(false, name + ' extraction is syntactically valid: ' + error.message); }
|
|
}
|
|
return source;
|
|
}
|
|
|
|
const lastMilePage = section(moduleSource, 'id="page-last-mile-orders"', 'id="page-last-mile-order-detail"');
|
|
const listTable = section(lastMilePage, '<table', '</table>');
|
|
const listHeader = section(listTable, '<thead', '</thead>');
|
|
const headerCount = (listHeader.match(/<th\b/g) || []).length;
|
|
expect(lastMilePage.length > 0, 'tail-order list page section is present');
|
|
expect(/尾程单号<\/th>\s*<th[^>]*>预警<\/th>\s*<th[^>]*>订单类型<\/th>\s*<th[^>]*>拒收时间<\/th>\s*<th[^>]*>入仓码<\/th>/.test(listHeader), 'tail-order header sequence is exactly 尾程单号, 预警, 订单类型, 拒收时间, 入仓码');
|
|
expect(headerCount === 33, 'tail-order list has 33 table headers');
|
|
const orderTypeSelect = section(moduleSource, 'id="lmo-order-type"', '</select>');
|
|
expect(orderTypeSelect.includes('拒收订单') && orderTypeSelect.includes('快递订单'), 'lmo-order-type contains 拒收订单 and 快递订单');
|
|
expect(/#page-last-mile-orders\s+\.lmo-warning-list\b/.test(moduleSource), 'module CSS scopes .lmo-warning-list to the tail-order page');
|
|
|
|
const fieldConfigSource = section(appSource, 'const lastMileFieldConfig = [', '\n];');
|
|
const completeFieldConfigSource = fieldConfigSource ? fieldConfigSource + '\n];' : '';
|
|
expect(/\{\s*key:'rejectionTime'\s*,\s*label:'拒收时间'[^}]*\}/.test(completeFieldConfigSource), 'lastMileFieldConfig contains rejectionTime / 拒收时间');
|
|
expect(/\{\s*key:'actualInboundTime'\s*,\s*label:'实际入库时间'[^}]*\}/.test(completeFieldConfigSource), 'lastMileFieldConfig contains actualInboundTime / 实际入库时间');
|
|
|
|
const createSource = expectExtractedFunction('createLastMileOrderFromFirstLeg');
|
|
expect(/rejectionTime\s*:\s*firstLegOrder\.rejectionTime\s*\|\|\s*''/.test(createSource), 'createLastMileOrderFromFirstLeg copies rejectionTime from first-leg order');
|
|
expect(/actualInboundTime\s*:\s*firstLegOrder\.actualInboundTime\s*\|\|\s*''/.test(createSource), 'createLastMileOrderFromFirstLeg copies actualInboundTime from first-leg order');
|
|
|
|
const containerSamplesSource = section(appSource, 'const ctListData = [', '\n];');
|
|
const firstLegSamplesSource = section(appSource, 'const lastMileFirstLegSamples = [', '\n];');
|
|
const firstLegSample = no => section(firstLegSamplesSource, `{firstLegNo:'${no}'`, '\n');
|
|
expect(/no:'FFAU6685194'[^\n]*isAbnormal:true[^\n]*abnormalReason:'箱体右侧破损,待处理'/.test(containerSamplesSource), 'container samples retain the abnormal FFAU6685194 scenario');
|
|
expect(/serviceType:'拒收订单'[^\n]*containerNo:'FFAU6685194'[^\n]*rejectionTime:'2026-08-12 14:30'/.test(firstLegSample('HC20260801001')), 'first tail-order sample retains combined rejection and abnormal-container scenario');
|
|
expect(/serviceType:'快递订单'[^\n]*actualInboundTime:'2026-08-10 08:00'/.test(firstLegSample('HC20260801006')) && !firstLegSample('HC20260801006').includes('outboundNo:'), 'courier sample retains overdue in-warehouse scenario');
|
|
expect(/serviceType:'快递订单'[^\n]*actualInboundTime:'2026-08-07 08:00'[^\n]*outboundNo:'FBO20260807001'/.test(firstLegSample('HC20260801004')), 'shipped courier sample retains timeout-cancellation scenario');
|
|
expect(firstLegSample('HC20260801003').includes("containerNo:'CMAU26080103'") && !firstLegSample('HC20260801003').includes('rejectionTime:') && !firstLegSample('HC20260801003').includes('actualInboundTime:'), 'first-leg samples retain a normal no-warning scenario');
|
|
|
|
const listSource = expectExtractedFunction('renderLastMileOrders');
|
|
expect(listSource.includes('renderLastMileWarningTags(order)'), 'renderLastMileOrders calls renderLastMileWarningTags(order)');
|
|
expect(/order\.rejectionTime/.test(listSource), 'renderLastMileOrders renders order.rejectionTime');
|
|
const emptyColspan = /colspan="(\d+)"/.exec(listSource);
|
|
expect(Boolean(emptyColspan) && Number(emptyColspan[1]) === headerCount, 'renderLastMileOrders empty-state colspan matches the structural header count');
|
|
|
|
const detailSource = expectExtractedFunction('renderLastMileOrderDetail');
|
|
expect(/lmoInfo\('拒收时间'\s*,\s*order\.rejectionTime\)/.test(detailSource), 'detail binds 拒收时间 to order.rejectionTime');
|
|
expect(/lmoInfo\('实际入库时间'\s*,\s*order\.actualInboundTime\)/.test(detailSource), 'detail binds 实际入库时间 to order.actualInboundTime');
|
|
|
|
const csvFunctionNames = ['getLastMileFieldDefaults', 'getLastMileField', 'getSelectedImportFieldKeys', 'getLastMileFieldValue', 'csvEscape', 'buildLastMileCsv', 'splitLastMileCsvLine', 'parseLastMileCsv'];
|
|
const csvSources = Object.fromEntries(csvFunctionNames.map(name => [name, expectExtractedFunction(name)]));
|
|
if (completeFieldConfigSource && csvFunctionNames.every(name => csvSources[name])) {
|
|
try {
|
|
const csvContext = { console };
|
|
vm.createContext(csvContext);
|
|
vm.runInContext([
|
|
completeFieldConfigSource,
|
|
'const orderedLastMileFieldConfig=lastMileFieldConfig.slice().sort((a,b)=>a.order-b.order);',
|
|
csvSources.getLastMileFieldDefaults,
|
|
'const lastMileFieldSelectionStore={import:getLastMileFieldDefaults(\'import\'),export:getLastMileFieldDefaults(\'export\')};',
|
|
'const lastMileOrderData=[];',
|
|
csvSources.getLastMileField,
|
|
csvSources.getSelectedImportFieldKeys,
|
|
csvSources.getLastMileFieldValue,
|
|
csvSources.csvEscape,
|
|
csvSources.buildLastMileCsv,
|
|
csvSources.splitLastMileCsvLine,
|
|
csvSources.parseLastMileCsv,
|
|
'this.__lastMileCsv={parseLastMileCsv,buildLastMileCsv};'
|
|
].join('\n'), csvContext, { filename: 'last-mile-csv-functions.js' });
|
|
const selectedKeys = ['orderType', 'firstLegNo', 'owner', 'warehouse', 'fbaWarehouse', 'rejectionTime', 'actualInboundTime'];
|
|
const csv = ['订单类型,头程单号,货主,当前仓,FBA仓库,拒收时间,实际入库时间', '拒收订单,HC-REJECT,客户甲,洛杉矶长滩海外仓,FBA ONT8,2026-08-01 08:00,2026-08-01 09:00', '快递订单,HC-COURIER,客户乙,纽约海外仓,FBA CLT2,,2026-08-01 10:00'].join('\n');
|
|
const parsed = csvContext.__lastMileCsv.parseLastMileCsv(csv, selectedKeys);
|
|
expect(parsed.length === 2 && parsed.every(row => row.valid), 'parseLastMileCsv returns valid 拒收订单 and 快递订单 rows');
|
|
expect(parsed[0] && parsed[0].data.rejectionTime === '2026-08-01 08:00' && parsed[0].data.actualInboundTime === '2026-08-01 09:00', 'parseLastMileCsv maps rejectionTime and actualInboundTime into data');
|
|
const exported = csvContext.__lastMileCsv.buildLastMileCsv([parsed[0] && parsed[0].data || {}], selectedKeys);
|
|
expect(exported.includes('"拒收时间"') && exported.includes('"实际入库时间"') && exported.includes('"2026-08-01 08:00"') && exported.includes('"2026-08-01 09:00"'), 'buildLastMileCsv exports selected warning fields and values');
|
|
} catch (error) {
|
|
expect(false, 'CSV VM execution succeeds: ' + error.message);
|
|
}
|
|
}
|
|
|
|
const warningFunctionNames = ['parseLastMileTime', 'getLastMileWarnings', 'renderLastMileWarningTags'];
|
|
const warningSources = Object.fromEntries(warningFunctionNames.map(name => [name, expectExtractedFunction(name)]));
|
|
const lmoEscSource = expectExtractedFunction('lmoEsc');
|
|
if (lmoEscSource && warningFunctionNames.every(name => warningSources[name])) {
|
|
const abnormalContainers = new Set(['ABNORMAL-1', 'ABNORMAL-XSS']);
|
|
const context = {
|
|
getContainerAbnormalState(containerNo) {
|
|
if (!abnormalContainers.has(containerNo)) return { isAbnormal: false, reason: '' };
|
|
return { isAbnormal: true, reason: containerNo === 'ABNORMAL-XSS' ? '柜门 <异常>' : '柜门损坏' };
|
|
}
|
|
};
|
|
try {
|
|
vm.createContext(context);
|
|
vm.runInContext([lmoEscSource, ...warningFunctionNames.map(name => warningSources[name])].join('\n'), context, { filename: 'last-mile-warning-functions.js' });
|
|
const now = '2026-08-03T00:00:00Z';
|
|
const courier = overrides => ({ serviceType: '快递订单', actualInboundTime: '2026-08-01T00:01:00Z', outboundNo: '', rejectionTime: '', containerNo: '', ...overrides });
|
|
const warningTypes = order => context.getLastMileWarnings(order, now).map(warning => warning.type);
|
|
|
|
expect(!warningTypes(courier()).includes('warehouse-timeout'), 'courier at 47h59m has no warehouse-timeout warning');
|
|
const atThreshold = context.getLastMileWarnings(courier({ actualInboundTime: '2026-08-01T00:00:00Z' }), now);
|
|
expect(atThreshold.some(warning => warning.type === 'warehouse-timeout' && warning.label === '在库超时 48h'), 'courier at exactly 48h has warehouse-timeout label 在库超时 48h');
|
|
expect(!warningTypes(courier({ outboundNo: 'OUT-001', actualInboundTime: '2026-08-01T00:00:00Z' })).includes('warehouse-timeout'), 'courier with outboundNo has no warehouse-timeout warning');
|
|
expect(!warningTypes(courier({ actualInboundTime: '' })).includes('warehouse-timeout'), 'courier missing actualInboundTime has no warehouse-timeout warning');
|
|
expect(!warningTypes(courier({ actualInboundTime: 'not-a-date' })).includes('warehouse-timeout'), 'courier with unparseable actualInboundTime has no warehouse-timeout warning');
|
|
expect(!warningTypes(courier({ actualInboundTime: '2026-02-30 00:00' })).includes('warehouse-timeout'), 'courier with calendar-invalid February date has no warehouse-timeout warning');
|
|
expect(!warningTypes(courier({ actualInboundTime: '2026-04-31 00:00' })).includes('warehouse-timeout'), 'courier with Date.parse-normalized April date has no warehouse-timeout warning');
|
|
const combined = courier({ serviceType: '拒收订单', containerNo: 'ABNORMAL-1', rejectionTime: '2026-08-01T00:00:00Z' });
|
|
expect(warningTypes(combined).slice(0, 2).join(',') === 'container,rejection', 'abnormal-container rejection warnings are ordered container,rejection');
|
|
const combinedHtml = context.renderLastMileWarningTags(combined, now);
|
|
expect(combinedHtml.includes('货柜异常') && combinedHtml.includes('拒收') && (combinedHtml.match(/<span\b[^>]*>/g) || []).length === 2, 'warning renderer emits two separate tags for the combined warning');
|
|
const escapedTitle = context.renderLastMileWarningTags(courier({ serviceType: '头程', actualInboundTime: '', containerNo: 'ABNORMAL-XSS' }), now);
|
|
expect(escapedTitle.includes('title=') && escapedTitle.includes('<异常>') && !escapedTitle.includes('<异常>'), 'warning renderer escapes dynamic warning titles');
|
|
expect(context.renderLastMileWarningTags(courier({ serviceType: '头程', actualInboundTime: '', containerNo: '' }), now) === '—', 'normal order warning renderer returns exact em dash placeholder');
|
|
} catch (error) {
|
|
expect(false, 'warning function VM execution succeeds: ' + error.message);
|
|
}
|
|
}
|
|
|
|
if (failures.length) {
|
|
console.error('LAST_MILE_ORDER_WARNINGS_CHECK_FAIL');
|
|
failures.forEach(message => console.error('- ' + message));
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log('LAST_MILE_ORDER_WARNINGS_CHECK_PASS');
|
|
}
|