import assert from 'node:assert/strict' import { execFile } from 'node:child_process' import { lstat, readFile, realpath } from 'node:fs/promises' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import path from 'node:path' import { promisify } from 'node:util' import * as vm from 'node:vm' import * as testBatchContext from '../src/utils/testBatchContext.js' const frontendRoot = fileURLToPath(new URL('..', import.meta.url)) const execFileAsync = promisify(execFile) test('Web 端统一使用 Sylvan 横版标识、折叠图标和浏览器图标', async () => { const [logoComponent, toolBuilder, indexHtml, fullLogo, markLogo] = await Promise.all([ readFile(path.join(frontendRoot, 'src/layout/components/Sidebar/Logo.vue'), 'utf8'), readFile(path.join(frontendRoot, 'src/views/tool/build/index.vue'), 'utf8'), readFile(path.join(frontendRoot, 'index.html'), 'utf8'), readFile(path.join(frontendRoot, 'src/assets/logo/sylvan-logo.svg'), 'utf8'), readFile(path.join(frontendRoot, 'src/assets/logo/sylvan-mark.svg'), 'utf8') ]) assert.match(logoComponent, /@\/assets\/logo\/sylvan-logo\.svg/) assert.match(logoComponent, /@\/assets\/logo\/sylvan-mark\.svg/) assert.match(toolBuilder, /@\/assets\/logo\/sylvan-mark\.svg/) assert.match(indexHtml, /href="\/sylvan-mark\.svg"/) assert.match(fullLogo, /viewBox="0 0 516\.1 194\.52"/) assert.match(markLogo, /viewBox="0 0 140 140"/) assert.doesNotMatch(`${logoComponent}\n${toolBuilder}`, /assets\/logo\/logo\.png/) assert.doesNotMatch(`${fullLogo}\n${markLogo}`, /= 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|system):[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, 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, 66) 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/views/report/palletTrace/index.vue'), 'utf8') const sourceLabels = [ ['INBOUND', '入库单'], ['FILLING', '装填记录'], ['SHIPMENT', '发货单'], ['USAGE', '使用记录'], ['RETURN', '退回单'] ] const flowStateLabels = [ ['UNSTOCKED', '未入库'], ['EMPTY_IN_WAREHOUSE', '仓库空托盘'], ['FILLED_IN_WAREHOUSE', '仓库有料'], ['AT_SITE_UNUSED', '现场未使用'], ['AT_SITE_PARTIAL', '现场部分使用'], ['AT_SITE_EMPTY', '现场空托盘'], ['RETURNING', '退回中'] ] for (const [value, label] of sourceLabels) { assert.match(source, new RegExp(`value: '${value}', label: '${label}'`)) } for (const [value, label] of flowStateLabels) { assert.match(source, new RegExp(`value: '${value}', label: '${label}'`)) } assert.match(source, /sourceLabel\(scope\.row\.sourceType,\s*scope\.row\.sourceId\)/) assert.match(source, /flowStateLabel\(scope\.row\.beforeState\)/) assert.match(source, /flowStateLabel\(scope\.row\.afterState\)/) assert.match(source, /function sourceLabel\([\s\S]*?sourceTypeOptions\.find\(/) assert.match(source, /function flowStateLabel\([\s\S]*?flowStateOptions\.find\(/) }) 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*\(/) const queryFormTag = source.match(/]*ref="queryRef"[^>]*>/)?.[0] || '' assert.match(queryFormTag, /v-show="showSearch"/, `${resource} 搜索区必须支持显隐`) assert.match(queryFormTag, /label-width="88px"/, `${resource} 筛选标签宽度必须统一`) assert.match(source, /const showSearch = ref\(true\);?/, `${resource} 搜索区必须默认展开`) const reportFilterFooter = source.match(/[\s\S]*?<\/div>/)?.[0] || '' assert.ok(reportFilterFooter, `${resource} 日期和搜索操作必须位于统一底部操作栏`) assert.match(reportFilterFooter, /]*class="report-filter__date"/, `${resource} 底部操作栏必须包含日期范围`) assert.match(reportFilterFooter, /]*class="report-filter__actions"/, `${resource} 底部操作栏必须包含搜索操作`) assert.ok( reportFilterFooter.indexOf('report-filter__date') < reportFilterFooter.indexOf('report-filter__actions'), `${resource} 日期范围必须位于搜索操作之前` ) const reportToolbar = source.match(/]*class="[^"]*\breport-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || '' assert.ok(reportToolbar, `${resource} 缺少报表操作栏`) assert.match(reportToolbar, //, `${resource} 必须提供搜索显隐和刷新入口`) assert.match(source, /\.report-filter\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fit,\s*minmax\(260px,\s*1fr\)\)/, `${resource} 筛选区必须使用响应式等宽网格`) assert.match( source, /\.report-filter__footer\s*\{[^}]*grid-column:\s*1\s*\/\s*-1;[^}]*display:\s*flex;[^}]*align-items:\s*flex-end;[^}]*gap:\s*16px;/, `${resource} 日期和操作项必须位于独占整行的底部操作栏` ) assert.doesNotMatch(source, /\.report-filter__footer\s*\{[^}]*display:\s*contents;/, `${resource} 底部操作栏不得使用 contents 分散到筛选网格`) assert.doesNotMatch(source, /\.report-filter__date\s*\{[^}]*grid-column:\s*-3\s*\/\s*-1;/, `${resource} 日期范围不得单独跨最后两列`) assert.match( source, /\.report-filter\s+\.report-filter__date\s*\{[^}]*flex:\s*0\s+1\s+420px;[^}]*width:\s*420px;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;/, `${resource} 桌面日期范围必须固定基础宽度并覆盖通用表单项宽度` ) assert.match(source, /\.report-filter\s+\.report-filter__actions\s*\{[^}]*flex:\s*0\s+0\s+auto;[^}]*width:\s*auto;/, `${resource} 操作按钮必须按自身宽度紧邻日期范围`) assert.match(source, /\.report-filter :deep\(\.el-form-item__content\)\s*\{[^}]*min-width:\s*0;/, `${resource} 筛选内容必须允许收缩`) assert.match( source, /\.report-filter :deep\(\.el-input\),\s*\.report-filter :deep\(\.el-input-number\),\s*\.report-filter :deep\(\.el-select\),\s*\.report-filter :deep\(\.el-date-editor\)\s*\{[^}]*width:\s*100%\s*!important;[^}]*min-width:\s*0;/, `${resource} 筛选控件必须等宽且不得越界` ) assert.match( source, /@media\s*\(max-width:\s*900px\)[\s\S]*?\.report-filter__footer\s*\{[^}]*grid-column:\s*1;[^}]*flex-direction:\s*column;[^}]*align-items:\s*stretch;/, `${resource} 窄屏底部操作栏必须恢复纵向布局` ) assert.match( source, /@media\s*\(max-width:\s*900px\)[\s\S]*?\.report-filter\s+\.report-filter__date\s*\{[^}]*flex:\s*0\s+0\s+auto;[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*max-width:\s*none;/, `${resource} 窄屏日期范围必须恢复全宽` ) for (const queryKey of contract.queryKeys) { assert.match(source, new RegExp(`\\b${queryKey}\\b`), `${resource} 缺少筛选键 ${queryKey}`) } for (const columnKey of contract.columnKeys) { assert.match(source, new RegExp(`\\b${columnKey}\\b`), `${resource} 缺少核心列 ${columnKey}`) } if (resource === 'palletTrace') { assert.match( source, /formatLocation\(\s*scope\.row\.beforeWarehouseName,\s*scope\.row\.beforeWarehouseId,\s*scope\.row\.beforeSiteName,\s*scope\.row\.beforeSiteId,\s*scope\.row\.beforeLineName,\s*scope\.row\.beforeLineId\s*\)/, 'palletTrace 变更前位置必须同时传入仓库、现场和产线的名称与 ID' ) assert.match( source, /formatLocation\(\s*scope\.row\.afterWarehouseName,\s*scope\.row\.afterWarehouseId,\s*scope\.row\.afterSiteName,\s*scope\.row\.afterSiteId,\s*scope\.row\.afterLineName,\s*scope\.row\.afterLineId\s*\)/, 'palletTrace 变更后位置必须同时传入仓库、现场和产线的名称与 ID' ) } if (resource === 'shipment') { assert.doesNotMatch(source, /\bDRAFT\b/, 'shipment 报表只展示已确认事实,不得提供草稿筛选') assert.doesNotMatch( source, /queryParams\.status|prop=["']status["']|\bstatus\s*:/, 'shipment 报表不得提交与固定 CONFIRMED 范围冲突的状态筛选' ) } assert.match(source, /]*v-loading="loading"[^>]*:data="reportList"/) assert.match(source, /[\s\S]*?]*v-if="loadError"/) assert.match(source, / \{\s*invalidateListRequest\(\)\s*}\)/) }) } }) test('首页指标全部来自受批次作用域保护的业务与报表 API', async () => { const source = await readFile(path.join(frontendRoot, 'src/views/dashboard/index.vue'), 'utf8') for (const api of ['listInbound', 'listReturn', 'listShipment', 'listPalletInventory', 'listShipmentReport']) { assert.match(source, new RegExp(`\\b${api}\\b`)) } assert.doesNotMatch(source, /axios\.|fetch\(/) }) test('扫码组件声明交互契约并保持触控尺寸', async () => { const source = await readFile(path.join(frontendRoot, 'src/components/PalletScanInput/index.vue'), 'utf8') for (const propName of ['disabled', 'loading', 'minLength', 'duplicateWindowMs']) { assert.match(source, new RegExp(`\\b${propName}\\b`)) } for (const eventName of ['scan', 'invalid', 'duplicate']) { assert.match(source, new RegExp(`['"]${eventName}['"]`)) } assert.match(source, /@keyup\.enter="handleSubmit"/) assert.match(source, /normalizeScanValue\(scanValue\.value\)/) assert.match(source, /isDuplicateScan\(/) assert.match(source, /emit\(['"]invalid['"]/) assert.match(source, /emit\(['"]duplicate['"]/) assert.match(source, /emit\(['"]scan['"]/) assert.match(source, /nextTick\(/) assert.match(source, /inputRef\.value\?\.focus\(\)/) assert.match(source, /if \(!props\.disabled && !props\.loading\)/) assert.match(source, /focusInput\(true\)/) assert.match(source, /inputRef\.value\?\.select\(\)/) assert.match(source, /nextTick\([\s\S]*?focus\(\)[\s\S]*?select\(\)/) assert.match(source, /watch\(\s*\(\) => \[props\.loading, props\.disabled\]/) assert.match(source, /if \(!loading && !disabled\)/) assert.match(source, /min-height:\s*48px/) assert.match(source, /width:\s*100%/) assert.match(source, /is-loading/) assert.match(source, /is-disabled/) assert.match(source, /is-error/) assert.match(source, /focus-within/) assert.doesNotMatch(source, /BarcodeDetector|camera|getUserMedia/i) }) test('六个基础资料页面逐页满足 API、权限、分页和安全删除契约', async (t) => { for (const [resource, contract] of Object.entries(masterPageContracts)) { await t.test(contract.relativePath, async () => { const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8') const permissionPrefix = `master:${resource}` const suffix = resource[0].toUpperCase() + resource.slice(1) const idKey = `${resource}Id` const importedModules = [...source.matchAll(/from\s*['"]@\/api\/master\/([a-z]+)['"]/g)] .map((match) => match[1]) assert.deepEqual(importedModules, [resource], `${resource} 页面不得跨调其他主数据 API`) assert.deepEqual(extractImportedNames(source, `@/api/master/${resource}`), contract.apiNames) const actualPermissions = extractGuardedVuePermissions(source) .filter((permission) => permission.startsWith(`${permissionPrefix}:`)) .map((permission) => permission.slice(permissionPrefix.length + 1)) assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`) assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`) assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`) assertButtonsUsePermission(source, 'handleAdd', `${permissionPrefix}:add`) assertButtonsUsePermission(source, 'submitCreate', `${permissionPrefix}:add`) assertButtonsUsePermission(source, 'handleUpdate', `${permissionPrefix}:edit`) assertButtonsUsePermission(source, 'submitUpdate', `${permissionPrefix}:edit`) for (const button of extractButtonBlocks(source, 'handleUpdate')) { assert.match( button, new RegExp(`v-if="proxy\\.\\$auth\\.hasPermiAnd\\(\\['${permissionPrefix}:query', '${permissionPrefix}:edit'\\]\\)"`), `${resource} 修改按钮必须同时校验 query 与 edit` ) } assert.match(source, /proxy\.useDict\(['"]sys_normal_disable['"]\)/) assert.match(source, /]*ref="queryRef"[^>]*>/)?.[0] || '' assert.match(queryFormTag, /v-show="showSearch"/, `${resource} 搜索区必须支持显隐`) assert.match(source, /const showSearch = ref\(true\);/, `${resource} 搜索区必须默认展开`) const masterToolbar = source.match(/]*class="[^"]*\bmaster-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || '' assert.ok(masterToolbar, `${resource} 缺少基础资料操作栏`) assert.doesNotMatch(masterToolbar.match(/^]*>/)?.[0] || '', /v-if="isMasterWriteAllowed"/, `${resource} 搜索与刷新入口不得受写权限限制`) assert.match(masterToolbar, /