import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import path from 'node:path' const frontendRoot = fileURLToPath(new URL('..', import.meta.url)) const expectedRequests = { 'src/api/master/strain.js': [ { url: "'/master/strain/list'", method: 'get' }, { url: "'/master/strain/' + strainId", method: 'get' }, { url: "'/master/strain'", method: 'post' }, { url: "'/master/strain'", method: 'put' }, { url: "'/master/strain/' + strainIds", method: 'delete' } ], 'src/api/master/customer.js': [ { url: "'/master/customer/list'", method: 'get' }, { url: "'/master/customer/' + customerId", method: 'get' }, { url: "'/master/customer'", method: 'post' }, { url: "'/master/customer'", method: 'put' }, { url: "'/master/customer/' + customerIds", method: 'delete' } ], 'src/api/master/site.js': [ { url: "'/master/site/list'", method: 'get' }, { url: "'/master/site/' + siteId", method: 'get' }, { url: "'/master/site'", method: 'post' }, { url: "'/master/site'", method: 'put' }, { url: "'/master/site/' + siteIds", method: 'delete' }, { url: "'/master/site/' + siteId + '/users'", method: 'get' }, { url: "'/master/site/' + siteId + '/users'", method: 'put' } ], 'src/api/master/line.js': [ { url: "'/master/line/list'", method: 'get' }, { url: "'/master/line/' + lineId", method: 'get' }, { url: "'/master/line'", method: 'post' }, { url: "'/master/line'", method: 'put' }, { url: "'/master/line/' + lineIds", method: 'delete' } ], 'src/api/master/warehouse.js': [ { url: "'/master/warehouse/list'", method: 'get' }, { url: "'/master/warehouse/' + warehouseId", method: 'get' }, { url: "'/master/warehouse'", method: 'post' }, { url: "'/master/warehouse'", method: 'put' }, { url: "'/master/warehouse/' + warehouseIds", method: 'delete' }, { url: "'/master/warehouse/' + warehouseId + '/users'", method: 'get' }, { url: "'/master/warehouse/' + warehouseId + '/users'", method: 'put' } ], 'src/api/master/pallet.js': [ { url: "'/master/pallet/list'", method: 'get' }, { url: "'/master/pallet/' + palletId", method: 'get' }, { url: "'/master/pallet/' + palletId + '/qrcode'", method: 'get' }, { url: "'/master/pallet'", method: 'post' }, { url: "'/master/pallet'", method: 'put' }, { url: "'/master/pallet/' + palletIds", method: 'delete' } ], 'src/api/business/inbound.js': [ { url: "'/business/inbound/list'", method: 'get' }, { url: "'/business/inbound/warehouse-options'", method: 'get' }, { url: "'/business/inbound/scan/' + qrToken", method: 'get' }, { url: "'/business/inbound/' + inboundId", method: 'get' }, { url: "'/business/inbound'", method: 'post' }, { url: "'/business/inbound'", method: 'put' }, { url: "'/business/inbound/' + inboundIds", method: 'delete' }, { url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' } ], 'src/api/business/filling.js': [ { url: "'/business/filling/list'", method: 'get' }, { url: "'/business/filling/strain-options'", method: 'get' }, { url: "'/business/filling/scan/' + qrToken", method: 'get' }, { url: "'/business/filling/' + fillingId", method: 'get' }, { url: "'/business/filling'", method: 'post' } ], 'src/api/business/shipment.js': [ { url: "'/business/shipment/list'", method: 'get' }, { url: "'/business/shipment/warehouse-options'", method: 'get' }, { url: "'/business/shipment/site-options'", method: 'get' }, { url: "'/business/shipment/scan/' + qrToken", method: 'get' }, { url: "'/business/shipment/' + shipmentId", method: 'get' }, { url: "'/business/shipment'", method: 'post' }, { url: "'/business/shipment'", method: 'put' }, { url: "'/business/shipment/' + shipmentIds", method: 'delete' }, { url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' } ], 'src/api/business/usage.js': [ { url: "'/business/usage/list'", method: 'get' }, { url: "'/business/usage/line-options'", method: 'get' }, { url: "'/business/usage/scan/' + qrToken", method: 'get' }, { url: "'/business/usage/' + usageId", method: 'get' }, { url: "'/business/usage'", method: 'post' } ], 'src/api/business/return.js': [ { url: "'/business/return/list'", method: 'get' }, { url: "'/business/return/warehouse-options'", method: 'get' }, { url: "'/business/return/scan/' + qrToken", method: 'get' }, { url: "'/business/return/request'", method: 'post' }, { url: "'/business/return/' + returnId", method: 'get' }, { url: "'/business/return/' + returnId + '/confirm'", method: 'put' } ], 'src/api/report/palletInventory.js': [ { url: "'/report/pallet-inventory/list'", method: 'get' } ], 'src/api/report/filling.js': [ { url: "'/report/filling/list'", method: 'get' } ], 'src/api/report/shipment.js': [ { url: "'/report/shipment/list'", method: 'get' } ], 'src/api/report/consumption.js': [ { url: "'/report/consumption/list'", method: 'get' } ], 'src/api/report/return.js': [ { url: "'/report/return/list'", method: 'get' } ], 'src/api/report/palletTrace.js': [ { url: "'/report/pallet-trace/list'", method: 'get' } ] } const expectedReportExports = { 'src/api/report/palletInventory.js': ['listPalletInventory'], 'src/api/report/filling.js': ['listFillingReport'], 'src/api/report/shipment.js': ['listShipmentReport'], 'src/api/report/consumption.js': ['listConsumptionReport'], 'src/api/report/return.js': ['listReturnReport'], 'src/api/report/palletTrace.js': ['listPalletTrace'] } const masterPageContracts = { strain: { relativePath: 'src/views/master/strain/index.vue', apiNames: ['addStrain', 'delStrain', 'getStrain', 'listStrain', 'updateStrain'], permissions: ['add', 'edit', 'list', 'query', 'remove'], addPayloadKeys: ['remark', 'status', 'strainCode', 'strainName'], editPayloadKeys: ['remark', 'status', 'strainCode', 'strainId', 'strainName'] }, customer: { relativePath: 'src/views/master/customer/index.vue', apiNames: ['addCustomer', 'delCustomer', 'getCustomer', 'listCustomer', 'updateCustomer'], permissions: ['add', 'edit', 'list', 'query', 'remove'], addPayloadKeys: ['customerCode', 'customerName', 'remark', 'status'], editPayloadKeys: ['customerCode', 'customerId', 'customerName', 'remark', 'status'] }, site: { relativePath: 'src/views/master/site/index.vue', apiNames: ['addSite', 'delSite', 'getSite', 'listSite', 'updateSite'], permissions: ['add', 'assign', 'edit', 'list', 'query', 'remove'], addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'], editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status'] }, line: { relativePath: 'src/views/master/line/index.vue', apiNames: ['addLine', 'delLine', 'getLine', 'listLine', 'updateLine'], permissions: ['add', 'edit', 'list', 'query', 'remove'], addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'], editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status'] }, warehouse: { relativePath: 'src/views/master/warehouse/index.vue', apiNames: ['addWarehouse', 'delWarehouse', 'getWarehouse', 'listWarehouse', 'updateWarehouse'], permissions: ['add', 'assign', 'edit', 'list', 'query', 'remove'], addPayloadKeys: ['address', 'remark', 'status', 'warehouseCode', 'warehouseName'], editPayloadKeys: ['address', 'remark', 'status', 'warehouseCode', 'warehouseId', 'warehouseName'] }, pallet: { relativePath: 'src/views/master/pallet/index.vue', apiNames: ['addPallet', 'delPallet', 'getPallet', 'getPalletQrcode', 'listPallet', 'updatePallet'], permissions: ['add', 'edit', 'list', 'print', 'query', 'remove'], addPayloadKeys: ['capacityPackages', 'palletCode', 'remark'], editPayloadKeys: ['assetStatus', 'capacityPackages', 'palletId', 'remark'] } } const delimiterPairs = { '(': ')', '[': ']', '{': '}' } function sanitizeSource(source) { 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 (quote === '`' && source[literalEnd] === '$' && source[literalEnd + 1] === '{') { throw new Error('契约解析器不支持包含插值的模板字符串') } literalEnd += 1 } if (literalEnd >= source.length) { throw new Error('契约源码包含未闭合字符串') } structure.fill('\0', index, literalEnd + 1) index = literalEnd + 1 continue } const lineComment = source[index] === '/' && source[index + 1] === '/' const blockComment = source[index] === '/' && source[index + 1] === '*' if (!lineComment && !blockComment) { index += 1 continue } let commentEnd = lineComment ? source.indexOf('\n', index + 2) : source.indexOf('*/', index + 2) if (commentEnd === -1 && blockComment) { throw new Error('契约源码包含未闭合注释') } commentEnd = commentEnd === -1 ? source.length : commentEnd + (blockComment ? 2 : 0) while (index < commentEnd) { if (source[index] !== '\n' && source[index] !== '\r') { code[index] = ' ' structure[index] = ' ' } index += 1 } } return { code: code.join(''), structure: structure.join('') } } function findClosingDelimiter(structure, startIndex) { const open = structure[startIndex] const close = delimiterPairs[open] let depth = 0 for (let index = startIndex; index < structure.length; index += 1) { if (structure[index] === open) { depth += 1 } else if (structure[index] === close && --depth === 0) { return index } } throw new Error(`契约源码包含未闭合的 ${open}${close}`) } function splitTopLevelRanges(structure, separator) { const ranges = [] const stack = [] let rangeStart = 0 for (let index = 0; index < structure.length; index += 1) { const character = structure[index] if (Object.hasOwn(delimiterPairs, character)) { stack.push(character) } else if (Object.values(delimiterPairs).includes(character)) { const open = stack.pop() if (delimiterPairs[open] !== character) { throw new Error('request 对象属性括号不匹配') } } else if (character === separator && stack.length === 0) { ranges.push([rangeStart, index]) rangeStart = index + 1 } } if (stack.length) { throw new Error('request 对象属性括号不匹配') } ranges.push([rangeStart, structure.length]) return ranges } function normalizeUrlExpression(code, structure) { let normalized = '' for (let index = 0; index < code.length; index += 1) { if (structure[index] === '\0') { normalized += code[index] } else if (/\s/.test(code[index])) { continue } else if (code[index] === '+') { normalized = normalized.trimEnd() + ' + ' } else { normalized += code[index] } } return normalized.trim() } function parseRequestArgument(argument, callIndex) { let objectStart = 0 while (/\s/.test(argument.structure[objectStart])) { objectStart += 1 } if (argument.structure[objectStart] !== '{') { throw new Error(`第 ${callIndex} 个 request 调用必须使用对象字面量`) } const objectEnd = findClosingDelimiter(argument.structure, objectStart) if (argument.structure.slice(objectEnd + 1).trim()) { throw new Error(`第 ${callIndex} 个 request 调用必须只传入一个对象字面量`) } const bodyCode = argument.code.slice(objectStart + 1, objectEnd) const bodyStructure = argument.structure.slice(objectStart + 1, objectEnd) const values = { url: [], method: [] } for (const [start, end] of splitTopLevelRanges(bodyStructure, ',')) { const propertyCode = bodyCode.slice(start, end) const propertyStructure = bodyStructure.slice(start, end) if (!propertyCode.trim()) { continue } const propertyParts = splitTopLevelRanges(propertyStructure, ':') const key = propertyCode.slice(...propertyParts[0]).trim() if (propertyParts.length !== 2 || !/^[A-Za-z_$][\w$]*$/.test(key)) { throw new Error(`第 ${callIndex} 个 request 对象只支持静态键值属性`) } if (key === 'url' || key === 'method') { const [valueStart, valueEnd] = propertyParts[1] values[key].push({ code: propertyCode.slice(valueStart, valueEnd), structure: propertyStructure.slice(valueStart, valueEnd) }) } } if (values.url.length !== 1 || values.method.length !== 1) { throw new Error(`第 ${callIndex} 个 request 对象必须包含唯一 url 和 method`) } const methodMatch = /^\s*(['"])([a-z]+)\1\s*$/.exec(values.method[0].code) if (!methodMatch) { throw new Error(`第 ${callIndex} 个 request method 必须是静态小写字符串`) } return { url: normalizeUrlExpression(values.url[0].code, values.url[0].structure), method: methodMatch[2] } } function extractRequests(source) { const sanitized = sanitizeSource(source) const requestPattern = /(? name.trim()) .filter(Boolean) .sort() } function extractCallObjectKeys(source, callName) { const escapedCallName = callName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const callMatch = new RegExp(`(? property.trim()).filter(Boolean) return properties.map((property) => { const keyMatch = /^([A-Za-z_$][\w$]*)\s*:/.exec(property) assert.ok(keyMatch, `${callName} payload 禁止展开、简写或动态属性`) return keyMatch[1] }).sort() } function extractButtonBlocks(source, clickHandler) { return [...source.matchAll(//g)] .map((match) => match[0]) .filter((button) => button.includes(`@click="${clickHandler}`)) } function assertButtonsUsePermission(source, clickHandler, permission) { const buttons = extractButtonBlocks(source, clickHandler) assert.ok(buttons.length > 0, `缺少 ${clickHandler} 按钮`) for (const button of buttons) { assert.match(button, new RegExp(`v-hasPermi="\\['${permission}'\\]"`), `${clickHandler} 权限不精确`) } } function hasExpectedRequest(expectedRequest) { return Object.values(expectedRequests).some((requests) => requests.some((request) => request.url === expectedRequest.url && request.method === expectedRequest.method ) ) } test('契约解析器支持 url 与 method 换序及空白格式变化', () => { const source = ` export function sample(query) { return request( { method : 'get', params: query, url : '/sample/' + query.id } ) } ` assert.deepEqual(extractRequests(source), [ { url: "'/sample/' + query.id", method: 'get' } ]) }) test('契约解析器拒绝无法静态核对的 request 调用', () => { assert.throws( () => extractRequests('export function sample(config) { return request(config) }'), /request 调用必须使用对象字面量/ ) assert.throws( () => extractRequests("return request({ url: '/sample', url: '/other', method: 'get' })"), /唯一 url 和 method/ ) assert.throws( () => extractRequests("return request({ url: '/sample' })"), /唯一 url 和 method/ ) }) test('契约解析器忽略注释和字符串中的伪 request 调用', () => { const source = ` // return request({ url: '/commented', method: 'delete' }) const example = "request(config)" /* request({ method: 'post', url: '/also-commented' }) */ export function sample() { return request({ url: '/sample', /* 属性间注释不应干扰解析 */ method: 'get' }) } ` assert.deepEqual(extractRequests(source), [ { url: "'/sample'", method: 'get' } ]) }) test('扫码值会去除首尾空白,空白二维码会被拒绝', async () => { const { normalizeScanValue } = await import('../src/utils/palletScan.js') assert.equal(normalizeScanValue(' qr-12345678\n'), 'qr-12345678') assert.throws(() => normalizeScanValue(' \n\t '), /二维码不能为空/) }) test('重复扫码仅在相同值和时间窗口内成立', async () => { const { isDuplicateScan } = await import('../src/utils/palletScan.js') assert.equal(isDuplicateScan('qr-a', 'qr-a', 1200, 1000, 500), true) assert.equal(isDuplicateScan('qr-a', 'qr-b', 1200, 1000, 500), false) }) test('幂等键由 Web Crypto randomUUID 生成且不降级为伪随机值', async () => { const palletScanPath = path.join(frontendRoot, 'src/utils/palletScan.js') const source = await readFile(palletScanPath, 'utf8') const { createIdempotencyKey } = await import('../src/utils/palletScan.js') assert.match(source, /globalThis\.crypto\.randomUUID\(\)/) assert.doesNotMatch(source, /Date\.now|Math\.random|new Date/) assert.match(createIdempotencyKey(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) }) test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => { assert.equal(Object.keys(expectedRequests).length, 17) assert.equal(Object.values(expectedRequests).flat().length, 74) for (const [relativePath, expected] of Object.entries(expectedRequests)) { await t.test(relativePath, async () => { const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') const actual = extractRequests(source) assert.deepEqual(actual, expected) }) } }) test('关键扫码、选项、确认与二维码契约被显式覆盖', () => { const requiredRequests = [ { url: "'/business/inbound/scan/' + qrToken", method: 'get' }, { url: "'/business/filling/scan/' + qrToken", method: 'get' }, { url: "'/business/shipment/scan/' + qrToken", method: 'get' }, { url: "'/business/usage/scan/' + qrToken", method: 'get' }, { url: "'/business/return/scan/' + qrToken", method: 'get' }, { url: "'/business/inbound/warehouse-options'", method: 'get' }, { url: "'/business/filling/strain-options'", method: 'get' }, { url: "'/business/shipment/warehouse-options'", method: 'get' }, { url: "'/business/shipment/site-options'", method: 'get' }, { url: "'/business/usage/line-options'", method: 'get' }, { url: "'/business/return/warehouse-options'", method: 'get' }, { url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' }, { url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' }, { url: "'/business/return/' + returnId + '/confirm'", method: 'put' }, { url: "'/business/return/request'", method: 'post' }, { url: "'/master/pallet/' + palletId + '/qrcode'", method: 'get' } ] for (const requiredRequest of requiredRequests) { assert.equal(hasExpectedRequest(requiredRequest), true, JSON.stringify(requiredRequest)) } }) test('六个报表 API 只提供 GET /list,不提供导出接口', async () => { const reportEntries = Object.entries(expectedRequests).filter(([relativePath]) => relativePath.startsWith('src/api/report/') ) assert.equal(reportEntries.length, 6) for (const [relativePath, expected] of reportEntries) { assert.equal(expected.length, 1) assert.equal(expected[0].method, 'get') assert.match(expected[0].url, /^'\/report\/.+\/list'$/) const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') const exportedFunctionNames = [...source.matchAll(/\bexport\s+function\s+(\w+)/g)].map((match) => match[1]) assert.doesNotMatch(expected[0].url, /export/i) assert.deepEqual(exportedFunctionNames, expectedReportExports[relativePath]) } }) test('扫码组件声明交互契约并保持触控尺寸', 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 importedModules = [...source.matchAll(/from\s*['"]@\/api\/master\/([a-z]+)['"]/g)] .map((match) => match[1]) assert.deepEqual(importedModules, [resource], `${resource} 页面不得跨调其他主数据 API`) assert.deepEqual(extractImportedNames(source, `@/api/master/${resource}`), contract.apiNames) const actualPermissions = [...new Set( [...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))] .map((match) => match[1].slice(permissionPrefix.length + 1)) )].sort() assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`) assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`) assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`) assertButtonsUsePermission(source, 'handleAdd', `${permissionPrefix}:add`) assertButtonsUsePermission(source, 'submitCreate', `${permissionPrefix}:add`) assertButtonsUsePermission(source, 'handleUpdate', `${permissionPrefix}:edit`) assertButtonsUsePermission(source, 'submitUpdate', `${permissionPrefix}:edit`) assertButtonsUsePermission(source, 'handleDelete', `${permissionPrefix}:remove`) for (const button of extractButtonBlocks(source, 'handleUpdate')) { assert.match( button, new RegExp(`v-if="proxy\\.\\$auth\\.hasPermiAnd\\(\\['${permissionPrefix}:query', '${permissionPrefix}:edit'\\]\\)"`), `${resource} 修改按钮必须同时校验 query 与 edit` ) } assert.match(source, /proxy\.useDict\(['"]sys_normal_disable['"]\)/) assert.match(source, /]*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, /]*type="selection"[^>]*:selectable="isRowSelectable"/) assert.match(source, /function isRowSelectable\(row\)\s*{\s*return row\.canRemove === true;?\s*}/) assert.match(source, /ids\.value\s*=\s*selection\s*\.filter\(\(item\) => item\.canRemove === true\)/) 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 suffix = resource[0].toUpperCase() + resource.slice(1) 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, /['"]afterprint['"][\s\S]*?URL\.revokeObjectURL/) 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 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, /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, /assignUsers\(resourceId\.value, \{ userIds }\)/) 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['"]\)/) assert.match(source, /]*v-if="loadError"/) assert.match(source, /width="min\(720px, 92vw\)"/) })