|
|
|
@@ -504,6 +504,25 @@ function extractPromiseCallback(functionSource, methodName, occurrence = 0) {
|
|
|
|
|
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])
|
|
|
|
@@ -898,6 +917,7 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础
|
|
|
|
|
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/)
|
|
|
|
@@ -956,6 +976,95 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
|
|
|
@@ -966,6 +1075,13 @@ test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确
|
|
|
|
|
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 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\)/)
|
|
|
|
@@ -1053,20 +1169,41 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时
|
|
|
|
|
const scanSource = extractNamedFunction(source, 'handleFillingScan')
|
|
|
|
|
const scanCallIndex = scanSource.indexOf('scanFillingPallet(')
|
|
|
|
|
assert.ok(scanCallIndex >= 0, '装填扫码必须调用 scanFillingPallet')
|
|
|
|
|
for (const [snippet, message] of [
|
|
|
|
|
['clearFillingIdempotencyKey()', '新装填扫码前必须清除旧幂等键'],
|
|
|
|
|
['scannedPallet.value = null', '新装填扫码前必须清除旧托盘'],
|
|
|
|
|
["form.value.qrToken = ''", '新装填扫码前必须清除旧二维码令牌'],
|
|
|
|
|
['form.value.warehouseId = undefined', '新装填扫码前必须清除旧仓库']
|
|
|
|
|
const scanPrefix = scanSource.slice(0, scanCallIndex)
|
|
|
|
|
const clearKeyIndex = scanPrefix.indexOf('clearFillingIdempotencyKey()')
|
|
|
|
|
const scanPendingIndex = scanPrefix.indexOf('scanLoading.value = true')
|
|
|
|
|
assert.ok(
|
|
|
|
|
clearKeyIndex >= 0 && scanPendingIndex >= 0 && clearKeyIndex < scanPendingIndex,
|
|
|
|
|
'新装填扫码必须在进入 pending 前明确清除旧幂等键'
|
|
|
|
|
)
|
|
|
|
|
assert.match(scanPrefix, /scannedPallet\.value = null/, '新装填扫码前必须清除旧托盘')
|
|
|
|
|
for (const [field, emptyValue] of [
|
|
|
|
|
['qrToken', "''"],
|
|
|
|
|
['warehouseId', 'undefined'],
|
|
|
|
|
['strainId', 'undefined'],
|
|
|
|
|
['batchNo', "''"],
|
|
|
|
|
['packageSpec', "''"],
|
|
|
|
|
['initialPackages', 'undefined']
|
|
|
|
|
]) {
|
|
|
|
|
const index = scanSource.indexOf(snippet)
|
|
|
|
|
assert.ok(index >= 0 && index < scanCallIndex, message)
|
|
|
|
|
const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`)
|
|
|
|
|
assert.match(scanPrefix, fieldPattern, `新装填扫码前必须清除旧 ${field}`)
|
|
|
|
|
}
|
|
|
|
|
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
|
|
|
|
|
assert.match(invalidSource, /clearFillingIdempotencyKey\(\)/)
|
|
|
|
|
assert.match(invalidSource, /scannedPallet\.value = null/)
|
|
|
|
|
assert.match(invalidSource, /form\.value\.qrToken = ''/)
|
|
|
|
|
assert.match(invalidSource, /form\.value\.warehouseId = undefined/)
|
|
|
|
|
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(invalidSource, fieldPattern, `装填扫码输入失败后必须清除旧 ${field}`)
|
|
|
|
|
}
|
|
|
|
|
const fillingSubmitButton = extractButtonBlocks(source, 'submitFilling')[0]
|
|
|
|
|
assert.match(fillingSubmitButton, /:disabled="[^"]*!scannedPallet[^"]*"/)
|
|
|
|
|
|
|
|
|
|
const ensureSource = extractNamedFunction(source, 'ensureFillingIdempotencyKey')
|
|
|
|
|
assert.match(ensureSource, /if \(!fillingIdempotencyKey\.value\)/)
|
|
|
|
@@ -1077,6 +1214,7 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时
|
|
|
|
|
'装填提交必须在异步校验前加锁'
|
|
|
|
|
)
|
|
|
|
|
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\(\)/)
|
|
|
|
@@ -1084,7 +1222,7 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时
|
|
|
|
|
assert.match(catchSource, /formError\.value\s*=/)
|
|
|
|
|
assert.doesNotMatch(catchSource, /clearFillingIdempotencyKey/)
|
|
|
|
|
assert.match(extractNamedFunction(source, 'cancelFilling'), /clearFillingIdempotencyKey\(\)/)
|
|
|
|
|
assert.match(source, /watch\([\s\S]*?clearFillingIdempotencyKey\(\)/)
|
|
|
|
|
assert.match(source, /watch\([\s\S]*?if \(!submitLoading\.value\) clearFillingIdempotencyKey\(\)/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂等键', async () => {
|
|
|
|
@@ -1097,6 +1235,13 @@ test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂
|
|
|
|
|
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 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\)/)
|
|
|
|
@@ -1153,6 +1298,13 @@ test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂
|
|
|
|
|
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.match(
|
|
|
|
|
detailSuccessSource,
|
|
|
|
|
/remainingPackages:\s*item\.remainingPackages\s*\?\?\s*item\.packageQty\s*\?\?\s*null/,
|
|
|
|
|
'发货详情必须优先使用当前余量,仅以确认快照 packageQty 回退'
|
|
|
|
|
)
|
|
|
|
|
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\(\)/)
|
|
|
|
@@ -1298,8 +1450,21 @@ test('退回页面分离申请与确认幂等状态并在提交前严格复核
|
|
|
|
|
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(extractPromiseCallback(confirmSubmit, 'catch'), /clearReturnConfirmIdempotencyKey/)
|
|
|
|
|
assert.doesNotMatch(confirmSubmitCatch, /clearReturnConfirmIdempotencyKey/)
|
|
|
|
|
|
|
|
|
|
assert.match(extractNamedFunction(source, 'resetReturnRequest'), /clearRequestIdempotencyKey\(\)/)
|
|
|
|
|
assert.match(extractNamedFunction(source, 'resetReturnConfirm'), /clearReturnConfirmIdempotencyKey\(\)/)
|
|
|
|
|