Files
ShiErFeng/shierfeng-fastapi-frontend/scripts/shipment-receipt-behavior.test.mjs

379 lines
23 KiB
JavaScript

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'])
})