1
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const sourceRoot = path.join(root, 'prototype-src');
|
||||
const readSource = (relativePath) => fs.readFileSync(path.join(sourceRoot, relativePath), 'utf8');
|
||||
|
||||
const manifest = JSON.parse(readSource('manifest.json'));
|
||||
const shell = readSource('shell.html');
|
||||
const app = readSource('app.js');
|
||||
const moduleSources = new Map(
|
||||
manifest.modules.map((module) => [module.file, readSource(module.file)]),
|
||||
);
|
||||
const domesticHtml = moduleSources.get('modules/02-domestic-warehouse.html');
|
||||
const overseasHtml = moduleSources.get('modules/03-overseas-warehouse.html');
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getAttribute(tag, attribute) {
|
||||
const match = new RegExp(`\\b${escapeRegExp(attribute)}\\s*=\\s*(["'])(.*?)\\1`, 'i').exec(tag);
|
||||
return match ? match[2] : null;
|
||||
}
|
||||
|
||||
function hasClass(tag, className) {
|
||||
return (getAttribute(tag, 'class') || '').split(/\s+/).includes(className);
|
||||
}
|
||||
|
||||
function countIdOccurrences(source, id) {
|
||||
const idPattern = /\bid\s*=\s*(["'])(.*?)\1/gi;
|
||||
return [...source.matchAll(idPattern)].filter((match) => match[2] === id).length;
|
||||
}
|
||||
|
||||
function countExact(source, text) {
|
||||
let count = 0;
|
||||
let cursor = 0;
|
||||
while ((cursor = source.indexOf(text, cursor)) !== -1) {
|
||||
count += 1;
|
||||
cursor += text.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function getPageSource(source, pageId, moduleLabel) {
|
||||
const openingDiv = /<div\b[^>]*>/gi;
|
||||
let match;
|
||||
let start = -1;
|
||||
|
||||
while ((match = openingDiv.exec(source))) {
|
||||
if (hasClass(match[0], 'page') && getAttribute(match[0], 'id') === `page-${pageId}`) {
|
||||
start = match.index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert.notEqual(start, -1, `${moduleLabel}找不到页面起点 page-${pageId}`);
|
||||
|
||||
while ((match = openingDiv.exec(source))) {
|
||||
if (hasClass(match[0], 'page') && (getAttribute(match[0], 'id') || '').startsWith('page-')) {
|
||||
return source.slice(start, match.index);
|
||||
}
|
||||
}
|
||||
return source.slice(start);
|
||||
}
|
||||
|
||||
function getDivBlock(source, start, label) {
|
||||
const tags = /<div\b[^>]*>|<\/div\s*>/gi;
|
||||
tags.lastIndex = start;
|
||||
let depth = 0;
|
||||
let match;
|
||||
|
||||
while ((match = tags.exec(source))) {
|
||||
if (match.index === start || depth > 0) {
|
||||
depth += /^<\/div/i.test(match[0]) ? -1 : 1;
|
||||
if (depth === 0) return source.slice(start, tags.lastIndex);
|
||||
}
|
||||
}
|
||||
|
||||
assert.fail(`${label}的 div 结构未闭合`);
|
||||
}
|
||||
|
||||
function getSidebarBlock(source, label) {
|
||||
const openingDiv = /<div\b[^>]*>/gi;
|
||||
const blocks = [];
|
||||
let match;
|
||||
|
||||
while ((match = openingDiv.exec(source))) {
|
||||
if (!hasClass(match[0], 'sidebar-item')) continue;
|
||||
const block = getDivBlock(source, match.index, `${label}侧栏`);
|
||||
if (block.includes(`>${label}<span class="arrow">`)) blocks.push(block);
|
||||
}
|
||||
|
||||
assert.equal(blocks.length, 1, `shell 中应恰好有一个${label}侧栏块`);
|
||||
return blocks[0];
|
||||
}
|
||||
|
||||
function getHeaders(pageSource, pageId) {
|
||||
const head = pageSource.match(/<thead\b[^>]*>([\s\S]*?)<\/thead>/i);
|
||||
assert.ok(head, `page-${pageId} 缺少表头`);
|
||||
return [...head[1].matchAll(/<th\b[^>]*>([\s\S]*?)<\/th>/gi)].map((match) =>
|
||||
match[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function getBracedBody(source, openBrace, label) {
|
||||
let depth = 0;
|
||||
let quote = '';
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
|
||||
for (let index = openBrace; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
const next = source[index + 1];
|
||||
|
||||
if (lineComment) {
|
||||
if (char === '\n') lineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (blockComment) {
|
||||
if (char === '*' && next === '/') {
|
||||
blockComment = false;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === '\\') {
|
||||
index += 1;
|
||||
} else if (char === quote) {
|
||||
quote = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && next === '/') {
|
||||
lineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && next === '*') {
|
||||
blockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === '{') depth += 1;
|
||||
if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(openBrace + 1, index);
|
||||
}
|
||||
}
|
||||
|
||||
assert.fail(`${label}的代码块未闭合`);
|
||||
}
|
||||
|
||||
function getFunctionBody(source, functionName) {
|
||||
const signature = new RegExp(`function\\s+${escapeRegExp(functionName)}\\s*\\([^)]*\\)\\s*\\{`).exec(source);
|
||||
assert.ok(signature, `app.js 缺少 ${functionName} 函数`);
|
||||
const openBrace = signature.index + signature[0].lastIndexOf('{');
|
||||
return getBracedBody(source, openBrace, functionName);
|
||||
}
|
||||
|
||||
function getConstObjectBody(source, constName) {
|
||||
const declaration = new RegExp(`const\\s+${escapeRegExp(constName)}\\s*=\\s*\\{`).exec(source);
|
||||
assert.ok(declaration, `app.js 缺少 ${constName} 配置对象`);
|
||||
const openBrace = declaration.index + declaration[0].lastIndexOf('{');
|
||||
return getBracedBody(source, openBrace, constName);
|
||||
}
|
||||
|
||||
const domesticModule = manifest.modules.find((module) => module.file === 'modules/02-domestic-warehouse.html');
|
||||
const overseasModule = manifest.modules.find((module) => module.file === 'modules/03-overseas-warehouse.html');
|
||||
|
||||
assert.ok(domesticModule, 'manifest 缺少国内仓模块');
|
||||
assert.ok(overseasModule, 'manifest 缺少海外仓模块');
|
||||
assert.ok(domesticModule.pages.includes('domestic-location'), '国内仓模块缺少 domestic-location');
|
||||
assert.ok(!domesticModule.pages.includes('overseas-location'), '国内仓模块不应包含 overseas-location');
|
||||
assert.ok(overseasModule.pages.includes('overseas-location'), '海外仓模块缺少 overseas-location');
|
||||
|
||||
for (const [pageId, expectedOwner] of [
|
||||
['domestic-location', 'modules/02-domestic-warehouse.html'],
|
||||
['overseas-location', 'modules/03-overseas-warehouse.html'],
|
||||
]) {
|
||||
const owners = manifest.modules.filter((module) => module.pages.includes(pageId));
|
||||
assert.equal(owners.length, 1, `${pageId} 在 manifest 中必须恰好归属一个模块`);
|
||||
assert.equal(owners[0].file, expectedOwner, `${pageId} 的 manifest 模块归属错误`);
|
||||
const sourceOccurrences = [...moduleSources.values()].reduce(
|
||||
(total, source) => total + countIdOccurrences(source, `page-${pageId}`),
|
||||
0,
|
||||
);
|
||||
assert.equal(sourceOccurrences, 1, `page-${pageId} 在全部模块源码中必须恰好出现一次`);
|
||||
}
|
||||
|
||||
assert.equal(countIdOccurrences(domesticHtml, 'page-domestic-location'), 1, '国内仓模块必须有且仅有一个 page-domestic-location');
|
||||
assert.equal(countIdOccurrences(domesticHtml, 'page-overseas-location'), 0, '国内仓模块不应包含 page-overseas-location');
|
||||
assert.equal(countIdOccurrences(overseasHtml, 'page-overseas-location'), 1, '海外仓模块必须有且仅有一个 page-overseas-location');
|
||||
|
||||
const domesticCall = "switchPage('domestic-location',this)";
|
||||
const overseasCall = "switchPage('overseas-location',this)";
|
||||
assert.equal(countExact(shell, domesticCall), 1, `shell 中 ${domesticCall} 应恰好出现一次`);
|
||||
assert.equal(countExact(shell, overseasCall), 1, `shell 中 ${overseasCall} 应恰好出现一次`);
|
||||
|
||||
const domesticSidebar = getSidebarBlock(shell, '国内仓');
|
||||
const overseasSidebar = getSidebarBlock(shell, '海外仓');
|
||||
assert.equal(countExact(domesticSidebar, domesticCall), 1, 'domestic-location 导航必须位于国内仓侧栏块');
|
||||
assert.equal(countExact(overseasSidebar, overseasCall), 1, 'overseas-location 导航必须位于海外仓侧栏块');
|
||||
function getSidebarPageOrder(sidebar) {
|
||||
return [...sidebar.matchAll(/class=["']sub-menu-item[^"']*["'][^>]*onclick=["']switchPage\(["']([^"']+)["']/g)]
|
||||
.map((match) => match[1]);
|
||||
}
|
||||
const domesticSidebarOrder = getSidebarPageOrder(domesticSidebar);
|
||||
const overseasSidebarOrder = getSidebarPageOrder(overseasSidebar);
|
||||
assert.equal(
|
||||
domesticSidebarOrder.indexOf('domestic-location'),
|
||||
domesticSidebarOrder.indexOf('pallet-manage') + 1,
|
||||
'国内仓库位管理必须紧跟托盘管理',
|
||||
);
|
||||
assert.equal(
|
||||
overseasSidebarOrder.indexOf('overseas-location'),
|
||||
overseasSidebarOrder.indexOf('fba-pallet') + 1,
|
||||
'海外仓库位管理必须紧跟托盘管理',
|
||||
);
|
||||
|
||||
const domesticPage = getPageSource(domesticHtml, 'domestic-location', '国内仓模块');
|
||||
const overseasPage = getPageSource(overseasHtml, 'overseas-location', '海外仓模块');
|
||||
const expectedHeaders = ['操作', '库位编码', '仓库', '库区', '库位类型', '最大承重(kg)', '状态', '备注'];
|
||||
|
||||
assert.deepEqual(getHeaders(domesticPage, 'domestic-location'), expectedHeaders, '国内仓库位管理表头不符合契约');
|
||||
assert.deepEqual(getHeaders(overseasPage, 'overseas-location'), expectedHeaders, '海外仓库位管理表头不符合契约');
|
||||
|
||||
const locationControlSuffixes = [
|
||||
'wh',
|
||||
'zone',
|
||||
'code',
|
||||
'status',
|
||||
'code-in',
|
||||
'wh-in',
|
||||
'zone-in',
|
||||
'type-in',
|
||||
'weight-in',
|
||||
'status-in',
|
||||
'remark-in',
|
||||
];
|
||||
|
||||
function assertLocationControlIds(pageSource, prefix, pageLabel) {
|
||||
for (const suffix of locationControlSuffixes) {
|
||||
const id = `${prefix}-${suffix}`;
|
||||
assert.equal(countIdOccurrences(pageSource, id), 1, `${pageLabel}必须有且仅有一个控件 ID ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
assertLocationControlIds(domesticPage, 'dom-loc', '国内仓库位管理页面');
|
||||
assertLocationControlIds(overseasPage, 'ovs-loc', '海外仓库位管理页面');
|
||||
|
||||
for (const contract of [
|
||||
{
|
||||
page: domesticPage,
|
||||
module: domesticHtml,
|
||||
label: '国内仓',
|
||||
ids: ['modal-domestic-location', 'dom-loc-modal-title', 'tbody-domestic-location', 'dom-loc-total'],
|
||||
},
|
||||
{
|
||||
page: overseasPage,
|
||||
module: overseasHtml,
|
||||
label: '海外仓',
|
||||
ids: ['modal-overseas-location', 'ovs-loc-modal-title', 'tbody-overseas-location', 'ovs-loc-total'],
|
||||
},
|
||||
]) {
|
||||
for (const id of contract.ids) {
|
||||
assert.equal(countIdOccurrences(contract.page, id), 1, `${contract.label}库位页面必须有且仅有一个 ID ${id}`);
|
||||
assert.equal(countIdOccurrences(contract.module, id), 1, `${contract.label}模块必须有且仅有一个 ID ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIndependentArrayInitializer(name) {
|
||||
const declarations = new RegExp(`\\bconst\\s+${escapeRegExp(name)}\\s*=`, 'g');
|
||||
assert.equal(countOccurrences(app, declarations), 1, `app.js 必须有且仅有一个 ${name} 声明`);
|
||||
const initializer = new RegExp(
|
||||
`\\bconst\\s+${escapeRegExp(name)}\\s*=\\s*(?:\\[|\\(\\s*function\\s*\\()`,
|
||||
);
|
||||
assert.match(app, initializer, `${name} 必须使用独立数组初始化器,不能引用另一数据集`);
|
||||
}
|
||||
|
||||
function countOccurrences(source, pattern) {
|
||||
return [...source.matchAll(pattern)].length;
|
||||
}
|
||||
|
||||
assertIndependentArrayInitializer('domesticLocationData');
|
||||
assertIndependentArrayInitializer('overseasLocationData');
|
||||
|
||||
const configBody = getConstObjectBody(app, 'locationManagementConfigs');
|
||||
function getScopeConfig(scope) {
|
||||
const match = new RegExp(
|
||||
`(?:^|,)\\s*(?:${escapeRegExp(scope)}|["']${escapeRegExp(scope)}["'])\\s*:\\s*\\{([\\s\\S]*?)\\}(?=\\s*(?:,|$))`,
|
||||
).exec(configBody);
|
||||
assert.ok(match, `locationManagementConfigs 缺少 ${scope} 配置`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
assert.match(getScopeConfig('domestic'), /\bdata\s*:\s*domesticLocationData\b/, 'domestic 配置必须绑定 domesticLocationData');
|
||||
assert.match(getScopeConfig('overseas'), /\bdata\s*:\s*overseasLocationData\b/, 'overseas 配置必须绑定 overseasLocationData');
|
||||
|
||||
assert.ok(app.includes('库位编码已存在'), 'app.js 缺少库位编码重复校验提示');
|
||||
assert.ok(app.includes('最大承重不能小于 0'), 'app.js 缺少最大承重非负校验提示');
|
||||
assert.match(app, /function\s+renderDomesticLocation\s*\(/, 'app.js 缺少 renderDomesticLocation 函数');
|
||||
assert.match(app, /function\s+renderOverseasLocation\s*\(/, 'app.js 缺少 renderOverseasLocation 函数');
|
||||
assert.ok(
|
||||
shell.includes('#modal-domestic-location .form-row,#modal-overseas-location .form-row{grid-template-columns:minmax(0,1fr)'),
|
||||
'国内仓和海外仓库位弹窗在窄屏下必须切换为单列表单',
|
||||
);
|
||||
|
||||
const switchPageBody = getFunctionBody(app, 'switchPage');
|
||||
assert.match(
|
||||
switchPageBody,
|
||||
/if\s*\(\s*page\s*===\s*["']domestic-location["']\s*\)\s*renderDomesticLocation\s*\(/,
|
||||
'switchPage 缺少 domestic-location 初始化钩子',
|
||||
);
|
||||
assert.match(
|
||||
switchPageBody,
|
||||
/if\s*\(\s*page\s*===\s*["']overseas-location["']\s*\)\s*renderOverseasLocation\s*\(/,
|
||||
'switchPage 缺少 overseas-location 初始化钩子',
|
||||
);
|
||||
|
||||
console.log('库位管理结构与交互契约验证通过。');
|
||||
Reference in New Issue
Block a user