This commit is contained in:
2026-08-21 09:20:26 +08:00
parent c9c6d5a0f9
commit 49465315e2
89 changed files with 16413 additions and 1776 deletions
@@ -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('库位管理结构与交互契约验证通过。');