import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { test } from 'node:test' import { fileURLToPath } from 'node:url' import path from 'node:path' const frontendRoot = fileURLToPath(new URL('..', import.meta.url)) const expectedRequests = { 'src/api/master/strain.js': [ { url: "'/master/strain/list'", method: 'get' }, { url: "'/master/strain/' + strainId", method: 'get' }, { url: "'/master/strain'", method: 'post' }, { url: "'/master/strain'", method: 'put' }, { url: "'/master/strain/' + strainIds", method: 'delete' } ], 'src/api/master/customer.js': [ { url: "'/master/customer/list'", method: 'get' }, { url: "'/master/customer/' + customerId", method: 'get' }, { url: "'/master/customer'", method: 'post' }, { url: "'/master/customer'", method: 'put' }, { url: "'/master/customer/' + customerIds", method: 'delete' } ], 'src/api/master/site.js': [ { url: "'/master/site/list'", method: 'get' }, { url: "'/master/site/' + siteId", method: 'get' }, { url: "'/master/site'", method: 'post' }, { url: "'/master/site'", method: 'put' }, { url: "'/master/site/' + siteIds", method: 'delete' }, { url: "'/master/site/' + siteId + '/users'", method: 'get' }, { url: "'/master/site/' + siteId + '/users'", method: 'put' } ], 'src/api/master/line.js': [ { url: "'/master/line/list'", method: 'get' }, { url: "'/master/line/' + lineId", method: 'get' }, { url: "'/master/line'", method: 'post' }, { url: "'/master/line'", method: 'put' }, { url: "'/master/line/' + lineIds", method: 'delete' } ], 'src/api/master/warehouse.js': [ { url: "'/master/warehouse/list'", method: 'get' }, { url: "'/master/warehouse/' + warehouseId", method: 'get' }, { url: "'/master/warehouse'", method: 'post' }, { url: "'/master/warehouse'", method: 'put' }, { url: "'/master/warehouse/' + warehouseIds", method: 'delete' }, { url: "'/master/warehouse/' + warehouseId + '/users'", method: 'get' }, { url: "'/master/warehouse/' + warehouseId + '/users'", method: 'put' } ], 'src/api/master/pallet.js': [ { url: "'/master/pallet/list'", method: 'get' }, { url: "'/master/pallet/' + palletId", method: 'get' }, { url: "'/master/pallet/' + palletId + '/qrcode'", method: 'get' }, { url: "'/master/pallet'", method: 'post' }, { url: "'/master/pallet'", method: 'put' }, { url: "'/master/pallet/' + palletIds", method: 'delete' } ], 'src/api/business/inbound.js': [ { url: "'/business/inbound/list'", method: 'get' }, { url: "'/business/inbound/warehouse-options'", method: 'get' }, { url: "'/business/inbound/scan/' + qrToken", method: 'get' }, { url: "'/business/inbound/' + inboundId", method: 'get' }, { url: "'/business/inbound'", method: 'post' }, { url: "'/business/inbound'", method: 'put' }, { url: "'/business/inbound/' + inboundIds", method: 'delete' }, { url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' } ], 'src/api/business/filling.js': [ { url: "'/business/filling/list'", method: 'get' }, { url: "'/business/filling/strain-options'", method: 'get' }, { url: "'/business/filling/scan/' + qrToken", method: 'get' }, { url: "'/business/filling/' + fillingId", method: 'get' }, { url: "'/business/filling'", method: 'post' } ], 'src/api/business/shipment.js': [ { url: "'/business/shipment/list'", method: 'get' }, { url: "'/business/shipment/warehouse-options'", method: 'get' }, { url: "'/business/shipment/site-options'", method: 'get' }, { url: "'/business/shipment/scan/' + qrToken", method: 'get' }, { url: "'/business/shipment/' + shipmentId", method: 'get' }, { url: "'/business/shipment'", method: 'post' }, { url: "'/business/shipment'", method: 'put' }, { url: "'/business/shipment/' + shipmentIds", method: 'delete' }, { url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' } ], 'src/api/business/usage.js': [ { url: "'/business/usage/list'", method: 'get' }, { url: "'/business/usage/line-options'", method: 'get' }, { url: "'/business/usage/scan/' + qrToken", method: 'get' }, { url: "'/business/usage/' + usageId", method: 'get' }, { url: "'/business/usage'", method: 'post' } ], 'src/api/business/return.js': [ { url: "'/business/return/list'", method: 'get' }, { url: "'/business/return/warehouse-options'", method: 'get' }, { url: "'/business/return/scan/' + qrToken", method: 'get' }, { url: "'/business/return/request'", method: 'post' }, { url: "'/business/return/' + returnId", method: 'get' }, { url: "'/business/return/' + returnId + '/confirm'", method: 'put' } ], 'src/api/report/palletInventory.js': [ { url: "'/report/pallet-inventory/list'", method: 'get' } ], 'src/api/report/filling.js': [ { url: "'/report/filling/list'", method: 'get' } ], 'src/api/report/shipment.js': [ { url: "'/report/shipment/list'", method: 'get' } ], 'src/api/report/consumption.js': [ { url: "'/report/consumption/list'", method: 'get' } ], 'src/api/report/return.js': [ { url: "'/report/return/list'", method: 'get' } ], 'src/api/report/palletTrace.js': [ { url: "'/report/pallet-trace/list'", method: 'get' } ] } function extractRequests(source) { const requests = [] const requestPattern = /return\s+request\(\{\s*url:\s*([^,\r\n]+),\s*method:\s*['"]([a-z]+)['"]/g let match while ((match = requestPattern.exec(source)) !== null) { requests.push({ url: match[1].replace(/\s+/g, ' ').trim(), method: match[2] }) } return requests } function hasExpectedRequest(expectedRequest) { return Object.values(expectedRequests).some((requests) => requests.some((request) => request.url === expectedRequest.url && request.method === expectedRequest.method ) ) } test('扫码值会去除首尾空白,空白二维码会被拒绝', async () => { const { normalizeScanValue } = await import('../src/utils/palletScan.js') assert.equal(normalizeScanValue(' qr-12345678\n'), 'qr-12345678') assert.throws(() => normalizeScanValue(' \n\t '), /二维码不能为空/) }) test('重复扫码仅在相同值和时间窗口内成立', async () => { const { isDuplicateScan } = await import('../src/utils/palletScan.js') assert.equal(isDuplicateScan('qr-a', 'qr-a', 1200, 1000, 500), true) assert.equal(isDuplicateScan('qr-a', 'qr-b', 1200, 1000, 500), false) }) test('幂等键由 Web Crypto randomUUID 生成且不降级为伪随机值', async () => { const palletScanPath = path.join(frontendRoot, 'src/utils/palletScan.js') const source = await readFile(palletScanPath, 'utf8') const { createIdempotencyKey } = await import('../src/utils/palletScan.js') assert.match(source, /globalThis\.crypto\.randomUUID\(\)/) assert.doesNotMatch(source, /Date\.now|Math\.random|new Date/) assert.match(createIdempotencyKey(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) }) test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => { assert.equal(Object.keys(expectedRequests).length, 17) assert.equal(Object.values(expectedRequests).flat().length, 74) for (const [relativePath, expected] of Object.entries(expectedRequests)) { await t.test(relativePath, async () => { const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') const actual = extractRequests(source) const requestCallCount = source.match(/\brequest\s*\(\{/g)?.length ?? 0 assert.equal(actual.length, requestCallCount, '存在未被契约解析器覆盖的 request 调用') assert.deepEqual(actual, expected) }) } }) test('关键扫码、选项、确认与二维码契约被显式覆盖', () => { const requiredRequests = [ { url: "'/business/inbound/scan/' + qrToken", method: 'get' }, { url: "'/business/filling/scan/' + qrToken", method: 'get' }, { url: "'/business/shipment/scan/' + qrToken", method: 'get' }, { url: "'/business/usage/scan/' + qrToken", method: 'get' }, { url: "'/business/return/scan/' + qrToken", method: 'get' }, { url: "'/business/inbound/warehouse-options'", method: 'get' }, { url: "'/business/filling/strain-options'", method: 'get' }, { url: "'/business/shipment/warehouse-options'", method: 'get' }, { url: "'/business/shipment/site-options'", method: 'get' }, { url: "'/business/usage/line-options'", method: 'get' }, { url: "'/business/return/warehouse-options'", method: 'get' }, { url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' }, { url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' }, { url: "'/business/return/' + returnId + '/confirm'", method: 'put' }, { url: "'/business/return/request'", method: 'post' }, { url: "'/master/pallet/' + palletId + '/qrcode'", method: 'get' } ] for (const requiredRequest of requiredRequests) { assert.equal(hasExpectedRequest(requiredRequest), true, JSON.stringify(requiredRequest)) } }) test('六个报表 API 只提供 GET /list,不提供导出接口', async () => { const reportEntries = Object.entries(expectedRequests).filter(([relativePath]) => relativePath.startsWith('src/api/report/') ) assert.equal(reportEntries.length, 6) for (const [relativePath, expected] of reportEntries) { assert.equal(expected.length, 1) assert.equal(expected[0].method, 'get') assert.match(expected[0].url, /^'\/report\/.+\/list'$/) const source = await readFile(path.join(frontendRoot, relativePath), 'utf8') const exportedFunctionNames = [...source.matchAll(/\bexport\s+function\s+(\w+)/g)].map((match) => match[1]) assert.doesNotMatch(expected[0].url, /export/i) assert.equal(exportedFunctionNames.some((name) => /export/i.test(name)), false) } }) 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, /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) })