Files
ShiErFeng/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs
T
yujiangandClaude Opus 5.5 167df4adb8 Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00

2977 lines
149 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i)
})
test('PC 登录页在版权信息旁提供开源协议外链', async () => {
const [loginPage, router, permission] = await Promise.all([
readFile(path.join(frontendRoot, 'src/views/login.vue'), 'utf8'),
readFile(path.join(frontendRoot, 'src/router/index.js'), 'utf8'),
readFile(path.join(frontendRoot, 'src/permission.js'), 'utf8')
])
assert.match(loginPage, /开源协议/)
assert.match(loginPage, /to="\/open-source-notice"/)
assert.doesNotMatch(loginPage, /git\.yujiangrubber\.cn\/niuniu\/ShiErFeng/)
assert.match(router, /path: '\/open-source-notice'/)
assert.match(router, /@\/views\/openSourceNotice\/index/)
assert.match(permission, /whiteList = \['\/login', '\/register', '\/open-source-notice'\]/)
})
test('站内开源协议页展示 RuoYi 来源与 MIT 许可全文', async () => {
const noticePage = await readFile(path.join(frontendRoot, 'src/views/openSourceNotice/index.vue'), 'utf8')
assert.match(noticePage, /RuoYi-Vue3-FastAPI/)
assert.match(noticePage, /Copyright \(c\) 2018 RuoYi/)
assert.match(noticePage, /The MIT License \(MIT\)/)
assert.match(noticePage, /自有业务代码、界面设计和文档不因本声明而开放授权/)
})
test('版权归属与 RuoYi 第三方 MIT 许可范围明确', async () => {
const [settings, rootLicense, thirdPartyNotices, packageJson] = await Promise.all([
readFile(path.join(frontendRoot, 'src/settings.js'), 'utf8'),
readFile(path.join(frontendRoot, '..', 'LICENSE'), 'utf8'),
readFile(path.join(frontendRoot, '..', 'THIRD-PARTY-NOTICES.md'), 'utf8'),
readFile(path.join(frontendRoot, 'package.json'), 'utf8')
])
assert.match(settings, /Copyright © 2024-2026 Shierfeng International Biotechnology Co\., Ltd\. All Rights Reserved\./)
assert.match(rootLicense, /Shierfeng International Biotechnology Co\., Ltd\./)
assert.match(rootLicense, /All rights reserved\./i)
assert.doesNotMatch(rootLicense, /^MIT License$/m)
assert.match(thirdPartyNotices, /RuoYi-Vue3-FastAPI/)
assert.match(thirdPartyNotices, /Copyright \(c\) 2018 RuoYi/)
assert.match(thirdPartyNotices, /The MIT License \(MIT\)/)
assert.equal(JSON.parse(packageJson).license, 'UNLICENSED')
})
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' },
],
'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' },
],
'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/' + siteId + '/users'", method: 'get' },
{ url: "'/master/site/' + siteId + '/users'", method: 'put' },
{ url: "'/master/site/customer-options'", method: 'get' }
],
'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/site-options'", method: 'get' }
],
'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/' + 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' },
],
'src/api/master/tunnel.js': [
{ url: "'/master/tunnel/list'", method: 'get' },
{ url: "'/master/tunnel/' + tunnelId", method: 'get' },
{ url: "'/master/tunnel'", method: 'post' },
{ url: "'/master/tunnel'", method: 'put' },
{ url: "'/master/tunnel/site-options'", method: 'get' }
],
'src/api/business/inbound.js': [
{ url: "'/business/inbound/list'", method: 'get' },
{ url: "'/business/inbound/summary'", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/inbound/' + inboundId", method: 'get' },
{ url: "'/business/inbound'", method: 'post' },
{ url: "'/business/inbound'", method: 'put' },
{ url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' }
],
'src/api/business/filling.js': [
{ url: "'/business/filling/list'", method: 'get' },
{ url: "'/business/filling/summary'", method: 'get' },
{ url: "'/business/filling/batches'", method: 'get' },
{ url: "'/business/filling/batches/detail'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/filling/scan/' + encodeURIComponent(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/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/shipment/' + shipmentId", method: 'get' },
{ url: "'/business/shipment/' + shipmentId + '/labels'", method: 'get' },
{ url: "'/business/shipment'", method: 'post' },
{ url: "'/business/shipment'", method: 'put' },
{ 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/tunnel-options'", method: 'get' },
{ url: "'/business/usage/site-options'", method: 'get' },
{ url: "'/business/usage/site-board'", method: 'get' },
{ url: "'/business/usage-fifo/queue'", method: 'get' },
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/usage-fifo/recommendation'", 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/summary'", method: 'get' },
{ url: "'/business/return/site-options'", method: 'get' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/return/scan/' + encodeURIComponent(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', 'getStrain', 'listStrain', 'updateStrain'],
permissions: ['add', 'edit', 'list', 'query'],
addPayloadKeys: ['remark', 'status', 'strainCode', 'strainName'],
editPayloadKeys: ['remark', 'status', 'strainCode', 'strainId', 'strainName']
},
customer: {
relativePath: 'src/views/master/customer/index.vue',
apiNames: ['addCustomer', 'getCustomer', 'listCustomer', 'updateCustomer'],
permissions: ['add', 'edit', 'list', 'query'],
addPayloadKeys: ['customerCode', 'customerName', 'remark', 'status'],
editPayloadKeys: ['customerCode', 'customerId', 'customerName', 'remark', 'status']
},
site: {
relativePath: 'src/views/master/site/index.vue',
apiNames: ['addSite', 'getSite', 'getSiteCustomerOptions', 'listSite', 'updateSite'],
permissions: ['add', 'assign', 'edit', 'list', 'query'],
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', 'getLine', 'getLineSiteOptions', 'listLine', 'updateLine'],
permissions: ['add', 'edit', 'list', 'query'],
addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'],
editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status']
},
warehouse: {
relativePath: 'src/views/master/warehouse/index.vue',
apiNames: ['addWarehouse', 'getWarehouse', 'listWarehouse', 'updateWarehouse'],
permissions: ['add', 'assign', 'edit', 'list', 'query'],
addPayloadKeys: ['address', 'remark', 'status', 'warehouseCode', 'warehouseName'],
editPayloadKeys: ['address', 'remark', 'status', 'warehouseCode', 'warehouseId', 'warehouseName']
},
pallet: {
relativePath: 'src/views/master/pallet/index.vue',
apiNames: ['addPallet', 'getPallet', 'getPalletQrcode', 'listPallet', 'updatePallet'],
permissions: ['add', 'edit', 'list', 'print', 'query'],
addPayloadKeys: ['capacityPackages', 'palletCode', 'remark'],
editPayloadKeys: ['assetStatus', 'capacityPackages', 'palletId', 'remark']
}
}
const businessPageContracts = {
inbound: {
relativePath: 'src/views/business/inbound/index.vue',
apiNames: [
'addInbound',
'confirmInbound',
'getInbound',
'getInboundSummary',
'getInboundWarehouseOptions',
'listInbound',
'scanInboundPallet',
'updateInbound'
],
permissions: ['add', 'confirm', 'edit', 'list', 'query'],
payloads: {
addInbound: ['palletIds', 'warehouseId'],
updateInbound: ['inboundId', 'palletIds', 'warehouseId'],
confirmInbound: ['idempotencyKey']
}
},
filling: {
relativePath: 'src/views/business/filling/index.vue',
apiNames: [
'addFilling',
'getFillingBatchDetail',
'getFillingStrainOptions',
'getFillingSummary',
'listFilling',
'listFillingBatches',
'scanFillingPallet'
],
permissions: ['add', 'list', 'query'],
payloads: {
addFilling: [
'batchNo',
'expiryDate',
'idempotencyKey',
'initialPackages',
'packageSpec',
'productionDate',
'qrToken',
'strainId',
'warehouseId'
]
}
},
shipment: {
relativePath: 'src/views/business/shipment/index.vue',
apiNames: [
'addShipment',
'confirmShipment',
'getShipment',
'getShipmentLabels',
'getShipmentSiteOptions',
'getShipmentWarehouseOptions',
'listShipment',
'scanShipmentPallet',
'updateShipment'
],
permissions: ['add', 'confirm', 'edit', 'list', 'query', 'receive'],
payloads: {
addShipment: ['palletIds', 'sourceWarehouseId', 'targetSiteId'],
updateShipment: ['palletIds', 'shipmentId', 'sourceWarehouseId', 'targetSiteId'],
confirmShipment: ['idempotencyKey']
}
},
usage: {
relativePath: 'src/views/business/usage/index.vue',
apiNames: [
'addUsage',
'enterUsageTunnel',
'getUsageFifoQueue',
'getUsageFifoRecommendation',
'getUsageLineOptions',
'getUsageSiteBoard',
'getUsageSiteOptions',
'getUsageTunnelOptions',
'listUsage',
'scanUsagePallet'
],
permissions: ['add', 'list', 'query'],
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
payloads: {}
},
return: {
relativePath: 'src/views/business/return/index.vue',
apiNames: [
'confirmReturn',
'getReturn',
'getReturnSiteOptions',
'getReturnSummary',
'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:customer:query',
'master:customer:add',
'master:customer:edit',
'master:site:query',
'master:site:add',
'master:site:edit',
'master:site:assign',
'master:line:query',
'master:line:add',
'master:line:edit',
'master:warehouse:query',
'master:warehouse:add',
'master:warehouse:edit',
'master:warehouse:assign',
'master:pallet:query',
'master:pallet:add',
'master:pallet:edit',
'master:pallet:print',
'master:strain:import',
'master:strain:export',
'master:customer:import',
'master:customer:export',
'master:site:import',
'master:site:export',
'master:line:import',
'master:line:export',
'master:warehouse:import',
'master:warehouse:export',
'master:pallet:import',
'master:pallet:export',
'master:tunnel:import',
'master:tunnel:export',
'business:inbound:query',
'business:inbound:add',
'business:inbound:edit',
'business:inbound:confirm',
'business:filling:query',
'business:filling:add',
'business:shipment:query',
'business:shipment:add',
'business:shipment:edit',
'business:shipment:confirm',
'business:shipment:receive',
'business:usage:query',
'business:usage:add',
'business:return:query',
'business:return:request',
'business:return:confirm',
'system:testbatch:adopt'
]
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|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 = /(?<![\w$.])proxy\.\$auth\.hasPermiAnd\s*\(/g
const canonicalCalls = [...sanitized.structure.matchAll(callPattern)]
assert.equal(
canonicalCalls.length,
permissionTokens.length,
'hasPermiAnd 必须使用 canonical proxy.$auth.hasPermiAnd(static-array) 形式'
)
const permissions = []
for (const match of canonicalCalls) {
const openParenthesis = match.index + match[0].lastIndexOf('(')
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
const argument = sanitized.code.slice(openParenthesis + 1, closeParenthesis)
permissions.push(...parseStaticPermissionArray(argument))
}
return permissions
}
function stripVueHtmlComments(source) {
const withoutComments = source.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\r\n]/g, ' '))
assert.doesNotMatch(withoutComments, /<!--/, 'Vue 源码包含未闭合 HTML 注释')
return withoutComments
}
function extractGuardedVuePermissions(source) {
const cleanSource = stripVueHtmlComments(source)
const sanitized = sanitizeSource(cleanSource, { allowTemplateInterpolation: true })
const permissions = extractStaticHasPermiAnd(cleanSource)
const directivePattern = /^v-hasPermi\s*=\s*(?:"([^"]*)"|'([^']*)')/
const vIfPattern = /^v-if\s*=\s*(?:"([^"]*)"|'([^']*)')/
for (const token of sanitized.structure.matchAll(/\bv-hasPermi\b/g)) {
const directive = directivePattern.exec(sanitized.code.slice(token.index))
assert.ok(directive, 'v-hasPermi 必须直接绑定静态字符串数组')
permissions.push(...parseStaticPermissionArray(directive[1] ?? directive[2]))
}
for (const token of sanitized.structure.matchAll(/\bv-if\b/g)) {
const attribute = vIfPattern.exec(sanitized.code.slice(token.index))
assert.ok(attribute, 'v-if 必须使用可静态解析的属性值')
permissions.push(...extractStaticHasPermiAnd(attribute[1] ?? attribute[2]))
}
return [...new Set(permissions)].sort()
}
function assertExactPermissionLayers(layers) {
for (const [name, permissions] of Object.entries(layers)) {
assert.deepEqual([...new Set(permissions)].sort(), expectedPermissions, `${name} 权限集合不精确`)
}
}
async function assertPageComponentContract(
componentEntries,
pageRelativePaths,
inspectPath = lstat,
resolvePath = realpath
) {
const viewsRoot = path.resolve(frontendRoot, 'src/views')
const resolvedViewsRoot = await resolvePath(viewsRoot)
assert.equal(componentEntries.length, 17, '固定 component 映射必须正好 17 个')
assert.equal(new Set(componentEntries.map(([component]) => component)).size, 17, '固定 component 映射必须唯一')
assert.equal(pageRelativePaths.length, 17, 'page contract 必须正好 17 个')
assert.equal(new Set(pageRelativePaths).size, 17, 'page contract relativePath 必须唯一')
for (const [component, relativePath] of componentEntries) {
assert.ok(!path.isAbsolute(component), `${component} 不得为绝对路径`)
assert.ok(!component.split('/').includes('..'), `${component} 不得越出 src/views`)
assert.equal(relativePath, `src/views/${component}.vue`, `${component} 与 page contract 路径不一致`)
const absolutePath = path.resolve(frontendRoot, relativePath)
const relativeToViews = path.relative(viewsRoot, absolutePath)
assert.ok(
relativeToViews && relativeToViews !== '..' && !relativeToViews.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeToViews),
`${component} 必须位于 src/views 内`
)
const stats = await inspectPath(absolutePath)
assert.ok(stats.isFile() && !stats.isSymbolicLink(), `${component}.vue 必须为普通文件`)
const resolvedPath = await resolvePath(absolutePath)
const relativeToResolvedViews = path.relative(resolvedViewsRoot, resolvedPath)
assert.ok(
relativeToResolvedViews && relativeToResolvedViews !== '..' &&
!relativeToResolvedViews.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeToResolvedViews),
`${component} 真实路径必须位于 src/views 内`
)
}
assert.deepEqual(
[...componentEntries].sort(([left], [right]) => left.localeCompare(right)),
Object.entries(expectedPageComponents).sort(([left], [right]) => left.localeCompare(right)),
'固定 component 映射与硬编码预期不一致'
)
assert.deepEqual([...pageRelativePaths].sort(), Object.values(expectedPageComponents).sort())
}
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 = /(?<![\w$])request\s*\(/g
const requests = []
let match
while ((match = requestPattern.exec(sanitized.structure)) !== null) {
const openParenthesis = match.index + match[0].lastIndexOf('(')
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
requests.push(parseRequestArgument({
code: sanitized.code.slice(openParenthesis + 1, closeParenthesis),
structure: sanitized.structure.slice(openParenthesis + 1, closeParenthesis)
}, requests.length + 1))
}
return requests
}
function extractImportedNames(source, modulePath) {
const escapedPath = modulePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const importMatch = new RegExp(`import\\s*{([^}]*)}\\s*from\\s*['"]${escapedPath}['"]`).exec(source)
assert.ok(importMatch, `缺少 ${modulePath} 的具名导入`)
return importMatch[1]
.split(',')
.map((name) => name.trim())
.filter(Boolean)
.sort()
}
function extractCallObjectKeys(source, callName) {
const escapedCallName = callName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const callMatch = new RegExp(`(?<![\\w$])${escapedCallName}\\s*\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)`).exec(source)
assert.ok(callMatch, `缺少 ${callName}({ ... }) 显式对象调用`)
const properties = callMatch[1].split(',').map((property) => 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 extractSecondCallObjectKeys(source, callName) {
const escapedCallName = callName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const callMatch = new RegExp(
`(?<![\\w$])${escapedCallName}\\s*\\(\\s*[^,]+,\\s*\\{([\\s\\S]*?)\\}\\s*\\)`
).exec(source)
assert.ok(callMatch, `缺少 ${callName}(id, { ... }) 显式对象调用`)
const properties = callMatch[1].split(',').map((property) => 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 extractNamedFunction(source, functionName) {
const sanitized = sanitizeSource(source, { allowTemplateInterpolation: true })
const escapedFunctionName = functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = new RegExp(`function\\s+${escapedFunctionName}\\s*\\(`).exec(sanitized.structure)
assert.ok(match, `缺少函数 ${functionName}`)
const parametersStart = sanitized.structure.indexOf('(', match.index)
const parametersEnd = findClosingDelimiter(sanitized.structure, parametersStart)
const bodyStart = sanitized.structure.indexOf('{', parametersEnd)
assert.ok(bodyStart >= 0, `${functionName} 缺少函数体`)
const bodyEnd = findClosingDelimiter(sanitized.structure, bodyStart)
return source.slice(match.index, bodyEnd + 1)
}
function extractScriptSetup(source) {
const match = /<script\s+setup(?:\s+[^>]*)?>([\s\S]*?)<\/script>/.exec(source)
assert.ok(match, '缺少 script setup')
return match[1].replace(/^import\s+.*$/gm, '')
}
function createDeferred() {
let resolve
let reject
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
async function flushPromises() {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
}
function createTestBatchPageHarness(pageSource, api = {}) {
const noopRequest = () => Promise.reject(new Error('未配置的测试请求'))
const ref = value => ({ value })
const context = vm.createContext({
Array,
Boolean,
Error,
JSON,
Number,
Object,
Promise,
Set,
String,
console,
ref,
reactive: value => value,
computed: getter => ({ get value() { return getter() } }),
getCurrentInstance: () => ({ proxy: { $modal: { confirm: () => Promise.resolve(), msgError: () => {}, msgSuccess: () => {} } } }),
onBeforeUnmount: () => {},
onMounted: () => {},
window: { innerWidth: 1280, addEventListener: () => {}, removeEventListener: () => {} },
useUserStore: () => ({ admin: true }),
useTestBatchStore: () => ({ currentBatchId: undefined, leaveTestMode: () => {}, refreshOptions: () => Promise.resolve() }),
addTestBatch: api.addTestBatch || noopRequest,
assignTestBatchUsers: api.assignTestBatchUsers || noopRequest,
closeTestBatch: api.closeTestBatch || noopRequest,
getTestBatch: api.getTestBatch || noopRequest,
listTestBatches: api.listTestBatches || noopRequest,
previewTestBatchPurge: api.previewTestBatchPurge || noopRequest,
purgeTestBatch: api.purgeTestBatch || noopRequest,
reopenTestBatch: api.reopenTestBatch || noopRequest,
updateTestBatch: api.updateTestBatch || noopRequest
})
const exposure = `
globalThis.__testBatchHarness = {
openAssign,
submitAssign,
getList,
hydrateParticipantCounts,
state: {
assignBatch,
assignError,
assignLoading,
assignVisible,
assignedUserIds,
batchList,
detailLoaded,
loadError,
loading
}
}
`
vm.runInContext(`${extractScriptSetup(pageSource)}\n${exposure}`, context, { filename: 'src/views/system/testBatch/index.vue' })
return context.__testBatchHarness
}
function extractPromiseCallback(functionSource, methodName, occurrence = 0) {
const sanitized = sanitizeSource(functionSource, { allowTemplateInterpolation: true })
const marker = `.${methodName}(`
let markerIndex = -1
for (let index = 0; index <= occurrence; index += 1) {
markerIndex = sanitized.structure.indexOf(marker, markerIndex + 1)
assert.ok(markerIndex >= 0, `缺少第 ${occurrence + 1} 个 .${methodName}(...)`)
}
const callStart = markerIndex + marker.length - 1
const callEnd = findClosingDelimiter(sanitized.structure, callStart)
const arrowIndex = sanitized.structure.indexOf('=>', callStart)
assert.ok(arrowIndex > callStart && arrowIndex < callEnd, `.${methodName} 必须传入箭头函数`)
const bodyStart = sanitized.structure.indexOf('{', arrowIndex)
assert.ok(bodyStart > arrowIndex && bodyStart < callEnd, `.${methodName} 回调必须使用块函数体`)
const bodyEnd = findClosingDelimiter(sanitized.structure, bodyStart)
assert.ok(bodyEnd < callEnd, `.${methodName} 回调块超出调用范围`)
return functionSource.slice(bodyStart + 1, bodyEnd)
}
function assertPromiseCallbackStartsWithGuard(functionSource, methodName, guardName, occurrence = 0) {
const callback = extractPromiseCallback(functionSource, methodName, occurrence)
const escapedGuardName = guardName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const guardPattern = new RegExp(
`^\\s*if\\s*\\(\\s*!${escapedGuardName}\\([^)]*\\)\\s*\\)\\s*(?:\\{\\s*return;?\\s*\\}|return;?)`
)
assert.match(
callback,
guardPattern,
`${guardName} 必须是第 ${occurrence + 1} 个 .${methodName} 回调写 UI 前的首个门禁`
)
}
function assertPromiseCallbacksStartWithGuard(functionSource, guardName) {
for (const methodName of ['then', 'catch', 'finally']) {
assertPromiseCallbackStartsWithGuard(functionSource, methodName, guardName)
}
}
function extractButtonBlocks(source, clickHandler) {
return [...source.matchAll(/<el-button\b[\s\S]*?<\/el-button>/g)]
.map((match) => match[0])
.filter((button) => button.includes(`@click="${clickHandler}`))
}
function assertButtonInSingleToolbar(source, relativePath, toolbarClass, clickHandler) {
const buttonIndex = source.indexOf(`@click="${clickHandler}`)
assert.ok(buttonIndex >= 0, `${relativePath} 缺少 ${clickHandler} 按钮`)
const beforeButton = source.slice(0, buttonIndex)
const rowStart = beforeButton.lastIndexOf('<el-row')
const rowEnd = beforeButton.lastIndexOf('</el-row>')
assert.ok(rowStart > rowEnd, `${relativePath} 的 ${clickHandler} 按钮必须位于 el-row 工具栏内`)
const rowTagEnd = source.indexOf('>', rowStart)
const rowTag = source.slice(rowStart, rowTagEnd + 1)
assert.match(rowTag, new RegExp(`class="[^"]*\\b${toolbarClass}\\b`), `${relativePath} 必须使用 ${toolbarClass}`)
const toolbarRows = [...source.matchAll(new RegExp(`<el-row\\b[^>]*class="[^"]*\\b${toolbarClass}\\b[^"]*"`, 'g'))]
assert.equal(toolbarRows.length, 1, `${relativePath} 的操作按钮必须合并到同一个 ${toolbarClass}`)
}
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
)
)
}
const source = (relativePath) => readFile(path.join(frontendRoot, relativePath), 'utf8')
async function runRequestUnavailableInterceptorContract() {
const harness = String.raw`
const { readFile } = require('node:fs/promises')
const vm = require('node:vm')
async function main() {
const requestPath = process.argv[1]
const requestSource = await readFile(requestPath, 'utf8')
const interceptors = {}
const recoveries = []
const service = {
interceptors: {
request: { use: (fulfilled, rejected) => { interceptors.request = { fulfilled, rejected } } },
response: { use: (fulfilled, rejected) => { interceptors.response = { fulfilled, rejected } } }
},
post: () => Promise.resolve()
}
const context = vm.createContext({ console, Promise, Error, JSON, Object, Date, setTimeout })
const moduleFromExports = exports => new vm.SyntheticModule(
Object.keys(exports),
function setExports() {
for (const [name, value] of Object.entries(exports)) this.setExport(name, value)
},
{ context }
)
const modules = {
axios: { default: { defaults: { headers: {} }, create: () => service } },
'element-plus': {
ElNotification: { error: () => {} },
ElMessageBox: { confirm: () => Promise.resolve() },
ElMessage: () => {},
ElLoading: { service: () => ({ close: () => {} }) }
},
'@/utils/auth': { getToken: () => 'token' },
'@/utils/errorCode': { default: {} },
'@/utils/shierfeng': { tansParams: () => '', blobValidate: () => false },
'@/plugins/cache': { default: { session: { getJSON: () => undefined, setJSON: () => {} } } },
'file-saver': { saveAs: () => {} },
'@/store/modules/user': { default: () => ({ logOut: () => Promise.resolve() }) },
'@/utils/testBatchContext': {
applyTestBatchHeader: config => config,
getStoredTestBatchId: () => '',
recoverUnavailableTestBatch: async ({ notify, message }) => {
recoveries.push(message)
notify(message)
}
},
'@/utils/transportCrypto': {
decryptTransportErrorResponse: async error => error,
decryptTransportResponse: async response => response,
encryptTransportRequest: async config => config,
invalidateTransportKeyMeta: () => {},
resetTransportRequestConfig: () => {},
shouldRetryTransportWithFreshKey: () => false
}
}
const requestModule = new vm.SourceTextModule(requestSource, {
context,
identifier: requestPath,
initializeImportMeta(meta) {
meta.env = { VITE_APP_BASE_API: '' }
}
})
await requestModule.link(specifier => {
if (!modules[specifier]) throw new Error('未受控依赖: ' + specifier)
return moduleFromExports(modules[specifier])
})
await requestModule.evaluate()
const invoke = async callback => {
try {
await callback()
return { resolved: true }
} catch (error) {
return { resolved: false, message: error.message, reason: error.reason }
}
}
const success = await invoke(() => interceptors.response.fulfilled({
data: { reason: 'TEST_BATCH_UNAVAILABLE', msg: '成功响应批次不可用' },
request: { responseType: '' }
}))
const failure = await invoke(() => interceptors.response.rejected({
response: { data: { reason: 'TEST_BATCH_UNAVAILABLE', msg: '异常响应批次不可用' } },
message: 'Request failed with status code 400'
}))
process.stdout.write(JSON.stringify({ recoveries, success, failure }))
}
main().catch(error => {
console.error(error)
process.exitCode = 1
})
`
const { stdout } = await execFileAsync(process.execPath, [
'--experimental-vm-modules',
'-e',
harness,
path.join(frontendRoot, 'src/utils/request.js')
])
return JSON.parse(stdout)
}
test('Web 测试模式统一附加请求头并通过路由重建清空页面状态', async () => {
const appMainSource = await source('src/layout/components/AppMain.vue')
const navbarSource = await source('src/layout/components/Navbar.vue')
assert.match(appMainSource, /scopeVersion/)
assert.match(navbarSource, /TestBatchModeSwitch/)
assert.match(navbarSource, /测试数据/)
})
test('Web 测试批次上下文仅持久化 ID,登出清除 ID 且管理员标记严格来自后端布尔值', async () => {
const storeSource = await source('src/store/modules/testBatch.js')
const userSource = await source('src/store/modules/user.js')
assert.match(storeSource, /getStoredTestBatchId/)
assert.match(storeSource, /setStoredTestBatchId/)
assert.match(storeSource, /clearStoredTestBatchId/)
assert.match(userSource, /admin:\s*false/)
assert.match(userSource, /this\.admin\s*=\s*user\.admin\s*===\s*true/)
assert.match(userSource, /logoutWithLocalCleanup/)
})
test('Web 测试批次真实上下文逻辑只在登录测试模式附加请求头,并对并发失效做一次回退', async () => {
const storage = new Map()
globalThis.sessionStorage = {
getItem: key => storage.get(key) || null,
setItem: (key, value) => storage.set(key, value),
removeItem: key => storage.delete(key)
}
const formalConfig = { headers: {} }
testBatchContext.applyTestBatchHeader(formalConfig, '', 'batch-1')
assert.deepEqual(formalConfig.headers, {})
const testConfig = { headers: {} }
testBatchContext.applyTestBatchHeader(testConfig, 'token', 'batch-1')
assert.equal(testConfig.headers['X-Test-Batch-Id'], 'batch-1')
testBatchContext.setStoredTestBatchId('batch-1')
let loadCount = 0
let leaveCount = 0
let notifyCount = 0
const loadStore = async () => {
loadCount += 1
await Promise.resolve()
return { leaveTestMode: () => { leaveCount += 1 } }
}
const recovery = {
loadStore,
notify: () => { notifyCount += 1 },
message: '测试批次不可用'
}
await Promise.all([
testBatchContext.recoverUnavailableTestBatch(recovery),
testBatchContext.recoverUnavailableTestBatch(recovery),
testBatchContext.recoverUnavailableTestBatch(recovery)
])
assert.equal(loadCount, 1)
assert.equal(leaveCount, 1)
assert.equal(notifyCount, 1)
assert.equal(testBatchContext.getStoredTestBatchId(), '')
await testBatchContext.recoverUnavailableTestBatch(recovery)
assert.equal(loadCount, 2)
assert.equal(leaveCount, 2)
assert.equal(notifyCount, 2)
})
test('刷新失效批次复用统一回退提示,登出无论远端结果都清理管理员与批次上下文', async () => {
const storeSource = await source('src/store/modules/testBatch.js')
const userSource = await source('src/store/modules/user.js')
assert.match(storeSource, /recoverUnavailableTestBatch/)
assert.match(storeSource, /当前测试批次已不可用/)
assert.match(userSource, /logoutWithLocalCleanup/)
globalThis.sessionStorage.setItem('test-batch-id', 'batch-2')
const state = { admin: true }
await testBatchContext.logoutWithLocalCleanup(() => Promise.resolve('ok'), state)
assert.equal(state.admin, false)
assert.equal(testBatchContext.getStoredTestBatchId(), '')
globalThis.sessionStorage.setItem('test-batch-id', 'batch-3')
state.admin = true
const remoteError = new Error('远端登出失败')
await assert.rejects(
testBatchContext.logoutWithLocalCleanup(() => Promise.reject(remoteError), state),
remoteError
)
assert.equal(state.admin, false)
assert.equal(testBatchContext.getStoredTestBatchId(), '')
})
test('request.js 的成功与异常响应拦截器都会恢复不可用批次并拒绝原请求', async () => {
const result = await runRequestUnavailableInterceptorContract()
assert.deepEqual(result.recoveries, ['成功响应批次不可用', '异常响应批次不可用'])
assert.deepEqual(result.success, {
resolved: false,
message: '成功响应批次不可用',
reason: 'TEST_BATCH_UNAVAILABLE'
})
assert.deepEqual(result.failure, {
resolved: false,
message: '异常响应批次不可用',
reason: 'TEST_BATCH_UNAVAILABLE'
})
})
test('窄屏模式控件仍保留正式退出与测试批次切换入口', async () => {
const switchSource = await source('src/components/TestBatchModeSwitch/index.vue')
const navbarSource = await source('src/layout/components/Navbar.vue')
const mobileSection = switchSource.slice(switchSource.indexOf('@media screen and (max-width: 767px)'))
const navbarMobileSection = navbarSource.slice(navbarSource.indexOf('@media screen and (max-width: 767px)'))
assert.match(mobileSection, /\.mode-selector[\s\S]*\.batch-selector/)
assert.doesNotMatch(mobileSection, /display:\s*none/)
assert.match(mobileSection, /max-width:\s*48px/)
assert.match(mobileSection, /width:\s*72px/)
assert.match(mobileSection, /padding-right:\s*2px/)
assert.match(mobileSection, /padding-left:\s*2px/)
assert.match(navbarMobileSection, /\.test-batch-mode\s*\{\s*padding:\s*0/)
assert.match(navbarMobileSection, /\.breadcrumb-container\s*\{\s*display:\s*none/)
assert.match(navbarMobileSection, /\.right-menu\s*\{\s*overflow:\s*visible/)
})
test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => {
assert.equal(expectedPagePermissions.length, 17)
assert.equal(expectedButtonPermissions.length, 52)
assert.equal(new Set(expectedPermissions).size, 69)
const relativePaths = [
...Object.values(masterPageContracts),
...Object.values(businessPageContracts),
...Object.values(reportPageContracts)
].map((contract) => contract.relativePath)
assert.equal(relativePaths.length, 17)
const actualPermissions = []
for (const relativePath of [...relativePaths, 'src/components/MasterExcelActions/index.vue']) {
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
const guarded = extractGuardedVuePermissions(source)
assert.ok(guarded.length > 0, `${relativePath} 缺少真实权限门禁`)
actualPermissions.push(...guarded)
}
assertExactPermissionLayers({ vue: actualPermissions })
})
test('真实权限门禁解析拒绝注释常量、hasPermiOr 和动态数组假阳性', () => {
const staleSource = `
<!-- v-hasPermi="['master:strain:query']" -->
<script setup>const stalePermission = 'master:strain:query'</script>
`
assert.deepEqual(extractGuardedVuePermissions(staleSource), [])
assert.throws(
() => extractGuardedVuePermissions(
`<button v-if="proxy.$auth.hasPermiOr(['master:strain:query'])">错误门禁</button>`
),
/hasPermiOr/
)
assert.throws(
() => extractGuardedVuePermissions('<button v-hasPermi="permissionArray">动态门禁</button>'),
/静态字符串数组/
)
assert.throws(
() => extractGuardedVuePermissions('<button v-if="proxy.$auth.hasPermiAnd(permissionArray)">动态门禁</button>'),
/静态字符串数组/
)
assert.throws(
() => extractGuardedVuePermissions(`<button v-if="proxy.$auth.hasPermiAnd(['master:strain:list', permission])">动态门禁</button>`),
/动态表达式/
)
const missing = expectedPermissions.filter((permission) => permission !== 'master:strain:query')
assert.throws(
() => assertExactPermissionLayers({ mysql: missing, controller: missing, vue: missing }),
/权限集合不精确/
)
})
test('合法门禁混入额外 hasPermiOr 时直接 fail-closed', () => {
const source = `
<button v-hasPermi="['master:strain:list']">合法门禁</button>
<button v-if="proxy.$auth.hasPermiAnd(['master:strain:list'])">合法组合门禁</button>
<button v-if="proxy.$auth.hasPermiOr(['master:strain:query'])">非约定门禁</button>
`
assert.throws(() => extractGuardedVuePermissions(source), /hasPermiOr/)
})
test('完整合法门禁旁的非 canonical 权限调用必须 fail-closed', () => {
const validGuard = `<button v-hasPermi="['master:strain:list']">合法门禁</button>`
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}<button v-if="${invalidCall}">非约定门禁</button>`
assert.throws(() => extractGuardedVuePermissions(source), /hasPermi|canonical/)
}
})
test('权限门禁拒绝带前缀的非 canonical proxy 接收者', () => {
const validGuard = `<button v-hasPermi="['master:strain:list']">合法门禁</button>`
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}<button v-if="${invalidCall}">错误接收者</button>`
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(`<button v-hasPermi="${permissions}">稀疏门禁</button>`),
/稀疏/
)
}
assert.deepEqual(
extractGuardedVuePermissions(`<button v-hasPermi="['master:strain:list',]">尾逗号</button>`),
['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('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
assert.equal(Object.keys(expectedRequests).length, 18)
assert.equal(Object.values(expectedRequests).flat().length, 86)
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/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/shipment/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/return/scan/' + encodeURIComponent(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/usage/tunnel-options'", method: 'get' },
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
{ 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(/<el-form\b[^>]*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(/<div\s+class="report-filter__footer">[\s\S]*?<\/div>/)?.[0] || ''
assert.ok(reportFilterFooter, `${resource} 日期和搜索操作必须位于统一底部操作栏`)
assert.match(reportFilterFooter, /<el-form-item\b[^>]*class="report-filter__date"/, `${resource} 底部操作栏必须包含日期范围`)
assert.match(reportFilterFooter, /<el-form-item\b[^>]*class="report-filter__actions"/, `${resource} 底部操作栏必须包含搜索操作`)
assert.ok(
reportFilterFooter.indexOf('report-filter__date') < reportFilterFooter.indexOf('report-filter__actions'),
`${resource} 日期范围必须位于搜索操作之前`
)
const reportToolbar = source.match(/<el-row\b[^>]*class="[^"]*\breport-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || ''
assert.ok(reportToolbar, `${resource} 缺少报表操作栏`)
assert.match(reportToolbar, /<right-toolbar\s+v-model:showSearch="showSearch"\s+@queryTable="getList"\s*\/>/, `${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, /<el-date-picker\b[\s\S]*?type="daterange"[\s\S]*?value-format="YYYY-MM-DD"/)
assert.match(source, /<el-table\b[^>]*v-loading="loading"[^>]*:data="reportList"/)
assert.match(source, /<template\s+#empty>[\s\S]*?<el-empty\b/)
assert.match(source, /<el-alert\b[^>]*v-if="loadError"/)
assert.match(source, /<pagination\b[\s\S]*?:total="total"[\s\S]*?:page="queryParams\.pageNum"[\s\S]*?:limit="queryParams\.pageSize"/)
assert.match(source, /@update:page="handleCurrentChange"/)
assert.match(source, /@update:limit="handleSizeChange"/)
assert.match(source, /@pagination="getList"/)
assert.match(source, /let listGeneration = 0;?/)
assert.match(source, /function invalidateListRequest\(\)\s*{[\s\S]*?listGeneration \+= 1[\s\S]*?loading\.value = false/)
assert.match(source, /function isCurrentListRequest\(listContext\)/)
const listSource = extractNamedFunction(source, 'getList')
assert.match(listSource, /generation: \+\+listGeneration/)
assert.match(listSource, /query:/)
assertPromiseCallbacksStartWithGuard(listSource, 'isCurrentListRequest')
assert.match(listSource, /reportList\.value = Array\.isArray\(response\.rows\) \? response\.rows : \[\]/)
assert.match(listSource, /total\.value = response\.total/)
assert.match(listSource, /loadError\.value\s*=/)
assert.match(extractNamedFunction(source, 'handleQuery'), /queryParams\.value\.pageNum = 1[\s\S]*?getList\(\)/)
const resetSource = extractNamedFunction(source, 'resetQuery')
assert.match(resetSource, /dateRange\.value = \[\]/)
assert.match(resetSource, /resetForm\(['"]queryRef['"]\)/)
assert.match(resetSource, /queryParams\.value\.pageNum = 1[\s\S]*?getList\(\)/)
assert.match(extractNamedFunction(source, 'handleSizeChange'), /queryParams\.value\.pageSize = pageSize[\s\S]*?queryParams\.value\.pageNum = 1/)
assert.match(extractNamedFunction(source, 'handleCurrentChange'), /queryParams\.value\.pageNum = pageNum/)
assert.match(source, /onBeforeUnmount\(\(\) => \{\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, /<dict-tag\s+:options="sys_normal_disable"/)
assert.match(source, /\.value\s*=\s*response\.rows/)
assert.match(source, /total\.value\s*=\s*response\.total/)
assert.match(source, /const detail\s*=\s*response\.data/)
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\(\)/)
const queryFormTag = source.match(/<el-form\b[^>]*ref="queryRef"[^>]*>/)?.[0] || ''
assert.match(queryFormTag, /v-show="showSearch"/, `${resource} 搜索区必须支持显隐`)
assert.match(source, /const showSearch = ref\(true\);/, `${resource} 搜索区必须默认展开`)
const masterToolbar = source.match(/<el-row\b[^>]*class="[^"]*\bmaster-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || ''
assert.ok(masterToolbar, `${resource} 缺少基础资料操作栏`)
assert.doesNotMatch(masterToolbar.match(/^<el-row\b[^>]*>/)?.[0] || '', /v-if="isMasterWriteAllowed"/, `${resource} 搜索与刷新入口不得受写权限限制`)
assert.match(masterToolbar, /<template v-if="isMasterWriteAllowed">/, `${resource} 写操作仍需受测试模式限制`)
assert.match(masterToolbar, /<right-toolbar\s+v-model:showSearch="showSearch"\s+@queryTable="getList"\s*\/>/, `${resource} 必须复用右侧搜索与刷新工具栏`)
assert.match(
source,
/\.master-query :deep\(\.el-input\),\s*\.master-query :deep\(\.el-input-number\),\s*\.master-query :deep\(\.el-select\)\s*\{[^}]*min-width:\s*0;/,
`${resource} 查询控件必须允许收缩到网格内容宽度`
)
assert.match(source, /<pagination\b[\s\S]*?v-model:page="queryParams\.pageNum"[\s\S]*?v-model:limit="queryParams\.pageSize"[\s\S]*?@pagination="getList"/)
assert.match(source, /<el-table\b[^>]*v-loading="loading"[^>]*empty-text="暂无数据"/)
assert.match(source, /<el-alert\b[^>]*v-if="loadError"/)
assert.match(source, /<el-alert\b[^>]*v-if="formError"/)
assert.match(source, /const formError = ref\(['"]['"]\)/)
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`)
assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/)
assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/)
assert.match(
masterToolbar,
new RegExp(`<master-excel-actions resource="${resource}" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList"\\s*/>`),
`${resource} 必须复用基础资料导入导出组件`
)
assert.match(
masterToolbar,
/<\/template>\s*<master-excel-actions\b[\s\S]*?\/>\s*<right-toolbar\b/,
`${resource} 导入导出应放在写操作之后、刷新工具栏之前`
)
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(/<el-dialog\b[^>]*>/)?.[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(/<el-table-column\b[^>]*type="selection"[^>]*\/>/)?.[0] || ''
assert.ok(selectionColumn, `${resource} 缺少选择列`)
assert.doesNotMatch(selectionColumn, /:selectable=/)
assert.doesNotMatch(source, /function isRowSelectable\(/)
assert.match(source, /const selectedIds = ref\(\[\]\)/)
const selectionSource = source.slice(source.indexOf('function handleSelectionChange('), source.indexOf('function handleAdd('))
assert.match(selectionSource, new RegExp(`selectedIds\\.value = selection\\.map\\(\\(item\\) => item\\.${idKey}\\)`))
assert.match(selectionSource, /single\.value = selectedIds\.value\.length !== 1/)
assert.match(detailSource, new RegExp(`const ${idKey} = row\\.${idKey} \\|\\| selectedIds\\.value\\[0\\]`))
const rowUpdateButtons = extractButtonBlocks(source, 'handleUpdate')
.filter((button) => button.includes('handleUpdate(scope.row)'))
assert.ok(rowUpdateButtons.length > 0, `${resource} 缺少行修改按钮`)
for (const button of rowUpdateButtons) {
assert.match(button, /:disabled="detailLoading"/)
}
assert.deepEqual(extractCallObjectKeys(source, `add${suffix}`), contract.addPayloadKeys)
assert.deepEqual(extractCallObjectKeys(source, `update${suffix}`), contract.editPayloadKeys)
assert.match(source, /validator:\s*validateRequiredText/)
assert.match(source, /maxlength="500"/)
if (resource !== 'pallet') {
assert.match(source, /maxlength="64"/)
assert.match(source, /maxlength="100"/)
}
if (resource === 'site' || resource === 'line' || resource === 'pallet') {
assert.match(source, /validator:\s*validatePositiveInteger/)
}
if (resource === 'site' || resource === 'warehouse') {
assertButtonsUsePermission(source, 'handleAssign', `${permissionPrefix}:assign`)
assert.match(source, /import BusinessUserAssignDialog from ['"]@\/components\/BusinessUserAssignDialog['"]/)
assert.match(source, /<business-user-assign-dialog\s+ref="assignDialogRef"\s+@ok="getList"\s*\/>/)
assert.match(
source,
new RegExp(`assignDialogRef\\.value\\.show\\(\\{[\\s\\S]*?resourceType: ['"]${resource}['"][\\s\\S]*?resourceId: row\\.${resource}Id[\\s\\S]*?resourceName: row\\.${resource}Name[\\s\\S]*?}\\)`)
)
}
if (resource === 'pallet') {
assertButtonsUsePermission(source, 'handlePrint', 'master:pallet:print')
const printHandler = source.slice(source.indexOf('function handlePrint'))
assert.match(printHandler, /const printWindow = window\.open\('', '_blank'\)/)
assert.ok(
printHandler.indexOf("window.open('', '_blank')") < printHandler.indexOf('getPalletQrcode('),
'打印窗口必须在异步二维码请求前同步打开'
)
assert.match(printHandler, /getPalletQrcode\(row\.palletId\)/)
assert.match(printHandler, /instanceof Blob/)
assert.match(printHandler, /URL\.createObjectURL\(/)
assert.match(printHandler, /URL\.revokeObjectURL\(/)
assert.match(printHandler, /document\.createElement\(['"](?:h1|img|style)['"]\)/)
assert.match(printHandler, /\.textContent\s*=/)
assert.match(printHandler, /['"]load['"][\s\S]*?printWindow\.print\(\)/)
assert.match(printHandler, /let closeMonitorId;/)
assert.match(printHandler, /let cleanupFinished = false;/)
assert.doesNotMatch(printHandler, /if \(cleanupFinished\) return;/)
assert.match(printHandler, /const cleanupPrint = \(\) => \{[\s\S]*?window\.clearInterval\(closeMonitorId\)[\s\S]*?URL\.revokeObjectURL\(objectUrl\)[\s\S]*?if \(!cleanupFinished\) \{\s*cleanupFinished = true;\s*setPrinting\(row\.palletId, false\);\s*}/)
assert.match(printHandler, /window\.clearInterval\(closeMonitorId\)/)
assert.equal((printHandler.match(/URL\.revokeObjectURL\(objectUrl\)/g) || []).length, 1)
assert.equal((printHandler.match(/setPrinting\(row\.palletId, false\)/g) || []).length, 1)
assert.match(printHandler, /objectUrl = URL\.createObjectURL\([^;]+\);\s*if \(cleanupFinished \|\| printWindow\.closed\) \{\s*cleanupPrint\(\);\s*return;\s*}/)
assert.match(printHandler, /closeMonitorId = window\.setInterval\(\(\) => \{\s*if \(printWindow\.closed\) cleanupPrint\(\);\s*}, \d+\)/)
assert.ok(
printHandler.indexOf('window.setInterval(') < printHandler.indexOf('getPalletQrcode('),
'打印窗口关闭监测必须在异步二维码请求前启动'
)
assert.match(printHandler, /['"]error['"][\s\S]*?cleanupPrint\(\)[\s\S]*?printWindow\.close\(\)/)
assert.match(printHandler, /['"]afterprint['"][\s\S]*?cleanupPrint\(\)[\s\S]*?printWindow\.close\(\)/)
assert.match(printHandler, /\.catch\(\(\) => \{\s*cleanupPrint\(\)/)
assert.doesNotMatch(printHandler, /document\.write|innerHTML|BarcodeDetector|getUserMedia|camera/i)
assert.match(source, /v-if="isEdit"[\s\S]*?v-model="form\.palletCode"[\s\S]*?readonly/)
assert.match(source, /v-if="isEdit"[\s\S]*?v-model="form\.qrToken"[\s\S]*?readonly/)
assert.doesNotMatch(contract.editPayloadKeys.join(','), /palletCode|qrToken/)
}
})
}
})
test('隧道资料页复用基础资料导入导出组件', async () => {
const source = await readFile(path.join(frontendRoot, 'src/views/master/tunnel/index.vue'), 'utf8')
const masterToolbar = source.match(/<el-row\b[^>]*class="[^"]*\bmaster-toolbar\b[^"]*"[^>]*>[\s\S]*?<\/el-row>/)?.[0] || ''
assert.match(
masterToolbar,
/<master-excel-actions resource="tunnel" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList"\s*\/>/
)
assert.match(masterToolbar, /<\/template>\s*<master-excel-actions\b[\s\S]*?\/>\s*<right-toolbar\b/)
assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/)
assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/)
})
test('五个业务页面逐页满足 API、精确权限、列表和交互基础契约', async (t) => {
for (const [resource, contract] of Object.entries(businessPageContracts)) {
await t.test(contract.relativePath, async () => {
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
const permissionPrefix = `business:${resource}`
const importedBusinessModules = [...source.matchAll(/from\s*['"]@\/api\/business\/([a-z]+)['"]/g)]
.map((match) => match[1])
assert.deepEqual(importedBusinessModules, [resource], `${resource} 页面只能导入本业务 API`)
assert.deepEqual(extractImportedNames(source, `@/api/business/${resource}`), contract.apiNames)
assert.doesNotMatch(source, /@\/api\/master\//, `${resource} 页面不得借用主数据 API`)
assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/)
assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/)
assert.doesNotMatch(source, /BarcodeDetector|getUserMedia|camera/i)
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`)
const actualPermissions = extractGuardedVuePermissions(source)
.filter((permission) => permission.startsWith(`${permissionPrefix}:`))
.map((permission) => permission.slice(permissionPrefix.length + 1))
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`)
assert.match(source, /function handleQuery\(\)\s*{[\s\S]*?queryParams\.value\.pageNum\s*=\s*1[\s\S]*?getList\(\)/)
assert.match(source, /function resetQuery\(\)\s*{[\s\S]*?resetForm\(['"]queryRef['"]\)[\s\S]*?handleQuery\(\)/)
assert.match(source, /<pagination\b[\s\S]*?v-model:page="queryParams\.pageNum"[\s\S]*?v-model:limit="queryParams\.pageSize"[\s\S]*?@pagination="getList"/)
assert.match(source, /<el-table\b[^>]*v-loading="loading"[^>]*empty-text="暂无数据"/)
assert.match(source, /<el-alert\b[^>]*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(/<pallet-scan-input\b[^>]*\/>/g)].map((match) => match[0])
assert.ok(scanTags.length > 0, `${resource} 页面必须复用 PalletScanInput`)
for (const scanTag of scanTags) {
assert.match(scanTag, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用扫码`)
assert.match(scanTag, /:loading="[^"]*scanLoading[^"]*"/, `${resource} 扫码请求必须显示 loading`)
}
assert.match(source, /const operationPending = computed\(\(\) =>/)
assert.match(source, /let optionGeneration = 0;?/)
assert.match(source, /function invalidateOptionRequest\(\)/)
assert.match(source, /function isCurrentOptionRequest\(/)
if (resource === 'return') {
assert.match(source, /let requestScanGeneration = 0;?/)
assert.match(source, /let confirmScanGeneration = 0;?/)
assert.match(source, /function invalidateRequestScan\(\)/)
assert.match(source, /function invalidateConfirmScan\(\)/)
assert.match(source, /function isCurrentRequestScan\(/)
assert.match(source, /function isCurrentConfirmScan\(/)
} else {
assert.match(source, /let scanGeneration = 0;?/)
assert.match(source, /function invalidateScanRequest\(\)/)
assert.match(source, /function isCurrentScanRequest\(/)
}
const businessControls = [...source.matchAll(/<(?:el-input|el-input-number|el-select)\b[^>]*class="business-control"[^>]*>/g)]
.map((match) => match[0])
assert.ok(businessControls.length > 0, `${resource} 页面缺少业务输入控件`)
for (const control of businessControls) {
assert.match(control, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*operationPending[^"]*")/, `${resource} 提交期间必须禁用业务输入`)
}
const businessActions = [...source.matchAll(/<el-button\b[^>]*class="business-action"[^>]*>/g)]
.map((match) => match[0])
assert.ok(businessActions.length > 0, `${resource} 页面缺少业务提交按钮`)
for (const action of businessActions) {
assert.match(action, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务按钮`)
}
for (const [callName, payloadKeys] of Object.entries(contract.payloads)) {
const actualKeys = callName.startsWith('confirm')
? extractSecondCallObjectKeys(source, callName)
: extractCallObjectKeys(source, callName)
assert.deepEqual(actualKeys, payloadKeys, `${callName} payload 必须最小且精确`)
}
const scriptTail = source.slice(source.lastIndexOf('getList();'))
assert.doesNotMatch(
scriptTail,
/get(?:InboundWarehouseOptions|FillingStrainOptions|ShipmentWarehouseOptions|ShipmentSiteOptions|UsageLineOptions|ReturnWarehouseOptions)\(/,
`${resource} 页面不得在启动阶段无条件请求 query 权限接口`
)
})
}
})
test('五个业务页面的读取请求逐分支执行 stale guard,并在上下文切换时失效旧请求', async (t) => {
const contracts = {
inbound: {
flows: [
['loadWarehouseOptions', ['getInboundWarehouseOptions'], 'isCurrentOptionRequest'],
['handleInboundScan', ['scanInboundPallet'], 'isCurrentScanRequest'],
['handleUpdate', ['getInbound'], 'isCurrentDetailRequest']
],
resets: [['resetInboundForm', ['invalidateOptionRequest()', 'invalidateDetailRequest()', 'invalidateScanRequest()']]],
closes: [['cancelInbound', 'resetInboundForm()'], ['handleDialogBeforeClose', 'resetInboundForm()']],
watchInvalidators: ['invalidateScanRequest()']
},
filling: {
flows: [
['loadStrainOptions', ['getFillingStrainOptions'], 'isCurrentOptionRequest'],
['handleFillingScan', ['scanFillingPallet'], 'isCurrentScanRequest']
],
resets: [['resetFillingOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]],
closes: [['cancelFilling', 'resetFillingOperation()'], ['handleDialogBeforeClose', 'resetFillingOperation()']]
},
shipment: {
flows: [
['loadShipmentOptions', ['getShipmentWarehouseOptions', 'getShipmentSiteOptions'], 'isCurrentOptionRequest'],
['handleShipmentScan', ['scanShipmentPallet'], 'isCurrentScanRequest'],
['handleUpdate', ['getShipment'], 'isCurrentDetailRequest']
],
resets: [['resetShipmentForm', ['invalidateOptionRequest()', 'invalidateDetailRequest()', 'invalidateScanRequest()']]],
closes: [['cancelShipment', 'resetShipmentForm()'], ['handleDialogBeforeClose', 'resetShipmentForm()']],
watchInvalidators: ['invalidateScanRequest()']
},
usage: {
flows: [
['loadUsageOptions', ['getUsageLineOptions', 'getUsageTunnelOptions'], 'isCurrentOptionRequest'],
['handleUsageScan', ['scanUsagePallet'], 'isCurrentScanRequest']
],
resets: [['resetUsageOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]],
transitions: [['handleUsageScan', ['invalidateOptionRequest()']]]
},
return: {
flows: [
['loadReturnWarehouseOptions', ['getReturnWarehouseOptions'], 'isCurrentOptionRequest'],
['handleRequestScan', ['scanReturnPallet'], 'isCurrentRequestScan'],
['handleConfirmReturnSelection', ['getReturn'], 'isCurrentDetailRequest'],
['handleConfirmScan', ['scanReturnPallet'], 'isCurrentConfirmScan']
],
resets: [
['resetReturnRequest', ['invalidateOptionRequest()', 'invalidateRequestScan()']],
['resetReturnConfirm', ['invalidateDetailRequest()', 'invalidateConfirmScan()']]
],
transitions: [['handleConfirmReturnSelection', ['invalidateDetailRequest()', 'invalidateConfirmScan()']]],
watchInvalidators: ['invalidateRequestScan()']
}
}
for (const [resource, contract] of Object.entries(contracts)) {
await t.test(businessPageContracts[resource].relativePath, async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts[resource].relativePath), 'utf8')
for (const [functionName, requestCalls, guardName] of contract.flows) {
const functionSource = extractNamedFunction(source, functionName)
for (const requestCall of requestCalls) {
assert.ok(functionSource.includes(`${requestCall}(`), `${functionName} 缺少 ${requestCall} 实际请求`)
}
assertPromiseCallbacksStartWithGuard(functionSource, guardName)
}
for (const [functionName, invalidators] of contract.resets) {
const functionSource = extractNamedFunction(source, functionName)
for (const invalidator of invalidators) {
assert.ok(functionSource.includes(invalidator), `${functionName} 必须调用 ${invalidator}`)
}
}
for (const [functionName, resetCall] of contract.closes || []) {
assert.ok(extractNamedFunction(source, functionName).includes(resetCall), `${functionName} 必须调用 ${resetCall}`)
}
for (const [functionName, invalidators] of contract.transitions || []) {
const functionSource = extractNamedFunction(source, functionName)
for (const invalidator of invalidators) {
assert.ok(functionSource.includes(invalidator), `${functionName} 切换上下文时必须调用 ${invalidator}`)
}
}
if (contract.watchInvalidators) {
const watchSource = source.slice(source.lastIndexOf('watch('), source.lastIndexOf('getList();'))
for (const invalidator of contract.watchInvalidators) {
assert.ok(watchSource.includes(invalidator), `${resource} 输入切换 watch 必须调用 ${invalidator}`)
}
}
})
}
})
test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确认幂等键工作', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.inbound.relativePath), 'utf8')
assertButtonsUsePermission(source, 'handleAdd', 'business:inbound:add')
assertButtonsUsePermission(source, 'submitInbound', 'business:inbound:add')
assertButtonsUsePermission(source, 'handleUpdate', 'business:inbound:edit')
assertButtonsUsePermission(source, 'handleConfirm', 'business:inbound:confirm')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:add'\]\)/)
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:edit'\]\)/)
const confirmButton = extractButtonBlocks(source, 'handleConfirm')[0]
assert.match(confirmButton, /v-if="scope\.row\.status === 'DRAFT'"/)
assert.doesNotMatch(source, /canConfirmInbound/)
assert.doesNotMatch(
source,
/proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:confirm'\]\)/
)
const addButton = extractButtonBlocks(source, 'handleAdd')[0]
assert.match(addButton, /:disabled="[^"]*operationPending[^"]*optionLoading[^"]*detailLoading[^"]*open[^"]*"/)
const addSource = extractNamedFunction(source, 'handleAdd')
assert.match(
addSource,
/^function handleAdd\(\)\s*\{\s*if \(!canAddInbound\.value \|\| operationPending\.value \|\| optionLoading\.value \|\| detailLoading\.value \|\| open\.value\) return/
)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleUpdate\(scope\.row\)/)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleConfirm\(scope\.row\)/)
for (const functionName of ['handleUpdate', 'handleConfirm']) {
assert.match(extractNamedFunction(source, functionName), /status !== ['"]DRAFT['"]/)
}
assert.match(source, /const editDetailReady = ref\(false\)/)
const updateSource = extractNamedFunction(source, 'handleUpdate')
const detailRequestIndex = updateSource.indexOf('getInbound(')
assert.ok(detailRequestIndex >= 0, '编辑入库草稿必须读取 authoritative 详情')
for (const [snippet, message] of [
['form.value.inboundId = row.inboundId', '读取详情前必须固定入库编辑身份'],
['editDetailReady.value = false', '读取详情前必须关闭入库编辑就绪门禁']
]) {
const index = updateSource.indexOf(snippet)
assert.ok(index >= 0 && index < detailRequestIndex, message)
}
const detailSuccessSource = extractPromiseCallback(updateSource, 'then')
assert.ok(
detailSuccessSource.indexOf("detail.status !== 'DRAFT'") < detailSuccessSource.indexOf('editDetailReady.value = true'),
'仅 authoritative DRAFT 入库详情可开启编辑就绪门禁'
)
const warehouseSelect = source.match(/<el-select\b[\s\S]*?v-model="form\.warehouseId"[\s\S]*?<\/el-select>/)?.[0]
assert.ok(warehouseSelect, '缺少入库仓库选择器')
assert.match(warehouseSelect, /:disabled="[^"]*!editDetailReady[^"]*"/)
const inboundScanTag = [...source.matchAll(/<pallet-scan-input\b[^>]*\/>/g)]
.map((match) => match[0])
.find((tag) => tag.includes('@scan="handleInboundScan"'))
assert.ok(inboundScanTag, '缺少入库扫码控件')
assert.match(inboundScanTag, /:disabled="[^"]*!editDetailReady[^"]*"/)
const inboundRemoveButton = extractButtonBlocks(source, 'removeScannedPallet')[0]
assert.match(inboundRemoveButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
const inboundEditButton = extractButtonBlocks(source, 'saveInboundUpdate')[0]
assert.match(inboundEditButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
const submitSource = extractNamedFunction(source, 'submitInboundForm')
assert.match(submitSource, /editing !== isEdit\.value\s*\|\|\s*\(editing && !editDetailReady\.value\)/)
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'入库提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
assert.deepEqual(extractSecondCallObjectKeys(source, 'scanInboundPallet'), ['warehouseId'])
const scanSource = extractNamedFunction(source, 'handleInboundScan')
assert.match(scanSource, /pallet\.flowState !== ['"]UNSTOCKED['"]/)
assert.match(scanSource, /scannedPallets\.value\.some\(\(item\) => item\.palletId === pallet\.palletId\)/)
assert.match(source, /const detail = response\.data;/)
assert.match(source, /\(Array\.isArray\(detail\.items\) \? detail\.items : \[\]\)\.map\(\(item\) => \(\{[\s\S]*?palletId: item\.palletId/)
assert.match(source, /item\.palletCode \|\| `#\$\{item\.palletId\}`/)
const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey')
assert.match(ensureSource, /createIdempotencyKey\(\)/)
assert.match(ensureSource, /confirmIdempotency\.key/)
const confirmSource = extractNamedFunction(source, 'handleConfirm')
assert.doesNotMatch(confirmSource, /canConfirmInbound|business:inbound:query/)
assert.ok(
confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('),
'入库确认必须在打开二次确认框前加锁'
)
assert.ok(confirmSource.indexOf('proxy.$modal.confirm(') < confirmSource.indexOf('confirmInbound('))
assert.match(confirmSource, /confirmInbound\([^,]+,\s*\{\s*idempotencyKey:/)
assert.match(extractPromiseCallback(confirmSource, 'then', 1), /clearConfirmIdempotency\(\)/)
const confirmCatch = extractPromiseCallback(confirmSource, 'catch')
assert.match(confirmCatch, /error === ['"]cancel['"] \|\| error === ['"]close['"]/)
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
})
test('装填扫码 pending 期间禁用全部业务输入', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.filling.relativePath), 'utf8')
for (const field of ['strainId', 'batchNo', 'packageSpec', 'initialPackages']) {
const businessControl = source.match(new RegExp(`<[^>]+v-model="form\\.${field}"[^>]*>`))?.[0]
assert.ok(businessControl, `缺少装填 ${field} 业务控件`)
assert.match(businessControl, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*scanLoading[^"]*")/, `装填扫码 pending 时必须禁用 ${field}`)
}
})
test('装填页面锁定扫码上下文、校验正整数并在失败重试时保留幂等键', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.filling.relativePath), 'utf8')
assertButtonsUsePermission(source, 'handleOpenFilling', 'business:filling:add')
assertButtonsUsePermission(source, 'submitFilling', 'business:filling:add')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:filling:query', 'business:filling:add'\]\)/)
assert.match(source, /validator:\s*validatePositiveInteger/)
assert.match(source, /pallet\.warehouseId/)
assert.match(source, /pallet\.palletCode \|\| `#\$\{pallet\.palletId\}`/)
assert.match(source, /getFillingStrainOptions\(\)/)
const clearPayloadSource = extractNamedFunction(source, 'clearFillingPayload')
assert.match(clearPayloadSource, /clearFillingIdempotencyKey\(\)/)
assert.match(clearPayloadSource, /scannedPallet\.value = null/)
for (const [field, emptyValue] of [
['qrToken', "''"],
['warehouseId', 'undefined'],
['strainId', 'undefined'],
['batchNo', "''"],
['packageSpec', "''"],
['initialPackages', '100']
]) {
const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`)
assert.match(clearPayloadSource, fieldPattern, `装填载荷清理必须重置 ${field}`)
}
assert.match(extractNamedFunction(source, 'resetFillingOperation'), /clearFillingPayload\(\)/)
const scanSource = extractNamedFunction(source, 'handleFillingScan')
const scanCallIndex = scanSource.indexOf('scanFillingPallet(')
assert.ok(scanCallIndex >= 0, '装填扫码必须调用 scanFillingPallet')
const scanPrefix = scanSource.slice(0, scanCallIndex)
const clearPayloadIndex = scanPrefix.indexOf('clearFillingPayload()')
const scanPendingIndex = scanPrefix.indexOf('scanLoading.value = true')
assert.ok(
clearPayloadIndex >= 0 && scanPendingIndex >= 0 && clearPayloadIndex < scanPendingIndex,
'新装填扫码必须在进入 pending 前清除旧业务载荷'
)
const scanSuccessSource = extractPromiseCallback(scanSource, 'then')
const scanSuccessGuardIndex = scanSuccessSource.indexOf('if (!isCurrentScanRequest(scanContext)) return')
const invalidStateIndex = scanSuccessSource.indexOf("if (pallet.flowState !== 'EMPTY_IN_WAREHOUSE')")
const invalidStateClearIndex = scanSuccessSource.indexOf('clearFillingPayload()', invalidStateIndex)
const invalidStateErrorIndex = scanSuccessSource.indexOf("scanError.value = '该托盘当前不可装填'", invalidStateIndex)
assert.ok(
scanSuccessGuardIndex >= 0 &&
invalidStateIndex > scanSuccessGuardIndex &&
invalidStateClearIndex > invalidStateIndex &&
invalidStateErrorIndex > invalidStateClearIndex,
'不可装填响应必须在当前请求门禁后、写入错误状态前清除业务载荷'
)
const scanCatchSource = extractPromiseCallback(scanSource, 'catch')
const scanCatchGuardIndex = scanCatchSource.indexOf('if (!isCurrentScanRequest(scanContext)) return')
const scanCatchClearIndex = scanCatchSource.indexOf('clearFillingPayload()')
const scanCatchErrorIndex = scanCatchSource.indexOf("scanError.value = '托盘扫码校验失败,请检查后重试'")
assert.ok(
scanCatchGuardIndex >= 0 && scanCatchClearIndex > scanCatchGuardIndex && scanCatchErrorIndex > scanCatchClearIndex,
'装填扫码异常必须在当前请求门禁后、写入错误状态前清除业务载荷'
)
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
const invalidClearIndex = invalidSource.indexOf('clearFillingPayload()')
const invalidErrorIndex = invalidSource.indexOf('scanError.value = message')
assert.ok(
invalidClearIndex >= 0 && invalidErrorIndex > invalidClearIndex,
'装填扫码输入失败后必须先清除业务载荷再展示错误'
)
const fillingSubmitButton = extractButtonBlocks(source, 'submitFilling')[0]
assert.match(fillingSubmitButton, /:disabled="[^"]*!scannedPallet[^"]*"/)
const ensureSource = extractNamedFunction(source, 'ensureFillingIdempotencyKey')
assert.match(ensureSource, /if \(!fillingIdempotencyKey\.value\)/)
assert.match(ensureSource, /createIdempotencyKey\(\)/)
const submitSource = extractNamedFunction(source, 'submitFilling')
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'装填提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /if \(!scannedPallet\.value \|\| !form\.value\.qrToken \|\| !form\.value\.warehouseId\)/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
assert.match(submitSource, /const idempotencyKey = ensureFillingIdempotencyKey\(\)/)
assert.match(extractPromiseCallback(submitSource, 'then'), /clearFillingIdempotencyKey\(\)/)
const catchSource = extractPromiseCallback(submitSource, 'catch')
assert.match(catchSource, /formError\.value\s*=/)
assert.doesNotMatch(catchSource, /clearFillingIdempotencyKey/)
assert.match(extractNamedFunction(source, 'cancelFilling'), /clearFillingIdempotencyKey\(\)/)
assert.match(source, /watch\([\s\S]*?if \(!submitLoading\.value\) clearFillingIdempotencyKey\(\)/)
})
test('发货详情仅在 remainingPackages 缺失时回退快照并保留 null', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.shipment.relativePath), 'utf8')
const detailSuccessSource = extractPromiseCallback(extractNamedFunction(source, 'handleUpdate'), 'then')
assert.match(
detailSuccessSource,
/remainingPackages:\s*item\.remainingPackages\s*===\s*undefined\s*\?\s*\(?\s*item\.packageQty\s*\?\?\s*null\s*\)?\s*:\s*item\.remainingPackages/,
'发货详情仅在 remainingPackages 缺失时回退确认快照 packageQty,并保留 null'
)
assert.doesNotMatch(detailSuccessSource, /item\.remainingPackages\s*\?\?\s*item\.packageQty/)
})
test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂等键', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.shipment.relativePath), 'utf8')
assertButtonsUsePermission(source, 'handleAdd', 'business:shipment:add')
assertButtonsUsePermission(source, 'submitShipment', 'business:shipment:add')
assertButtonsUsePermission(source, 'handleUpdate', 'business:shipment:edit')
assertButtonsUsePermission(source, 'handleConfirm', 'business:shipment:confirm')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:add'\]\)/)
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:edit'\]\)/)
const confirmButton = extractButtonBlocks(source, 'handleConfirm')[0]
assert.match(confirmButton, /v-if="scope\.row\.status === 'DRAFT'"/)
assert.doesNotMatch(source, /canConfirmShipment/)
assert.doesNotMatch(
source,
/proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:confirm'\]\)/
)
const addButton = extractButtonBlocks(source, 'handleAdd')[0]
assert.match(addButton, /:disabled="[^"]*operationPending[^"]*optionLoading[^"]*detailLoading[^"]*open[^"]*"/)
const addSource = extractNamedFunction(source, 'handleAdd')
assert.match(
addSource,
/^function handleAdd\(\)\s*\{\s*if \(!canAddShipment\.value \|\| operationPending\.value \|\| optionLoading\.value \|\| detailLoading\.value \|\| open\.value\) return/
)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleUpdate\(scope\.row\)/)
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleConfirm\(scope\.row\)/)
for (const functionName of ['handleUpdate', 'handleConfirm']) {
assert.match(extractNamedFunction(source, functionName), /status !== ['"]DRAFT['"]/)
}
assert.match(source, /const editDetailReady = ref\(false\)/)
const updateSource = extractNamedFunction(source, 'handleUpdate')
const detailRequestIndex = updateSource.indexOf('getShipment(')
assert.ok(detailRequestIndex >= 0, '编辑发货草稿必须读取 authoritative 详情')
for (const [snippet, message] of [
['form.value.shipmentId = row.shipmentId', '读取详情前必须固定发货编辑身份'],
['editDetailReady.value = false', '读取详情前必须关闭发货编辑就绪门禁']
]) {
const index = updateSource.indexOf(snippet)
assert.ok(index >= 0 && index < detailRequestIndex, message)
}
const detailSuccessSource = extractPromiseCallback(updateSource, 'then')
assert.ok(
detailSuccessSource.indexOf("detail.status !== 'DRAFT'") < detailSuccessSource.indexOf('editDetailReady.value = true'),
'仅 authoritative DRAFT 发货详情可开启编辑就绪门禁'
)
for (const model of ['sourceWarehouseId', 'targetSiteId']) {
const select = source.match(new RegExp(`<el-select\\b[\\s\\S]*?v-model="form\\.${model}"[\\s\\S]*?<\\/el-select>`))?.[0]
assert.ok(select, `缺少发货 ${model} 选择器`)
assert.match(select, /:disabled="[^"]*!editDetailReady[^"]*"/)
}
const shipmentScanTag = [...source.matchAll(/<pallet-scan-input\b[^>]*\/>/g)]
.map((match) => match[0])
.find((tag) => tag.includes('@scan="handleShipmentScan"'))
assert.ok(shipmentScanTag, '缺少发货扫码控件')
assert.match(shipmentScanTag, /:disabled="[^"]*!editDetailReady[^"]*"/)
const shipmentRemoveButton = extractButtonBlocks(source, 'removeScannedPallet')[0]
assert.match(shipmentRemoveButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
const shipmentEditButton = extractButtonBlocks(source, 'saveShipmentUpdate')[0]
assert.match(shipmentEditButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
const submitSource = extractNamedFunction(source, 'submitShipmentForm')
assert.match(submitSource, /editing !== isEdit\.value\s*\|\|\s*\(editing && !editDetailReady\.value\)/)
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'发货提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
assert.deepEqual(extractSecondCallObjectKeys(source, 'scanShipmentPallet'), ['sourceWarehouseId', 'targetSiteId'])
assert.match(source, /label="[^"]*只读[^"]*"[^>]*prop="remainingPackages"|prop="remainingPackages"[\s\S]*?不可修改/)
assert.match(source, /const detail = response\.data;/)
assert.match(source, /\(Array\.isArray\(detail\.items\) \? detail\.items : \[\]\)\.map\(\(item\) => \(\{[\s\S]*?palletId: item\.palletId/)
assert.match(source, /item\.palletCode \|\| `#\$\{item\.palletId\}`/)
assert.doesNotMatch(source, /remainingPackages\s*[-+]=|Math\.(?:max|min)\([^)]*remainingPackages/)
assert.match(extractNamedFunction(source, 'handleShipmentScan'), /remainingPackages:\s*pallet\.remainingPackages/)
const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey')
assert.match(ensureSource, /createIdempotencyKey\(\)/)
const confirmSource = extractNamedFunction(source, 'handleConfirm')
assert.doesNotMatch(confirmSource, /canConfirmShipment|business:shipment:query/)
assert.ok(
confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('),
'发货确认必须在打开二次确认框前加锁'
)
assert.ok(confirmSource.indexOf('proxy.$modal.confirm(') < confirmSource.indexOf('confirmShipment('))
assert.match(extractPromiseCallback(confirmSource, 'then', 1), /clearConfirmIdempotency\(\)/)
const confirmCatch = extractPromiseCallback(confirmSource, 'catch')
assert.match(confirmCatch, /error === ['"]cancel['"] \|\| error === ['"]close['"]/)
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
})
test('使用页面依扫码结果完成入隧、锁定产线并只采用后端返回的余量和状态', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.usage.relativePath), 'utf8')
assertButtonsUsePermission(source, 'submitUsage', 'business:usage:add')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:usage:query', 'business:usage:add'\]\)/)
// 执行真实页面/API行为,避免把“必须每次重扫清空幂等键”的旧实现误当成安全要求。
await execFileAsync(process.execPath, ['--test', 'scripts/usage-behavior.test.mjs'], { cwd: frontendRoot })
assert.match(source, /@media \(max-width: 767px\)[\s\S]*?min-height:\s*(?:48|52|56)px/)
})
test('退回页面分离申请与确认幂等状态并在提交前严格复核三组托盘集合', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.return.relativePath), 'utf8')
assertButtonsUsePermission(source, 'submitReturnRequest', 'business:return:request')
assertButtonsUsePermission(source, 'submitReturnConfirm', 'business:return:confirm')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:return:query', 'business:return:request'\]\)/)
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:return:query', 'business:return:confirm'\]\)/)
assert.match(source, /status === ['"]REQUESTED['"]/)
assert.doesNotMatch(source, /\b(?:IN_TRANSIT|RECEIVED|REJECTED)\b/)
assert.match(source, /pallet\.siteId !== requestForm\.value\.sourceSiteId/)
assert.match(source, /mode:\s*['"]request['"]/)
assert.match(source, /mode:\s*['"]confirm['"][\s\S]*?returnId:/)
const invalidateRequestScan = extractNamedFunction(source, 'invalidateRequestScan')
assert.match(invalidateRequestScan, /requestScanGeneration \+= 1/)
assert.match(invalidateRequestScan, /scanLoading\.request = false/)
assert.doesNotMatch(invalidateRequestScan, /confirmScanGeneration|scanLoading\.confirm/)
const invalidateConfirmScan = extractNamedFunction(source, 'invalidateConfirmScan')
assert.match(invalidateConfirmScan, /confirmScanGeneration \+= 1/)
assert.match(invalidateConfirmScan, /scanLoading\.confirm = false/)
assert.doesNotMatch(invalidateConfirmScan, /requestScanGeneration|scanLoading\.request/)
const requestScan = extractNamedFunction(source, 'handleRequestScan')
assert.ok(
requestScan.indexOf('invalidateRequestScan()') < requestScan.indexOf('scanReturnPallet('),
'申请扫码必须先失效本区旧请求'
)
assert.match(requestScan, /generation: \+\+requestScanGeneration/)
assert.match(requestScan, /isCurrentRequestScan\(scanContext\)/)
assert.match(extractPromiseCallback(requestScan, 'finally'), /scanLoading\.request = false/)
assert.doesNotMatch(requestScan, /invalidateConfirmScan|confirmScanGeneration|scanLoading\.confirm/)
const confirmScan = extractNamedFunction(source, 'handleConfirmScan')
assert.ok(
confirmScan.indexOf('invalidateConfirmScan()') < confirmScan.indexOf('scanReturnPallet('),
'确认扫码必须先失效本区旧请求'
)
assert.match(confirmScan, /generation: \+\+confirmScanGeneration/)
assert.match(confirmScan, /isCurrentConfirmScan\(scanContext\)/)
assert.match(extractPromiseCallback(confirmScan, 'finally'), /scanLoading\.confirm = false/)
assert.doesNotMatch(confirmScan, /invalidateRequestScan|requestScanGeneration|scanLoading\.request/)
const resetRequest = extractNamedFunction(source, 'resetReturnRequest')
assert.match(resetRequest, /invalidateRequestScan\(\)/)
assert.doesNotMatch(resetRequest, /invalidateConfirmScan/)
const resetConfirm = extractNamedFunction(source, 'resetReturnConfirm')
assert.match(resetConfirm, /invalidateConfirmScan\(\)/)
assert.doesNotMatch(resetConfirm, /invalidateRequestScan/)
const confirmSelection = extractNamedFunction(source, 'handleConfirmReturnSelection')
assert.match(confirmSelection, /invalidateConfirmScan\(\)/)
assert.doesNotMatch(confirmSelection, /invalidateRequestScan/)
const warehouseWatch = source.slice(source.lastIndexOf('watch('), source.lastIndexOf('getList();'))
assert.match(warehouseWatch, /invalidateRequestScan\(\)/)
assert.doesNotMatch(warehouseWatch, /invalidateConfirmScan/)
const requestEnsure = extractNamedFunction(source, 'ensureRequestIdempotencyKey')
const confirmEnsure = extractNamedFunction(source, 'ensureReturnConfirmIdempotencyKey')
assert.match(requestEnsure, /createIdempotencyKey\(\)/)
assert.match(confirmEnsure, /createIdempotencyKey\(\)/)
assert.doesNotMatch(requestEnsure, /confirmIdempotencyKey/)
assert.doesNotMatch(confirmEnsure, /requestIdempotencyKey/)
const requestSubmit = extractNamedFunction(source, 'submitReturnRequest')
assert.match(requestSubmit, /const idempotencyKey = ensureRequestIdempotencyKey\(\)/)
assert.match(extractPromiseCallback(requestSubmit, 'then'), /clearRequestIdempotencyKey\(\)/)
assert.doesNotMatch(extractPromiseCallback(requestSubmit, 'catch'), /clearRequestIdempotencyKey/)
const confirmSubmit = extractNamedFunction(source, 'submitReturnConfirm')
const detailIndex = confirmSubmit.indexOf('getReturn(')
const confirmIndex = confirmSubmit.indexOf('confirmReturn(')
assert.ok(detailIndex >= 0 && confirmIndex > detailIndex, '确认退回写请求前必须重新读取详情')
assert.match(confirmSubmit, /const initialExpectedIds = \[\.\.\.confirmExpectedPalletIds\.value\]/)
assert.match(confirmSubmit, /const scannedPalletIds = \[\.\.\.confirmScannedPallets\.value\.map\(\(item\) => item\.palletId\)\]/)
assert.match(confirmSubmit, /const latestPalletIds = latestDetail\.items\.map\(\(item\) => item\.palletId\)/)
assert.match(confirmSubmit, /!sameIdSet\(latestPalletIds, initialExpectedIds\)\s*\|\|\s*!sameIdSet\(latestPalletIds, scannedPalletIds\)/)
assert.match(confirmSubmit, /latestDetail\.status !== ['"]REQUESTED['"]/)
assert.match(confirmSubmit, /const idempotencyKey = ensureReturnConfirmIdempotencyKey\(returnId\)/)
const detailThen = extractPromiseCallback(confirmSubmit, 'then')
assert.match(detailThen, /^\s*if \(!isCurrentConfirmSubmit\(submitContext\)\) throw/)
assertPromiseCallbackStartsWithGuard(confirmSubmit, 'then', 'isCurrentConfirmSubmit', 1)
const confirmSubmitCatch = extractPromiseCallback(confirmSubmit, 'catch')
const confirmSubmitCatchGuardIndex = confirmSubmitCatch.indexOf('if (!isCurrentConfirmSubmit(submitContext)) return')
const confirmSubmitCatchWriteIndex = confirmSubmitCatch.indexOf('confirmError.value =')
assert.ok(
confirmSubmitCatchGuardIndex >= 0 &&
confirmSubmitCatchWriteIndex >= 0 &&
confirmSubmitCatchGuardIndex < confirmSubmitCatchWriteIndex,
'退回确认 catch 写错误状态前必须执行提交上下文门禁'
)
assertPromiseCallbackStartsWithGuard(confirmSubmit, 'finally', 'isCurrentConfirmSubmit')
assert.match(extractPromiseCallback(confirmSubmit, 'then', 1), /clearReturnConfirmIdempotencyKey\(\)/)
assert.doesNotMatch(confirmSubmitCatch, /clearReturnConfirmIdempotencyKey/)
assert.match(extractNamedFunction(source, 'resetReturnRequest'), /clearRequestIdempotencyKey\(\)/)
assert.match(extractNamedFunction(source, 'resetReturnConfirm'), /clearReturnConfirmIdempotencyKey\(\)/)
})
test('业务用户分配组件使用固定映射并允许空数组完整替换', async () => {
const relativePath = 'src/components/BusinessUserAssignDialog/index.vue'
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
assert.deepEqual(
extractImportedNames(source, '@/api/master/site'),
['assignSiteUsers', 'getSiteUsers']
)
assert.deepEqual(
extractImportedNames(source, '@/api/master/warehouse'),
['assignWarehouseUsers', 'getWarehouseUsers']
)
assert.match(source, /const resourceApiMap = Object\.freeze\(\{[\s\S]*?site:\s*Object\.freeze\(\{\s*getUsers:\s*getSiteUsers,\s*assignUsers:\s*assignSiteUsers\s*}\)[\s\S]*?warehouse:\s*Object\.freeze\(\{\s*getUsers:\s*getWarehouseUsers,\s*assignUsers:\s*assignWarehouseUsers\s*}\)[\s\S]*?}\)/)
assert.doesNotMatch(source, /defineProps\(/)
assert.match(source, /function show\(\{ resourceType, resourceId, resourceName }\)/)
assert.match(source, /let loadGeneration = 0;/)
assert.match(source, /function isCurrentUserLoad\(loadContext\)\s*{[\s\S]*?loadContext\.generation === loadGeneration[\s\S]*?loadContext\.resourceType === resourceType\.value[\s\S]*?loadContext\.resourceId === resourceId\.value[\s\S]*?}/)
const loadUsersSource = source.slice(source.indexOf('function loadUsers()'), source.indexOf('function resetDialogState()'))
const staleLoadGuard = 'if \\(!isCurrentUserLoad\\(loadContext\\)\\) \\{\\s*return;\\s*}'
assert.match(loadUsersSource, /const loadContext = \{\s*generation: \+\+loadGeneration,\s*resourceType: resourceType\.value,\s*resourceId: resourceId\.value\s*};/)
assert.match(loadUsersSource, /resourceApi\.getUsers\(loadContext\.resourceId\)/)
assert.match(loadUsersSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleLoadGuard}`))
assert.match(loadUsersSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleLoadGuard}`))
assert.match(loadUsersSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleLoadGuard}`))
const resetDialogSource = source.slice(source.indexOf('function resetDialogState()'), source.indexOf('function show('))
assert.match(resetDialogSource, /loadGeneration \+= 1;/)
assert.match(source, /response\.data/)
assert.match(source, /userId:\s*row\.userId,\s*userName:\s*row\.userName,\s*nickName:\s*row\.nickName,\s*assigned:\s*row\.assigned === true/)
assert.doesNotMatch(source, /\.\.\.row|email:\s*row\.|phonenumber:\s*row\.|status:\s*row\./)
assert.match(source, /filteredUsers\s*=\s*computed\([\s\S]*?user\.userName[\s\S]*?user\.nickName/)
assert.match(source, /nextTick\([\s\S]*?toggleRowSelection\(row, selectedUserIds\.value\.includes\(row\.userId\)\)/)
assert.match(source, /@row-click="handleRowClick"/)
assert.match(source, /const userIds = \[\.\.\.new Set\(selectedUserIds\.value\.filter\(\(userId\) => Number\.isInteger\(userId\) && userId > 0\)\)\]/)
assert.match(source, /let submitGeneration = 0;/)
assert.match(source, /function isCurrentSubmit\(submitContext\)\s*{[\s\S]*?submitContext\.generation === submitGeneration[\s\S]*?submitContext\.resourceType === resourceType\.value[\s\S]*?submitContext\.resourceId === resourceId\.value[\s\S]*?}/)
const submitSource = source.slice(source.indexOf('function submitAssignment()'), source.indexOf('defineExpose('))
const staleSubmitGuard = 'if \\(!isCurrentSubmit\\(submitContext\\)\\) \\{\\s*return;\\s*\\}'
assert.match(submitSource, /const submitContext = \{\s*generation: \+\+submitGeneration,\s*resourceType: resourceType\.value,\s*resourceId: resourceId\.value\s*};/)
assert.match(submitSource, /resourceApiMap\[submitContext\.resourceType\]\.assignUsers\(submitContext\.resourceId, \{ userIds }\)/)
assert.match(submitSource, new RegExp(`\\.then\\(\\(response\\) => \\{\\s*${staleSubmitGuard}`))
assert.match(submitSource, new RegExp(`\\.catch\\(\\(\\) => \\{\\s*${staleSubmitGuard}`))
assert.match(submitSource, new RegExp(`\\.finally\\(\\(\\) => \\{\\s*${staleSubmitGuard}`))
assert.match(resetDialogSource, /submitGeneration \+= 1;/)
assert.doesNotMatch(source, /请选择要分配的用户|userIds\.length\s*===?\s*0/)
assert.match(source, /:loading="submitLoading"/)
assert.match(source, /const loadFailed = ref\(false\)/)
assert.match(source, /:disabled="loading \|\| submitLoading \|\| loadFailed"/)
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, '分配组件禁止空 catch')
const submitButtons = extractButtonBlocks(source, 'submitAssignment')
const submitPermissions = submitButtons.flatMap((button) =>
[...button.matchAll(/v-hasPermi="\['(master:(?:site|warehouse):assign)'\]"/g)].map((match) => match[1])
).sort()
assert.deepEqual(submitPermissions, ['master:site:assign', 'master:warehouse:assign'])
assert.match(source, /proxy\.\$modal\.msgSuccess\(response\.msg \|\| ['"]分配成功['"]\)/)
assert.match(source, /visible\.value = false[\s\S]*?emit\(['"]ok['"]\)/)
const dialogTag = source.match(/<el-dialog\b[^>]*>/)?.[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, /<el-alert\b[^>]*v-if="loadError"/)
assert.match(source, /width="min\(720px, 92vw\)"/)
})
test('测试批次管理与旧数据纳入遵守 root、权限和预览提交契约', async () => {
const apiPath = 'src/api/system/testBatch.js'
const apiSource = await readFile(path.join(frontendRoot, apiPath), 'utf8')
const expectedTestBatchRequests = [
{ url: "'/system/test-batch/options'", method: 'get' },
{ url: "'/system/test-batch/list'", method: 'get' },
{ url: "'/system/test-batch/' + batchId", method: 'get' },
{ url: "'/system/test-batch'", method: 'post' },
{ url: "'/system/test-batch/' + batchId", method: 'put' },
{ url: "'/system/test-batch/' + batchId + '/users'", method: 'put' },
{ url: "'/system/test-batch/' + batchId + '/close'", method: 'put' },
{ url: "'/system/test-batch/' + batchId + '/reopen'", method: 'put' },
{ url: "'/system/test-batch/' + batchId + '/adoption/preview'", method: 'post' },
{ url: "'/system/test-batch/' + batchId + '/adoption/execute'", method: 'post' },
{ url: "'/system/test-batch/' + batchId + '/purge/preview'", method: 'post' },
{ url: "'/system/test-batch/' + batchId + '/purge'", method: 'post' }
]
assert.deepEqual(extractRequests(apiSource), expectedTestBatchRequests)
const adoptionRoots = {
strain: { path: 'src/views/master/strain/index.vue', id: 'strainId' },
customer: { path: 'src/views/master/customer/index.vue', id: 'customerId' },
site: { path: 'src/views/master/site/index.vue', id: 'siteId' },
line: { path: 'src/views/master/line/index.vue', id: 'lineId' },
warehouse: { path: 'src/views/master/warehouse/index.vue', id: 'warehouseId' },
pallet: { path: 'src/views/master/pallet/index.vue', id: 'palletId' },
inbound: { path: 'src/views/business/inbound/index.vue', id: 'inboundId' },
filling: { path: 'src/views/business/filling/index.vue', id: 'fillingId' },
shipment: { path: 'src/views/business/shipment/index.vue', id: 'shipmentId' },
usage: { path: 'src/views/business/usage/index.vue', id: 'usageId' },
return: { path: 'src/views/business/return/index.vue', id: 'returnId' }
}
assert.equal(Object.keys(adoptionRoots).length, 11, '纳入来源必须固定为 11 类')
for (const [rootType, contract] of Object.entries(adoptionRoots)) {
const source = await readFile(path.join(frontendRoot, contract.path), 'utf8')
assert.match(source, /import TestBatchAdoptionDialog from ['"]@\/components\/TestBatchAdoptionDialog['"]/)
assert.match(source, /useUserStore\(\)/, `${rootType} 必须读取 admin 可见性`)
assert.match(source, /useTestBatchStore\(\)/, `${rootType} 必须读取测试模式`)
assert.match(source, /userStore\.admin === true\s*&&\s*!testBatchStore\.isTestMode/, `${rootType} 纳入入口必须只在管理员正式模式显示`)
assert.match(source, /v-hasPermi="\['system:testbatch:adopt'\]"/, `${rootType} 纳入入口必须校验批次纳入权限`)
assert.match(source, new RegExp(`<test-batch-adoption-dialog\\b[\\s\\S]*?:root-type="['\"]${rootType}['\"][\\s\\S]*?:resource-ids="selectedIds"`))
assert.match(source, new RegExp(`selectedIds\\.value\\s*=\\s*selection\\.map\\(\\(item\\)\\s*=>\\s*item\\.${contract.id}\\)`), `${rootType} 必须传递正整数根 ID`)
assertButtonInSingleToolbar(
source,
contract.path,
contract.path.includes('/master/') ? 'master-toolbar' : 'page-toolbar',
'openAdoptionDialog'
)
}
for (const rootType of ['strain', 'customer', 'site', 'line', 'warehouse', 'pallet']) {
const source = await readFile(path.join(frontendRoot, adoptionRoots[rootType].path), 'utf8')
assert.match(source, /userStore\.admin === true\s*\|\|\s*!testBatchStore\.isTestMode/, `${rootType} 测试模式主数据写操作必须要求管理员`)
}
const adoptionDialogPath = 'src/components/TestBatchAdoptionDialog/index.vue'
const adoptionDialog = await readFile(path.join(frontendRoot, adoptionDialogPath), 'utf8')
assert.match(adoptionDialog, /item\.status === ['"]ACTIVE['"]/)
assert.match(adoptionDialog, /Number\.isInteger\(id\)\s*&&\s*id\s*>\s*0/)
assert.match(adoptionDialog, /previewTestBatchAdoption\(.*\{\s*roots:\s*roots\.value\s*}\s*\)/s)
assert.match(adoptionDialog, /executeTestBatchAdoption\([^,]+,\s*\{\s*roots:\s*roots\.value,\s*batchName:\s*batchName\.value,\s*previewToken:\s*previewToken\.value\s*}\s*\)/s)
assert.match(adoptionDialog, /batchName\.value\s*!==\s*preview\.value\.batchName/)
assert.match(adoptionDialog, /function invalidatePreview\(\)[\s\S]*?previewToken\.value\s*=\s*['"][\s\S]*?batchName\.value\s*=\s*['"]/)
const managementPath = 'src/views/system/testBatch/index.vue'
const management = await readFile(path.join(frontendRoot, managementPath), 'utf8')
assert.match(management, /const actionsFor\s*=\s*row\s*=>\s*\(\{[\s\S]*?edit:\s*row\.status\s*!==\s*['"]PURGED['"][\s\S]*?assign:\s*row\.status\s*===\s*['"]ACTIVE['"][\s\S]*?close:\s*row\.status\s*===\s*['"]ACTIVE['"][\s\S]*?reopen:\s*row\.status\s*===\s*['"]CLOSED['"][\s\S]*?purgePreview:\s*row\.status\s*===\s*['"]CLOSED['"][\s\S]*?audit:\s*row\.status\s*===\s*['"]PURGED['"][\s\S]*?}\)/)
assert.match(management, /onlinePurgeAllowed\.value\s*===\s*false/)
assert.match(management, /blockingReason/)
assert.match(management, /testBatchStore\.leaveTestMode\(\)/)
const reportPaths = Object.values(reportPageContracts).map((contract) => contract.relativePath)
for (const reportPath of reportPaths) {
const source = await readFile(path.join(frontendRoot, reportPath), 'utf8')
assert.doesNotMatch(source, /TestBatchAdoptionDialog/, `${reportPath} 不得提供纳入入口`)
}
})
test('测试批次授权、清理和对话框异步状态拒绝错误目标与过期会话', async () => {
const managementPath = 'src/views/system/testBatch/index.vue'
const management = await readFile(path.join(frontendRoot, managementPath), 'utf8')
const parseAssignedUserIds = Function(`return (${extractNamedFunction(management, 'parseAssignedUserIds')})`)()
const shouldLeaveTestMode = Function(`return (${extractNamedFunction(management, 'shouldLeaveTestMode')})`)()
assert.deepEqual(parseAssignedUserIds(''), { valid: true, userIds: [] }, '空输入是显式移除全部授权')
assert.deepEqual(parseAssignedUserIds('1, 2,1'), { valid: true, userIds: [1, 2] })
for (const invalidInput of ['1,abc,2', '1,,2', '1,', '0', '-1', '1.5']) {
assert.deepEqual(parseAssignedUserIds(invalidInput), { valid: false, userIds: [] }, `${invalidInput} 必须整体拒绝`)
}
assert.equal(shouldLeaveTestMode('12', 12), true, '仅相同批次清理后退出测试模式')
assert.equal(shouldLeaveTestMode('13', 12), false, '清理其他批次不得退出当前测试模式')
assert.equal(shouldLeaveTestMode('', 12), false)
assert.doesNotMatch(management, /row\.userCount/, '列表 VO 未声明 userCount,禁止直接消费')
assert.match(management, /let listRequestVersion = 0/)
assert.match(management, /function hydrateParticipantCounts\(/)
assert.match(management, /participantState:\s*['"]loading['"]/, '详情 hydration 前必须显式 loading')
assert.match(management, /participantState:\s*['"]error['"]/, '单个详情失败必须展示明确失败状态')
assert.match(management, /userIds\.length/, '参与人数必须来自详情已声明的 userIds')
assert.match(management, /function isCurrentListRequest\(listContext\)[\s\S]*?listContext\.version === listRequestVersion/)
assert.match(management, /const detailLoaded = ref\(false\)/)
assert.match(management, /let assignSession = 0/)
assert.match(management, /function isCurrentAssignContext\(context\)[\s\S]*?context\.session === assignSession[\s\S]*?context\.batchId === assignRequestBatchId\.value/)
const openAssignSource = extractNamedFunction(management, 'openAssign')
assert.match(openAssignSource, /detailLoaded\.value = false/)
assert.match(openAssignSource, /if \(!isCurrentAssignContext\(context\)\) return/)
const submitAssignSource = extractNamedFunction(management, 'submitAssign')
assert.match(submitAssignSource, /if \(assignSubmitting\.value \|\| !detailLoaded\.value \|\| !assignBatch\.value\) return/)
assert.match(submitAssignSource, /const parsed = parseAssignedUserIds\(assignedUserIds\.value\)/)
assert.match(submitAssignSource, /if \(!parsed\.valid\)/)
assert.match(submitAssignSource, /if \(!isCurrentAssignContext\(context\)\) return/)
assert.match(management, /function resetAssignDialog\(\)[\s\S]*?assignSession \+= 1[\s\S]*?assignBatch\.value = null[\s\S]*?detailLoaded\.value = false/)
assert.match(management, /:disabled="assignLoading \|\| !detailLoaded \|\| assignSubmitting"/)
assert.match(management, /if \(shouldLeaveTestMode\(testBatchStore\.currentBatchId, purgeBatch\.value\.batchId\)\) \{\s*testBatchStore\.leaveTestMode\(\)/)
assert.match(management, /class="test-batch-dialog"/)
assert.match(management, /:column="descriptionColumns"/)
assert.match(management, /max-height:\s*90vh/)
assert.match(management, /overflow-y:\s*auto/)
const adoptionPath = 'src/components/TestBatchAdoptionDialog/index.vue'
const adoption = await readFile(path.join(frontendRoot, adoptionPath), 'utf8')
assert.match(adoption, /let dialogSession = 0/)
assert.match(adoption, /let optionsRequestVersion = 0/)
assert.match(adoption, /function isCurrentOptionsRequest\(context\)[\s\S]*?context\.session === dialogSession[\s\S]*?context\.version === optionsRequestVersion[\s\S]*?visible\.value/)
const loadBatchesSource = extractNamedFunction(adoption, 'loadBatches')
for (const callback of ['then', 'catch', 'finally']) {
assert.match(loadBatchesSource, new RegExp(`\\.${callback}\\([\\s\\S]*?if \\(!isCurrentOptionsRequest\\(context\\)\\) return`), `options ${callback} 必须拒绝过期会话`)
}
assert.match(adoption, /function resetState\(\)[\s\S]*?dialogSession \+= 1[\s\S]*?optionsRequestVersion \+= 1/)
assert.match(adoption, /class="test-batch-adoption-dialog"/)
assert.match(adoption, /:column="descriptionColumns"/)
assert.match(adoption, /max-height:\s*90vh/)
assert.match(adoption, /overflow-y:\s*auto/)
})
test('测试批次管理页真实授权详情 success、failure 与 finally 不得覆盖新会话', async () => {
const management = await source('src/views/system/testBatch/index.vue')
const detailRequests = []
const page = createTestBatchPageHarness(management, {
getTestBatch: batchId => {
const request = createDeferred()
detailRequests.push({ batchId, request })
return request.promise
}
})
page.openAssign({ batchId: 1 })
page.openAssign({ batchId: 2 })
detailRequests[0].request.resolve({ data: { batchId: 1, batchName: '批次 A', userIds: [101] } })
await flushPromises()
assert.equal(page.state.assignBatch.value, null, '过期 success 不得写入新批次详情')
assert.equal(page.state.assignedUserIds.value, '', '过期 success 不得写入新批次用户')
assert.equal(page.state.detailLoaded.value, false, '过期 success 不得解锁保存')
assert.equal(page.state.assignLoading.value, true, '过期 finally 不得结束新会话 loading')
detailRequests[1].request.resolve({ data: { batchId: 2, batchName: '批次 B', userIds: [202] } })
await flushPromises()
assert.deepEqual(page.state.assignBatch.value, { batchId: 2, batchName: '批次 B', userIds: [202] })
assert.equal(page.state.assignedUserIds.value, '202')
assert.equal(page.state.detailLoaded.value, true)
assert.equal(page.state.assignLoading.value, false)
const failedRequests = []
const failedPage = createTestBatchPageHarness(management, {
getTestBatch: batchId => {
const request = createDeferred()
failedRequests.push({ batchId, request })
return request.promise
}
})
failedPage.openAssign({ batchId: 3 })
failedPage.openAssign({ batchId: 4 })
failedRequests[1].request.resolve({ data: { batchId: 4, batchName: '批次 B', userIds: [404] } })
await flushPromises()
failedRequests[0].request.reject(new Error('A 详情失败'))
await flushPromises()
assert.deepEqual(failedPage.state.assignBatch.value, { batchId: 4, batchName: '批次 B', userIds: [404] }, '过期 failure 不得清空已加载的新会话详情')
assert.equal(failedPage.state.assignedUserIds.value, '404', '过期 failure 不得改写新批次输入')
assert.equal(failedPage.state.detailLoaded.value, true)
assert.equal(failedPage.state.assignLoading.value, false)
assert.equal(failedPage.state.assignError.value, '', '过期 failure 不得展示旧批次错误')
})
test('测试批次管理页当前授权详情失败保持禁用并拒绝空数组提交', async () => {
const management = await source('src/views/system/testBatch/index.vue')
const detailRequest = createDeferred()
const assignments = []
const page = createTestBatchPageHarness(management, {
getTestBatch: () => detailRequest.promise,
assignTestBatchUsers: (batchId, payload) => {
assignments.push({ batchId, payload })
return Promise.resolve({ msg: '不应调用' })
}
})
page.openAssign({ batchId: 5 })
detailRequest.reject(new Error('详情失败'))
await flushPromises()
assert.equal(page.state.assignBatch.value, null)
assert.equal(page.state.assignedUserIds.value, '')
assert.equal(page.state.detailLoaded.value, false, '详情失败后不得解锁保存')
assert.equal(page.state.assignLoading.value, false)
assert.match(page.state.assignError.value, /详情加载失败/)
page.submitAssign()
assert.deepEqual(assignments, [], '详情未成功加载时不得提交移除全部用户的空数组')
})
test('测试批次管理页真实 hydration 拒绝旧版本回写并保留当前失败状态', async () => {
const management = await source('src/views/system/testBatch/index.vue')
const listRequests = []
const detailRequests = []
const page = createTestBatchPageHarness(management, {
listTestBatches: () => {
const request = createDeferred()
listRequests.push(request)
return request.promise
},
getTestBatch: batchId => {
const request = createDeferred()
detailRequests.push({ batchId, request })
return request.promise
}
})
page.getList()
listRequests[0].resolve({ rows: [{ batchId: 7, batchName: '旧列表' }], total: 1 })
await flushPromises()
page.getList()
listRequests[1].resolve({ rows: [{ batchId: 7, batchName: '新列表' }], total: 1 })
await flushPromises()
detailRequests[0].request.resolve({ data: { batchId: 7, userIds: [1, 2] } })
await flushPromises()
assert.equal(page.state.batchList.value[0].batchName, '新列表')
assert.equal(page.state.batchList.value[0].participantState, 'loading', '旧 success 不得写入新列表同 ID 行')
assert.equal(page.state.batchList.value[0].participantCount, undefined)
const failedListRequests = []
const failedDetailRequests = []
const failedPage = createTestBatchPageHarness(management, {
listTestBatches: () => {
const request = createDeferred()
failedListRequests.push(request)
return request.promise
},
getTestBatch: batchId => {
const request = createDeferred()
failedDetailRequests.push({ batchId, request })
return request.promise
}
})
failedPage.getList()
failedListRequests[0].resolve({ rows: [{ batchId: 8, batchName: '旧列表' }], total: 1 })
await flushPromises()
failedPage.getList()
failedListRequests[1].resolve({ rows: [{ batchId: 8, batchName: '新列表' }], total: 1 })
await flushPromises()
failedDetailRequests[0].request.reject(new Error('旧详情失败'))
await flushPromises()
assert.equal(failedPage.state.batchList.value[0].participantState, 'loading', '旧 failure 不得把新列表同 ID 行标为失败')
failedDetailRequests[1].request.reject(new Error('当前详情失败'))
await flushPromises()
assert.equal(failedPage.state.batchList.value.length, 1, '当前详情失败不得清空整个列表')
assert.equal(failedPage.state.batchList.value[0].participantState, 'error')
assert.equal(failedPage.state.batchList.value[0].participantCount, undefined)
assert.equal(failedPage.state.loadError.value, '', '当前详情失败必须是单项状态,不得升级为列表失败')
})
test('三个业务菜单公共样式统一使用 Element Plus 主题变量', async () => {
const styles = await source('src/assets/styles/data-pages.scss')
assert.match(styles, /background:\s*var\(--el-bg-color-page,\s*#f5f7fb\)/)
assert.match(styles, /background:\s*var\(--el-bg-color,\s*#fff\)/)
assert.match(styles, /border:\s*1px solid var\(--el-border-color-lighter,\s*#e4e7ed\)/)
assert.match(styles, /background:\s*var\(--el-fill-color-light,\s*#f5f7fa\)/)
assert.match(styles, /color:\s*var\(--el-text-color-regular,\s*#606266\)/)
assert.match(styles, /background:\s*var\(--el-fill-color-lighter,\s*#fafafa\)/)
assert.match(styles, /box-shadow:\s*var\(--el-box-shadow-lighter,/)
assert.doesNotMatch(styles, /background:\s*#f5f7fb\s*;/)
assert.doesNotMatch(styles, /background:\s*#fff\s*;/)
assert.doesNotMatch(styles, /border:\s*1px solid #e7ebf2\s*;/)
assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/)
assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/)
})
test('业务页面导入的组件不被同名响应式状态遮蔽', async (t) => {
// 非 .vue 路径导入的组件按 maybe-ref 绑定,模板标签会优先解析为同名 camelCase 状态(如 scanFeedback 遮蔽 ScanFeedback)
const { compileScript, parse } = await import('@vue/compiler-sfc')
for (const [resource, contract] of Object.entries(businessPageContracts)) {
await t.test(resource, async () => {
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
const { descriptor } = parse(source)
const compiled = compileScript(descriptor, { id: `contract-${resource}`, inlineTemplate: true }).content
const components = [...source.matchAll(/^import\s+([A-Z]\w*)\s+from\s+['"]@\/components\/[^'"]+['"]/gm)].map((match) => match[1])
for (const component of components) {
const shadow = component[0].toLowerCase() + component.slice(1)
assert.doesNotMatch(compiled, new RegExp(`_create(?:VNode|Block)\\(_unref\\(${shadow}\\)`), `${component} 被同名状态 ${shadow} 遮蔽`)
}
})
}
})