import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { webcrypto } from 'node:crypto' import { test } from 'node:test' // 两端均编译、挂载真实 SFC,并执行真实 API;仅替换 HTTP、平台生命周期与 UI 宿主。 const roots = { pc: new URL('../', import.meta.url), app: new URL('../../shierfeng-fastapi-app/', import.meta.url) } const read = (platform, path) => readFile(new URL(path, roots[platform]), 'utf8').catch(error => { if (error.code === 'ENOENT') assert.fail(`尚未实现:${platform}/${path}`) throw error }) const deferred = () => { let resolve, reject const promise = new Promise((yes, no) => { resolve = yes; reject = no }) return { promise, resolve, reject } } const flush = () => new Promise(resolve => setImmediate(resolve)) const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, path) => { const binding = names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }` return `const ${binding} = modules[${JSON.stringify(path)}];` }) const item = (palletId = 11) => ({ palletId, palletCode: `原托盘-${palletId}`, qrToken: `QR-${palletId}`, activeFillingId: palletId + 100, packageQty: 37, strainName: '原品种', batchNo: '原批号-2026', productionDate: '2026-09-01', expiryDate: '2027-02-28' }) const detail = (shipmentId = 7, overrides = {}) => ({ shipmentId, shipmentNo: `SH-${shipmentId}`, targetSiteId: 21, sourceWarehouseId: 31, status: 'CONFIRMED', receiptState: 'PENDING', receivedAt: null, items: [item(11), item(12)], ...overrides }) function hostRenderer(Vue) { const node = (type, text = '') => ({ type, text, children: [], props: {}, style: {}, parent: null }) return Vue.createRenderer({ createElement: type => node(type), createText: text => node('#text', text), createComment: () => node('#comment'), insert(child, parent, anchor) { if (child.parent) child.parent.children.splice(child.parent.children.indexOf(child), 1) const index = anchor ? parent.children.indexOf(anchor) : -1 parent.children.splice(index < 0 ? parent.children.length : index, 0, child) child.parent = parent }, remove(child) { child.parent?.children.splice(child.parent.children.indexOf(child), 1); child.parent = null }, setText: (el, text) => { el.text = text }, setElementText: (el, text) => { el.text = text; el.children = [] }, patchProp: (el, key, old, value) => { el.props[key] = value }, parentNode: el => el.parent, nextSibling: el => el.parent?.children[el.parent.children.indexOf(el) + 1] || null }) } const nodes = root => [root, ...(root.children || []).flatMap(nodes)] const visibleText = root => nodes(root).map(el => el.text || '').join(' ') const confirmButton = page => nodes(page.root).find(el => el.type === 'button' && /确认收货/.test(visibleText(el))) async function mount(platform, { kind = 'panel', permissions = ['business:shipment:receive'], request: override } = {}) { const require = createRequire(new URL('package.json', roots[platform])) const Vue = require('vue') const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc') const calls = [], messages = [], emitted = [], navigations = [] const hooks = { onShow: [], onHide: [], onUnload: [], onActivated: [], onDeactivated: [] } const request = config => { calls.push(structuredClone(config)) const result = override?.(config) if (result !== undefined) return result if (config.url.endsWith('/list')) return Promise.resolve({ rows: [detail()], total: 1 }) if (config.url.includes('/scan/')) return Promise.resolve({ data: item(Number(decodeURIComponent(config.url.split('/scan/')[1]).replace('QR-', ''))) }) if (config.url.endsWith('/confirm')) return Promise.resolve({ msg: '收货成功' }) if (/\/shipment-receipt\/\d+$/.test(config.url)) return Promise.resolve({ data: detail(Number(config.url.split('/').at(-1))) }) if (config.url.endsWith('-options')) return Promise.resolve({ data: [] }) throw new Error(`出现意外请求 ${config.method} ${config.url}`) } async function api(path) { const source = await read(platform, path) const names = [...source.matchAll(/export function (\w+)/g)].map(match => match[1]) return new Function('modules', `${bindImports(source).replaceAll('export function ', 'function ')}; return {${names.join(',')}};`)({ '@/utils/request': { default: request } }) } const hasPermi = permission => permissions.includes('*:*:*') || permissions.includes(permission) const auth = { hasPermi, hasPermiOr: values => values.some(hasPermi), hasPermiAnd: values => values.every(hasPermi) } const empty = { render: () => null } const receiptStub = { name: 'ShipmentReceiptPanel', render: () => Vue.h('receipt-panel') } const runtime = { ...Vue, // uni-app 原生 input 没有浏览器 DOM;宿主保留值与输入回调边界。 vModelText: { mounted(el, { value }, vnode) { el.props.value = value; el.props.onInput = vnode.props['onUpdate:modelValue'] }, beforeUpdate(el, { value }, vnode) { el.props.value = value; el.props.onInput = vnode.props['onUpdate:modelValue'] } }, defineAsyncComponent: () => receiptStub, onActivated: fn => hooks.onActivated.push(fn), onDeactivated: fn => hooks.onDeactivated.push(fn) } const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read(platform, 'src/utils/palletScan.js'))}`) const modules = { vue: runtime, '@dcloudio/uni-app': Object.fromEntries(Object.entries(hooks).map(([name, values]) => [name, fn => values.push(fn)])), '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) }, '@/components/PalletScanInput': { default: empty }, '@/components/business/PalletScanInput.vue': { default: empty }, '@/components/business/TestBatchBanner.vue': { default: empty }, '@/components/business/TestBatchModeSwitch.vue': { default: empty }, '@/components/TestBatchAdoptionDialog': { default: empty }, '@/components/ShipmentReceiptPanel': { default: receiptStub }, '@/components/business/ShipmentReceiptPanel.vue': { default: receiptStub }, '@/store/modules/user': { default: () => ({ admin: false }) }, '@/store/modules/testBatch': { default: () => ({ isTestMode: false }) }, '@/utils/testBatchContext': { getStoredTestBatchId: () => '' }, '@/utils/shipmentLabelPrint': { printShipmentLabels() {} } } if (kind === 'panel') modules['@/api/business/shipmentReceipt'] = await api('src/api/business/shipmentReceipt.js') if (kind === 'page') modules['@/api/business/shipment'] = await api('src/api/business/shipment.js') if (kind === 'work') { const source = await read('app', 'src/config/businessMenu.js') modules['@/config/businessMenu'] = new Function(`${source.replaceAll('export ', '')}; return { businessMenuEntries };`)() } const path = kind === 'work' ? 'src/pages/work/index.vue' : kind === 'page' ? `src/${platform === 'pc' ? 'views' : 'pages'}/business/shipment/index.vue` : platform === 'pc' ? 'src/components/ShipmentReceiptPanel/index.vue' : 'src/components/business/ShipmentReceiptPanel.vue' const { descriptor } = parse(await read(platform, path)) const script = compileScript(descriptor, { id: 'receipt-test', genDefaultAs: 'component' }) const template = compileTemplate({ source: descriptor.template.content, filename: path, id: 'receipt-test', compilerOptions: { bindingMetadata: script.bindings, hoistStatic: false, isCustomElement: tag => ['view', 'text', 'scroll-view', 'picker'].includes(tag) } }) assert.deepEqual(template.errors, []) const component = new Function('modules', ...Object.keys(runtime), `return (function () { ${bindImports(script.content)}; return component; })();`)(modules, ...Object.values(runtime)) component.render = new Function('modules', `${bindImports(template.code).replace('export function render', 'function render')}; return render;`)(modules) let state const setup = component.setup component.setup = (props, ctx) => { state = setup(props, ctx); return state } const root = { children: [] } const app = hostRenderer(Vue).createApp(component, { onReceived: () => emitted.push('received') }) app.config.globalProperties.$auth = auth app.config.globalProperties.$modal = Object.fromEntries(['msg', 'msgWarning', 'msgError', 'msgSuccess'].map(name => [name, msg => messages.push(msg)])) app.config.globalProperties.$tab = { navigateTo: path => navigations.push(path) } app.config.globalProperties.parseTime = value => value || '—' for (const directive of ['loading', 'hasPermi']) app.directive(directive, {}) // Element Plus 外观替身保留默认/表格作用域插槽,断言实际渲染的文本与按钮。 for (const tag of ['el-form', 'el-form-item', 'el-row', 'el-col', 'el-tag', 'el-tabs', 'el-tab-pane', 'el-select', 'el-option', 'el-input', 'el-input-number', 'el-descriptions', 'el-descriptions-item', 'pagination']) { app.component(tag, { setup: (_, { slots }) => () => Vue.h('div', slots.default?.()) }) } app.component('el-button', { setup: (_, { slots }) => () => Vue.h('button', slots.default?.()) }) app.component('el-alert', { props: ['title'], setup: props => () => Vue.h('div', props.title) }) app.component('el-dialog', { props: ['modelValue'], setup: (props, { slots }) => () => props.modelValue ? Vue.h('div', [slots.default?.(), slots.footer?.()]) : null }) app.component('el-table', { props: ['data'], setup(props, { slots }) { Vue.provide('rows', Vue.computed(() => props.data || [])); return () => Vue.h('div', slots.default?.()) } }) app.component('el-table-column', { props: ['prop'], setup(props, { slots }) { const rows = Vue.inject('rows'); return () => Vue.h('div', rows.value.map(row => slots.default ? slots.default({ row }) : Vue.h('span', String(row[props.prop] ?? '')))) } }) app.mount(root) await flush() return { state, calls, messages, emitted, root, navigations, writes: () => calls.filter(call => call.method === 'put'), open: async (id = 7) => { await state.openReceipt({ shipmentId: id }); await flush() }, scan: async (value = 'QR-11') => { await state.handleReceiptScan(value); await flush() }, lifecycle: async name => { for (const fn of hooks[name]) fn(); await flush() }, close: () => app.unmount() } } for (const platform of ['pc', 'app']) { test(`${platform} 原发货扫码只编码标识路径,保持出库查询参数`, async t => { const page = await mount(platform, { kind: 'page', permissions: ['business:shipment:query', 'business:shipment:add'], request: config => config.url.includes('/scan/') ? Promise.resolve({ data: { ...item(), flowState: 'FILLED_IN_WAREHOUSE', remainingPackages: 37 } }) : undefined }); t.after(page.close) const form = platform === 'pc' ? page.state.form.value : page.state.form Object.assign(form, { sourceWarehouseId: 31, targetSiteId: 21 }) await flush() page.state.handleShipmentScan('原托盘 /?#') await flush() const scans = page.calls.filter(call => call.url.includes('/scan/')) assert.deepEqual(scans, [{ url: '/business/shipment/scan/%E5%8E%9F%E6%89%98%E7%9B%98%20%2F%3F%23', method: 'get', params: { sourceWarehouseId: 31, targetSiteId: 21 } }]) }) test(`${platform} receive-only 进入收货页签,不自动调用出库 API`, async t => { const page = await mount(platform, { kind: 'page' }); t.after(page.close) await page.lifecycle('onShow') assert.equal(page.state.activeTab?.value, 'receipt') assert.ok(nodes(page.root).some(node => node.type === 'receipt-panel')) assert.deepEqual(page.calls, []) }) test(`${platform} 收货列表按服务端状态、单号分页查询,不查询来源仓库权限`, async t => { const page = await mount(platform); t.after(page.close) assert.deepEqual(page.calls, [{ url: '/business/shipment-receipt/list', method: 'get', params: { pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined } }]) Object.assign(page.state.queryParams, { receiptState: 'LEGACY', shipmentNo: 'SH-9', pageNum: 3 }) await page.state.loadReceipts() assert.deepEqual(page.calls.at(-1).params, { pageNum: 3, pageSize: 10, receiptState: 'LEGACY', shipmentNo: 'SH-9' }) assert.equal(page.state.total.value, 1) page.state.queryParams.receiptState = platform === 'pc' ? '' : undefined await page.state.handleQuery() assert.equal(page.calls.at(-1).params.receiptState, undefined, '全部状态必须省略筛选,不能向枚举参数发送空字符串') assert.equal(page.calls.at(-1).params.pageNum, 1) }) test(`${platform} 无接收权限不能列单、打开、扫码或确认`, async t => { const page = await mount(platform, { permissions: ['business:shipment:list'] }); t.after(page.close) await page.open(); await page.scan(); await page.state.confirmReceipt() assert.deepEqual(page.calls, []) }) test(`${platform} 首次核对从零开始,扫码保留原品种批号日期包数,全数核对才解锁`, async t => { const page = await mount(platform); t.after(page.close) await page.open() assert.deepEqual(page.state.scannedPalletIds.value, []) assert.equal(confirmButton(page).props.disabled, true) await page.state.confirmReceipt() assert.equal(page.writes().length, 0) await page.scan() assert.deepEqual(page.state.scannedPalletIds.value, [11]) for (const text of ['原托盘-11', '原品种', '原批号-2026', '2026-09-01', '2027-02-28', '37']) assert.ok(visibleText(page.root).includes(text), text) assert.equal(confirmButton(page).props.disabled, true) await page.scan('QR-12') assert.equal(confirmButton(page).props.disabled, false) }) test(`${platform} 扫码编码路径,错单/旧装填/服务端拒绝不会计入,重复不追加`, async t => { let result = item() const page = await mount(platform, { request: config => config.url.includes('/scan/') ? Promise.resolve({ data: result }) : undefined }); t.after(page.close) await page.open(); await page.scan('托盘 /?#') assert.equal(page.calls.at(-1).url, '/business/shipment-receipt/7/scan/%E6%89%98%E7%9B%98%20%2F%3F%23') await page.scan('QR-11') assert.deepEqual(page.state.scannedPalletIds.value, [11]) for (const wrong of [item(99), { ...item(12), activeFillingId: 999 }]) { result = wrong; await page.scan('wrong') assert.deepEqual(page.state.scannedPalletIds.value, [11]) assert.ok(page.state.scanError.value) } }) test(`${platform} 网络失败重试同键,移除再扫后新键,确认锁阻止重复及修改集合`, async t => { const pending = deferred() let attempts = 0 const page = await mount(platform, { request: config => config.method === 'put' ? (++attempts < 3 ? Promise.reject(new Error('网络断开')) : pending.promise) : undefined }); t.after(page.close) await page.open(); await page.scan(); await page.scan('QR-12') await page.state.confirmReceipt(); await page.state.confirmReceipt() assert.equal(page.writes().length, 2) assert.ok(page.state.confirmError.value) assert.equal(page.writes()[0].data.idempotencyKey, page.writes()[1].data.idempotencyKey) page.state.removeScannedPallet(12); await page.scan('QR-12') const confirming = page.state.confirmReceipt() await page.state.confirmReceipt(); page.state.removeScannedPallet(11) await page.scan('QR-11') assert.equal(page.writes().length, 3) assert.notEqual(page.writes()[2].data.idempotencyKey, page.writes()[1].data.idempotencyKey) assert.deepEqual(page.writes()[2].data.scannedPalletIds, [11, 12]) assert.deepEqual(page.state.scannedPalletIds.value, [11, 12]) pending.reject(new Error('仍不可用')); await confirming assert.equal(page.state.confirmLoading.value, false) }) test(`${platform} 扫码进行中锁住重复请求,服务端拒绝保留已核对集合且允许重试`, async t => { const pending = deferred() let block = false const page = await mount(platform, { request: config => block && config.url.includes('/scan/') ? pending.promise : undefined }); t.after(page.close) await page.open(); await page.scan() block = true const scanning = page.state.handleReceiptScan('QR-12') await page.state.handleReceiptScan('QR-12') page.state.removeScannedPallet(11) await page.state.confirmReceipt() assert.equal(page.calls.filter(call => call.url.includes('/scan/')).length, 2) assert.deepEqual(page.state.scannedPalletIds.value, [11]) assert.equal(page.writes().length, 0) pending.reject(new Error('托盘状态已变化,拒绝核对')); await scanning assert.match(page.state.scanError.value, /状态已变化/) assert.equal(page.state.scanLoading.value, false) block = false; await page.scan('QR-12') assert.deepEqual(page.state.scannedPalletIds.value, [11, 12]) }) test(`${platform} 列表过期响应不覆盖新筛选,详情失败可重试,空明细不可确认`, async t => { const oldList = deferred() let lists = 0, failDetail = true const page = await mount(platform, { request: config => { if (config.url.endsWith('/list') && ++lists === 1) return oldList.promise if (config.url.endsWith('/7')) return failDetail ? Promise.reject(new Error('详情暂不可用')) : Promise.resolve({ data: detail(7, { items: [] }) }) } }); t.after(page.close) page.state.queryParams.receiptState = 'RECEIVED' await page.state.handleQuery() oldList.resolve({ rows: [detail(99)], total: 99 }); await flush() assert.equal(page.state.receiptList.value[0].shipmentId, 7) assert.equal(page.state.total.value, 1) await page.open(); assert.ok(page.state.detailError.value) await page.scan(); assert.equal(page.calls.some(call => call.url.includes('/scan/')), false) failDetail = false; await page.state.retryDetail(); await flush() assert.equal(page.state.detailError.value, '') assert.equal(confirmButton(page).props.disabled, true) await page.state.confirmReceipt(); assert.equal(page.writes().length, 0) }) test(`${platform} 收货成功仅重新读取服务端状态,并通知出库列表刷新`, async t => { let confirmed = false const page = await mount(platform, { request: config => { if (config.method === 'put') { confirmed = true; return Promise.resolve({ msg: '收货成功' }) } if (confirmed && /\/shipment-receipt\/7$/.test(config.url)) return Promise.resolve({ data: detail(7, { receiptState: 'RECEIVED', receivedAt: '2026-10-08 11:22:33' }) }) } }); t.after(page.close) await page.open(); await page.scan(); await page.scan('QR-12'); await page.state.confirmReceipt(); await flush() assert.equal(page.state.receiptDetail.value.receiptState, 'RECEIVED') assert.ok(visibleText(page.root).includes('2026-10-08 11:22:33')) assert.deepEqual(page.emitted, ['received']) assert.equal(page.calls.filter(call => call.url.endsWith('/list')).length, 2) await page.state.confirmReceipt() assert.equal(page.writes().length, 1) }) test(`${platform} 成功后状态刷新失败不会伪造已收货或再次提交`, async t => { let confirmed = false const page = await mount(platform, { request: config => { if (config.method === 'put') { confirmed = true; return Promise.resolve({ msg: '成功' }) } if (confirmed && /\/shipment-receipt\/7$/.test(config.url)) return Promise.reject(new Error('读取失败')) } }); t.after(page.close) await page.open(); await page.scan(); await page.scan('QR-12'); await page.state.confirmReceipt(); await flush() assert.ok(page.state.detailError.value) assert.notEqual(page.state.receiptDetail.value?.receiptState, 'RECEIVED') await page.state.confirmReceipt() assert.equal(page.writes().length, 1) }) for (const receiptState of ['LEGACY', 'RECEIVED']) { test(`${platform} ${receiptState} 只读,历史记录不伪造收货时间`, async t => { const page = await mount(platform, { request: config => /\/shipment-receipt\/7$/.test(config.url) ? Promise.resolve({ data: detail(7, { receiptState, receivedAt: receiptState === 'LEGACY' ? '错误历史日期' : '2026-10-08' }) }) : undefined }); t.after(page.close) await page.open(); await page.scan(); await page.state.confirmReceipt() assert.equal(page.calls.some(call => call.url.includes('/scan/') || call.method === 'put'), false) assert.ok(visibleText(page.root).includes(receiptState === 'LEGACY' ? '历史未登记收货' : '已收货')) assert.equal(visibleText(page.root).includes('错误历史日期'), false) }) } test(`${platform} 切换单据丢弃旧详情,关闭丢弃旧扫码,重开从零核对`, async t => { const oldDetail = deferred(), oldScan = deferred() let deferDetail = true const page = await mount(platform, { request: config => { if (deferDetail && config.url.endsWith('/7')) return oldDetail.promise if (config.url.includes('/scan/')) return oldScan.promise } }); t.after(page.close) const opening = page.state.openReceipt({ shipmentId: 7 }) await page.open(8); oldDetail.resolve({ data: detail(7) }); await opening assert.equal(page.state.receiptDetail.value.shipmentId, 8) const scanning = page.state.handleReceiptScan('QR-11') page.state.closeReceipt(); deferDetail = false await page.open(7); oldScan.resolve({ data: item() }); await scanning assert.deepEqual(page.state.scannedPalletIds.value, []) assert.equal(page.state.scanLoading.value, false) }) test(`${platform} 旧确认响应不能关闭新单或通知成功,离开页面使请求失效`, async t => { const oldConfirm = deferred(), oldScan = deferred() let deferScan = false const page = await mount(platform, { request: config => { if (config.method === 'put') return oldConfirm.promise if (deferScan && config.url.includes('/scan/')) return oldScan.promise } }); t.after(page.close) await page.open(); await page.scan(); await page.scan('QR-12') const confirming = page.state.confirmReceipt() await page.open(8); oldConfirm.resolve({ msg: '旧单成功' }); await confirming assert.equal(page.state.receiptDetail.value.shipmentId, 8) assert.deepEqual(page.emitted, []) deferScan = true const scanning = page.state.handleReceiptScan('QR-11') await page.lifecycle(platform === 'pc' ? 'onDeactivated' : 'onHide') oldScan.resolve({ data: item() }); await scanning assert.deepEqual(page.state.scannedPalletIds.value, []) }) test(`${platform} 新发货与收货结果显示服务端状态`, async t => { const page = await mount(platform, { kind: 'page', permissions: ['business:shipment:list'], request: config => config.url.endsWith('/list') ? Promise.resolve({ rows: [detail(), detail(8, { receiptState: 'RECEIVED' }), detail(9, { receiptState: 'LEGACY' })], total: 3 }) : undefined }); t.after(page.close) await page.lifecycle('onShow') const text = visibleText(page.root) for (const label of ['待收货', '已收货', '历史未登记收货']) assert.ok(text.includes(label), label) }) } test('APP 工作台允许 receive-only 账号从原发货入口进入,未授权入口仍不可见', async t => { const page = await mount('app', { kind: 'work' }); t.after(page.close) const entries = page.state.visibleBusinessEntries.value assert.equal(entries.length, 1) assert.equal(entries[0].path, '/pages/business/shipment/index') page.state.handleBusinessEntry(entries[0]) assert.deepEqual(page.navigations, ['/pages/business/shipment/index']) })