Files
ShiErFeng/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs
T

1725 lines
91 KiB
JavaScript

import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const frontendRoot = fileURLToPath(new URL('..', import.meta.url))
const expectedRequests = {
'src/api/master/strain.js': [
{ url: "'/master/strain/list'", method: 'get' },
{ url: "'/master/strain/' + strainId", method: 'get' },
{ url: "'/master/strain'", method: 'post' },
{ url: "'/master/strain'", method: 'put' },
{ url: "'/master/strain/' + strainIds", method: 'delete' }
],
'src/api/master/customer.js': [
{ url: "'/master/customer/list'", method: 'get' },
{ url: "'/master/customer/' + customerId", method: 'get' },
{ url: "'/master/customer'", method: 'post' },
{ url: "'/master/customer'", method: 'put' },
{ url: "'/master/customer/' + customerIds", method: 'delete' }
],
'src/api/master/site.js': [
{ url: "'/master/site/list'", method: 'get' },
{ url: "'/master/site/' + siteId", method: 'get' },
{ url: "'/master/site'", method: 'post' },
{ url: "'/master/site'", method: 'put' },
{ url: "'/master/site/' + siteIds", method: 'delete' },
{ url: "'/master/site/' + siteId + '/users'", method: 'get' },
{ url: "'/master/site/' + siteId + '/users'", method: 'put' }
],
'src/api/master/line.js': [
{ url: "'/master/line/list'", method: 'get' },
{ url: "'/master/line/' + lineId", method: 'get' },
{ url: "'/master/line'", method: 'post' },
{ url: "'/master/line'", method: 'put' },
{ url: "'/master/line/' + lineIds", method: 'delete' }
],
'src/api/master/warehouse.js': [
{ url: "'/master/warehouse/list'", method: 'get' },
{ url: "'/master/warehouse/' + warehouseId", method: 'get' },
{ url: "'/master/warehouse'", method: 'post' },
{ url: "'/master/warehouse'", method: 'put' },
{ url: "'/master/warehouse/' + warehouseIds", method: 'delete' },
{ url: "'/master/warehouse/' + warehouseId + '/users'", method: 'get' },
{ url: "'/master/warehouse/' + warehouseId + '/users'", method: 'put' }
],
'src/api/master/pallet.js': [
{ url: "'/master/pallet/list'", method: 'get' },
{ url: "'/master/pallet/' + palletId", method: 'get' },
{ url: "'/master/pallet/' + palletId + '/qrcode'", method: 'get' },
{ url: "'/master/pallet'", method: 'post' },
{ url: "'/master/pallet'", method: 'put' },
{ url: "'/master/pallet/' + palletIds", method: 'delete' }
],
'src/api/business/inbound.js': [
{ url: "'/business/inbound/list'", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
{ url: "'/business/inbound/' + inboundId", method: 'get' },
{ url: "'/business/inbound'", method: 'post' },
{ url: "'/business/inbound'", method: 'put' },
{ url: "'/business/inbound/' + inboundIds", method: 'delete' },
{ url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' }
],
'src/api/business/filling.js': [
{ url: "'/business/filling/list'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
{ url: "'/business/filling/' + fillingId", method: 'get' },
{ url: "'/business/filling'", method: 'post' }
],
'src/api/business/shipment.js': [
{ url: "'/business/shipment/list'", method: 'get' },
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
{ url: "'/business/shipment/site-options'", method: 'get' },
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
{ url: "'/business/shipment/' + shipmentId", method: 'get' },
{ url: "'/business/shipment'", method: 'post' },
{ url: "'/business/shipment'", method: 'put' },
{ url: "'/business/shipment/' + shipmentIds", method: 'delete' },
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' }
],
'src/api/business/usage.js': [
{ url: "'/business/usage/list'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
{ url: "'/business/usage/' + usageId", method: 'get' },
{ url: "'/business/usage'", method: 'post' }
],
'src/api/business/return.js': [
{ url: "'/business/return/list'", method: 'get' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
{ url: "'/business/return/request'", method: 'post' },
{ url: "'/business/return/' + returnId", method: 'get' },
{ url: "'/business/return/' + returnId + '/confirm'", method: 'put' }
],
'src/api/report/palletInventory.js': [
{ url: "'/report/pallet-inventory/list'", method: 'get' }
],
'src/api/report/filling.js': [
{ url: "'/report/filling/list'", method: 'get' }
],
'src/api/report/shipment.js': [
{ url: "'/report/shipment/list'", method: 'get' }
],
'src/api/report/consumption.js': [
{ url: "'/report/consumption/list'", method: 'get' }
],
'src/api/report/return.js': [
{ url: "'/report/return/list'", method: 'get' }
],
'src/api/report/palletTrace.js': [
{ url: "'/report/pallet-trace/list'", method: 'get' }
]
}
const expectedReportExports = {
'src/api/report/palletInventory.js': ['listPalletInventory'],
'src/api/report/filling.js': ['listFillingReport'],
'src/api/report/shipment.js': ['listShipmentReport'],
'src/api/report/consumption.js': ['listConsumptionReport'],
'src/api/report/return.js': ['listReturnReport'],
'src/api/report/palletTrace.js': ['listPalletTrace']
}
const masterPageContracts = {
strain: {
relativePath: 'src/views/master/strain/index.vue',
apiNames: ['addStrain', 'delStrain', 'getStrain', 'listStrain', 'updateStrain'],
permissions: ['add', 'edit', 'list', 'query', 'remove'],
addPayloadKeys: ['remark', 'status', 'strainCode', 'strainName'],
editPayloadKeys: ['remark', 'status', 'strainCode', 'strainId', 'strainName']
},
customer: {
relativePath: 'src/views/master/customer/index.vue',
apiNames: ['addCustomer', 'delCustomer', 'getCustomer', 'listCustomer', 'updateCustomer'],
permissions: ['add', 'edit', 'list', 'query', 'remove'],
addPayloadKeys: ['customerCode', 'customerName', 'remark', 'status'],
editPayloadKeys: ['customerCode', 'customerId', 'customerName', 'remark', 'status']
},
site: {
relativePath: 'src/views/master/site/index.vue',
apiNames: ['addSite', 'delSite', 'getSite', 'listSite', 'updateSite'],
permissions: ['add', 'assign', 'edit', 'list', 'query', 'remove'],
addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'],
editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status']
},
line: {
relativePath: 'src/views/master/line/index.vue',
apiNames: ['addLine', 'delLine', 'getLine', 'listLine', 'updateLine'],
permissions: ['add', 'edit', 'list', 'query', 'remove'],
addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'],
editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status']
},
warehouse: {
relativePath: 'src/views/master/warehouse/index.vue',
apiNames: ['addWarehouse', 'delWarehouse', 'getWarehouse', 'listWarehouse', 'updateWarehouse'],
permissions: ['add', 'assign', 'edit', 'list', 'query', 'remove'],
addPayloadKeys: ['address', 'remark', 'status', 'warehouseCode', 'warehouseName'],
editPayloadKeys: ['address', 'remark', 'status', 'warehouseCode', 'warehouseId', 'warehouseName']
},
pallet: {
relativePath: 'src/views/master/pallet/index.vue',
apiNames: ['addPallet', 'delPallet', 'getPallet', 'getPalletQrcode', 'listPallet', 'updatePallet'],
permissions: ['add', 'edit', 'list', 'print', 'query', 'remove'],
addPayloadKeys: ['capacityPackages', 'palletCode', 'remark'],
editPayloadKeys: ['assetStatus', 'capacityPackages', 'palletId', 'remark']
}
}
const businessPageContracts = {
inbound: {
relativePath: 'src/views/business/inbound/index.vue',
apiNames: [
'addInbound',
'confirmInbound',
'delInbound',
'getInbound',
'getInboundWarehouseOptions',
'listInbound',
'scanInboundPallet',
'updateInbound'
],
permissions: ['add', 'confirm', 'edit', 'list', 'query', 'remove'],
payloads: {
addInbound: ['palletIds', 'warehouseId'],
updateInbound: ['inboundId', 'palletIds', 'warehouseId'],
confirmInbound: ['idempotencyKey']
}
},
filling: {
relativePath: 'src/views/business/filling/index.vue',
apiNames: ['addFilling', 'getFillingStrainOptions', 'listFilling', 'scanFillingPallet'],
permissions: ['add', 'list', 'query'],
payloads: {
addFilling: [
'batchNo',
'idempotencyKey',
'initialPackages',
'packageSpec',
'qrToken',
'strainId',
'warehouseId'
]
}
},
shipment: {
relativePath: 'src/views/business/shipment/index.vue',
apiNames: [
'addShipment',
'confirmShipment',
'delShipment',
'getShipment',
'getShipmentSiteOptions',
'getShipmentWarehouseOptions',
'listShipment',
'scanShipmentPallet',
'updateShipment'
],
permissions: ['add', 'confirm', 'edit', 'list', 'query', 'remove'],
payloads: {
addShipment: ['palletIds', 'sourceWarehouseId', 'targetSiteId'],
updateShipment: ['palletIds', 'shipmentId', 'sourceWarehouseId', 'targetSiteId'],
confirmShipment: ['idempotencyKey']
}
},
usage: {
relativePath: 'src/views/business/usage/index.vue',
apiNames: ['addUsage', 'getUsageLineOptions', 'listUsage', 'scanUsagePallet'],
permissions: ['add', 'list', 'query'],
payloads: {
addUsage: ['idempotencyKey', 'lineId', 'qrToken', 'usedPackages']
}
},
return: {
relativePath: 'src/views/business/return/index.vue',
apiNames: [
'confirmReturn',
'getReturn',
'getReturnWarehouseOptions',
'listReturn',
'requestReturn',
'scanReturnPallet'
],
permissions: ['confirm', 'list', 'query', 'request'],
payloads: {
requestReturn: ['idempotencyKey', 'palletIds', 'sourceSiteId', 'targetWarehouseId'],
confirmReturn: ['idempotencyKey', 'scannedPalletIds']
}
}
}
const reportPageContracts = {
palletInventory: {
relativePath: 'src/views/report/palletInventory/index.vue',
apiName: 'listPalletInventory',
permission: 'report:palletinventory:list',
queryKeys: [
'pageNum', 'pageSize', 'beginTime', 'endTime', 'palletCode', 'flowState',
'warehouseId', 'siteId', 'lineId', 'strainId', 'batchNo'
],
columnKeys: [
'palletCode', 'flowState', 'warehouseName', 'siteName', 'lineName',
'strainName', 'batchNo', 'initialPackages', 'remainingPackages'
]
},
filling: {
relativePath: 'src/views/report/filling/index.vue',
apiName: 'listFillingReport',
permission: 'report:filling:list',
queryKeys: ['pageNum', 'pageSize', 'beginTime', 'endTime', 'warehouseId', 'palletCode', 'strainId', 'batchNo'],
columnKeys: [
'operatedAt', 'warehouseName', 'palletCode', 'strainCodeSnapshot',
'strainNameSnapshot', 'batchNo', 'packageSpec', 'initialPackages'
]
},
shipment: {
relativePath: 'src/views/report/shipment/index.vue',
apiName: 'listShipmentReport',
permission: 'report:shipment:list',
queryKeys: [
'pageNum', 'pageSize', 'beginTime', 'endTime', 'shipmentNo',
'sourceWarehouseId', 'targetSiteId', 'palletCode', 'status'
],
columnKeys: [
'shipmentNo', 'confirmedAt', 'sourceWarehouseName', 'customerName',
'targetSiteName', 'palletCode', 'packageQty'
]
},
consumption: {
relativePath: 'src/views/report/consumption/index.vue',
apiName: 'listConsumptionReport',
permission: 'report:consumption:list',
queryKeys: [
'pageNum', 'pageSize', 'beginTime', 'endTime', 'customerId',
'siteId', 'lineId', 'palletCode', 'strainId', 'batchNo'
],
columnKeys: [
'occurredAt', 'customerName', 'siteName', 'lineName', 'palletCode',
'strainName', 'batchNo', 'usedPackages', 'remainingPackagesAfter'
]
},
return: {
relativePath: 'src/views/report/return/index.vue',
apiName: 'listReturnReport',
permission: 'report:return:list',
queryKeys: [
'pageNum', 'pageSize', 'beginTime', 'endTime', 'returnNo',
'sourceSiteId', 'targetWarehouseId', 'palletCode', 'status'
],
columnKeys: [
'returnNo', 'status', 'requestedAt', 'confirmedAt', 'sourceSiteName',
'targetWarehouseName', 'palletCode', 'remainingPackagesSnapshot'
]
},
palletTrace: {
relativePath: 'src/views/report/palletTrace/index.vue',
apiName: 'listPalletTrace',
permission: 'report:pallettrace:list',
queryKeys: ['pageNum', 'pageSize', 'beginTime', 'endTime', 'palletCode', 'eventType'],
columnKeys: [
'occurredAt', 'eventType', 'sourceType', 'sourceId', 'palletCode',
'beforeState', 'afterState', 'beforeWarehouseName', 'beforeSiteName',
'beforeLineName', 'afterWarehouseName', 'afterSiteName', 'afterLineName',
'beforeRemainingPackages', 'afterRemainingPackages', 'operatorName'
]
}
}
const delimiterPairs = { '(': ')', '[': ']', '{': '}' }
function sanitizeSource(source, { allowTemplateInterpolation = false } = {}) {
const code = source.split('')
const structure = source.split('')
for (let index = 0; index < source.length;) {
const quote = source[index]
if (quote === '\'' || quote === '"' || quote === '`') {
let literalEnd = index + 1
while (literalEnd < source.length && source[literalEnd] !== quote) {
if (source[literalEnd] === '\\') {
literalEnd += 1
} else if (
!allowTemplateInterpolation &&
quote === '`' &&
source[literalEnd] === '$' &&
source[literalEnd + 1] === '{'
) {
throw new Error('契约解析器不支持包含插值的模板字符串')
}
literalEnd += 1
}
if (literalEnd >= source.length) {
throw new Error('契约源码包含未闭合字符串')
}
structure.fill('\0', index, literalEnd + 1)
index = literalEnd + 1
continue
}
const lineComment = source[index] === '/' && source[index + 1] === '/'
const blockComment = source[index] === '/' && source[index + 1] === '*'
if (!lineComment && !blockComment) {
index += 1
continue
}
let commentEnd = lineComment ? source.indexOf('\n', index + 2) : source.indexOf('*/', index + 2)
if (commentEnd === -1 && blockComment) {
throw new Error('契约源码包含未闭合注释')
}
commentEnd = commentEnd === -1 ? source.length : commentEnd + (blockComment ? 2 : 0)
while (index < commentEnd) {
if (source[index] !== '\n' && source[index] !== '\r') {
code[index] = ' '
structure[index] = ' '
}
index += 1
}
}
return { code: code.join(''), structure: structure.join('') }
}
function findClosingDelimiter(structure, startIndex) {
const open = structure[startIndex]
const close = delimiterPairs[open]
let depth = 0
for (let index = startIndex; index < structure.length; index += 1) {
if (structure[index] === open) {
depth += 1
} else if (structure[index] === close && --depth === 0) {
return index
}
}
throw new Error(`契约源码包含未闭合的 ${open}${close}`)
}
function splitTopLevelRanges(structure, separator) {
const ranges = []
const stack = []
let rangeStart = 0
for (let index = 0; index < structure.length; index += 1) {
const character = structure[index]
if (Object.hasOwn(delimiterPairs, character)) {
stack.push(character)
} else if (Object.values(delimiterPairs).includes(character)) {
const open = stack.pop()
if (delimiterPairs[open] !== character) {
throw new Error('request 对象属性括号不匹配')
}
} else if (character === separator && stack.length === 0) {
ranges.push([rangeStart, index])
rangeStart = index + 1
}
}
if (stack.length) {
throw new Error('request 对象属性括号不匹配')
}
ranges.push([rangeStart, structure.length])
return ranges
}
function normalizeUrlExpression(code, structure) {
let normalized = ''
for (let index = 0; index < code.length; index += 1) {
if (structure[index] === '\0') {
normalized += code[index]
} else if (/\s/.test(code[index])) {
continue
} else if (code[index] === '+') {
normalized = normalized.trimEnd() + ' + '
} else {
normalized += code[index]
}
}
return normalized.trim()
}
function parseRequestArgument(argument, callIndex) {
let objectStart = 0
while (/\s/.test(argument.structure[objectStart])) {
objectStart += 1
}
if (argument.structure[objectStart] !== '{') {
throw new Error(`第 ${callIndex} 个 request 调用必须使用对象字面量`)
}
const objectEnd = findClosingDelimiter(argument.structure, objectStart)
if (argument.structure.slice(objectEnd + 1).trim()) {
throw new Error(`第 ${callIndex} 个 request 调用必须只传入一个对象字面量`)
}
const bodyCode = argument.code.slice(objectStart + 1, objectEnd)
const bodyStructure = argument.structure.slice(objectStart + 1, objectEnd)
const values = { url: [], method: [] }
for (const [start, end] of splitTopLevelRanges(bodyStructure, ',')) {
const propertyCode = bodyCode.slice(start, end)
const propertyStructure = bodyStructure.slice(start, end)
if (!propertyCode.trim()) {
continue
}
const propertyParts = splitTopLevelRanges(propertyStructure, ':')
const key = propertyCode.slice(...propertyParts[0]).trim()
if (propertyParts.length !== 2 || !/^[A-Za-z_$][\w$]*$/.test(key)) {
throw new Error(`第 ${callIndex} 个 request 对象只支持静态键值属性`)
}
if (key === 'url' || key === 'method') {
const [valueStart, valueEnd] = propertyParts[1]
values[key].push({
code: propertyCode.slice(valueStart, valueEnd),
structure: propertyStructure.slice(valueStart, valueEnd)
})
}
}
if (values.url.length !== 1 || values.method.length !== 1) {
throw new Error(`第 ${callIndex} 个 request 对象必须包含唯一 url 和 method`)
}
const methodMatch = /^\s*(['"])([a-z]+)\1\s*$/.exec(values.method[0].code)
if (!methodMatch) {
throw new Error(`第 ${callIndex} 个 request method 必须是静态小写字符串`)
}
return {
url: normalizeUrlExpression(values.url[0].code, values.url[0].structure),
method: methodMatch[2]
}
}
function extractRequests(source) {
const sanitized = sanitizeSource(source)
const requestPattern = /(?<![\w$])request\s*\(/g
const requests = []
let match
while ((match = requestPattern.exec(sanitized.structure)) !== null) {
const openParenthesis = match.index + match[0].lastIndexOf('(')
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
requests.push(parseRequestArgument({
code: sanitized.code.slice(openParenthesis + 1, closeParenthesis),
structure: sanitized.structure.slice(openParenthesis + 1, closeParenthesis)
}, requests.length + 1))
}
return requests
}
function extractImportedNames(source, modulePath) {
const escapedPath = modulePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const importMatch = new RegExp(`import\\s*{([^}]*)}\\s*from\\s*['"]${escapedPath}['"]`).exec(source)
assert.ok(importMatch, `缺少 ${modulePath} 的具名导入`)
return importMatch[1]
.split(',')
.map((name) => name.trim())
.filter(Boolean)
.sort()
}
function extractCallObjectKeys(source, callName) {
const escapedCallName = callName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const callMatch = new RegExp(`(?<![\\w$])${escapedCallName}\\s*\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)`).exec(source)
assert.ok(callMatch, `缺少 ${callName}({ ... }) 显式对象调用`)
const properties = callMatch[1].split(',').map((property) => property.trim()).filter(Boolean)
return properties.map((property) => {
const keyMatch = /^([A-Za-z_$][\w$]*)\s*:/.exec(property)
assert.ok(keyMatch, `${callName} payload 禁止展开、简写或动态属性`)
return keyMatch[1]
}).sort()
}
function extractSecondCallObjectKeys(source, callName) {
const escapedCallName = callName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const callMatch = new RegExp(
`(?<![\\w$])${escapedCallName}\\s*\\(\\s*[^,]+,\\s*\\{([\\s\\S]*?)\\}\\s*\\)`
).exec(source)
assert.ok(callMatch, `缺少 ${callName}(id, { ... }) 显式对象调用`)
const properties = callMatch[1].split(',').map((property) => property.trim()).filter(Boolean)
return properties.map((property) => {
const keyMatch = /^([A-Za-z_$][\w$]*)\s*:/.exec(property)
assert.ok(keyMatch, `${callName} payload 禁止展开、简写或动态属性`)
return keyMatch[1]
}).sort()
}
function extractNamedFunction(source, functionName) {
const sanitized = sanitizeSource(source, { allowTemplateInterpolation: true })
const escapedFunctionName = functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = new RegExp(`function\\s+${escapedFunctionName}\\s*\\(`).exec(sanitized.structure)
assert.ok(match, `缺少函数 ${functionName}`)
const parametersStart = sanitized.structure.indexOf('(', match.index)
const parametersEnd = findClosingDelimiter(sanitized.structure, parametersStart)
const bodyStart = sanitized.structure.indexOf('{', parametersEnd)
assert.ok(bodyStart >= 0, `${functionName} 缺少函数体`)
const bodyEnd = findClosingDelimiter(sanitized.structure, bodyStart)
return source.slice(match.index, bodyEnd + 1)
}
function extractPromiseCallback(functionSource, methodName, occurrence = 0) {
const sanitized = sanitizeSource(functionSource, { allowTemplateInterpolation: true })
const marker = `.${methodName}(`
let markerIndex = -1
for (let index = 0; index <= occurrence; index += 1) {
markerIndex = sanitized.structure.indexOf(marker, markerIndex + 1)
assert.ok(markerIndex >= 0, `缺少第 ${occurrence + 1} 个 .${methodName}(...)`)
}
const callStart = markerIndex + marker.length - 1
const callEnd = findClosingDelimiter(sanitized.structure, callStart)
const arrowIndex = sanitized.structure.indexOf('=>', callStart)
assert.ok(arrowIndex > callStart && arrowIndex < callEnd, `.${methodName} 必须传入箭头函数`)
const bodyStart = sanitized.structure.indexOf('{', arrowIndex)
assert.ok(bodyStart > arrowIndex && bodyStart < callEnd, `.${methodName} 回调必须使用块函数体`)
const bodyEnd = findClosingDelimiter(sanitized.structure, bodyStart)
assert.ok(bodyEnd < callEnd, `.${methodName} 回调块超出调用范围`)
return functionSource.slice(bodyStart + 1, bodyEnd)
}
function assertPromiseCallbackStartsWithGuard(functionSource, methodName, guardName, occurrence = 0) {
const callback = extractPromiseCallback(functionSource, methodName, occurrence)
const escapedGuardName = guardName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const guardPattern = new RegExp(
`^\\s*if\\s*\\(\\s*!${escapedGuardName}\\([^)]*\\)\\s*\\)\\s*(?:\\{\\s*return;?\\s*\\}|return;?)`
)
assert.match(
callback,
guardPattern,
`${guardName} 必须是第 ${occurrence + 1} 个 .${methodName} 回调写 UI 前的首个门禁`
)
}
function assertPromiseCallbacksStartWithGuard(functionSource, guardName) {
for (const methodName of ['then', 'catch', 'finally']) {
assertPromiseCallbackStartsWithGuard(functionSource, methodName, guardName)
}
}
function extractButtonBlocks(source, clickHandler) {
return [...source.matchAll(/<el-button\b[\s\S]*?<\/el-button>/g)]
.map((match) => match[0])
.filter((button) => button.includes(`@click="${clickHandler}`))
}
function assertButtonsUsePermission(source, clickHandler, permission) {
const buttons = extractButtonBlocks(source, clickHandler)
assert.ok(buttons.length > 0, `缺少 ${clickHandler} 按钮`)
for (const button of buttons) {
assert.match(button, new RegExp(`v-hasPermi="\\['${permission}'\\]"`), `${clickHandler} 权限不精确`)
}
}
function hasExpectedRequest(expectedRequest) {
return Object.values(expectedRequests).some((requests) =>
requests.some((request) =>
request.url === expectedRequest.url && request.method === expectedRequest.method
)
)
}
test('契约解析器支持 url 与 method 换序及空白格式变化', () => {
const source = `
export function sample(query) {
return request(
{
method : 'get',
params: query,
url :
'/sample/' + query.id
}
)
}
`
assert.deepEqual(extractRequests(source), [
{ url: "'/sample/' + query.id", method: 'get' }
])
})
test('契约解析器拒绝无法静态核对的 request 调用', () => {
assert.throws(
() => extractRequests('export function sample(config) { return request(config) }'),
/request 调用必须使用对象字面量/
)
assert.throws(
() => extractRequests("return request({ url: '/sample', url: '/other', method: 'get' })"),
/唯一 url 和 method/
)
assert.throws(
() => extractRequests("return request({ url: '/sample' })"),
/唯一 url 和 method/
)
})
test('契约解析器忽略注释和字符串中的伪 request 调用', () => {
const source = `
// return request({ url: '/commented', method: 'delete' })
const example = "request(config)"
/*
request({ method: 'post', url: '/also-commented' })
*/
export function sample() {
return request({
url: '/sample',
/* 属性间注释不应干扰解析 */
method: 'get'
})
}
`
assert.deepEqual(extractRequests(source), [
{ url: "'/sample'", method: 'get' }
])
})
test('扫码值会去除首尾空白,空白二维码会被拒绝', async () => {
const { normalizeScanValue } = await import('../src/utils/palletScan.js')
assert.equal(normalizeScanValue(' qr-12345678\n'), 'qr-12345678')
assert.throws(() => normalizeScanValue(' \n\t '), /二维码不能为空/)
})
test('重复扫码仅在相同值和时间窗口内成立', async () => {
const { isDuplicateScan } = await import('../src/utils/palletScan.js')
assert.equal(isDuplicateScan('qr-a', 'qr-a', 1200, 1000, 500), true)
assert.equal(isDuplicateScan('qr-a', 'qr-b', 1200, 1000, 500), false)
})
test('幂等键由 Web Crypto randomUUID 生成且不降级为伪随机值', async () => {
const palletScanPath = path.join(frontendRoot, 'src/utils/palletScan.js')
const source = await readFile(palletScanPath, 'utf8')
const { createIdempotencyKey } = await import('../src/utils/palletScan.js')
assert.match(source, /globalThis\.crypto\.randomUUID\(\)/)
assert.doesNotMatch(source, /Date\.now|Math\.random|new Date/)
assert.match(createIdempotencyKey(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
})
test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
assert.equal(Object.keys(expectedRequests).length, 17)
assert.equal(Object.values(expectedRequests).flat().length, 74)
for (const [relativePath, expected] of Object.entries(expectedRequests)) {
await t.test(relativePath, async () => {
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
const actual = extractRequests(source)
assert.deepEqual(actual, expected)
})
}
})
test('关键扫码、选项、确认与二维码契约被显式覆盖', () => {
const requiredRequests = [
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
{ url: "'/business/shipment/site-options'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' },
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' },
{ url: "'/business/return/' + returnId + '/confirm'", method: 'put' },
{ url: "'/business/return/request'", method: 'post' },
{ url: "'/master/pallet/' + palletId + '/qrcode'", method: 'get' }
]
for (const requiredRequest of requiredRequests) {
assert.equal(hasExpectedRequest(requiredRequest), true, JSON.stringify(requiredRequest))
}
})
test('六个报表 API 只提供 GET /list,不提供导出接口', async () => {
const reportEntries = Object.entries(expectedRequests).filter(([relativePath]) =>
relativePath.startsWith('src/api/report/')
)
assert.equal(reportEntries.length, 6)
for (const [relativePath, expected] of reportEntries) {
assert.equal(expected.length, 1)
assert.equal(expected[0].method, 'get')
assert.match(expected[0].url, /^'\/report\/.+\/list'$/)
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
const exportedFunctionNames = [...source.matchAll(/\bexport\s+function\s+(\w+)/g)].map((match) => match[1])
assert.doesNotMatch(expected[0].url, /export/i)
assert.deepEqual(exportedFunctionNames, expectedReportExports[relativePath])
}
})
test('六个只读报表页面逐页满足 API、权限、筛选、列和异步分页契约', async (t) => {
for (const [resource, contract] of Object.entries(reportPageContracts)) {
await t.test(contract.relativePath, async () => {
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
const importedApiModules = [...source.matchAll(/from\s*['"]@\/api\/(report|master|business)\/([A-Za-z]+)['"]/g)]
.map((match) => `${match[1]}/${match[2]}`)
assert.deepEqual(importedApiModules, [`report/${resource}`], `${resource} 页面只能导入自己的报表 API`)
assert.deepEqual(extractImportedNames(source, `@/api/report/${resource}`), [contract.apiName])
assert.doesNotMatch(source, /@\/api\/(?:master|business)\//)
assert.match(source, new RegExp(`${contract.apiName}\\(listContext\\.query\\)`))
const actualPermissions = [...new Set(
[...source.matchAll(/['"](report:[a-z]+:[a-z]+)['"]/g)].map((match) => match[1])
)]
assert.deepEqual(actualPermissions, [contract.permission], `${resource} 页面只能出现自己的 list 权限`)
assertButtonsUsePermission(source, 'handleQuery', contract.permission)
assertButtonsUsePermission(source, 'resetQuery', contract.permission)
assert.doesNotMatch(source, /report:[a-z]+:(?:add|edit|remove|export|print)/)
assert.doesNotMatch(source, /\bhandle(?:Add|Edit|Delete|Remove|Export|Print|Download)\b|proxy\.download\s*\(/)
for (const queryKey of contract.queryKeys) {
assert.match(source, new RegExp(`\\b${queryKey}\\b`), `${resource} 缺少筛选键 ${queryKey}`)
}
for (const columnKey of contract.columnKeys) {
assert.match(source, new RegExp(`\\b${columnKey}\\b`), `${resource} 缺少核心列 ${columnKey}`)
}
assert.match(source, /<el-date-picker\b[\s\S]*?type="daterange"[\s\S]*?value-format="YYYY-MM-DD"/)
assert.match(source, /<el-table\b[^>]*v-loading="loading"[^>]*:data="reportList"/)
assert.match(source, /<template\s+#empty>[\s\S]*?<el-empty\b/)
assert.match(source, /<el-alert\b[^>]*v-if="loadError"/)
assert.match(source, /<pagination\b[\s\S]*?:total="total"[\s\S]*?:page="queryParams\.pageNum"[\s\S]*?:limit="queryParams\.pageSize"/)
assert.match(source, /@update:page="handleCurrentChange"/)
assert.match(source, /@update:limit="handleSizeChange"/)
assert.match(source, /@pagination="getList"/)
assert.match(source, /let listGeneration = 0;?/)
assert.match(source, /function invalidateListRequest\(\)\s*{[\s\S]*?listGeneration \+= 1[\s\S]*?loading\.value = false/)
assert.match(source, /function isCurrentListRequest\(listContext\)/)
const listSource = extractNamedFunction(source, 'getList')
assert.match(listSource, /generation: \+\+listGeneration/)
assert.match(listSource, /query:/)
assertPromiseCallbacksStartWithGuard(listSource, 'isCurrentListRequest')
assert.match(listSource, /reportList\.value = Array\.isArray\(response\.rows\) \? response\.rows : \[\]/)
assert.match(listSource, /total\.value = response\.total/)
assert.match(listSource, /loadError\.value\s*=/)
assert.match(extractNamedFunction(source, 'handleQuery'), /queryParams\.value\.pageNum = 1[\s\S]*?getList\(\)/)
const resetSource = extractNamedFunction(source, 'resetQuery')
assert.match(resetSource, /dateRange\.value = \[\]/)
assert.match(resetSource, /resetForm\(['"]queryRef['"]\)/)
assert.match(resetSource, /queryParams\.value\.pageNum = 1[\s\S]*?getList\(\)/)
assert.match(extractNamedFunction(source, 'handleSizeChange'), /queryParams\.value\.pageSize = pageSize[\s\S]*?queryParams\.value\.pageNum = 1/)
assert.match(extractNamedFunction(source, 'handleCurrentChange'), /queryParams\.value\.pageNum = pageNum/)
assert.match(source, /onBeforeUnmount\(\(\) => \{\s*invalidateListRequest\(\)\s*}\)/)
})
}
})
test('扫码组件声明交互契约并保持触控尺寸', async () => {
const source = await readFile(path.join(frontendRoot, 'src/components/PalletScanInput/index.vue'), 'utf8')
for (const propName of ['disabled', 'loading', 'minLength', 'duplicateWindowMs']) {
assert.match(source, new RegExp(`\\b${propName}\\b`))
}
for (const eventName of ['scan', 'invalid', 'duplicate']) {
assert.match(source, new RegExp(`['"]${eventName}['"]`))
}
assert.match(source, /@keyup\.enter="handleSubmit"/)
assert.match(source, /normalizeScanValue\(scanValue\.value\)/)
assert.match(source, /isDuplicateScan\(/)
assert.match(source, /emit\(['"]invalid['"]/)
assert.match(source, /emit\(['"]duplicate['"]/)
assert.match(source, /emit\(['"]scan['"]/)
assert.match(source, /nextTick\(/)
assert.match(source, /inputRef\.value\?\.focus\(\)/)
assert.match(source, /if \(!props\.disabled && !props\.loading\)/)
assert.match(source, /focusInput\(true\)/)
assert.match(source, /inputRef\.value\?\.select\(\)/)
assert.match(source, /nextTick\([\s\S]*?focus\(\)[\s\S]*?select\(\)/)
assert.match(source, /watch\(\s*\(\) => \[props\.loading, props\.disabled\]/)
assert.match(source, /if \(!loading && !disabled\)/)
assert.match(source, /min-height:\s*48px/)
assert.match(source, /width:\s*100%/)
assert.match(source, /is-loading/)
assert.match(source, /is-disabled/)
assert.match(source, /is-error/)
assert.match(source, /focus-within/)
assert.doesNotMatch(source, /BarcodeDetector|camera|getUserMedia/i)
})
test('六个基础资料页面逐页满足 API、权限、分页和安全删除契约', async (t) => {
for (const [resource, contract] of Object.entries(masterPageContracts)) {
await t.test(contract.relativePath, async () => {
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
const permissionPrefix = `master:${resource}`
const suffix = resource[0].toUpperCase() + resource.slice(1)
const idKey = `${resource}Id`
const importedModules = [...source.matchAll(/from\s*['"]@\/api\/master\/([a-z]+)['"]/g)]
.map((match) => match[1])
assert.deepEqual(importedModules, [resource], `${resource} 页面不得跨调其他主数据 API`)
assert.deepEqual(extractImportedNames(source, `@/api/master/${resource}`), contract.apiNames)
const actualPermissions = [...new Set(
[...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))]
.map((match) => match[1].slice(permissionPrefix.length + 1))
)].sort()
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`)
assertButtonsUsePermission(source, 'handleAdd', `${permissionPrefix}:add`)
assertButtonsUsePermission(source, 'submitCreate', `${permissionPrefix}:add`)
assertButtonsUsePermission(source, 'handleUpdate', `${permissionPrefix}:edit`)
assertButtonsUsePermission(source, 'submitUpdate', `${permissionPrefix}:edit`)
assertButtonsUsePermission(source, 'handleDelete', `${permissionPrefix}:remove`)
for (const button of extractButtonBlocks(source, 'handleUpdate')) {
assert.match(
button,
new RegExp(`v-if="proxy\\.\\$auth\\.hasPermiAnd\\(\\['${permissionPrefix}:query', '${permissionPrefix}:edit'\\]\\)"`),
`${resource} 修改按钮必须同时校验 query 与 edit`
)
}
assert.match(source, /proxy\.useDict\(['"]sys_normal_disable['"]\)/)
assert.match(source, /<dict-tag\s+:options="sys_normal_disable"/)
assert.match(source, /\.value\s*=\s*response\.rows/)
assert.match(source, /total\.value\s*=\s*response\.total/)
assert.match(source, /const detail\s*=\s*response\.data/)
assert.match(source, /function handleQuery\(\)\s*{[\s\S]*?queryParams\.value\.pageNum\s*=\s*1[\s\S]*?getList\(\)/)
assert.match(source, /function resetQuery\(\)\s*{[\s\S]*?resetForm\(['"]queryRef['"]\)[\s\S]*?handleQuery\(\)/)
assert.match(source, /<pagination\b[\s\S]*?v-model:page="queryParams\.pageNum"[\s\S]*?v-model:limit="queryParams\.pageSize"[\s\S]*?@pagination="getList"/)
assert.match(source, /<el-table\b[^>]*v-loading="loading"[^>]*empty-text="暂无数据"/)
assert.match(source, /<el-alert\b[^>]*v-if="loadError"/)
assert.match(source, /<el-alert\b[^>]*v-if="formError"/)
assert.match(source, /const formError = ref\(['"]['"]\)/)
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`)
assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/)
assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/)
assert.match(source, /let listGeneration = 0;/)
assert.match(source, /function isCurrentListRequest\(listContext\)\s*{\s*return listContext\.generation === listGeneration;?\s*}/)
const listSource = source.slice(source.indexOf('function getList()'), source.indexOf('function reset()'))
const staleListGuard = 'if \\(!isCurrentListRequest\\(listContext\\)\\) \\{\\s*return;\\s*\\}'
assert.match(listSource, /const listContext = \{\s*generation: \+\+listGeneration,\s*query: \{ \.\.\.queryParams\.value \}\s*};/)
assert.match(listSource, new RegExp(`list${suffix}\\(listContext\\.query\\)`))
assert.match(listSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleListGuard}`))
assert.match(listSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleListGuard}`))
assert.match(listSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleListGuard}`))
assert.match(source, /let detailGeneration = 0;/)
assert.match(source, /function isCurrentDetailRequest\(detailContext\)\s*{\s*return detailContext\.generation === detailGeneration;?\s*}/)
assert.match(source, /function invalidateDetailRequest\(\)\s*{\s*detailGeneration \+= 1;\s*detailLoading\.value = false;\s*}/)
const detailSource = source.slice(source.indexOf('function handleUpdate('), source.indexOf('function afterSubmit('))
const staleDetailGuard = 'if \\(!isCurrentDetailRequest\\(detailContext\\)\\) \\{\\s*return;\\s*\\}'
assert.match(detailSource, new RegExp(`const detailContext = \\{\\s*generation: \\+\\+detailGeneration,\\s*resourceId: ${idKey}\\s*};`))
assert.match(detailSource, new RegExp(`get${suffix}\\(detailContext\\.resourceId\\)`))
assert.match(detailSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleDetailGuard}`))
assert.match(detailSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleDetailGuard}`))
assert.match(detailSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleDetailGuard}`))
assert.match(source, /function handleAdd\(\)\s*{\s*invalidateDetailRequest\(\)/)
assert.match(source, /function cancel\(\)\s*{\s*if \(submitLoading\.value\) return;\s*invalidateDetailRequest\(\)/)
const dialogTag = source.match(/<el-dialog\b[^>]*>/)?.[0] || ''
assert.match(dialogTag, /:close-on-click-modal="!submitLoading"/)
assert.match(dialogTag, /:close-on-press-escape="!submitLoading"/)
assert.match(dialogTag, /:show-close="!submitLoading"/)
assert.match(dialogTag, /:before-close="handleDialogBeforeClose"/)
assert.match(source, /function handleDialogBeforeClose\(done\)\s*{\s*if \(submitLoading\.value\) return;[\s\S]*?invalidateDetailRequest\(\);[\s\S]*?done\(\);\s*}/)
const selectionColumn = source.match(/<el-table-column\b[^>]*type="selection"[^>]*\/>/)?.[0] || ''
assert.ok(selectionColumn, `${resource} 缺少选择列`)
assert.doesNotMatch(selectionColumn, /:selectable=/)
assert.doesNotMatch(source, /function isRowSelectable\(/)
assert.match(source, /const selectedIds = ref\(\[\]\)/)
assert.match(source, /const removableIds = ref\(\[\]\)/)
const selectionSource = source.slice(source.indexOf('function handleSelectionChange('), source.indexOf('function handleAdd('))
assert.match(selectionSource, new RegExp(`selectedIds\\.value = selection\\.map\\(\\(item\\) => item\\.${idKey}\\)`))
assert.match(selectionSource, new RegExp(`removableIds\\.value = selection\\s*\\.filter\\(\\(item\\) => item\\.canRemove === true\\)\\s*\\.map\\(\\(item\\) => item\\.${idKey}\\)`))
assert.match(selectionSource, /single\.value = selectedIds\.value\.length !== 1/)
assert.match(selectionSource, /multiple\.value = removableIds\.value\.length === 0/)
assert.match(detailSource, new RegExp(`const ${idKey} = row\\.${idKey} \\|\\| selectedIds\\.value\\[0\\]`))
assert.match(source, new RegExp(`const targetIds = row\\?\\.${idKey} \\? \\(row\\.canRemove === true \\? \\[row\\.${idKey}\\] : \\[\\]\\) : removableIds\\.value`))
const rowDeleteButtons = extractButtonBlocks(source, 'handleDelete')
.filter((button) => button.includes('handleDelete(scope.row)'))
assert.ok(rowDeleteButtons.length > 0, `${resource} 缺少行删除按钮`)
for (const button of rowDeleteButtons) {
assert.match(button, /v-if="scope\.row\.canRemove === true"/)
}
const rowUpdateButtons = extractButtonBlocks(source, 'handleUpdate')
.filter((button) => button.includes('handleUpdate(scope.row)'))
assert.ok(rowUpdateButtons.length > 0, `${resource} 缺少行修改按钮`)
for (const button of rowUpdateButtons) {
assert.match(button, /:disabled="detailLoading"/)
}
assert.deepEqual(extractCallObjectKeys(source, `add${suffix}`), contract.addPayloadKeys)
assert.deepEqual(extractCallObjectKeys(source, `update${suffix}`), contract.editPayloadKeys)
assert.match(source, /validator:\s*validateRequiredText/)
assert.match(source, /maxlength="500"/)
if (resource !== 'pallet') {
assert.match(source, /maxlength="64"/)
assert.match(source, /maxlength="100"/)
}
if (resource === 'site' || resource === 'line' || resource === 'pallet') {
assert.match(source, /validator:\s*validatePositiveInteger/)
}
if (resource === 'site' || resource === 'warehouse') {
assertButtonsUsePermission(source, 'handleAssign', `${permissionPrefix}:assign`)
assert.match(source, /import BusinessUserAssignDialog from ['"]@\/components\/BusinessUserAssignDialog['"]/)
assert.match(source, /<business-user-assign-dialog\s+ref="assignDialogRef"\s+@ok="getList"\s*\/>/)
assert.match(
source,
new RegExp(`assignDialogRef\\.value\\.show\\(\\{[\\s\\S]*?resourceType: ['"]${resource}['"][\\s\\S]*?resourceId: row\\.${resource}Id[\\s\\S]*?resourceName: row\\.${resource}Name[\\s\\S]*?}\\)`)
)
}
if (resource === 'pallet') {
assertButtonsUsePermission(source, 'handlePrint', 'master:pallet:print')
const printHandler = source.slice(source.indexOf('function handlePrint'))
assert.match(printHandler, /const printWindow = window\.open\('', '_blank'\)/)
assert.ok(
printHandler.indexOf("window.open('', '_blank')") < printHandler.indexOf('getPalletQrcode('),
'打印窗口必须在异步二维码请求前同步打开'
)
assert.match(printHandler, /getPalletQrcode\(row\.palletId\)/)
assert.match(printHandler, /instanceof Blob/)
assert.match(printHandler, /URL\.createObjectURL\(/)
assert.match(printHandler, /URL\.revokeObjectURL\(/)
assert.match(printHandler, /document\.createElement\(['"](?:h1|img|style)['"]\)/)
assert.match(printHandler, /\.textContent\s*=/)
assert.match(printHandler, /['"]load['"][\s\S]*?printWindow\.print\(\)/)
assert.match(printHandler, /let closeMonitorId;/)
assert.match(printHandler, /let cleanupFinished = false;/)
assert.doesNotMatch(printHandler, /if \(cleanupFinished\) return;/)
assert.match(printHandler, /const cleanupPrint = \(\) => \{[\s\S]*?window\.clearInterval\(closeMonitorId\)[\s\S]*?URL\.revokeObjectURL\(objectUrl\)[\s\S]*?if \(!cleanupFinished\) \{\s*cleanupFinished = true;\s*setPrinting\(row\.palletId, false\);\s*}/)
assert.match(printHandler, /window\.clearInterval\(closeMonitorId\)/)
assert.equal((printHandler.match(/URL\.revokeObjectURL\(objectUrl\)/g) || []).length, 1)
assert.equal((printHandler.match(/setPrinting\(row\.palletId, false\)/g) || []).length, 1)
assert.match(printHandler, /objectUrl = URL\.createObjectURL\([^;]+\);\s*if \(cleanupFinished \|\| printWindow\.closed\) \{\s*cleanupPrint\(\);\s*return;\s*}/)
assert.match(printHandler, /closeMonitorId = window\.setInterval\(\(\) => \{\s*if \(printWindow\.closed\) cleanupPrint\(\);\s*}, \d+\)/)
assert.ok(
printHandler.indexOf('window.setInterval(') < printHandler.indexOf('getPalletQrcode('),
'打印窗口关闭监测必须在异步二维码请求前启动'
)
assert.match(printHandler, /['"]error['"][\s\S]*?cleanupPrint\(\)[\s\S]*?printWindow\.close\(\)/)
assert.match(printHandler, /['"]afterprint['"][\s\S]*?cleanupPrint\(\)[\s\S]*?printWindow\.close\(\)/)
assert.match(printHandler, /\.catch\(\(\) => \{\s*cleanupPrint\(\)/)
assert.doesNotMatch(printHandler, /document\.write|innerHTML|BarcodeDetector|getUserMedia|camera/i)
assert.match(source, /v-if="isEdit"[\s\S]*?v-model="form\.palletCode"[\s\S]*?readonly/)
assert.match(source, /v-if="isEdit"[\s\S]*?v-model="form\.qrToken"[\s\S]*?readonly/)
assert.doesNotMatch(contract.editPayloadKeys.join(','), /palletCode|qrToken/)
}
})
}
})
test('五个业务页面逐页满足 API、精确权限、列表和交互基础契约', async (t) => {
for (const [resource, contract] of Object.entries(businessPageContracts)) {
await t.test(contract.relativePath, async () => {
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
const permissionPrefix = `business:${resource}`
const importedBusinessModules = [...source.matchAll(/from\s*['"]@\/api\/business\/([a-z]+)['"]/g)]
.map((match) => match[1])
assert.deepEqual(importedBusinessModules, [resource], `${resource} 页面只能导入本业务 API`)
assert.deepEqual(extractImportedNames(source, `@/api/business/${resource}`), contract.apiNames)
assert.doesNotMatch(source, /@\/api\/master\//, `${resource} 页面不得借用主数据 API`)
assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/)
assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/)
assert.doesNotMatch(source, /BarcodeDetector|getUserMedia|camera/i)
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`)
const actualPermissions = [...new Set(
[...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))]
.map((match) => match[1].slice(permissionPrefix.length + 1))
)].sort()
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`)
assert.match(source, /function handleQuery\(\)\s*{[\s\S]*?queryParams\.value\.pageNum\s*=\s*1[\s\S]*?getList\(\)/)
assert.match(source, /function resetQuery\(\)\s*{[\s\S]*?resetForm\(['"]queryRef['"]\)[\s\S]*?handleQuery\(\)/)
assert.match(source, /<pagination\b[\s\S]*?v-model:page="queryParams\.pageNum"[\s\S]*?v-model:limit="queryParams\.pageSize"[\s\S]*?@pagination="getList"/)
assert.match(source, /<el-table\b[^>]*v-loading="loading"[^>]*empty-text="暂无数据"/)
assert.match(source, /<el-alert\b[^>]*v-if="loadError"/)
assert.match(source, /\.value\s*=\s*response\.rows/)
assert.match(source, /total\.value\s*=\s*response\.total/)
assert.match(source, /let listGeneration = 0;?/)
const listSource = extractNamedFunction(source, 'getList')
assert.match(listSource, /const listContext = \{\s*generation: \+\+listGeneration,\s*query: \{ \.\.\.queryParams\.value \}\s*};/)
assertPromiseCallbacksStartWithGuard(listSource, 'isCurrentListRequest')
assert.match(listSource, /if \(!isCurrentListRequest\(listContext\)\) \{\s*return;\s*\}/)
assert.match(listSource, /\.catch\(\(\) => \{[\s\S]*?loadError\.value\s*=/)
assert.match(listSource, /\.finally\(\(\) => \{[\s\S]*?loading\.value = false/)
assert.match(source, /import PalletScanInput from ['"]@\/components\/PalletScanInput['"]/)
const scanTags = [...source.matchAll(/<pallet-scan-input\b[^>]*\/>/g)].map((match) => match[0])
assert.ok(scanTags.length > 0, `${resource} 页面必须复用 PalletScanInput`)
for (const scanTag of scanTags) {
assert.match(scanTag, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用扫码`)
assert.match(scanTag, /:loading="[^"]*scanLoading[^"]*"/, `${resource} 扫码请求必须显示 loading`)
}
assert.match(source, /const operationPending = computed\(\(\) =>/)
assert.match(source, /let optionGeneration = 0;?/)
assert.match(source, /function invalidateOptionRequest\(\)/)
assert.match(source, /function isCurrentOptionRequest\(/)
if (resource === 'return') {
assert.match(source, /let requestScanGeneration = 0;?/)
assert.match(source, /let confirmScanGeneration = 0;?/)
assert.match(source, /function invalidateRequestScan\(\)/)
assert.match(source, /function invalidateConfirmScan\(\)/)
assert.match(source, /function isCurrentRequestScan\(/)
assert.match(source, /function isCurrentConfirmScan\(/)
} else {
assert.match(source, /let scanGeneration = 0;?/)
assert.match(source, /function invalidateScanRequest\(\)/)
assert.match(source, /function isCurrentScanRequest\(/)
}
const businessControls = [...source.matchAll(/<(?:el-input|el-input-number|el-select)\b[^>]*class="business-control"[^>]*>/g)]
.map((match) => match[0])
assert.ok(businessControls.length > 0, `${resource} 页面缺少业务输入控件`)
for (const control of businessControls) {
assert.match(control, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务输入`)
}
const businessActions = [...source.matchAll(/<el-button\b[^>]*class="business-action"[^>]*>/g)]
.map((match) => match[0])
assert.ok(businessActions.length > 0, `${resource} 页面缺少业务提交按钮`)
for (const action of businessActions) {
assert.match(action, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务按钮`)
}
for (const [callName, payloadKeys] of Object.entries(contract.payloads)) {
const actualKeys = callName.startsWith('confirm')
? extractSecondCallObjectKeys(source, callName)
: extractCallObjectKeys(source, callName)
assert.deepEqual(actualKeys, payloadKeys, `${callName} payload 必须最小且精确`)
}
const scriptTail = source.slice(source.lastIndexOf('getList();'))
assert.doesNotMatch(
scriptTail,
/get(?:InboundWarehouseOptions|FillingStrainOptions|ShipmentWarehouseOptions|ShipmentSiteOptions|UsageLineOptions|ReturnWarehouseOptions)\(/,
`${resource} 页面不得在启动阶段无条件请求 query 权限接口`
)
})
}
})
test('五个业务页面的读取请求逐分支执行 stale guard,并在上下文切换时失效旧请求', async (t) => {
const contracts = {
inbound: {
flows: [
['loadWarehouseOptions', ['getInboundWarehouseOptions'], 'isCurrentOptionRequest'],
['handleInboundScan', ['scanInboundPallet'], 'isCurrentScanRequest'],
['handleUpdate', ['getInbound'], 'isCurrentDetailRequest']
],
resets: [['resetInboundForm', ['invalidateOptionRequest()', 'invalidateDetailRequest()', 'invalidateScanRequest()']]],
closes: [['cancelInbound', 'resetInboundForm()'], ['handleDialogBeforeClose', 'resetInboundForm()']],
watchInvalidators: ['invalidateScanRequest()']
},
filling: {
flows: [
['loadStrainOptions', ['getFillingStrainOptions'], 'isCurrentOptionRequest'],
['handleFillingScan', ['scanFillingPallet'], 'isCurrentScanRequest']
],
resets: [['resetFillingOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]],
closes: [['cancelFilling', 'resetFillingOperation()'], ['handleDialogBeforeClose', 'resetFillingOperation()']]
},
shipment: {
flows: [
['loadShipmentOptions', ['getShipmentWarehouseOptions', 'getShipmentSiteOptions'], 'isCurrentOptionRequest'],
['handleShipmentScan', ['scanShipmentPallet'], 'isCurrentScanRequest'],
['handleUpdate', ['getShipment'], 'isCurrentDetailRequest']
],
resets: [['resetShipmentForm', ['invalidateOptionRequest()', 'invalidateDetailRequest()', 'invalidateScanRequest()']]],
closes: [['cancelShipment', 'resetShipmentForm()'], ['handleDialogBeforeClose', 'resetShipmentForm()']],
watchInvalidators: ['invalidateScanRequest()']
},
usage: {
flows: [
['loadLineOptions', ['getUsageLineOptions'], 'isCurrentOptionRequest'],
['handleUsageScan', ['scanUsagePallet'], 'isCurrentScanRequest']
],
resets: [['resetUsageOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]],
transitions: [['handleUsageScan', ['invalidateOptionRequest()']]]
},
return: {
flows: [
['loadReturnWarehouseOptions', ['getReturnWarehouseOptions'], 'isCurrentOptionRequest'],
['handleRequestScan', ['scanReturnPallet'], 'isCurrentRequestScan'],
['handleConfirmReturnSelection', ['getReturn'], 'isCurrentDetailRequest'],
['handleConfirmScan', ['scanReturnPallet'], 'isCurrentConfirmScan']
],
resets: [
['resetReturnRequest', ['invalidateOptionRequest()', 'invalidateRequestScan()']],
['resetReturnConfirm', ['invalidateDetailRequest()', 'invalidateConfirmScan()']]
],
transitions: [['handleConfirmReturnSelection', ['invalidateDetailRequest()', 'invalidateConfirmScan()']]],
watchInvalidators: ['invalidateRequestScan()']
}
}
for (const [resource, contract] of Object.entries(contracts)) {
await t.test(businessPageContracts[resource].relativePath, async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts[resource].relativePath), 'utf8')
for (const [functionName, requestCalls, guardName] of contract.flows) {
const functionSource = extractNamedFunction(source, functionName)
for (const requestCall of requestCalls) {
assert.ok(functionSource.includes(`${requestCall}(`), `${functionName} 缺少 ${requestCall} 实际请求`)
}
assertPromiseCallbacksStartWithGuard(functionSource, guardName)
}
for (const [functionName, invalidators] of contract.resets) {
const functionSource = extractNamedFunction(source, functionName)
for (const invalidator of invalidators) {
assert.ok(functionSource.includes(invalidator), `${functionName} 必须调用 ${invalidator}`)
}
}
for (const [functionName, resetCall] of contract.closes || []) {
assert.ok(extractNamedFunction(source, functionName).includes(resetCall), `${functionName} 必须调用 ${resetCall}`)
}
for (const [functionName, invalidators] of contract.transitions || []) {
const functionSource = extractNamedFunction(source, functionName)
for (const invalidator of invalidators) {
assert.ok(functionSource.includes(invalidator), `${functionName} 切换上下文时必须调用 ${invalidator}`)
}
}
if (contract.watchInvalidators) {
const watchSource = source.slice(source.lastIndexOf('watch('), source.lastIndexOf('getList();'))
for (const invalidator of contract.watchInvalidators) {
assert.ok(watchSource.includes(invalidator), `${resource} 输入切换 watch 必须调用 ${invalidator}`)
}
}
})
}
})
test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确认幂等键工作', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.inbound.relativePath), 'utf8')
assertButtonsUsePermission(source, 'handleAdd', 'business:inbound:add')
assertButtonsUsePermission(source, 'submitInbound', 'business:inbound:add')
assertButtonsUsePermission(source, 'handleUpdate', 'business:inbound:edit')
assertButtonsUsePermission(source, 'handleDelete', 'business:inbound:remove')
assertButtonsUsePermission(source, 'handleConfirm', 'business:inbound:confirm')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:add'\]\)/)
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:edit'\]\)/)
const confirmButton = extractButtonBlocks(source, 'handleConfirm')[0]
assert.match(confirmButton, /v-if="scope\.row\.status === 'DRAFT'"/)
assert.doesNotMatch(source, /canConfirmInbound/)
assert.doesNotMatch(
source,
/proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:confirm'\]\)/
)
const addButton = extractButtonBlocks(source, 'handleAdd')[0]
assert.match(addButton, /:disabled="[^"]*operationPending[^"]*optionLoading[^"]*detailLoading[^"]*open[^"]*"/)
const addSource = extractNamedFunction(source, 'handleAdd')
assert.match(
addSource,
/^function handleAdd\(\)\s*\{\s*if \(!canAddInbound\.value \|\| operationPending\.value \|\| optionLoading\.value \|\| detailLoading\.value \|\| open\.value\) return/
)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleUpdate\(scope\.row\)/)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleDelete\(scope\.row\)/)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleConfirm\(scope\.row\)/)
for (const functionName of ['handleUpdate', 'handleDelete', 'handleConfirm']) {
assert.match(extractNamedFunction(source, functionName), /status !== ['"]DRAFT['"]/)
}
assert.match(source, /const editDetailReady = ref\(false\)/)
const updateSource = extractNamedFunction(source, 'handleUpdate')
const detailRequestIndex = updateSource.indexOf('getInbound(')
assert.ok(detailRequestIndex >= 0, '编辑入库草稿必须读取 authoritative 详情')
for (const [snippet, message] of [
['form.value.inboundId = row.inboundId', '读取详情前必须固定入库编辑身份'],
['editDetailReady.value = false', '读取详情前必须关闭入库编辑就绪门禁']
]) {
const index = updateSource.indexOf(snippet)
assert.ok(index >= 0 && index < detailRequestIndex, message)
}
const detailSuccessSource = extractPromiseCallback(updateSource, 'then')
assert.ok(
detailSuccessSource.indexOf("detail.status !== 'DRAFT'") < detailSuccessSource.indexOf('editDetailReady.value = true'),
'仅 authoritative DRAFT 入库详情可开启编辑就绪门禁'
)
const warehouseSelect = source.match(/<el-select\b[\s\S]*?v-model="form\.warehouseId"[\s\S]*?<\/el-select>/)?.[0]
assert.ok(warehouseSelect, '缺少入库仓库选择器')
assert.match(warehouseSelect, /:disabled="[^"]*!editDetailReady[^"]*"/)
const inboundScanTag = [...source.matchAll(/<pallet-scan-input\b[^>]*\/>/g)]
.map((match) => match[0])
.find((tag) => tag.includes('@scan="handleInboundScan"'))
assert.ok(inboundScanTag, '缺少入库扫码控件')
assert.match(inboundScanTag, /:disabled="[^"]*!editDetailReady[^"]*"/)
const inboundRemoveButton = extractButtonBlocks(source, 'removeScannedPallet')[0]
assert.match(inboundRemoveButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
const inboundEditButton = extractButtonBlocks(source, 'saveInboundUpdate')[0]
assert.match(inboundEditButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
const submitSource = extractNamedFunction(source, 'submitInboundForm')
assert.match(submitSource, /editing !== isEdit\.value\s*\|\|\s*\(editing && !editDetailReady\.value\)/)
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'入库提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
const deleteSource = extractNamedFunction(source, 'handleDelete')
assert.ok(
deleteSource.indexOf('deleteLoading.value = true') < deleteSource.indexOf('proxy.$modal.confirm('),
'入库删除必须在打开二次确认框前加锁'
)
assert.deepEqual(extractSecondCallObjectKeys(source, 'scanInboundPallet'), ['warehouseId'])
const scanSource = extractNamedFunction(source, 'handleInboundScan')
assert.match(scanSource, /pallet\.flowState !== ['"]UNSTOCKED['"]/)
assert.match(scanSource, /scannedPallets\.value\.some\(\(item\) => item\.palletId === pallet\.palletId\)/)
assert.match(source, /const detail = response\.data;/)
assert.match(source, /\(Array\.isArray\(detail\.items\) \? detail\.items : \[\]\)\.map\(\(item\) => \(\{[\s\S]*?palletId: item\.palletId/)
assert.match(source, /item\.palletCode \|\| `#\$\{item\.palletId\}`/)
const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey')
assert.match(ensureSource, /createIdempotencyKey\(\)/)
assert.match(ensureSource, /confirmIdempotency\.key/)
const confirmSource = extractNamedFunction(source, 'handleConfirm')
assert.doesNotMatch(confirmSource, /canConfirmInbound|business:inbound:query/)
assert.ok(
confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('),
'入库确认必须在打开二次确认框前加锁'
)
assert.ok(confirmSource.indexOf('proxy.$modal.confirm(') < confirmSource.indexOf('confirmInbound('))
assert.match(confirmSource, /confirmInbound\([^,]+,\s*\{\s*idempotencyKey:/)
assert.match(extractPromiseCallback(confirmSource, 'then', 1), /clearConfirmIdempotency\(\)/)
const confirmCatch = extractPromiseCallback(confirmSource, 'catch')
assert.match(confirmCatch, /error === ['"]cancel['"] \|\| error === ['"]close['"]/)
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
})
test('装填扫码 pending 期间禁用全部业务输入', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.filling.relativePath), 'utf8')
for (const field of ['strainId', 'batchNo', 'packageSpec', 'initialPackages']) {
const businessControl = source.match(new RegExp(`<[^>]+v-model="form\\.${field}"[^>]*>`))?.[0]
assert.ok(businessControl, `缺少装填 ${field} 业务控件`)
assert.match(businessControl, /:disabled="[^"]*scanLoading[^"]*"/, `装填扫码 pending 时必须禁用 ${field}`)
}
})
test('装填页面锁定扫码上下文、校验正整数并在失败重试时保留幂等键', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.filling.relativePath), 'utf8')
assertButtonsUsePermission(source, 'handleOpenFilling', 'business:filling:add')
assertButtonsUsePermission(source, 'submitFilling', 'business:filling:add')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:filling:query', 'business:filling:add'\]\)/)
assert.match(source, /validator:\s*validatePositiveInteger/)
assert.match(source, /pallet\.warehouseId/)
assert.match(source, /pallet\.palletCode \|\| `#\$\{pallet\.palletId\}`/)
assert.match(source, /getFillingStrainOptions\(\)/)
const clearPayloadSource = extractNamedFunction(source, 'clearFillingPayload')
assert.match(clearPayloadSource, /clearFillingIdempotencyKey\(\)/)
assert.match(clearPayloadSource, /scannedPallet\.value = null/)
for (const [field, emptyValue] of [
['qrToken', "''"],
['warehouseId', 'undefined'],
['strainId', 'undefined'],
['batchNo', "''"],
['packageSpec', "''"],
['initialPackages', 'undefined']
]) {
const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`)
assert.match(clearPayloadSource, fieldPattern, `装填载荷清理必须重置 ${field}`)
}
assert.match(extractNamedFunction(source, 'resetFillingOperation'), /clearFillingPayload\(\)/)
const scanSource = extractNamedFunction(source, 'handleFillingScan')
const scanCallIndex = scanSource.indexOf('scanFillingPallet(')
assert.ok(scanCallIndex >= 0, '装填扫码必须调用 scanFillingPallet')
const scanPrefix = scanSource.slice(0, scanCallIndex)
const clearPayloadIndex = scanPrefix.indexOf('clearFillingPayload()')
const scanPendingIndex = scanPrefix.indexOf('scanLoading.value = true')
assert.ok(
clearPayloadIndex >= 0 && scanPendingIndex >= 0 && clearPayloadIndex < scanPendingIndex,
'新装填扫码必须在进入 pending 前清除旧业务载荷'
)
const scanSuccessSource = extractPromiseCallback(scanSource, 'then')
const scanSuccessGuardIndex = scanSuccessSource.indexOf('if (!isCurrentScanRequest(scanContext)) return')
const invalidStateIndex = scanSuccessSource.indexOf("if (pallet.flowState !== 'EMPTY_IN_WAREHOUSE')")
const invalidStateClearIndex = scanSuccessSource.indexOf('clearFillingPayload()', invalidStateIndex)
const invalidStateErrorIndex = scanSuccessSource.indexOf("scanError.value = '该托盘当前不可装填'", invalidStateIndex)
assert.ok(
scanSuccessGuardIndex >= 0 &&
invalidStateIndex > scanSuccessGuardIndex &&
invalidStateClearIndex > invalidStateIndex &&
invalidStateErrorIndex > invalidStateClearIndex,
'不可装填响应必须在当前请求门禁后、写入错误状态前清除业务载荷'
)
const scanCatchSource = extractPromiseCallback(scanSource, 'catch')
const scanCatchGuardIndex = scanCatchSource.indexOf('if (!isCurrentScanRequest(scanContext)) return')
const scanCatchClearIndex = scanCatchSource.indexOf('clearFillingPayload()')
const scanCatchErrorIndex = scanCatchSource.indexOf("scanError.value = '托盘扫码校验失败,请检查后重试'")
assert.ok(
scanCatchGuardIndex >= 0 && scanCatchClearIndex > scanCatchGuardIndex && scanCatchErrorIndex > scanCatchClearIndex,
'装填扫码异常必须在当前请求门禁后、写入错误状态前清除业务载荷'
)
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
const invalidClearIndex = invalidSource.indexOf('clearFillingPayload()')
const invalidErrorIndex = invalidSource.indexOf('scanError.value = message')
assert.ok(
invalidClearIndex >= 0 && invalidErrorIndex > invalidClearIndex,
'装填扫码输入失败后必须先清除业务载荷再展示错误'
)
const fillingSubmitButton = extractButtonBlocks(source, 'submitFilling')[0]
assert.match(fillingSubmitButton, /:disabled="[^"]*!scannedPallet[^"]*"/)
const ensureSource = extractNamedFunction(source, 'ensureFillingIdempotencyKey')
assert.match(ensureSource, /if \(!fillingIdempotencyKey\.value\)/)
assert.match(ensureSource, /createIdempotencyKey\(\)/)
const submitSource = extractNamedFunction(source, 'submitFilling')
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'装填提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /if \(!scannedPallet\.value \|\| !form\.value\.qrToken \|\| !form\.value\.warehouseId\)/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
assert.match(submitSource, /const idempotencyKey = ensureFillingIdempotencyKey\(\)/)
assert.match(extractPromiseCallback(submitSource, 'then'), /clearFillingIdempotencyKey\(\)/)
const catchSource = extractPromiseCallback(submitSource, 'catch')
assert.match(catchSource, /formError\.value\s*=/)
assert.doesNotMatch(catchSource, /clearFillingIdempotencyKey/)
assert.match(extractNamedFunction(source, 'cancelFilling'), /clearFillingIdempotencyKey\(\)/)
assert.match(source, /watch\([\s\S]*?if \(!submitLoading\.value\) clearFillingIdempotencyKey\(\)/)
})
test('发货详情仅在 remainingPackages 缺失时回退快照并保留 null', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.shipment.relativePath), 'utf8')
const detailSuccessSource = extractPromiseCallback(extractNamedFunction(source, 'handleUpdate'), 'then')
assert.match(
detailSuccessSource,
/remainingPackages:\s*item\.remainingPackages\s*===\s*undefined\s*\?\s*\(?\s*item\.packageQty\s*\?\?\s*null\s*\)?\s*:\s*item\.remainingPackages/,
'发货详情仅在 remainingPackages 缺失时回退确认快照 packageQty,并保留 null'
)
assert.doesNotMatch(detailSuccessSource, /item\.remainingPackages\s*\?\?\s*item\.packageQty/)
})
test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂等键', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.shipment.relativePath), 'utf8')
assertButtonsUsePermission(source, 'handleAdd', 'business:shipment:add')
assertButtonsUsePermission(source, 'submitShipment', 'business:shipment:add')
assertButtonsUsePermission(source, 'handleUpdate', 'business:shipment:edit')
assertButtonsUsePermission(source, 'handleDelete', 'business:shipment:remove')
assertButtonsUsePermission(source, 'handleConfirm', 'business:shipment:confirm')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:add'\]\)/)
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:edit'\]\)/)
const confirmButton = extractButtonBlocks(source, 'handleConfirm')[0]
assert.match(confirmButton, /v-if="scope\.row\.status === 'DRAFT'"/)
assert.doesNotMatch(source, /canConfirmShipment/)
assert.doesNotMatch(
source,
/proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:confirm'\]\)/
)
const addButton = extractButtonBlocks(source, 'handleAdd')[0]
assert.match(addButton, /:disabled="[^"]*operationPending[^"]*optionLoading[^"]*detailLoading[^"]*open[^"]*"/)
const addSource = extractNamedFunction(source, 'handleAdd')
assert.match(
addSource,
/^function handleAdd\(\)\s*\{\s*if \(!canAddShipment\.value \|\| operationPending\.value \|\| optionLoading\.value \|\| detailLoading\.value \|\| open\.value\) return/
)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleUpdate\(scope\.row\)/)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleDelete\(scope\.row\)/)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleConfirm\(scope\.row\)/)
for (const functionName of ['handleUpdate', 'handleDelete', 'handleConfirm']) {
assert.match(extractNamedFunction(source, functionName), /status !== ['"]DRAFT['"]/)
}
assert.match(source, /const editDetailReady = ref\(false\)/)
const updateSource = extractNamedFunction(source, 'handleUpdate')
const detailRequestIndex = updateSource.indexOf('getShipment(')
assert.ok(detailRequestIndex >= 0, '编辑发货草稿必须读取 authoritative 详情')
for (const [snippet, message] of [
['form.value.shipmentId = row.shipmentId', '读取详情前必须固定发货编辑身份'],
['editDetailReady.value = false', '读取详情前必须关闭发货编辑就绪门禁']
]) {
const index = updateSource.indexOf(snippet)
assert.ok(index >= 0 && index < detailRequestIndex, message)
}
const detailSuccessSource = extractPromiseCallback(updateSource, 'then')
assert.ok(
detailSuccessSource.indexOf("detail.status !== 'DRAFT'") < detailSuccessSource.indexOf('editDetailReady.value = true'),
'仅 authoritative DRAFT 发货详情可开启编辑就绪门禁'
)
for (const model of ['sourceWarehouseId', 'targetSiteId']) {
const select = source.match(new RegExp(`<el-select\\b[\\s\\S]*?v-model="form\\.${model}"[\\s\\S]*?<\\/el-select>`))?.[0]
assert.ok(select, `缺少发货 ${model} 选择器`)
assert.match(select, /:disabled="[^"]*!editDetailReady[^"]*"/)
}
const shipmentScanTag = [...source.matchAll(/<pallet-scan-input\b[^>]*\/>/g)]
.map((match) => match[0])
.find((tag) => tag.includes('@scan="handleShipmentScan"'))
assert.ok(shipmentScanTag, '缺少发货扫码控件')
assert.match(shipmentScanTag, /:disabled="[^"]*!editDetailReady[^"]*"/)
const shipmentRemoveButton = extractButtonBlocks(source, 'removeScannedPallet')[0]
assert.match(shipmentRemoveButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
const shipmentEditButton = extractButtonBlocks(source, 'saveShipmentUpdate')[0]
assert.match(shipmentEditButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
const submitSource = extractNamedFunction(source, 'submitShipmentForm')
assert.match(submitSource, /editing !== isEdit\.value\s*\|\|\s*\(editing && !editDetailReady\.value\)/)
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'发货提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
const deleteSource = extractNamedFunction(source, 'handleDelete')
assert.ok(
deleteSource.indexOf('deleteLoading.value = true') < deleteSource.indexOf('proxy.$modal.confirm('),
'发货删除必须在打开二次确认框前加锁'
)
assert.deepEqual(extractSecondCallObjectKeys(source, 'scanShipmentPallet'), ['sourceWarehouseId', 'targetSiteId'])
assert.match(source, /label="[^"]*只读[^"]*"[^>]*prop="remainingPackages"|prop="remainingPackages"[\s\S]*?不可修改/)
assert.match(source, /const detail = response\.data;/)
assert.match(source, /\(Array\.isArray\(detail\.items\) \? detail\.items : \[\]\)\.map\(\(item\) => \(\{[\s\S]*?palletId: item\.palletId/)
assert.match(source, /item\.palletCode \|\| `#\$\{item\.palletId\}`/)
assert.doesNotMatch(source, /remainingPackages\s*[-+]=|Math\.(?:max|min)\([^)]*remainingPackages/)
assert.match(extractNamedFunction(source, 'handleShipmentScan'), /remainingPackages:\s*pallet\.remainingPackages/)
const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey')
assert.match(ensureSource, /createIdempotencyKey\(\)/)
const confirmSource = extractNamedFunction(source, 'handleConfirm')
assert.doesNotMatch(confirmSource, /canConfirmShipment|business:shipment:query/)
assert.ok(
confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('),
'发货确认必须在打开二次确认框前加锁'
)
assert.ok(confirmSource.indexOf('proxy.$modal.confirm(') < confirmSource.indexOf('confirmShipment('))
assert.match(extractPromiseCallback(confirmSource, 'then', 1), /clearConfirmIdempotency\(\)/)
const confirmCatch = extractPromiseCallback(confirmSource, 'catch')
assert.match(confirmCatch, /error === ['"]cancel['"] \|\| error === ['"]close['"]/)
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
})
test('使用页面依扫码结果锁定产线并只采用后端返回的余量和状态', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.usage.relativePath), 'utf8')
assertButtonsUsePermission(source, 'submitUsage', 'business:usage:add')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:usage:query', 'business:usage:add'\]\)/)
assert.match(source, /pallet\.currentLineId == null/)
assert.match(source, /getUsageLineOptions\(pallet\.siteId\)/)
assert.match(source, /:disabled="operationPending \|\| lineLocked/)
assert.match(source, /validator:\s*validatePositiveInteger/)
const scanSource = extractNamedFunction(source, 'handleUsageScan')
const scanCallIndex = scanSource.indexOf('scanUsagePallet(')
assert.ok(scanCallIndex >= 0, '使用扫码必须调用 scanUsagePallet')
for (const [snippet, message] of [
['clearUsageIdempotencyKey()', '新使用扫码前必须清除旧幂等键'],
['scannedPallet.value = null', '新使用扫码前必须清除旧托盘'],
['usageResult.value = null', '新使用扫码前必须清除旧成功结果'],
['lineOptions.value = []', '新使用扫码前必须清除旧产线选项'],
['lineLocked.value = false', '新使用扫码前必须解除旧产线锁定'],
["form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }", '新使用扫码前必须清除旧提交 payload']
]) {
const index = scanSource.indexOf(snippet)
assert.ok(index >= 0 && index < scanCallIndex, message)
}
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
assert.match(invalidSource, /clearUsageIdempotencyKey\(\)/)
assert.match(invalidSource, /scannedPallet\.value = null/)
assert.match(invalidSource, /usageResult\.value = null/)
assert.match(invalidSource, /lineOptions\.value = \[\]/)
assert.match(invalidSource, /lineLocked\.value = false/)
assert.match(invalidSource, /form\.value = \{ qrToken: '', lineId: undefined, usedPackages: undefined \}/)
const submitSource = extractNamedFunction(source, 'submitUsage')
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'使用提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
assert.match(submitSource, /const idempotencyKey = ensureUsageIdempotencyKey\(\)/)
const successSource = extractPromiseCallback(submitSource, 'then')
assert.match(successSource, /const result = response\.data;/)
assert.match(successSource, /remainingPackages:\s*result\.remainingPackages/)
assert.match(successSource, /flowState:\s*result\.flowState/)
assert.doesNotMatch(source, /remainingPackages\s*[-+]=|remainingPackages\s*[-+]\s*form|Math\.(?:max|min)\([^)]*remainingPackages/)
assert.match(successSource, /clearUsageIdempotencyKey\(\)/)
const catchSource = extractPromiseCallback(submitSource, 'catch')
assert.doesNotMatch(catchSource, /clearUsageIdempotencyKey/)
assert.match(extractNamedFunction(source, 'resetUsageOperation'), /clearUsageIdempotencyKey\(\)/)
assert.match(source, /watch\([\s\S]*?clearUsageIdempotencyKey\(\)/)
assert.match(source, /@media \(max-width: 767px\)[\s\S]*?min-height:\s*(?:48|52|56)px/)
})
test('退回页面分离申请与确认幂等状态并在提交前严格复核三组托盘集合', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.return.relativePath), 'utf8')
assertButtonsUsePermission(source, 'submitReturnRequest', 'business:return:request')
assertButtonsUsePermission(source, 'submitReturnConfirm', 'business:return:confirm')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:return:query', 'business:return:request'\]\)/)
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:return:query', 'business:return:confirm'\]\)/)
assert.match(source, /status === ['"]REQUESTED['"]/)
assert.doesNotMatch(source, /\b(?:IN_TRANSIT|RECEIVED|REJECTED)\b/)
assert.match(source, /pallet\.siteId !== requestForm\.value\.sourceSiteId/)
assert.match(source, /mode:\s*['"]request['"]/)
assert.match(source, /mode:\s*['"]confirm['"][\s\S]*?returnId:/)
const invalidateRequestScan = extractNamedFunction(source, 'invalidateRequestScan')
assert.match(invalidateRequestScan, /requestScanGeneration \+= 1/)
assert.match(invalidateRequestScan, /scanLoading\.request = false/)
assert.doesNotMatch(invalidateRequestScan, /confirmScanGeneration|scanLoading\.confirm/)
const invalidateConfirmScan = extractNamedFunction(source, 'invalidateConfirmScan')
assert.match(invalidateConfirmScan, /confirmScanGeneration \+= 1/)
assert.match(invalidateConfirmScan, /scanLoading\.confirm = false/)
assert.doesNotMatch(invalidateConfirmScan, /requestScanGeneration|scanLoading\.request/)
const requestScan = extractNamedFunction(source, 'handleRequestScan')
assert.ok(
requestScan.indexOf('invalidateRequestScan()') < requestScan.indexOf('scanReturnPallet('),
'申请扫码必须先失效本区旧请求'
)
assert.match(requestScan, /generation: \+\+requestScanGeneration/)
assert.match(requestScan, /isCurrentRequestScan\(scanContext\)/)
assert.match(extractPromiseCallback(requestScan, 'finally'), /scanLoading\.request = false/)
assert.doesNotMatch(requestScan, /invalidateConfirmScan|confirmScanGeneration|scanLoading\.confirm/)
const confirmScan = extractNamedFunction(source, 'handleConfirmScan')
assert.ok(
confirmScan.indexOf('invalidateConfirmScan()') < confirmScan.indexOf('scanReturnPallet('),
'确认扫码必须先失效本区旧请求'
)
assert.match(confirmScan, /generation: \+\+confirmScanGeneration/)
assert.match(confirmScan, /isCurrentConfirmScan\(scanContext\)/)
assert.match(extractPromiseCallback(confirmScan, 'finally'), /scanLoading\.confirm = false/)
assert.doesNotMatch(confirmScan, /invalidateRequestScan|requestScanGeneration|scanLoading\.request/)
const resetRequest = extractNamedFunction(source, 'resetReturnRequest')
assert.match(resetRequest, /invalidateRequestScan\(\)/)
assert.doesNotMatch(resetRequest, /invalidateConfirmScan/)
const resetConfirm = extractNamedFunction(source, 'resetReturnConfirm')
assert.match(resetConfirm, /invalidateConfirmScan\(\)/)
assert.doesNotMatch(resetConfirm, /invalidateRequestScan/)
const confirmSelection = extractNamedFunction(source, 'handleConfirmReturnSelection')
assert.match(confirmSelection, /invalidateConfirmScan\(\)/)
assert.doesNotMatch(confirmSelection, /invalidateRequestScan/)
const warehouseWatch = source.slice(source.lastIndexOf('watch('), source.lastIndexOf('getList();'))
assert.match(warehouseWatch, /invalidateRequestScan\(\)/)
assert.doesNotMatch(warehouseWatch, /invalidateConfirmScan/)
const requestEnsure = extractNamedFunction(source, 'ensureRequestIdempotencyKey')
const confirmEnsure = extractNamedFunction(source, 'ensureReturnConfirmIdempotencyKey')
assert.match(requestEnsure, /createIdempotencyKey\(\)/)
assert.match(confirmEnsure, /createIdempotencyKey\(\)/)
assert.doesNotMatch(requestEnsure, /confirmIdempotencyKey/)
assert.doesNotMatch(confirmEnsure, /requestIdempotencyKey/)
const requestSubmit = extractNamedFunction(source, 'submitReturnRequest')
assert.match(requestSubmit, /const idempotencyKey = ensureRequestIdempotencyKey\(\)/)
assert.match(extractPromiseCallback(requestSubmit, 'then'), /clearRequestIdempotencyKey\(\)/)
assert.doesNotMatch(extractPromiseCallback(requestSubmit, 'catch'), /clearRequestIdempotencyKey/)
const confirmSubmit = extractNamedFunction(source, 'submitReturnConfirm')
const detailIndex = confirmSubmit.indexOf('getReturn(')
const confirmIndex = confirmSubmit.indexOf('confirmReturn(')
assert.ok(detailIndex >= 0 && confirmIndex > detailIndex, '确认退回写请求前必须重新读取详情')
assert.match(confirmSubmit, /const initialExpectedIds = \[\.\.\.confirmExpectedPalletIds\.value\]/)
assert.match(confirmSubmit, /const scannedPalletIds = \[\.\.\.confirmScannedPallets\.value\.map\(\(item\) => item\.palletId\)\]/)
assert.match(confirmSubmit, /const latestPalletIds = latestDetail\.items\.map\(\(item\) => item\.palletId\)/)
assert.match(confirmSubmit, /!sameIdSet\(latestPalletIds, initialExpectedIds\)\s*\|\|\s*!sameIdSet\(latestPalletIds, scannedPalletIds\)/)
assert.match(confirmSubmit, /latestDetail\.status !== ['"]REQUESTED['"]/)
assert.match(confirmSubmit, /const idempotencyKey = ensureReturnConfirmIdempotencyKey\(returnId\)/)
const detailThen = extractPromiseCallback(confirmSubmit, 'then')
assert.match(detailThen, /^\s*if \(!isCurrentConfirmSubmit\(submitContext\)\) throw/)
assertPromiseCallbackStartsWithGuard(confirmSubmit, 'then', 'isCurrentConfirmSubmit', 1)
const confirmSubmitCatch = extractPromiseCallback(confirmSubmit, 'catch')
const confirmSubmitCatchGuardIndex = confirmSubmitCatch.indexOf('if (!isCurrentConfirmSubmit(submitContext)) return')
const confirmSubmitCatchWriteIndex = confirmSubmitCatch.indexOf('confirmError.value =')
assert.ok(
confirmSubmitCatchGuardIndex >= 0 &&
confirmSubmitCatchWriteIndex >= 0 &&
confirmSubmitCatchGuardIndex < confirmSubmitCatchWriteIndex,
'退回确认 catch 写错误状态前必须执行提交上下文门禁'
)
assertPromiseCallbackStartsWithGuard(confirmSubmit, 'finally', 'isCurrentConfirmSubmit')
assert.match(extractPromiseCallback(confirmSubmit, 'then', 1), /clearReturnConfirmIdempotencyKey\(\)/)
assert.doesNotMatch(confirmSubmitCatch, /clearReturnConfirmIdempotencyKey/)
assert.match(extractNamedFunction(source, 'resetReturnRequest'), /clearRequestIdempotencyKey\(\)/)
assert.match(extractNamedFunction(source, 'resetReturnConfirm'), /clearReturnConfirmIdempotencyKey\(\)/)
})
test('业务用户分配组件使用固定映射并允许空数组完整替换', async () => {
const relativePath = 'src/components/BusinessUserAssignDialog/index.vue'
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
assert.deepEqual(
extractImportedNames(source, '@/api/master/site'),
['assignSiteUsers', 'getSiteUsers']
)
assert.deepEqual(
extractImportedNames(source, '@/api/master/warehouse'),
['assignWarehouseUsers', 'getWarehouseUsers']
)
assert.match(source, /const resourceApiMap = Object\.freeze\(\{[\s\S]*?site:\s*Object\.freeze\(\{\s*getUsers:\s*getSiteUsers,\s*assignUsers:\s*assignSiteUsers\s*}\)[\s\S]*?warehouse:\s*Object\.freeze\(\{\s*getUsers:\s*getWarehouseUsers,\s*assignUsers:\s*assignWarehouseUsers\s*}\)[\s\S]*?}\)/)
assert.doesNotMatch(source, /defineProps\(/)
assert.match(source, /function show\(\{ resourceType, resourceId, resourceName }\)/)
assert.match(source, /let loadGeneration = 0;/)
assert.match(source, /function isCurrentUserLoad\(loadContext\)\s*{[\s\S]*?loadContext\.generation === loadGeneration[\s\S]*?loadContext\.resourceType === resourceType\.value[\s\S]*?loadContext\.resourceId === resourceId\.value[\s\S]*?}/)
const loadUsersSource = source.slice(source.indexOf('function loadUsers()'), source.indexOf('function resetDialogState()'))
const staleLoadGuard = 'if \\(!isCurrentUserLoad\\(loadContext\\)\\) \\{\\s*return;\\s*}'
assert.match(loadUsersSource, /const loadContext = \{\s*generation: \+\+loadGeneration,\s*resourceType: resourceType\.value,\s*resourceId: resourceId\.value\s*};/)
assert.match(loadUsersSource, /resourceApi\.getUsers\(loadContext\.resourceId\)/)
assert.match(loadUsersSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleLoadGuard}`))
assert.match(loadUsersSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleLoadGuard}`))
assert.match(loadUsersSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleLoadGuard}`))
const resetDialogSource = source.slice(source.indexOf('function resetDialogState()'), source.indexOf('function show('))
assert.match(resetDialogSource, /loadGeneration \+= 1;/)
assert.match(source, /response\.data/)
assert.match(source, /userId:\s*row\.userId,\s*userName:\s*row\.userName,\s*nickName:\s*row\.nickName,\s*assigned:\s*row\.assigned === true/)
assert.doesNotMatch(source, /\.\.\.row|email:\s*row\.|phonenumber:\s*row\.|status:\s*row\./)
assert.match(source, /filteredUsers\s*=\s*computed\([\s\S]*?user\.userName[\s\S]*?user\.nickName/)
assert.match(source, /nextTick\([\s\S]*?toggleRowSelection\(row, selectedUserIds\.value\.includes\(row\.userId\)\)/)
assert.match(source, /@row-click="handleRowClick"/)
assert.match(source, /const userIds = \[\.\.\.new Set\(selectedUserIds\.value\.filter\(\(userId\) => Number\.isInteger\(userId\) && userId > 0\)\)\]/)
assert.match(source, /let submitGeneration = 0;/)
assert.match(source, /function isCurrentSubmit\(submitContext\)\s*{[\s\S]*?submitContext\.generation === submitGeneration[\s\S]*?submitContext\.resourceType === resourceType\.value[\s\S]*?submitContext\.resourceId === resourceId\.value[\s\S]*?}/)
const submitSource = source.slice(source.indexOf('function submitAssignment()'), source.indexOf('defineExpose('))
const staleSubmitGuard = 'if \\(!isCurrentSubmit\\(submitContext\\)\\) \\{\\s*return;\\s*\\}'
assert.match(submitSource, /const submitContext = \{\s*generation: \+\+submitGeneration,\s*resourceType: resourceType\.value,\s*resourceId: resourceId\.value\s*};/)
assert.match(submitSource, /resourceApiMap\[submitContext\.resourceType\]\.assignUsers\(submitContext\.resourceId, \{ userIds }\)/)
assert.match(submitSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleSubmitGuard}`))
assert.match(submitSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleSubmitGuard}`))
assert.match(submitSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleSubmitGuard}`))
assert.match(resetDialogSource, /submitGeneration \+= 1;/)
assert.doesNotMatch(source, /请选择要分配的用户|userIds\.length\s*===?\s*0/)
assert.match(source, /:loading="submitLoading"/)
assert.match(source, /const loadFailed = ref\(false\)/)
assert.match(source, /:disabled="loading \|\| submitLoading \|\| loadFailed"/)
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, '分配组件禁止空 catch')
const submitButtons = extractButtonBlocks(source, 'submitAssignment')
const submitPermissions = submitButtons.flatMap((button) =>
[...button.matchAll(/v-hasPermi="\['(master:(?:site|warehouse):assign)'\]"/g)].map((match) => match[1])
).sort()
assert.deepEqual(submitPermissions, ['master:site:assign', 'master:warehouse:assign'])
assert.match(source, /proxy\.\$modal\.msgSuccess\(response\.msg \|\| ['"]分配成功['"]\)/)
assert.match(source, /visible\.value = false[\s\S]*?emit\(['"]ok['"]\)/)
const dialogTag = source.match(/<el-dialog\b[^>]*>/)?.[0] || ''
assert.match(dialogTag, /:close-on-click-modal="!submitLoading"/)
assert.match(dialogTag, /:close-on-press-escape="!submitLoading"/)
assert.match(dialogTag, /:show-close="!submitLoading"/)
assert.match(dialogTag, /:before-close="handleDialogBeforeClose"/)
assert.match(source, /function handleDialogBeforeClose\(done\)\s*{\s*if \(submitLoading\.value\) return;[\s\S]*?resetDialogState\(\);[\s\S]*?done\(\);\s*}/)
assert.match(source, /function closeDialog\(\)\s*{\s*if \(submitLoading\.value\) return;/)
assert.match(source, /<el-alert\b[^>]*v-if="loadError"/)
assert.match(source, /width="min\(720px, 92vw\)"/)
})