import assert from 'node:assert/strict' import { lstat, readFile, realpath } 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' ], 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', 'beforeWarehouseId', 'beforeSiteName', 'beforeSiteId', 'beforeLineName', 'beforeLineId', 'afterWarehouseName', 'afterWarehouseId', 'afterSiteName', 'afterSiteId', 'afterLineName', 'afterLineId', 'beforeRemainingPackages', 'afterRemainingPackages', 'operatorName' ] } } const expectedPagePermissions = [ 'master:strain:list', 'master:customer:list', 'master:site:list', 'master:line:list', 'master:warehouse:list', 'master:pallet:list', 'business:inbound:list', 'business:filling:list', 'business:shipment:list', 'business:usage:list', 'business:return:list', 'report:palletinventory:list', 'report:filling:list', 'report:shipment:list', 'report:consumption:list', 'report:return:list', 'report:pallettrace:list' ] const expectedButtonPermissions = [ 'master:strain:query', 'master:strain:add', 'master:strain:edit', 'master:strain:remove', 'master:customer:query', 'master:customer:add', 'master:customer:edit', 'master:customer:remove', 'master:site:query', 'master:site:add', 'master:site:edit', 'master:site:remove', 'master:site:assign', 'master:line:query', 'master:line:add', 'master:line:edit', 'master:line:remove', 'master:warehouse:query', 'master:warehouse:add', 'master:warehouse:edit', 'master:warehouse:remove', 'master:warehouse:assign', 'master:pallet:query', 'master:pallet:add', 'master:pallet:edit', 'master:pallet:remove', 'master:pallet:print', 'business:inbound:query', 'business:inbound:add', 'business:inbound:edit', 'business:inbound:remove', 'business:inbound:confirm', 'business:filling:query', 'business:filling:add', 'business:shipment:query', 'business:shipment:add', 'business:shipment:edit', 'business:shipment:remove', 'business:shipment:confirm', 'business:usage:query', 'business:usage:add', 'business:return:query', 'business:return:request', 'business:return:confirm' ] const expectedPermissions = [...expectedPagePermissions, ...expectedButtonPermissions].sort() const expectedPageComponents = { 'master/strain/index': 'src/views/master/strain/index.vue', 'master/customer/index': 'src/views/master/customer/index.vue', 'master/site/index': 'src/views/master/site/index.vue', 'master/line/index': 'src/views/master/line/index.vue', 'master/warehouse/index': 'src/views/master/warehouse/index.vue', 'master/pallet/index': 'src/views/master/pallet/index.vue', 'business/inbound/index': 'src/views/business/inbound/index.vue', 'business/filling/index': 'src/views/business/filling/index.vue', 'business/shipment/index': 'src/views/business/shipment/index.vue', 'business/usage/index': 'src/views/business/usage/index.vue', 'business/return/index': 'src/views/business/return/index.vue', 'report/palletInventory/index': 'src/views/report/palletInventory/index.vue', 'report/filling/index': 'src/views/report/filling/index.vue', 'report/shipment/index': 'src/views/report/shipment/index.vue', 'report/consumption/index': 'src/views/report/consumption/index.vue', 'report/return/index': 'src/views/report/return/index.vue', 'report/palletTrace/index': 'src/views/report/palletTrace/index.vue' } 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 parseStaticPermissionArray(expression) { const trimmed = expression.trim() assert.ok(trimmed.startsWith('[') && trimmed.endsWith(']'), '权限门禁必须使用静态字符串数组') const body = trimmed.slice(1, -1) const sanitized = sanitizeSource(body, { allowTemplateInterpolation: true }) const permissions = [] const ranges = splitTopLevelRanges(sanitized.structure, ',') for (const [index, [start, end]] of ranges.entries()) { const item = sanitized.code.slice(start, end).trim() if (!item) { if (ranges.length === 1) continue assert.ok(index === ranges.length - 1, '权限门禁数组禁止稀疏元素,仅允许单个尾逗号') continue } const literal = /^(['"])((?:master|business|report):[a-z]+:[a-z]+)\1$/.exec(item) assert.ok(literal, '权限门禁数组禁止动态表达式或非法字面量') permissions.push(literal[2]) } assert.ok(permissions.length > 0, '权限门禁必须使用非空静态字符串数组') return permissions } function extractStaticHasPermiAnd(expression) { const sanitized = sanitizeSource(expression, { allowTemplateInterpolation: true }) const permissionTokens = [...sanitized.structure.matchAll(/\bhasPermi(?:And|Or)\b/g)] assert.ok( !permissionTokens.some((token) => token[0] === 'hasPermiOr'), '禁止使用 proxy.$auth.hasPermiOr,权限门禁必须使用约定的静态 AND 语义' ) const callPattern = /(?/g, (comment) => comment.replace(/[^\r\n]/g, ' ')) assert.doesNotMatch(withoutComments, / ` assert.deepEqual(extractGuardedVuePermissions(staleSource), []) assert.throws( () => extractGuardedVuePermissions( `` ), /hasPermiOr/ ) assert.throws( () => extractGuardedVuePermissions(''), /静态字符串数组/ ) assert.throws( () => extractGuardedVuePermissions(''), /静态字符串数组/ ) assert.throws( () => extractGuardedVuePermissions(``), /动态表达式/ ) const missing = expectedPermissions.filter((permission) => permission !== 'master:strain:query') assert.throws( () => assertExactPermissionLayers({ mysql: missing, postgresql: missing, controller: missing, vue: missing }), /权限集合不精确/ ) }) test('合法门禁混入额外 hasPermiOr 时直接 fail-closed', () => { const source = ` ` assert.throws(() => extractGuardedVuePermissions(source), /hasPermiOr/) }) test('完整合法门禁旁的非 canonical 权限调用必须 fail-closed', () => { const validGuard = `` const invalidCalls = [ `proxy . $auth . hasPermiOr(['master:strain:query'])`, 'proxy?.$auth?.hasPermiAnd(permissionArray)', `proxy['$auth'].hasPermiOr(['master:strain:query'])` ] for (const invalidCall of invalidCalls) { const source = `${validGuard}` assert.throws(() => extractGuardedVuePermissions(source), /hasPermi|canonical/) } }) test('权限门禁拒绝带前缀的非 canonical proxy 接收者', () => { const validGuard = `` const invalidCalls = [ `myproxy.$auth.hasPermiAnd(['master:strain:query'])`, `foo.proxy.$auth.hasPermiAnd(['master:strain:query'])`, `$proxy.$auth.hasPermiAnd(['master:strain:query'])`, `this.proxy.$auth.hasPermiAnd(['master:strain:query'])` ] for (const invalidCall of invalidCalls) { const source = `${validGuard}` assert.throws(() => extractGuardedVuePermissions(source), /canonical/) } }) test('静态权限数组拒绝稀疏元素且仅允许单个尾逗号', () => { const sparseArrays = [ `[, 'master:strain:list']`, `['master:strain:list',, 'master:strain:query']`, `['master:strain:list',,]` ] for (const permissions of sparseArrays) { assert.throws( () => extractGuardedVuePermissions(``), /稀疏/ ) } assert.deepEqual( extractGuardedVuePermissions(``), ['master:strain:list'] ) }) test('17 个固定 component 映射正好对应 17 个 page contract 普通文件', async () => { const componentEntries = Object.entries(expectedPageComponents) const pageRelativePaths = [ ...Object.values(masterPageContracts), ...Object.values(businessPageContracts), ...Object.values(reportPageContracts) ].map((contract) => contract.relativePath) await assertPageComponentContract(componentEntries, pageRelativePaths) }) test('component 路径校验拒绝目录、越界、重复和不存在目标', async (t) => { const validEntries = Object.entries(expectedPageComponents) const pageRelativePaths = Object.values(expectedPageComponents) await t.test('目录', async () => { const directoryStats = { isFile: () => false, isSymbolicLink: () => false } await assert.rejects( assertPageComponentContract(validEntries, pageRelativePaths, async () => directoryStats), /普通文件/ ) }) await t.test('越界', async () => { const outsideEntries = [...validEntries] outsideEntries[0] = ['../outside', 'src/views/../outside.vue'] await assert.rejects(assertPageComponentContract(outsideEntries, pageRelativePaths), /src\/views/) }) await t.test('中间符号链接解析到 views 外', async () => { const sourceViewsRoot = path.resolve(frontendRoot, 'src/views') const resolvedViewsRoot = path.resolve('/resolved/views') const resolveOutside = async (absolutePath) => { if (absolutePath === sourceViewsRoot) return resolvedViewsRoot const relativePath = path.relative(sourceViewsRoot, absolutePath) if (relativePath === 'master/strain/index.vue') { return path.resolve('/resolved/outside/index.vue') } return path.resolve(resolvedViewsRoot, relativePath) } await assert.rejects( assertPageComponentContract(validEntries, pageRelativePaths, lstat, resolveOutside), /真实路径.*src\/views/ ) }) await t.test('重复', async () => { const duplicateEntries = [...validEntries] duplicateEntries[1] = duplicateEntries[0] await assert.rejects(assertPageComponentContract(duplicateEntries, pageRelativePaths), /必须唯一/) }) await t.test('不存在', async () => { const missingEntries = [...validEntries] missingEntries[0] = ['master/missing/index', 'src/views/master/missing/index.vue'] const missingError = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) await assert.rejects( assertPageComponentContract(missingEntries, pageRelativePaths, async () => { throw missingError }), { code: 'ENOENT' } ) }) }) 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') let getRandomValuesCalled = false const expectedKey = '12345678-1234-4123-8123-123456789abc' const actualKey = createIdempotencyKey({ randomUUID: () => expectedKey, getRandomValues: () => { getRandomValuesCalled = true throw new Error('存在 randomUUID 时不应调用 getRandomValues') } }) assert.equal(actualKey, expectedKey) assert.equal(getRandomValuesCalled, false) assert.doesNotMatch(source, /Date\.now|Math\.random|new Date/) }) test('局域网 HTTP 环境缺少 randomUUID 时使用安全随机数生成 UUID v4', async () => { const { createIdempotencyKey } = await import('../src/utils/palletScan.js') const cryptoProvider = { getRandomValues(bytes) { bytes.fill(0) return bytes } } assert.equal(createIdempotencyKey(cryptoProvider), '00000000-0000-4000-8000-000000000000') }) test('首次入库确认由业务幂等键保护,不被通用短时重复提交规则拦截', async () => { const source = await readFile(path.join(frontendRoot, 'src/api/business/inbound.js'), 'utf8') const confirmInboundSource = extractNamedFunction(source, 'confirmInbound') assert.match(confirmInboundSource, /headers\s*:\s*\{\s*repeatSubmit\s*:\s*false\s*\}/) }) test('首次入库确认重试前清理旧错误,并明确重试会再次请求服务端', async () => { const source = await readFile(path.join(frontendRoot, 'src/views/business/inbound/index.vue'), 'utf8') const confirmSource = extractNamedFunction(source, 'handleConfirm') const confirmCatch = extractPromiseCallback(confirmSource, 'catch') const clearErrorIndex = confirmSource.indexOf("loadError.value = ''") assert.ok(clearErrorIndex >= 0, '入库确认缺少旧错误状态清理') assert.ok( clearErrorIndex < confirmSource.indexOf('proxy.$modal.confirm('), '打开确认框前必须清理上一次的失败状态' ) assert.match(confirmCatch, /再次确认将重新请求服务端,并复用本次幂等键/) }) test('首次入库确认失败后取消重试不丢弃原幂等键', async () => { const source = await readFile(path.join(frontendRoot, 'src/views/business/inbound/index.vue'), 'utf8') const confirmSource = extractNamedFunction(source, 'handleConfirm') const confirmCatch = extractPromiseCallback(confirmSource, 'catch') assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)|handleConfirmCancellation\(/) }) 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 = extractGuardedVuePermissions(source) 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}`) } 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`) 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, /]*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(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(/]*>/)?.[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\(\[\]\)/) 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, //) 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 = 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, /: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: [ ['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(//)?.[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/) 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(``))?.[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/) 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(/]*>/)?.[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\)"/) })