314 lines
15 KiB
JavaScript
314 lines
15 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 read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||
const general = read('prototype-src/modules/10-general.html');
|
||
const overlays = read('prototype-src/overlays.html');
|
||
const shell = read('prototype-src/shell.html');
|
||
const app = read('prototype-src/app.js');
|
||
const agents = read('AGENTS.md');
|
||
const manifest = JSON.parse(read('prototype-src/manifest.json'));
|
||
|
||
function count(source, pattern) {
|
||
return (source.match(pattern) || []).length;
|
||
}
|
||
|
||
function pageFragment(source, pageId) {
|
||
const marker = `<div class="page" id="page-${pageId}">`;
|
||
const start = source.indexOf(marker);
|
||
assert.ok(start >= 0, `缺少页面 page-${pageId}`);
|
||
const next = source.indexOf('<div class="page" id="page-', start + marker.length);
|
||
return source.slice(start, next < 0 ? source.length : next);
|
||
}
|
||
|
||
function headers(page) {
|
||
return Array.from(page.matchAll(/<th>([^<]+)<\/th>/g), match => match[1]);
|
||
}
|
||
|
||
function tabMarkup(page, targetPage) {
|
||
const match = new RegExp(`<button\\b[^>]*switchWarehousePage\\('${targetPage}',\\s*this\\)[^>]*>`, 'g').exec(page);
|
||
assert.ok(match, `缺少跳转至 ${targetPage} 的仓库页签`);
|
||
return match[0];
|
||
}
|
||
|
||
function extractBalanced(source, start, open, close) {
|
||
let depth = 0;
|
||
let quote = '';
|
||
for (let index = start; index < source.length; index += 1) {
|
||
const char = source[index];
|
||
if (quote) {
|
||
if (char === '\\') index += 1;
|
||
else if (char === quote) quote = '';
|
||
continue;
|
||
}
|
||
if (char === '\'' || char === '"' || char === '`') {
|
||
quote = char;
|
||
continue;
|
||
}
|
||
if (char === open) depth += 1;
|
||
if (char === close) {
|
||
depth -= 1;
|
||
if (depth === 0) return index;
|
||
}
|
||
}
|
||
throw new Error(`无法闭合 ${open}${close} 片段`);
|
||
}
|
||
|
||
function extractArrayDeclaration(source, name) {
|
||
const match = new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
|
||
assert.ok(match, `app.js 缺少 ${name} 数据数组`);
|
||
const open = source.indexOf('[', match.index + match[0].length);
|
||
const end = extractBalanced(source, open, '[', ']');
|
||
return source.slice(match.index, source.indexOf(';', end) + 1);
|
||
}
|
||
|
||
function extractVariableDeclaration(source, name) {
|
||
const match = new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
|
||
assert.ok(match, `app.js 缺少 ${name} 声明`);
|
||
const open = source.indexOf('{', match.index + match[0].length);
|
||
const end = extractBalanced(source, open, '{', '}');
|
||
return source.slice(match.index, source.indexOf(';', end) + 1);
|
||
}
|
||
|
||
function extractFunction(source, name) {
|
||
const match = new RegExp(`function\\s+${name}\\s*\\(`).exec(source);
|
||
assert.ok(match, `app.js 缺少函数 ${name}`);
|
||
const open = source.indexOf('{', match.index + match[0].length);
|
||
const end = extractBalanced(source, open, '{', '}');
|
||
return source.slice(match.index, end + 1);
|
||
}
|
||
|
||
function makeTbody(eventLog, name) {
|
||
let html = '';
|
||
const element = {};
|
||
Object.defineProperty(element, 'innerHTML', {
|
||
get: () => html,
|
||
set: value => {
|
||
html = String(value);
|
||
eventLog.push(['render', name, html]);
|
||
}
|
||
});
|
||
return element;
|
||
}
|
||
|
||
const ownedPage = pageFragment(general, 'gen-owned-warehouse');
|
||
const fbaPage = pageFragment(general, 'gen-fba');
|
||
const partnerPage = pageFragment(general, 'gen-partner-warehouse');
|
||
const pageCases = [
|
||
['自有仓', ownedPage, 'gen-owned-warehouse'],
|
||
['FBA仓库', fbaPage, 'gen-fba'],
|
||
['合作仓', partnerPage, 'gen-partner-warehouse']
|
||
];
|
||
|
||
for (const [name, page, activePage] of pageCases) {
|
||
for (const target of ['gen-owned-warehouse', 'gen-fba', 'gen-partner-warehouse']) {
|
||
assert.match(page, new RegExp(`switchWarehousePage\\('${target}',\\s*this\\)`), `${name}缺少 ${target} 页签`);
|
||
const tab = tabMarkup(page, target);
|
||
assert.match(tab, new RegExp(`aria-selected="${target === activePage ? 'true' : 'false'}"`), `${name}页签 aria-selected 错误`);
|
||
if (target === activePage) assert.match(tab, /class="[^"]*active[^"]*"/, `${name}当前页签未高亮`);
|
||
else assert.doesNotMatch(tab, /class="[^"]*active[^"]*"/, `${name}非当前页签错误高亮`);
|
||
}
|
||
}
|
||
|
||
assert.deepEqual(headers(ownedPage), ['自有仓名称', '仓库地址', '城市', '州/省', '邮编', '国家', '状态', '操作']);
|
||
assert.doesNotMatch(ownedPage, /距离(MILES)|data-owned-warehouse-select/, '自有仓页面不得维护距离');
|
||
assert.match(ownedPage, /openWarehouseAdd\('owned'\)/, '自有仓新增入口未指定 owned 目标');
|
||
const targetHeaders = ['仓库代码', '仓库地址', '城市', '州/省', '邮编', '国家', '距离(MILES)', '状态', '操作'];
|
||
assert.deepEqual(headers(fbaPage), targetHeaders);
|
||
assert.deepEqual(headers(partnerPage), targetHeaders);
|
||
assert.match(fbaPage, /id="gen-fba-owned-warehouse"[^>]*data-owned-warehouse-select/);
|
||
assert.match(partnerPage, /id="gen-partner-warehouse-owned-warehouse"[^>]*data-owned-warehouse-select/);
|
||
|
||
const sidebarStart = shell.indexOf('<aside');
|
||
const sidebarEnd = shell.indexOf('</aside>', sidebarStart);
|
||
const sidebar = shell.slice(sidebarStart, sidebarEnd);
|
||
assert.equal(count(sidebar, /switchPage\('gen-owned-warehouse',this\)">仓库管理<\/div>/g), 1, '侧栏仓库管理必须默认进入自有仓');
|
||
assert.doesNotMatch(sidebar, /sub-menu-item[^>]+gen-(?:fba|partner-warehouse)/, '侧栏不得为 FBA 或合作仓增加独立入口');
|
||
assert.match(shell, /#page-gen-owned-warehouse \.warehouse-table-wrap[^{}]*,[^{}]*#page-gen-fba \.warehouse-table-wrap[^{}]*,[^{}]*#page-gen-partner-warehouse \.warehouse-table-wrap/);
|
||
|
||
const generalModule = manifest.modules.find(module => module.file === 'modules/10-general.html');
|
||
assert.ok(generalModule, 'manifest 缺少通用资料模块');
|
||
for (const list of [manifest.pageOrder, generalModule.pages]) {
|
||
assert.equal(list.filter(id => id === 'gen-owned-warehouse').length, 1, 'gen-owned-warehouse 必须只登记一次');
|
||
const ownedIndex = list.indexOf('gen-owned-warehouse');
|
||
assert.equal(list[ownedIndex + 1], 'gen-fba', '自有仓后必须紧接 FBA仓库');
|
||
assert.equal(list[ownedIndex + 2], 'gen-partner-warehouse', 'FBA仓库后必须紧接合作仓');
|
||
}
|
||
assert.match(agents, /仓库(`gen-owned-warehouse`、`gen-fba`、`gen-partner-warehouse`)/);
|
||
|
||
const modalStart = overlays.indexOf('<div class="modal-overlay" id="modal-fba-add">');
|
||
assert.ok(modalStart >= 0, '共享浮层中缺少仓库表单弹窗');
|
||
const nextModalStart = overlays.indexOf('<div class="modal-overlay"', modalStart + 1);
|
||
const warehouseModal = overlays.slice(modalStart, nextModalStart < 0 ? overlays.length : nextModalStart);
|
||
assert.match(warehouseModal, /id="warehouse-form-title"/);
|
||
assert.match(warehouseModal, /id="warehouse-owned-name-row"/);
|
||
assert.match(warehouseModal, /id="warehouse-code-row"/);
|
||
assert.match(warehouseModal, /id="warehouse-distance-row"[^>]*style="display:none"/);
|
||
assert.match(warehouseModal, /id="warehouse-distance-origin"/);
|
||
assert.match(warehouseModal, /id="warehouse-distance-miles"[^>]*type="number"[^>]*min="0"/);
|
||
assert.match(warehouseModal, /onclick="saveWarehouseForm\(\)"/);
|
||
|
||
const eventLog = [];
|
||
const showModalCalls = [];
|
||
const hideModalCalls = [];
|
||
const toastCalls = [];
|
||
const ownedTbody = makeTbody(eventLog, 'owned');
|
||
const fbaTbody = makeTbody(eventLog, 'fba');
|
||
const partnerTbody = makeTbody(eventLog, 'partner');
|
||
const fbaOwnedSelect = {value: '洛杉矶仓', innerHTML: ''};
|
||
const partnerOwnedSelect = {value: '洛杉矶仓', innerHTML: ''};
|
||
const elements = {
|
||
'gen-owned-warehouse-keyword': {value: ''},
|
||
'gen-owned-warehouse-country': {value: ''},
|
||
'tbody-gen-owned-warehouse': ownedTbody,
|
||
'gen-fba-owned-warehouse': fbaOwnedSelect,
|
||
'gen-fba-keyword': {value: ''},
|
||
'gen-fba-country': {value: ''},
|
||
'tbody-gen-fba': fbaTbody,
|
||
'gen-partner-warehouse-owned-warehouse': partnerOwnedSelect,
|
||
'gen-partner-warehouse-keyword': {value: ''},
|
||
'gen-partner-warehouse-country': {value: ''},
|
||
'tbody-gen-partner-warehouse': partnerTbody,
|
||
'warehouse-form-title': {textContent: ''},
|
||
'warehouse-owned-name-row': {style: {display: 'none'}},
|
||
'warehouse-code-row': {style: {display: ''}},
|
||
'warehouse-distance-row': {style: {display: 'none'}},
|
||
'warehouse-distance-origin': {textContent: ''},
|
||
'warehouse-owned-name': {value: ''},
|
||
'fba-add-country': {value: ''},
|
||
'fba-add-code': {value: ''},
|
||
'fba-add-city': {value: ''},
|
||
'fba-add-state': {value: ''},
|
||
'fba-add-addr': {value: ''},
|
||
'fba-add-zip': {value: ''},
|
||
'fba-add-tax-rate': {value: ''},
|
||
'fba-add-no-tax-rate': {value: ''},
|
||
'fba-add-inner-code': {value: ''},
|
||
'warehouse-distance-miles': {value: ''}
|
||
};
|
||
const context = {
|
||
document: {
|
||
getElementById: id => elements[id] || null,
|
||
querySelectorAll: selector => selector === '[data-owned-warehouse-select]' ? [fbaOwnedSelect, partnerOwnedSelect] : []
|
||
},
|
||
showModal: id => { showModalCalls.push(id); eventLog.push(['show', id]); },
|
||
hideModal: id => { hideModalCalls.push(id); eventLog.push(['hide', id]); },
|
||
showToast: (message, type) => { toastCalls.push([message, type]); eventLog.push(['toast', message, type]); }
|
||
};
|
||
const functionNames = [
|
||
'normalizeWarehouseTarget', 'getWarehousePageConfig', 'getNextWarehouseId', 'getNextOwnedWarehouseId',
|
||
'resetWarehouseForm', 'syncOwnedWarehouseOptions', 'setWarehouseFormFieldVisibility',
|
||
'filterWarehouseRows', 'formatGenFbaDistance', 'renderWarehousePage', 'renderOwnedWarehouse',
|
||
'renderGenFba', 'renderPartnerWarehouse', 'openWarehouseAdd', 'openWarehouseEdit', 'saveWarehouseForm'
|
||
];
|
||
const vmSource = [
|
||
extractArrayDeclaration(app, 'genOwnedWarehouseData'),
|
||
extractArrayDeclaration(app, 'genFbaData'),
|
||
extractArrayDeclaration(app, 'genPartnerWarehouseData'),
|
||
extractVariableDeclaration(app, 'warehouseFormState'),
|
||
...functionNames.map(name => extractFunction(app, name)),
|
||
'this.api={genOwnedWarehouseData,genFbaData,genPartnerWarehouseData,warehouseFormState,renderOwnedWarehouse,renderGenFba,renderPartnerWarehouse,openWarehouseAdd,openWarehouseEdit,saveWarehouseForm,syncOwnedWarehouseOptions};'
|
||
].join('\n');
|
||
vm.runInNewContext(vmSource, context);
|
||
|
||
assert.deepEqual(Array.from(context.api.genOwnedWarehouseData, row => row.name), ['洛杉矶仓', '纽约仓']);
|
||
for (const row of context.api.genOwnedWarehouseData) {
|
||
assert.equal(Object.hasOwn(row, 'distanceMiles'), false, '自有仓记录不得包含距离数据');
|
||
}
|
||
context.api.syncOwnedWarehouseOptions();
|
||
assert.match(fbaOwnedSelect.innerHTML, /洛杉矶仓/);
|
||
assert.match(fbaOwnedSelect.innerHTML, /纽约仓/);
|
||
context.api.renderOwnedWarehouse();
|
||
assert.match(ownedTbody.innerHTML, /洛杉矶仓/);
|
||
assert.doesNotMatch(ownedTbody.innerHTML, /MILES|distanceMiles/);
|
||
|
||
context.api.openWarehouseEdit('owned', 'OWN001');
|
||
assert.equal(elements['warehouse-owned-name-row'].style.display, '');
|
||
assert.equal(elements['warehouse-code-row'].style.display, 'none');
|
||
assert.equal(elements['warehouse-distance-row'].style.display, 'none');
|
||
assert.equal(elements['warehouse-owned-name'].value, '洛杉矶仓');
|
||
|
||
fbaOwnedSelect.value = '洛杉矶仓';
|
||
context.api.openWarehouseEdit('fba', 'FBA001');
|
||
assert.equal(elements['warehouse-distance-row'].style.display, '');
|
||
assert.equal(elements['warehouse-distance-origin'].textContent, '洛杉矶仓');
|
||
assert.equal(elements['warehouse-distance-miles'].value, '22');
|
||
elements['warehouse-distance-miles'].value = '31.5';
|
||
context.api.saveWarehouseForm();
|
||
assert.equal(context.api.genFbaData[0].distanceMiles['洛杉矶仓'], 31.5);
|
||
assert.equal(context.api.genFbaData[0].distanceMiles['纽约仓'], 2800);
|
||
assert.equal(context.api.genFbaData[1].distanceMiles['洛杉矶仓'], 73);
|
||
|
||
partnerOwnedSelect.value = '纽约仓';
|
||
context.api.openWarehouseEdit('partner', 'FBA008');
|
||
assert.equal(elements['warehouse-distance-origin'].textContent, '纽约仓');
|
||
assert.equal(elements['warehouse-distance-miles'].value, '2770');
|
||
elements['warehouse-distance-miles'].value = '';
|
||
context.api.saveWarehouseForm();
|
||
assert.equal(Object.hasOwn(context.api.genPartnerWarehouseData[0].distanceMiles, '纽约仓'), false);
|
||
assert.equal(context.api.genPartnerWarehouseData[0].distanceMiles['洛杉矶仓'], 48);
|
||
|
||
partnerOwnedSelect.value = '洛杉矶仓';
|
||
context.api.openWarehouseEdit('partner', 'FBA008');
|
||
const hideCountBeforeInvalid = hideModalCalls.length;
|
||
elements['warehouse-distance-miles'].value = '-1';
|
||
context.api.saveWarehouseForm();
|
||
assert.equal(context.api.genPartnerWarehouseData[0].distanceMiles['洛杉矶仓'], 48);
|
||
assert.equal(hideModalCalls.length, hideCountBeforeInvalid, '非法距离不得关闭弹窗');
|
||
assert.deepEqual(toastCalls.at(-1), ['距离必须为非负数', 'warning']);
|
||
|
||
fbaOwnedSelect.value = '洛杉矶仓';
|
||
context.api.openWarehouseAdd('fba');
|
||
assert.equal(elements['warehouse-distance-row'].style.display, '', 'FBA 新增必须显示距离字段');
|
||
assert.equal(elements['warehouse-distance-origin'].textContent, '洛杉矶仓');
|
||
elements['fba-add-country'].value = '美国';
|
||
elements['fba-add-code'].value = 'SEA8';
|
||
elements['fba-add-city'].value = 'Seattle';
|
||
elements['fba-add-state'].value = 'WA';
|
||
elements['fba-add-addr'].value = '100 Demo St';
|
||
elements['fba-add-zip'].value = '98101';
|
||
elements['warehouse-distance-miles'].value = '1135.5';
|
||
context.api.saveWarehouseForm();
|
||
const addedFba = context.api.genFbaData.at(-1);
|
||
assert.equal(addedFba.code, 'SEA8');
|
||
assert.deepEqual(Object.keys(addedFba.distanceMiles), ['洛杉矶仓']);
|
||
assert.equal(addedFba.distanceMiles['洛杉矶仓'], 1135.5);
|
||
|
||
partnerOwnedSelect.value = '纽约仓';
|
||
context.api.openWarehouseAdd('partner');
|
||
assert.equal(elements['warehouse-distance-row'].style.display, '', '合作仓新增必须显示距离字段');
|
||
assert.equal(elements['warehouse-distance-origin'].textContent, '纽约仓');
|
||
elements['fba-add-country'].value = '美国';
|
||
elements['fba-add-code'].value = 'EWR-DEMO';
|
||
elements['fba-add-city'].value = 'Newark';
|
||
elements['fba-add-state'].value = 'NJ';
|
||
elements['fba-add-addr'].value = '200 Demo Ave';
|
||
elements['fba-add-zip'].value = '07101';
|
||
elements['warehouse-distance-miles'].value = '18';
|
||
context.api.saveWarehouseForm();
|
||
const addedPartner = context.api.genPartnerWarehouseData.at(-1);
|
||
assert.equal(addedPartner.code, 'EWR-DEMO');
|
||
assert.deepEqual(Object.keys(addedPartner.distanceMiles), ['纽约仓']);
|
||
assert.equal(addedPartner.distanceMiles['纽约仓'], 18);
|
||
|
||
context.api.openWarehouseAdd('owned');
|
||
elements['warehouse-owned-name'].value = '芝加哥仓';
|
||
elements['fba-add-country'].value = '美国';
|
||
elements['fba-add-city'].value = 'Chicago';
|
||
elements['fba-add-state'].value = 'IL';
|
||
elements['fba-add-addr'].value = '100 Demo Warehouse Rd';
|
||
elements['fba-add-zip'].value = '60601';
|
||
context.api.saveWarehouseForm();
|
||
assert.equal(context.api.genOwnedWarehouseData.length, 3);
|
||
assert.equal(context.api.genOwnedWarehouseData.at(-1).name, '芝加哥仓');
|
||
assert.equal(Object.hasOwn(context.api.genOwnedWarehouseData.at(-1), 'distanceMiles'), false);
|
||
assert.match(fbaOwnedSelect.innerHTML, /芝加哥仓/);
|
||
|
||
assert.ok(showModalCalls.length >= 4, '新增编辑流程必须打开共享弹窗');
|
||
console.log('GEN_FBA_OWNED_DISTANCE_CHECK_PASS');
|