[\s\S]*?<\/div>/)?.[0] || ''
assert.ok(reportFilterFooter, `${resource} 日期和搜索操作必须位于统一底部操作栏`)
assert.match(reportFilterFooter, /
]*class="report-filter__date"/, `${resource} 底部操作栏必须包含日期范围`)
assert.match(reportFilterFooter, /]*class="report-filter__actions"/, `${resource} 底部操作栏必须包含搜索操作`)
assert.ok(
reportFilterFooter.indexOf('report-filter__date') < reportFilterFooter.indexOf('report-filter__actions'),
`${resource} 日期范围必须位于搜索操作之前`
)
const reportToolbar = source.match(/]*class="[^"]*\breport-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || ''
assert.ok(reportToolbar, `${resource} 缺少报表操作栏`)
assert.match(reportToolbar, //, `${resource} 必须提供搜索显隐和刷新入口`)
assert.match(source, /\.report-filter\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fit,\s*minmax\(260px,\s*1fr\)\)/, `${resource} 筛选区必须使用响应式等宽网格`)
assert.match(
source,
/\.report-filter__footer\s*\{[^}]*grid-column:\s*1\s*\/\s*-1;[^}]*display:\s*flex;[^}]*align-items:\s*flex-end;[^}]*gap:\s*16px;/,
`${resource} 日期和操作项必须位于独占整行的底部操作栏`
)
assert.doesNotMatch(source, /\.report-filter__footer\s*\{[^}]*display:\s*contents;/, `${resource} 底部操作栏不得使用 contents 分散到筛选网格`)
assert.doesNotMatch(source, /\.report-filter__date\s*\{[^}]*grid-column:\s*-3\s*\/\s*-1;/, `${resource} 日期范围不得单独跨最后两列`)
assert.match(
source,
/\.report-filter\s+\.report-filter__date\s*\{[^}]*flex:\s*0\s+1\s+420px;[^}]*width:\s*420px;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;/,
`${resource} 桌面日期范围必须固定基础宽度并覆盖通用表单项宽度`
)
assert.match(source, /\.report-filter\s+\.report-filter__actions\s*\{[^}]*flex:\s*0\s+0\s+auto;[^}]*width:\s*auto;/, `${resource} 操作按钮必须按自身宽度紧邻日期范围`)
assert.match(source, /\.report-filter :deep\(\.el-form-item__content\)\s*\{[^}]*min-width:\s*0;/, `${resource} 筛选内容必须允许收缩`)
assert.match(
source,
/\.report-filter :deep\(\.el-input\),\s*\.report-filter :deep\(\.el-input-number\),\s*\.report-filter :deep\(\.el-select\),\s*\.report-filter :deep\(\.el-date-editor\)\s*\{[^}]*width:\s*100%\s*!important;[^}]*min-width:\s*0;/,
`${resource} 筛选控件必须等宽且不得越界`
)
assert.match(
source,
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.report-filter__footer\s*\{[^}]*grid-column:\s*1;[^}]*flex-direction:\s*column;[^}]*align-items:\s*stretch;/,
`${resource} 窄屏底部操作栏必须恢复纵向布局`
)
assert.match(
source,
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.report-filter\s+\.report-filter__date\s*\{[^}]*flex:\s*0\s+0\s+auto;[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*max-width:\s*none;/,
`${resource} 窄屏日期范围必须恢复全宽`
)
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}`)
}
if (resource === 'palletTrace') {
assert.match(
source,
/formatLocation\(\s*scope\.row\.beforeWarehouseName,\s*scope\.row\.beforeWarehouseId,\s*scope\.row\.beforeSiteName,\s*scope\.row\.beforeSiteId,\s*scope\.row\.beforeLineName,\s*scope\.row\.beforeLineId\s*\)/,
'palletTrace 变更前位置必须同时传入仓库、现场和产线的名称与 ID'
)
assert.match(
source,
/formatLocation\(\s*scope\.row\.afterWarehouseName,\s*scope\.row\.afterWarehouseId,\s*scope\.row\.afterSiteName,\s*scope\.row\.afterSiteId,\s*scope\.row\.afterLineName,\s*scope\.row\.afterLineId\s*\)/,
'palletTrace 变更后位置必须同时传入仓库、现场和产线的名称与 ID'
)
}
if (resource === 'shipment') {
assert.doesNotMatch(source, /\bDRAFT\b/, 'shipment 报表只展示已确认事实,不得提供草稿筛选')
assert.doesNotMatch(
source,
/queryParams\.status|prop=["']status["']|\bstatus\s*:/,
'shipment 报表不得提交与固定 CONFIRMED 范围冲突的状态筛选'
)
}
assert.match(source, /]*v-loading="loading"[^>]*:data="reportList"/)
assert.match(source, /[\s\S]*?]*v-if="loadError"/)
assert.match(source, / \{\s*invalidateListRequest\(\)\s*}\)/)
})
}
})
test('首页指标全部来自受批次作用域保护的业务与报表 API', async () => {
const source = await readFile(path.join(frontendRoot, 'src/views/dashboard/index.vue'), 'utf8')
for (const api of ['listInbound', 'listReturn', 'listShipment', 'listPalletInventory', 'listShipmentReport']) {
assert.match(source, new RegExp(`\\b${api}\\b`))
}
assert.doesNotMatch(source, /axios\.|fetch\(/)
})
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 = extractGuardedVuePermissions(source)
.filter((permission) => permission.startsWith(`${permissionPrefix}:`))
.map((permission) => permission.slice(permissionPrefix.length + 1))
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`)
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, /]*ref="queryRef"[^>]*>/)?.[0] || ''
assert.match(queryFormTag, /v-show="showSearch"/, `${resource} 搜索区必须支持显隐`)
assert.match(source, /const showSearch = ref\(true\);/, `${resource} 搜索区必须默认展开`)
const masterToolbar = source.match(/]*class="[^"]*\bmaster-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || ''
assert.ok(masterToolbar, `${resource} 缺少基础资料操作栏`)
assert.doesNotMatch(masterToolbar.match(/^]*>/)?.[0] || '', /v-if="isMasterWriteAllowed"/, `${resource} 搜索与刷新入口不得受写权限限制`)
assert.match(masterToolbar, //, `${resource} 写操作仍需受测试模式限制`)
assert.match(masterToolbar, //, `${resource} 必须复用右侧搜索与刷新工具栏`)
assert.match(
source,
/\.master-query :deep\(\.el-input\),\s*\.master-query :deep\(\.el-input-number\),\s*\.master-query :deep\(\.el-select\)\s*\{[^}]*min-width:\s*0;/,
`${resource} 查询控件必须允许收缩到网格内容宽度`
)
assert.match(source, /]*v-loading="loading"[^>]*empty-text="暂无数据"/)
assert.match(source, /]*v-if="loadError"/)
assert.match(source, /]*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(
masterToolbar,
new RegExp(``),
`${resource} 必须复用基础资料导入导出组件`
)
assert.match(
masterToolbar,
/<\/template>\s*\s* \\{\\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(/]*>/)?.[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(/]*type="selection"[^>]*\/>/)?.[0] || ''
assert.ok(selectionColumn, `${resource} 缺少选择列`)
assert.doesNotMatch(selectionColumn, /:selectable=/)
assert.doesNotMatch(source, /function isRowSelectable\(/)
assert.match(source, /const selectedIds = 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, /single\.value = selectedIds\.value\.length !== 1/)
assert.match(detailSource, new RegExp(`const ${idKey} = row\\.${idKey} \\|\\| selectedIds\\.value\\[0\\]`))
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, //)
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('隧道资料页复用基础资料导入导出组件', async () => {
const source = await readFile(path.join(frontendRoot, 'src/views/master/tunnel/index.vue'), 'utf8')
const masterToolbar = source.match(/]*class="[^"]*\bmaster-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || ''
assert.match(
masterToolbar,
//
)
assert.match(masterToolbar, /<\/template>\s*\s*\s*(?:导入|导出)\s*<\/el-button>/)
assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/)
})
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 = extractGuardedVuePermissions(source)
.filter((permission) => permission.startsWith(`${permissionPrefix}:`))
.map((permission) => permission.slice(permissionPrefix.length + 1))
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, /]*v-loading="loading"[^>]*empty-text="暂无数据"/)
assert.match(source, /]*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(/]*\/>/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, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*operationPending[^"]*")/, `${resource} 提交期间必须禁用业务输入`)
}
const businessActions = [...source.matchAll(/]*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: [
['loadUsageOptions', ['getUsageLineOptions', 'getUsageTunnelOptions'], '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, '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]*?handleConfirm\(scope\.row\)/)
for (const functionName of ['handleUpdate', '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(//)?.[0]
assert.ok(warehouseSelect, '缺少入库仓库选择器')
assert.match(warehouseSelect, /:disabled="[^"]*!editDetailReady[^"]*"/)
const inboundScanTag = [...source.matchAll(/]*\/>/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/)
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, /(?:\sdisabled(?:\s|\/?>)|: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', '100']
]) {
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, '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]*?handleConfirm\(scope\.row\)/)
for (const functionName of ['handleUpdate', '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(``))?.[0]
assert.ok(select, `缺少发货 ${model} 选择器`)
assert.match(select, /:disabled="[^"]*!editDetailReady[^"]*"/)
}
const shipmentScanTag = [...source.matchAll(/]*\/>/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/)
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'\]\)/)
// 执行真实页面/API行为,避免把“必须每次重扫清空幂等键”的旧实现误当成安全要求。
await execFileAsync(process.execPath, ['--test', 'scripts/usage-behavior.test.mjs'], { cwd: frontendRoot })
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(/]*>/)?.[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, /]*v-if="loadError"/)
assert.match(source, /width="min\(720px, 92vw\)"/)
})
test('测试批次管理与旧数据纳入遵守 root、权限和预览提交契约', async () => {
const apiPath = 'src/api/system/testBatch.js'
const apiSource = await readFile(path.join(frontendRoot, apiPath), 'utf8')
const expectedTestBatchRequests = [
{ url: "'/system/test-batch/options'", method: 'get' },
{ url: "'/system/test-batch/list'", method: 'get' },
{ url: "'/system/test-batch/' + batchId", method: 'get' },
{ url: "'/system/test-batch'", method: 'post' },
{ url: "'/system/test-batch/' + batchId", method: 'put' },
{ url: "'/system/test-batch/' + batchId + '/users'", method: 'put' },
{ url: "'/system/test-batch/' + batchId + '/close'", method: 'put' },
{ url: "'/system/test-batch/' + batchId + '/reopen'", method: 'put' },
{ url: "'/system/test-batch/' + batchId + '/adoption/preview'", method: 'post' },
{ url: "'/system/test-batch/' + batchId + '/adoption/execute'", method: 'post' },
{ url: "'/system/test-batch/' + batchId + '/purge/preview'", method: 'post' },
{ url: "'/system/test-batch/' + batchId + '/purge'", method: 'post' }
]
assert.deepEqual(extractRequests(apiSource), expectedTestBatchRequests)
const adoptionRoots = {
strain: { path: 'src/views/master/strain/index.vue', id: 'strainId' },
customer: { path: 'src/views/master/customer/index.vue', id: 'customerId' },
site: { path: 'src/views/master/site/index.vue', id: 'siteId' },
line: { path: 'src/views/master/line/index.vue', id: 'lineId' },
warehouse: { path: 'src/views/master/warehouse/index.vue', id: 'warehouseId' },
pallet: { path: 'src/views/master/pallet/index.vue', id: 'palletId' },
inbound: { path: 'src/views/business/inbound/index.vue', id: 'inboundId' },
filling: { path: 'src/views/business/filling/index.vue', id: 'fillingId' },
shipment: { path: 'src/views/business/shipment/index.vue', id: 'shipmentId' },
usage: { path: 'src/views/business/usage/index.vue', id: 'usageId' },
return: { path: 'src/views/business/return/index.vue', id: 'returnId' }
}
assert.equal(Object.keys(adoptionRoots).length, 11, '纳入来源必须固定为 11 类')
for (const [rootType, contract] of Object.entries(adoptionRoots)) {
const source = await readFile(path.join(frontendRoot, contract.path), 'utf8')
assert.match(source, /import TestBatchAdoptionDialog from ['"]@\/components\/TestBatchAdoptionDialog['"]/)
assert.match(source, /useUserStore\(\)/, `${rootType} 必须读取 admin 可见性`)
assert.match(source, /useTestBatchStore\(\)/, `${rootType} 必须读取测试模式`)
assert.match(source, /userStore\.admin === true\s*&&\s*!testBatchStore\.isTestMode/, `${rootType} 纳入入口必须只在管理员正式模式显示`)
assert.match(source, /v-hasPermi="\['system:testbatch:adopt'\]"/, `${rootType} 纳入入口必须校验批次纳入权限`)
assert.match(source, new RegExp(`\\s*item\\.${contract.id}\\)`), `${rootType} 必须传递正整数根 ID`)
assertButtonInSingleToolbar(
source,
contract.path,
contract.path.includes('/master/') ? 'master-toolbar' : 'page-toolbar',
'openAdoptionDialog'
)
}
for (const rootType of ['strain', 'customer', 'site', 'line', 'warehouse', 'pallet']) {
const source = await readFile(path.join(frontendRoot, adoptionRoots[rootType].path), 'utf8')
assert.match(source, /userStore\.admin === true\s*\|\|\s*!testBatchStore\.isTestMode/, `${rootType} 测试模式主数据写操作必须要求管理员`)
}
const adoptionDialogPath = 'src/components/TestBatchAdoptionDialog/index.vue'
const adoptionDialog = await readFile(path.join(frontendRoot, adoptionDialogPath), 'utf8')
assert.match(adoptionDialog, /item\.status === ['"]ACTIVE['"]/)
assert.match(adoptionDialog, /Number\.isInteger\(id\)\s*&&\s*id\s*>\s*0/)
assert.match(adoptionDialog, /previewTestBatchAdoption\(.*\{\s*roots:\s*roots\.value\s*}\s*\)/s)
assert.match(adoptionDialog, /executeTestBatchAdoption\([^,]+,\s*\{\s*roots:\s*roots\.value,\s*batchName:\s*batchName\.value,\s*previewToken:\s*previewToken\.value\s*}\s*\)/s)
assert.match(adoptionDialog, /batchName\.value\s*!==\s*preview\.value\.batchName/)
assert.match(adoptionDialog, /function invalidatePreview\(\)[\s\S]*?previewToken\.value\s*=\s*['"][\s\S]*?batchName\.value\s*=\s*['"]/)
const managementPath = 'src/views/system/testBatch/index.vue'
const management = await readFile(path.join(frontendRoot, managementPath), 'utf8')
assert.match(management, /const actionsFor\s*=\s*row\s*=>\s*\(\{[\s\S]*?edit:\s*row\.status\s*!==\s*['"]PURGED['"][\s\S]*?assign:\s*row\.status\s*===\s*['"]ACTIVE['"][\s\S]*?close:\s*row\.status\s*===\s*['"]ACTIVE['"][\s\S]*?reopen:\s*row\.status\s*===\s*['"]CLOSED['"][\s\S]*?purgePreview:\s*row\.status\s*===\s*['"]CLOSED['"][\s\S]*?audit:\s*row\.status\s*===\s*['"]PURGED['"][\s\S]*?}\)/)
assert.match(management, /onlinePurgeAllowed\.value\s*===\s*false/)
assert.match(management, /blockingReason/)
assert.match(management, /testBatchStore\.leaveTestMode\(\)/)
const reportPaths = Object.values(reportPageContracts).map((contract) => contract.relativePath)
for (const reportPath of reportPaths) {
const source = await readFile(path.join(frontendRoot, reportPath), 'utf8')
assert.doesNotMatch(source, /TestBatchAdoptionDialog/, `${reportPath} 不得提供纳入入口`)
}
})
test('测试批次授权、清理和对话框异步状态拒绝错误目标与过期会话', async () => {
const managementPath = 'src/views/system/testBatch/index.vue'
const management = await readFile(path.join(frontendRoot, managementPath), 'utf8')
const parseAssignedUserIds = Function(`return (${extractNamedFunction(management, 'parseAssignedUserIds')})`)()
const shouldLeaveTestMode = Function(`return (${extractNamedFunction(management, 'shouldLeaveTestMode')})`)()
assert.deepEqual(parseAssignedUserIds(''), { valid: true, userIds: [] }, '空输入是显式移除全部授权')
assert.deepEqual(parseAssignedUserIds('1, 2,1'), { valid: true, userIds: [1, 2] })
for (const invalidInput of ['1,abc,2', '1,,2', '1,', '0', '-1', '1.5']) {
assert.deepEqual(parseAssignedUserIds(invalidInput), { valid: false, userIds: [] }, `${invalidInput} 必须整体拒绝`)
}
assert.equal(shouldLeaveTestMode('12', 12), true, '仅相同批次清理后退出测试模式')
assert.equal(shouldLeaveTestMode('13', 12), false, '清理其他批次不得退出当前测试模式')
assert.equal(shouldLeaveTestMode('', 12), false)
assert.doesNotMatch(management, /row\.userCount/, '列表 VO 未声明 userCount,禁止直接消费')
assert.match(management, /let listRequestVersion = 0/)
assert.match(management, /function hydrateParticipantCounts\(/)
assert.match(management, /participantState:\s*['"]loading['"]/, '详情 hydration 前必须显式 loading')
assert.match(management, /participantState:\s*['"]error['"]/, '单个详情失败必须展示明确失败状态')
assert.match(management, /userIds\.length/, '参与人数必须来自详情已声明的 userIds')
assert.match(management, /function isCurrentListRequest\(listContext\)[\s\S]*?listContext\.version === listRequestVersion/)
assert.match(management, /const detailLoaded = ref\(false\)/)
assert.match(management, /let assignSession = 0/)
assert.match(management, /function isCurrentAssignContext\(context\)[\s\S]*?context\.session === assignSession[\s\S]*?context\.batchId === assignRequestBatchId\.value/)
const openAssignSource = extractNamedFunction(management, 'openAssign')
assert.match(openAssignSource, /detailLoaded\.value = false/)
assert.match(openAssignSource, /if \(!isCurrentAssignContext\(context\)\) return/)
const submitAssignSource = extractNamedFunction(management, 'submitAssign')
assert.match(submitAssignSource, /if \(assignSubmitting\.value \|\| !detailLoaded\.value \|\| !assignBatch\.value\) return/)
assert.match(submitAssignSource, /const parsed = parseAssignedUserIds\(assignedUserIds\.value\)/)
assert.match(submitAssignSource, /if \(!parsed\.valid\)/)
assert.match(submitAssignSource, /if \(!isCurrentAssignContext\(context\)\) return/)
assert.match(management, /function resetAssignDialog\(\)[\s\S]*?assignSession \+= 1[\s\S]*?assignBatch\.value = null[\s\S]*?detailLoaded\.value = false/)
assert.match(management, /:disabled="assignLoading \|\| !detailLoaded \|\| assignSubmitting"/)
assert.match(management, /if \(shouldLeaveTestMode\(testBatchStore\.currentBatchId, purgeBatch\.value\.batchId\)\) \{\s*testBatchStore\.leaveTestMode\(\)/)
assert.match(management, /class="test-batch-dialog"/)
assert.match(management, /:column="descriptionColumns"/)
assert.match(management, /max-height:\s*90vh/)
assert.match(management, /overflow-y:\s*auto/)
const adoptionPath = 'src/components/TestBatchAdoptionDialog/index.vue'
const adoption = await readFile(path.join(frontendRoot, adoptionPath), 'utf8')
assert.match(adoption, /let dialogSession = 0/)
assert.match(adoption, /let optionsRequestVersion = 0/)
assert.match(adoption, /function isCurrentOptionsRequest\(context\)[\s\S]*?context\.session === dialogSession[\s\S]*?context\.version === optionsRequestVersion[\s\S]*?visible\.value/)
const loadBatchesSource = extractNamedFunction(adoption, 'loadBatches')
for (const callback of ['then', 'catch', 'finally']) {
assert.match(loadBatchesSource, new RegExp(`\\.${callback}\\([\\s\\S]*?if \\(!isCurrentOptionsRequest\\(context\\)\\) return`), `options ${callback} 必须拒绝过期会话`)
}
assert.match(adoption, /function resetState\(\)[\s\S]*?dialogSession \+= 1[\s\S]*?optionsRequestVersion \+= 1/)
assert.match(adoption, /class="test-batch-adoption-dialog"/)
assert.match(adoption, /:column="descriptionColumns"/)
assert.match(adoption, /max-height:\s*90vh/)
assert.match(adoption, /overflow-y:\s*auto/)
})
test('测试批次管理页真实授权详情 success、failure 与 finally 不得覆盖新会话', async () => {
const management = await source('src/views/system/testBatch/index.vue')
const detailRequests = []
const page = createTestBatchPageHarness(management, {
getTestBatch: batchId => {
const request = createDeferred()
detailRequests.push({ batchId, request })
return request.promise
}
})
page.openAssign({ batchId: 1 })
page.openAssign({ batchId: 2 })
detailRequests[0].request.resolve({ data: { batchId: 1, batchName: '批次 A', userIds: [101] } })
await flushPromises()
assert.equal(page.state.assignBatch.value, null, '过期 success 不得写入新批次详情')
assert.equal(page.state.assignedUserIds.value, '', '过期 success 不得写入新批次用户')
assert.equal(page.state.detailLoaded.value, false, '过期 success 不得解锁保存')
assert.equal(page.state.assignLoading.value, true, '过期 finally 不得结束新会话 loading')
detailRequests[1].request.resolve({ data: { batchId: 2, batchName: '批次 B', userIds: [202] } })
await flushPromises()
assert.deepEqual(page.state.assignBatch.value, { batchId: 2, batchName: '批次 B', userIds: [202] })
assert.equal(page.state.assignedUserIds.value, '202')
assert.equal(page.state.detailLoaded.value, true)
assert.equal(page.state.assignLoading.value, false)
const failedRequests = []
const failedPage = createTestBatchPageHarness(management, {
getTestBatch: batchId => {
const request = createDeferred()
failedRequests.push({ batchId, request })
return request.promise
}
})
failedPage.openAssign({ batchId: 3 })
failedPage.openAssign({ batchId: 4 })
failedRequests[1].request.resolve({ data: { batchId: 4, batchName: '批次 B', userIds: [404] } })
await flushPromises()
failedRequests[0].request.reject(new Error('A 详情失败'))
await flushPromises()
assert.deepEqual(failedPage.state.assignBatch.value, { batchId: 4, batchName: '批次 B', userIds: [404] }, '过期 failure 不得清空已加载的新会话详情')
assert.equal(failedPage.state.assignedUserIds.value, '404', '过期 failure 不得改写新批次输入')
assert.equal(failedPage.state.detailLoaded.value, true)
assert.equal(failedPage.state.assignLoading.value, false)
assert.equal(failedPage.state.assignError.value, '', '过期 failure 不得展示旧批次错误')
})
test('测试批次管理页当前授权详情失败保持禁用并拒绝空数组提交', async () => {
const management = await source('src/views/system/testBatch/index.vue')
const detailRequest = createDeferred()
const assignments = []
const page = createTestBatchPageHarness(management, {
getTestBatch: () => detailRequest.promise,
assignTestBatchUsers: (batchId, payload) => {
assignments.push({ batchId, payload })
return Promise.resolve({ msg: '不应调用' })
}
})
page.openAssign({ batchId: 5 })
detailRequest.reject(new Error('详情失败'))
await flushPromises()
assert.equal(page.state.assignBatch.value, null)
assert.equal(page.state.assignedUserIds.value, '')
assert.equal(page.state.detailLoaded.value, false, '详情失败后不得解锁保存')
assert.equal(page.state.assignLoading.value, false)
assert.match(page.state.assignError.value, /详情加载失败/)
page.submitAssign()
assert.deepEqual(assignments, [], '详情未成功加载时不得提交移除全部用户的空数组')
})
test('测试批次管理页真实 hydration 拒绝旧版本回写并保留当前失败状态', async () => {
const management = await source('src/views/system/testBatch/index.vue')
const listRequests = []
const detailRequests = []
const page = createTestBatchPageHarness(management, {
listTestBatches: () => {
const request = createDeferred()
listRequests.push(request)
return request.promise
},
getTestBatch: batchId => {
const request = createDeferred()
detailRequests.push({ batchId, request })
return request.promise
}
})
page.getList()
listRequests[0].resolve({ rows: [{ batchId: 7, batchName: '旧列表' }], total: 1 })
await flushPromises()
page.getList()
listRequests[1].resolve({ rows: [{ batchId: 7, batchName: '新列表' }], total: 1 })
await flushPromises()
detailRequests[0].request.resolve({ data: { batchId: 7, userIds: [1, 2] } })
await flushPromises()
assert.equal(page.state.batchList.value[0].batchName, '新列表')
assert.equal(page.state.batchList.value[0].participantState, 'loading', '旧 success 不得写入新列表同 ID 行')
assert.equal(page.state.batchList.value[0].participantCount, undefined)
const failedListRequests = []
const failedDetailRequests = []
const failedPage = createTestBatchPageHarness(management, {
listTestBatches: () => {
const request = createDeferred()
failedListRequests.push(request)
return request.promise
},
getTestBatch: batchId => {
const request = createDeferred()
failedDetailRequests.push({ batchId, request })
return request.promise
}
})
failedPage.getList()
failedListRequests[0].resolve({ rows: [{ batchId: 8, batchName: '旧列表' }], total: 1 })
await flushPromises()
failedPage.getList()
failedListRequests[1].resolve({ rows: [{ batchId: 8, batchName: '新列表' }], total: 1 })
await flushPromises()
failedDetailRequests[0].request.reject(new Error('旧详情失败'))
await flushPromises()
assert.equal(failedPage.state.batchList.value[0].participantState, 'loading', '旧 failure 不得把新列表同 ID 行标为失败')
failedDetailRequests[1].request.reject(new Error('当前详情失败'))
await flushPromises()
assert.equal(failedPage.state.batchList.value.length, 1, '当前详情失败不得清空整个列表')
assert.equal(failedPage.state.batchList.value[0].participantState, 'error')
assert.equal(failedPage.state.batchList.value[0].participantCount, undefined)
assert.equal(failedPage.state.loadError.value, '', '当前详情失败必须是单项状态,不得升级为列表失败')
})
test('三个业务菜单公共样式统一使用 Element Plus 主题变量', async () => {
const styles = await source('src/assets/styles/data-pages.scss')
assert.match(styles, /background:\s*var\(--el-bg-color-page,\s*#f5f7fb\)/)
assert.match(styles, /background:\s*var\(--el-bg-color,\s*#fff\)/)
assert.match(styles, /border:\s*1px solid var\(--el-border-color-lighter,\s*#e4e7ed\)/)
assert.match(styles, /background:\s*var\(--el-fill-color-light,\s*#f5f7fa\)/)
assert.match(styles, /color:\s*var\(--el-text-color-regular,\s*#606266\)/)
assert.match(styles, /background:\s*var\(--el-fill-color-lighter,\s*#fafafa\)/)
assert.match(styles, /box-shadow:\s*var\(--el-box-shadow-lighter,/)
assert.doesNotMatch(styles, /background:\s*#f5f7fb\s*;/)
assert.doesNotMatch(styles, /background:\s*#fff\s*;/)
assert.doesNotMatch(styles, /border:\s*1px solid #e7ebf2\s*;/)
assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/)
assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/)
})
test('业务页面导入的组件不被同名响应式状态遮蔽', async (t) => {
// 非 .vue 路径导入的组件按 maybe-ref 绑定,模板标签会优先解析为同名 camelCase 状态(如 scanFeedback 遮蔽 ScanFeedback)
const { compileScript, parse } = await import('@vue/compiler-sfc')
for (const [resource, contract] of Object.entries(businessPageContracts)) {
await t.test(resource, async () => {
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
const { descriptor } = parse(source)
const compiled = compileScript(descriptor, { id: `contract-${resource}`, inlineTemplate: true }).content
const components = [...source.matchAll(/^import\s+([A-Z]\w*)\s+from\s+['"]@\/components\/[^'"]+['"]/gm)].map((match) => match[1])
for (const component of components) {
const shadow = component[0].toLowerCase() + component.slice(1)
assert.doesNotMatch(compiled, new RegExp(`_create(?:VNode|Block)\\(_unref\\(${shadow}\\)`), `${component} 被同名状态 ${shadow} 遮蔽`)
}
})
}
})