Files

756 lines
43 KiB
JavaScript
Raw Permalink 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 { readFile } from 'node:fs/promises'
import { webcrypto } from 'node:crypto'
import { test } from 'node:test'
import * as Vue from 'vue'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
// 执行真实 setup、模板和 API,仅替换网络、浏览器存储及组件宿主边界。
const read = path => readFile(new URL(`../src/${path}`, import.meta.url), 'utf8')
const { descriptor } = parse(await read('views/business/usage/index.vue'))
const script = compileScript(descriptor, { id: 'pc-usage-test', genDefaultAs: '__component' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'usage/index.vue', id: 'pc-usage-test', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const apiSource = await read('api/business/usage.js')
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletScan.js'))}`)
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) =>
`const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`)
const flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const tunnels = [{ id: 31, name: '一号隧道', code: 'SD-01' }]
const lines = [{ id: 41, name: '一号产线', code: 'CX-01' }]
const pallet = (overrides = {}) => ({ palletId: 11, palletCode: '托盘-001', qrToken: 'QR-001', siteId: 21, strainId: 1, strainName: '菌种A', batchNo: 'B-01', tunnelId: null, currentLineId: null, remainingPackages: 100, flowState: 'AT_SITE_UNUSED', productionDate: null, expiryDate: null, ...overrides })
const boundPallet = (overrides = {}) => pallet({ tunnelId: 31, tunnelName: '一号隧道', tunnelCode: 'SD-01', traceNo: 'INTERNAL-TRACE', ...overrides })
const renderer = Vue.createRenderer({ createElement: () => ({}), createText: () => ({}), createComment: () => ({}), insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {}, parentNode: () => null, nextSibling: () => null })
function flatten(vnode) {
if (Array.isArray(vnode)) return vnode.flatMap(flatten)
if (!vnode || typeof vnode !== 'object') return []
const children = vnode.children
const slots = children && !Array.isArray(children) && typeof children === 'object'
? Object.entries(children).filter(([key, value]) => !key.startsWith('_') && typeof value === 'function').flatMap(([, slot]) => flatten(slot({ row: {} }))) : []
return [vnode, ...flatten(children), ...slots]
}
function createPage({ scan = () => pallet(), request: override, storage = new Map(), storageWriteError = false, storageRemoveError = false, confirm = () => Promise.resolve(), canOperate = true, userId = 7, batchId = '' } = {}) {
const requests = [], messages = [], confirmations = [], successMessages = [], hooks = { onActivated: [], onDeactivated: [], onBeforeUnmount: [] }
const user = Vue.reactive({ id: userId, admin: false }), batch = Vue.reactive({ currentBatchId: batchId, scopeVersion: 0 })
const request = config => {
requests.push(structuredClone(config))
const custom = override?.(config)
if (custom !== undefined) return custom
if (config.url.includes('/scan/')) return Promise.resolve({ data: scan(decodeURIComponent(config.url.split('/scan/')[1])) })
if (config.url.endsWith('/tunnel-options')) return Promise.resolve({ data: tunnels })
if (config.url.endsWith('/line-options')) return Promise.resolve({ data: lines })
if (config.url.endsWith('/recommendation')) return Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: '请按实际接收时间先到先用' } })
if (config.url.endsWith('/list')) return Promise.resolve({ rows: [], total: 0 })
if (config.url.endsWith('/enter-tunnel')) return Promise.resolve({ data: { palletId: 11, tunnelId: config.data.tunnelId, traceNo: 'INTERNAL-TRACE' } })
if (config.url === '/business/usage') return Promise.resolve({ data: { usageId: 71, remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' } })
throw new Error(`未处理请求 ${config.url}`)
}
const names = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1])
const api = new Function('__modules', `${bindImports(apiSource).replace(/export function /g, 'function ')}\nreturn { ${names.join(',')} };`)({ '@/utils/request': { default: request } })
const localStorage = {
getItem: key => storage.get(key) ?? null,
setItem(key, value) { if (storageWriteError) throw new Error('存储不可用'); storage.set(key, value) },
removeItem(key) { if (storageRemoveError) throw new Error('存储清理失败'); storage.delete(key) }
}
const proxy = { $auth: { hasPermiAnd: () => canOperate }, $modal: {
msgSuccess(msg) { successMessages.push(msg); messages.push(msg) },
msgWarning: msg => messages.push(msg),
confirm(message) { confirmations.push(message); return confirm(message) }
}, resetForm() {}, $refs: { usageRef: { validate: callback => callback(true) } } }
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), ...Object.fromEntries(Object.keys(hooks).map(name => [name, fn => hooks[name].push(fn)])), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } }
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance', 'onActivated', 'onDeactivated', 'onBeforeUnmount']
const component = new Function('__modules', 'localStorage', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, localStorage, ...globals.map(name => vue[name]))
const scope = Vue.effectScope(), state = scope.run(() => component.setup({}, { expose() {} }))
const render = new Function('__modules', `${bindImports(template.code).replace('export function render', 'function render')}\nreturn render;`)(modules)
return {
state, api, requests, messages, confirmations, successMessages, storage, user, batch,
post: (url = '/business/usage') => requests.filter(item => item.method === 'post' && item.url === url),
async scan(value = 'QR-001') { state.handleUsageScan(value); await flush() },
async act(name) { state[name](); await flush() },
async lifecycle(name) { hooks[name].forEach(fn => fn()); await flush() },
nodes() {
let nodes
const app = renderer.createApp({ render() { nodes = flatten(render({ parseTime: value => value }, [], {}, Vue.proxyRefs(state), {}, {})); return null } })
app.mount({}); app.unmount()
return nodes
},
close() { hooks.onBeforeUnmount.forEach(fn => fn()); scope.stop() }
}
}
const text = page => page.nodes().map(node => [typeof node.children === 'string' ? node.children : '', node.props?.title || '', node.props?.label || ''].join(' ')).join(' ')
const button = (page, label) => page.nodes().find(node => node.type?.name === 'el-button' && flatten(node).some(child => typeof child.children === 'string' && child.children.includes(label)))
async function retry(page) {
const entry = button(page, '恢复重试')
assert.ok(entry, '必须提供无需重新扫码的恢复重试入口')
entry.props.onClick(); await flush()
}
test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => {
const page = createPage()
await page.api.scanUsagePallet('托盘/A?#')
assert.equal(page.requests.at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23')
await page.scan('印刷托盘号')
assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } })
page.close()
})
for (const failure of ['reject', 'pending']) test(`可选产线 ${failure} 不丢失或阻塞隧道成功结果`, async () => {
const slow = deferred()
const page = createPage({ request: config => config.url.endsWith('/line-options') ? (failure === 'reject' ? Promise.reject(new Error('产线不可用')) : slow.promise) : undefined })
await page.scan()
assert.deepEqual(page.state.tunnelOptions.value, tunnels)
assert.equal(page.state.optionLoading.value, false)
if (failure === 'reject') assert.match(text(page), /产线.*失败|产线.*不可用/)
page.state.form.value.tunnelId = 31
await page.act('enterTunnel')
page.state.form.value.usedPackages = 10
await page.act('submitUsage')
assert.equal(page.post().length, 1)
assert.equal(page.post()[0].data.lineId ?? null, null)
page.close(); slow.resolve({ data: [] }); await flush()
})
test('摘要展示历史日期未登记、真实日期及隧道名称编号,普通页无内部溯源输入列', async () => {
const page = createPage({ scan: () => boundPallet() })
await page.scan()
assert.match(text(page), /生产日期.*未登记/)
assert.match(text(page), /有效期.*未登记/)
assert.match(text(page), /一号隧道/)
assert.match(text(page), /SD-01/)
assert.doesNotMatch(text(page), /INTERNAL-TRACE|溯源号/)
assert.equal(page.nodes().filter(node => node.props?.prop === 'traceNo').length, 0)
page.close()
const dated = createPage({ scan: () => boundPallet({ productionDate: '2026-10-01', expiryDate: '2027-04-01' }) })
await dated.scan()
assert.match(text(dated), /2026-10-01/); assert.match(text(dated), /2027-04-01/)
dated.close()
})
test('新入隧后摘要使用选项的名称与编号', async () => {
const page = createPage()
await page.scan(); page.state.form.value.tunnelId = 31
await page.act('enterTunnel')
assert.match(text(page), /一号隧道/); assert.match(text(page), /SD-01/)
page.close()
})
test('已入隧扫码仅返回 ID 时从选项补齐隧道名称编号', async () => {
const page = createPage({ scan: () => boundPallet({ tunnelName: undefined, tunnelCode: undefined }) })
await page.scan()
assert.match(text(page), /一号隧道/); assert.match(text(page), /SD-01/)
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})
test('入隧成功保留已填写的可选产线及包数,尚未使用时产线仍可调整', async () => {
const page = createPage()
await page.scan()
page.state.form.value.tunnelId = 31
page.state.form.value.lineId = 41
page.state.form.value.usedPackages = 10
await page.act('enterTunnel')
assert.equal(page.state.form.value.lineId, 41)
assert.equal(page.state.form.value.usedPackages, 10)
assert.equal(page.state.lineLocked.value, false)
await page.act('submitUsage')
assert.equal(page.post()[0].data.lineId, 41)
page.close()
})
for (const kind of ['entry', 'usage']) test(`${kind} 缺少确认结果时保留原凭据,不把空响应当作成功`, async () => {
const page = createPage({ scan: () => kind === 'usage' ? boundPallet() : pallet(), request: config => config.method === 'post' ? Promise.resolve({ data: {} }) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; page.state.form.value.usedPackages = 10
await page.act(kind === 'usage' ? 'submitUsage' : 'enterTunnel')
assert.ok(button(page, '恢复重试'))
assert.deepEqual(page.messages, [])
assert.equal(page.state.usageResult.value, null)
page.close()
})
test('FIFO 展示服务端提示及推荐托盘,未知到货时间不能被当作排序依据', async () => {
const page = createPage({ request: config => config.url.endsWith('/recommendation') ? Promise.resolve({ data: { recommended: { palletId: 9, palletCode: '较早托盘', batchNo: '较早批次', remainingPackages: 25, siteArrivedAt: '2026-10-01 09:00:00' }, unknownArrivalCount: 2, message: '另有到货时间未登记的物料,请人工核对' } }) : undefined })
await page.scan()
assert.match(text(page), /另有到货时间未登记的物料,请人工核对/)
assert.match(text(page), /较早托盘/); assert.match(text(page), /较早批次/)
page.close()
})
for (const outcome of ['pending', 'reject']) test(`FIFO ${outcome} 不阻止入隧及实际使用`, async () => {
const slow = deferred()
const page = createPage({ request: config => config.url.endsWith('/recommendation') ? (outcome === 'reject' ? Promise.reject(new Error('FIFO 失败')) : slow.promise) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; await flush()
if (outcome === 'reject') assert.match(text(page), /先到先用.*人工核对|FIFO.*人工核对/)
await page.act('enterTunnel'); page.state.form.value.usedPackages = 10
await page.act('submitUsage')
assert.equal(page.post().length, 1)
assert.equal(page.state.usageResult.value.remainingPackages, 83)
page.close(); slow.resolve({ data: { recommended: null, message: '晚到提示' } }); await flush()
})
for (const outcome of ['resolve', 'reject']) test(`旧 FIFO ${outcome} 不污染重扫及离页后的状态`, async () => {
const slow = deferred(); let count = 0
const page = createPage({ request: config => config.url.endsWith('/recommendation') && ++count === 1 ? slow.promise : undefined })
await page.scan(); await page.act('resetUsageOperation'); await page.scan('NEW')
assert.match(text(page), /请按实际接收时间先到先用/)
if (outcome === 'resolve') slow.resolve({ data: { recommended: null, message: 'OLD-FIFO' } })
else slow.reject(new Error('OLD-FIFO'))
await flush()
assert.match(text(page), /请按实际接收时间先到先用/)
assert.doesNotMatch(text(page), /OLD-FIFO|建议获取失败/)
page.close()
})
for (const amount of [10, 60, 100]) test(`扣减 ${amount} 包后响应丢失:清空、重扫、重建页面只重放原 payload/key`, async () => {
const storage = new Map(), applied = new Map(); let remaining = 100, deductions = 0
const request = config => {
if (config.url !== '/business/usage') return
if (applied.has(config.data.idempotencyKey)) return Promise.resolve(applied.get(config.data.idempotencyKey))
deductions++; remaining -= config.data.usedPackages
applied.set(config.data.idempotencyKey, { data: { usageId: 71, remainingPackages: remaining, flowState: remaining ? 'AT_SITE_PARTIAL' : 'AT_SITE_EMPTY' } })
return Promise.reject(new Error('提交成功后响应丢失'))
}
const first = createPage({ storage, request, scan: () => boundPallet() })
await first.scan(); first.state.form.value.usedPackages = amount
await first.act('submitUsage')
const original = first.post()[0].data
await first.act('resetUsageOperation'); await first.scan()
first.state.form.value.usedPackages = amount + 1
await first.act('submitUsage')
assert.equal(first.post().length, 1, '未确认操作不可用新键再次扣减')
first.close()
const second = createPage({ storage, request, scan: () => boundPallet({ remainingPackages: remaining }) })
assert.match(text(second), /托盘-001/)
await retry(second)
assert.deepEqual(second.post()[0].data, original)
assert.equal(deductions, 1)
assert.equal(second.state.usageResult.value.remainingPackages, 100 - amount)
assert.equal(button(second, '恢复重试'), undefined)
second.close()
})
test('未知入隧结果同样持久化,并保持独立于使用的幂等键', async () => {
const storage = new Map()
const first = createPage({ storage, request: config => config.url.endsWith('/enter-tunnel') ? Promise.reject(new Error('响应丢失')) : undefined })
await first.scan(); first.state.form.value.tunnelId = 31
await first.act('enterTunnel')
const original = first.post('/business/usage/enter-tunnel')[0].data
first.state.form.value.tunnelId = 32
await first.act('enterTunnel')
assert.equal(first.post('/business/usage/enter-tunnel').length, 1)
first.close()
const second = createPage({ storage })
await retry(second)
assert.deepEqual(second.post('/business/usage/enter-tunnel')[0].data, original)
assert.equal(second.state.tunnelLocked.value, true)
second.state.form.value.usedPackages = 10
await second.act('submitUsage')
assert.notEqual(second.post()[0].data.idempotencyKey, original.idempotencyKey)
second.close()
})
test('恢复记录按账号和 test batch 隔离,切回原范围可恢复', async () => {
const storage = new Map()
const first = createPage({ storage, scan: () => boundPallet(), request: config => config.url === '/business/usage' ? Promise.reject(new Error('响应丢失')) : undefined })
await first.scan(); first.state.form.value.usedPackages = 10; await first.act('submitUsage'); first.close()
for (const identity of [{ userId: 8, batchId: '' }, { userId: 7, batchId: '99' }]) {
const other = createPage({ storage, ...identity })
assert.equal(button(other, '恢复重试'), undefined)
other.close()
}
const page = createPage({ storage })
assert.ok(button(page, '恢复重试'))
page.batch.currentBatchId = '99'; await flush()
assert.equal(button(page, '恢复重试'), undefined)
page.batch.currentBatchId = ''; await flush()
await retry(page); assert.equal(page.post().length, 1)
page.close()
})
for (const outcome of ['resolve', 'reject']) test(`离页后旧写请求 ${outcome} 不能污染重建状态或换掉恢复键`, async () => {
const slow = deferred(), storage = new Map()
const page = createPage({ storage, scan: () => boundPallet(), request: config => config.url === '/business/usage' ? slow.promise : undefined })
await page.scan(); page.state.form.value.usedPackages = 10
page.state.submitUsage(); await flush()
await page.lifecycle('onDeactivated'); await page.lifecycle('onActivated')
if (outcome === 'resolve') slow.resolve({ data: { remainingPackages: 90, flowState: 'AT_SITE_PARTIAL' } })
else slow.reject(new Error('OLD-ERROR'))
await flush()
assert.equal(page.state.usageResult.value, null)
assert.deepEqual(page.messages, [])
assert.ok(button(page, '恢复重试'))
page.close()
})
test('恢复凭据写入失败时不能先扣减', async () => {
const page = createPage({ storageWriteError: true, scan: () => boundPallet() })
await page.scan(); page.state.form.value.usedPackages = 10; await page.act('submitUsage')
assert.equal(page.post().length, 0)
assert.match(text(page), /存储/)
page.close()
})
test('恢复记录损坏时提示人工核对且不静默覆盖', async () => {
const storage = new Map()
const first = createPage({ storage, scan: () => boundPallet(), request: config => config.url === '/business/usage' ? Promise.reject(new Error('响应丢失')) : undefined })
await first.scan(); first.state.form.value.usedPackages = 10; await first.act('submitUsage'); first.close()
const key = [...storage.keys()][0]
storage.set(key, '损坏记录')
const second = createPage({ storage })
await second.scan(); await second.act('submitUsage')
assert.match(text(second), /损坏.*人工核对/)
assert.equal(second.post().length, 0)
assert.equal(storage.get(key), '损坏记录')
second.close()
})
for (const endpoint of ['/business/usage/scan/QR-001', '/business/usage/tunnel-options', '/business/usage/line-options', '/business/usage-fifo/recommendation', '/business/usage/list']) {
test(`${endpoint} 离页后晚到的读响应不污染新会话`, async () => {
const slow = deferred(); let count = 0
const page = createPage({ request: config => config.url === endpoint && ++count === 1 ? slow.promise : undefined })
await page.scan(); await page.lifecycle('onDeactivated'); await page.lifecycle('onActivated')
await page.scan('NEW')
const before = text(page)
slow.resolve({ rows: [{ usageId: 'OLD' }], total: 99, data: endpoint.includes('/scan/') ? pallet({ palletCode: 'OLD', palletId: 99 }) : endpoint.endsWith('-options') ? [{ id: 99, name: 'OLD' }] : { message: 'OLD', recommended: null } })
await flush()
assert.equal(text(page), before)
assert.equal(page.state.total.value, 0)
page.close()
})
}
test('既有权限、入隧顺序、包数校验与成功后防重复继续生效', async () => {
const denied = createPage({ canOperate: false })
await denied.scan(); denied.state.form.value.tunnelId = 31
await denied.act('enterTunnel'); await denied.act('submitUsage')
assert.equal(denied.requests.filter(item => item.method === 'post').length, 0)
denied.close()
const page = createPage()
await page.scan(); page.state.form.value.tunnelId = 31; page.state.form.value.usedPackages = 10
await page.act('submitUsage'); assert.equal(page.post().length, 0)
await page.act('enterTunnel')
for (const value of [undefined, 0, -1, 1.5, 101]) {
page.state.form.value.usedPackages = value; await page.act('submitUsage')
}
assert.equal(page.post().length, 0)
page.state.form.value.usedPackages = 10; await page.act('submitUsage'); await page.act('submitUsage')
assert.equal(page.post().length, 1)
assert.equal(page.state.usageResult.value.remainingPackages, 83)
page.close()
})
test('已绑定产线保持锁定并提交原产线,余量状态完全采用后端结果', async () => {
const page = createPage({ scan: () => boundPallet({ currentLineId: 41 }) })
await page.scan()
assert.equal(page.state.lineLocked.value, true)
assert.equal(page.state.form.value.lineId, 41)
const lineSelect = page.nodes().find(node => node.type?.name === 'el-select' && node.props?.modelValue === 41)
assert.equal(lineSelect.props.disabled, true)
page.state.form.value.usedPackages = 10
await page.act('submitUsage')
assert.equal(page.post()[0].data.lineId, 41)
assert.deepEqual(page.state.usageResult.value, { remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' })
page.close()
})
async function seedUnknown(storage, identity = {}, kind = 'usage') {
const page = createPage({ storage, ...identity, scan: () => kind === 'usage' ? boundPallet() : pallet(), request: config => config.method === 'post' ? Promise.reject(new Error('后端持续拒绝')) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; page.state.form.value.usedPackages = 10
await page.act(kind === 'usage' ? 'submitUsage' : 'enterTunnel')
assert.equal(page.requests.filter(item => item.method === 'post').length, 1)
page.close()
}
function releaseButton(page) {
const entry = button(page, '人工核对后解除待确认')
assert.ok(entry, '持续拒绝时必须有二次确认的人工核对释放入口')
return entry
}
for (const cancel of ['cancel', 'close']) test(`人工释放 ${cancel} 不清理记录且不发送业务请求`, async () => {
const storage = new Map()
await seedUnknown(storage)
const before = [...storage.entries()]
const page = createPage({ storage, confirm: () => Promise.reject(cancel) })
releaseButton(page).props.onClick(); await flush()
assert.deepEqual([...storage.entries()], before)
assert.ok(button(page, '恢复重试'))
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
assert.deepEqual(page.messages, [])
page.close()
})
for (const kind of ['entry', 'usage']) test(`人工确认只释放 ${kind} 本机待确认记录,不重放或宣称成功`, async () => {
const storage = new Map(), confirmation = deferred()
await seedUnknown(storage, {}, kind)
const before = [...storage.entries()]
const page = createPage({ storage, confirm: () => confirmation.promise })
releaseButton(page).props.onClick(); await flush()
assert.deepEqual([...storage.entries()], before, '弹窗出现时不能提前清理')
assert.match(page.confirmations[0], /使用记录/)
assert.match(page.confirmations[0], /库存/)
assert.match(page.confirmations[0], /重复.*扣减|重复.*提交/)
assert.match(page.confirmations[0], /本机/)
assert.match(page.confirmations[0], /托盘-001/)
assert.equal(button(page, '恢复重试').props.disabled, true)
await page.act('retryPendingOperation')
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
confirmation.resolve(); await flush()
assert.equal(storage.size, 0)
assert.equal(page.state.pendingOperation.value, null)
assert.equal(page.state.scannedPallet.value, null, '必须重新扫描核对当前库存,不能沿用旧快照')
assert.equal(page.state.form.value.qrToken, '')
assert.equal(page.state.usageResult.value, null)
assert.equal(page.state.recoveryRequired.value, false)
assert.equal(button(page, '恢复重试'), undefined)
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
assert.deepEqual(page.successMessages, [])
assert.match(page.messages.join(' '), /仅.*本机/)
page.close()
const rebuilt = createPage({ storage })
assert.equal(button(rebuilt, '恢复重试'), undefined)
rebuilt.close()
})
test('人工释放只影响当前账号及测试批次,新增权限撤销后仍可清理本机记录', async () => {
const storage = new Map()
for (const identity of [{}, { userId: 8 }, { batchId: '99' }]) await seedUnknown(storage, identity)
const page = createPage({ storage, canOperate: false })
releaseButton(page).props.onClick(); await flush()
assert.equal(storage.size, 2)
assert.equal(button(page, '恢复重试'), undefined)
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
page.close()
for (const identity of [{ userId: 8 }, { batchId: '99' }]) {
const other = createPage({ storage, ...identity })
assert.ok(button(other, '恢复重试'))
other.close()
}
})
for (const change of ['user', 'batch', 'page', 'record', 'removed']) test(`人工确认期间 ${change} 变化后旧确认不能删除记录`, async () => {
const storage = new Map(), confirmation = deferred()
await seedUnknown(storage)
const page = createPage({ storage, confirm: () => confirmation.promise })
releaseButton(page).props.onClick(); await flush()
if (change === 'user') page.user.id = 8
if (change === 'batch') page.batch.currentBatchId = '99'
if (change === 'page') await page.lifecycle('onDeactivated')
if (change === 'record') {
const [key, raw] = [...storage.entries()][0]
const changed = JSON.parse(raw)
changed.payload.idempotencyKey = 'OTHER-OPERATION-KEY'
storage.set(key, JSON.stringify(changed))
}
if (change === 'removed') storage.delete([...storage.keys()][0])
const before = [...storage.entries()]
confirmation.resolve(); await flush()
assert.deepEqual([...storage.entries()], before)
if (change === 'record' || change === 'removed') assert.equal(page.state.recoveryRequired.value, true, '记录发生变化时不能把旧确认当作有效释放')
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
assert.deepEqual(page.successMessages, [])
page.close()
})
test('人工释放遇到本机清理失败时保留凭据并显示错误', async () => {
const storage = new Map()
await seedUnknown(storage)
const before = [...storage.entries()]
const page = createPage({ storage, storageRemoveError: true })
releaseButton(page).props.onClick(); await flush()
assert.deepEqual([...storage.entries()], before)
assert.ok(button(page, '恢复重试'))
assert.match(text(page), /清理.*失败|解除.*失败/)
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
assert.deepEqual(page.successMessages, [])
page.close()
})
const fifoData = (requiresSkipReason = true) => ({ recommended: null, unknownArrivalCount: 0, requiresSkipReason, message: '请按实际到货时间核对' })
const fifoRejection = () => Object.assign(new Error('服务端拒绝'), { response: { data: { code: 500, data: 'FIFO_SKIP_REASON_REQUIRED', msg: '请填写跳过原因' } } })
const fifoRequests = page => page.requests.filter(item => item.url.endsWith('/recommendation'))
for (const kind of ['entry', 'usage']) {
const action = kind === 'entry' ? 'enterTunnel' : 'submitUsage'
const endpoint = kind === 'entry' ? '/business/usage/enter-tunnel' : '/business/usage'
for (const reason of [' \n ', '字'.repeat(501)]) test(`FIFO ${kind} 空白或超长原因不发送且不持久化(${reason.length})`, async () => {
const page = createPage({ scan: () => kind === 'entry' ? pallet() : boundPallet(), request: config => config.url.endsWith('/recommendation') ? Promise.resolve({ data: fifoData() }) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; await flush()
page.state.form.value.usedPackages = 10; page.state.form.value.fifoSkipReason = reason
await page.act(action)
assert.equal(page.post(endpoint).length, 0)
assert.equal(page.storage.size, 0)
assert.match(text(page), /原因|500/)
page.close()
})
test(`FIFO ${kind} trim 原因持久化,修改原因不能绕过待确认,重建后重放原原因及键`, async () => {
const storage = new Map()
const first = createPage({ storage, scan: () => kind === 'entry' ? pallet() : boundPallet(), request: config => config.url === endpoint ? Promise.reject(new Error('网络中断')) : undefined })
await first.scan(); first.state.form.value.tunnelId = 31; first.state.form.value.usedPackages = 10
first.state.form.value.fifoSkipReason = ' 先到托盘正在检验 \n'
await first.act(action)
const original = first.post(endpoint)[0].data
assert.equal(original.fifoSkipReason, '先到托盘正在检验')
assert.equal(JSON.parse([...storage.values()][0]).payload.fifoSkipReason, '先到托盘正在检验')
first.state.form.value.fifoSkipReason = '换了原因'; await first.act(action)
assert.equal(first.post(endpoint).length, 1)
first.close()
const second = createPage({ storage })
await retry(second)
assert.deepEqual(second.post(endpoint)[0].data, original)
assert.equal(second.state.form.value.fifoSkipReason, '先到托盘正在检验')
second.close()
})
test(`FIFO ${kind} 明确拒绝解除原凭据并刷新建议,补原因后新键提交`, async () => {
let rejected = false
const page = createPage({ scan: () => kind === 'entry' ? pallet() : boundPallet(), request: config => {
if (config.url === endpoint && !rejected) { rejected = true; return Promise.reject(fifoRejection()) }
if (config.url.endsWith('/recommendation')) return Promise.resolve({ data: fifoData(rejected) })
} })
await page.scan(); page.state.form.value.tunnelId = 31; await flush(); page.state.form.value.usedPackages = 10
const before = fifoRequests(page).length
await page.act(action)
const original = page.post(endpoint)[0].data
assert.equal(page.storage.size, 0)
assert.equal(page.state.recoveryRequired.value, false)
assert.match(text(page), /原因/)
assert.ok(fifoRequests(page).length > before)
page.state.form.value.fifoSkipReason = ' 质量待核实 '
await page.act(action)
assert.equal(page.post(endpoint)[1].data.fifoSkipReason, '质量待核实')
assert.notEqual(page.post(endpoint)[1].data.idempotencyKey, original.idempotencyKey)
page.close()
})
}
test('FIFO 清理失败保留待确认,恢复重试收到明确拒绝后可编辑', async () => {
const storage = new Map(); await seedUnknown(storage)
const before = [...storage.entries()]
const blocked = createPage({ storage, storageRemoveError: true, request: config => config.method === 'post' ? Promise.reject(fifoRejection()) : undefined })
await retry(blocked)
assert.deepEqual([...storage.entries()], before)
assert.ok(button(blocked, '恢复重试'))
blocked.close()
const page = createPage({ storage, request: config => config.method === 'post' ? Promise.reject(fifoRejection()) : undefined })
await retry(page)
assert.equal(storage.size, 0)
assert.equal(page.state.form.value.usedPackages, 10)
assert.equal(page.state.scannedPallet.value.palletId, 11)
assert.ok(fifoRequests(page).length)
page.close()
})
for (const error of [new Error('FIFO_SKIP_REASON_REQUIRED'), { response: { data: { code: 503, data: 'FIFO_SKIP_REASON_REQUIRED' } } }, { response: { data: { code: 500, data: 'OTHER_BUSINESS_ERROR' } } }]) {
test(`FIFO 非明确拒绝保留原凭据 ${JSON.stringify(error)}`, async () => {
const page = createPage({ scan: () => boundPallet(), request: config => config.method === 'post' ? Promise.reject(error) : undefined })
await page.scan(); page.state.form.value.usedPackages = 10; await page.act('submitUsage')
assert.equal(page.storage.size, 1)
assert.ok(button(page, '恢复重试'))
page.close()
})
}
for (const change of ['user', 'batch', 'page', 'record']) test(`FIFO 明确拒绝的旧回调不能清理 ${change} 变化后的凭据`, async () => {
const slow = deferred(), storage = new Map()
const page = createPage({ storage, scan: () => boundPallet(), request: config => config.method === 'post' ? slow.promise : undefined })
await page.scan(); page.state.form.value.usedPackages = 10; await page.act('submitUsage')
if (change === 'user') page.user.id = 8
if (change === 'batch') page.batch.currentBatchId = '99'
if (change === 'page') await page.lifecycle('onDeactivated')
if (change === 'record') {
const [key, raw] = [...storage.entries()][0], record = JSON.parse(raw)
record.payload.idempotencyKey = 'ANOTHER-KEY'; storage.set(key, JSON.stringify(record))
}
const before = [...storage.entries()]
slow.reject(fifoRejection()); await flush()
assert.deepEqual([...storage.entries()], before)
assert.equal(page.state.usageResult.value, null)
page.close()
})
for (const outcome of ['resolve', 'reject']) test(`FIFO 选择目标隧道重新请求并隔离旧 ${outcome} 响应`, async () => {
const old = deferred()
const page = createPage({ request: config => config.url.endsWith('/recommendation')
? (config.params.tunnelId === 31 ? old.promise : Promise.resolve({ data: fifoData(false) })) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; await flush()
assert.deepEqual(fifoRequests(page).at(-1).params, { qrToken: 'QR-001', tunnelId: 31 })
page.state.form.value.tunnelId = 32; await flush()
if (outcome === 'resolve') old.resolve({ data: fifoData(true) }); else old.reject(new Error('旧建议失败'))
await flush()
page.state.form.value.fifoSkipReason = ''; await page.act('enterTunnel')
assert.equal(page.post('/business/usage/enter-tunnel')[0].data.tunnelId, 32)
assert.equal(page.state.fifoRecommendation.value.requiresSkipReason, false)
assert.equal(page.state.fifoWarning.value, '')
page.close()
})
test('FIFO 原因多行输入绑定表单并只读展示列表原因', async () => {
const page = createPage(); await page.scan()
const input = page.nodes().find(node => node.type?.name === 'el-input' && node.props?.type === 'textarea')
assert.ok(input)
input.props['onUpdate:modelValue']('人工核对原因')
assert.equal(page.state.form.value.fifoSkipReason, '人工核对原因')
const column = page.nodes().find(node => node.type?.name === 'el-table-column' && node.props?.prop === 'fifoSkipReason')
assert.ok(column)
assert.ok(flatten(column.children.default({ row: { fifoSkipReason: '人工核对原因' } })).some(node => node.children === '人工核对原因'))
assert.ok(flatten(column.children.default({ row: {} })).some(node => node.children === '—'))
page.close()
})
test('PC 真实 request 拦截器透传 code500 响应供 FIFO 精确识别', async () => {
const source = await read('utils/request.js')
let onResponse
const service = { interceptors: { request: { use() {} }, response: { use(fn) { onResponse = fn } } } }
const modules = Object.fromEntries([...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map(([, name]) => [name, {}]))
modules.axios = { default: { defaults: { headers: {} }, create: () => service } }
modules['element-plus'] = { ElMessage() {} }
modules['@/utils/errorCode'] = { default: {} }
modules['@/utils/transportCrypto'] = { decryptTransportResponse: async response => response }
new Function('__modules', bindImports(source).replace(/import\.meta\.env\.VITE_APP_BASE_API/g, "''").replace(/export default service/g, '').replace(/export /g, ''))(modules)
const response = { data: { code: 500, data: 'FIFO_SKIP_REASON_REQUIRED', msg: '请填写原因' }, request: {} }
await assert.rejects(onResponse(response), error => error.response === response && error.message === '请填写原因')
})
for (const action of ['enterTunnel', 'submitUsage']) test(`FIFO ${action} 可选原因同样限制500字,trim后500字可提交`, async () => {
const page = createPage({ scan: () => action === 'enterTunnel' ? pallet() : boundPallet() })
await page.scan(); page.state.form.value.tunnelId = 31; await flush()
page.state.form.value.usedPackages = 10; page.state.form.value.fifoSkipReason = '字'.repeat(501)
await page.act(action)
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
page.state.form.value.fifoSkipReason = ` ${'字'.repeat(500)} \n`
await page.act(action)
assert.equal(page.requests.find(item => item.method === 'post').data.fifoSkipReason, '字'.repeat(500))
page.close()
})
for (const change of ['user', 'batch']) test(`FIFO ${change} 变化后的旧建议不能更新当前状态`, async () => {
const old = deferred()
const page = createPage({ request: config => config.url.endsWith('/recommendation') ? old.promise : undefined })
await page.scan()
if (change === 'user') page.user.id = 8
else page.batch.currentBatchId = '99'
await flush(); const before = text(page)
old.resolve({ data: { ...fifoData(), message: 'OLD-FIFO' } }); await flush()
assert.equal(text(page), before)
page.close()
})
test('FIFO 旧无原因入隧凭据恢复收到明确拒绝后可补原因重新入隧', async () => {
const storage = new Map(); await seedUnknown(storage, {}, 'entry')
const original = JSON.parse([...storage.values()][0]).payload
assert.equal(original.fifoSkipReason, undefined)
let rejected = false
const page = createPage({ storage, request: config => {
if (config.method === 'post' && !rejected) { rejected = true; return Promise.reject(fifoRejection()) }
} })
await retry(page)
assert.equal(page.state.recoveryRequired.value, false)
assert.equal(page.state.tunnelLocked.value, false)
assert.equal(page.state.form.value.tunnelId, 31)
page.state.form.value.fifoSkipReason = '先到托盘待检验'
await page.act('enterTunnel')
const posts = page.post('/business/usage/enter-tunnel')
assert.deepEqual(posts[0].data, original)
assert.equal(posts[1].data.fifoSkipReason, '先到托盘待检验')
assert.notEqual(posts[1].data.idempotencyKey, original.idempotencyKey)
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})
for (const kind of ['entry', 'usage']) test(`F2 两页面共享凭据,旧${kind}成功不能删除另一页的新未知操作`, async () => {
const storage = new Map(), old = deferred()
await seedUnknown(storage, {}, kind)
const first = createPage({ storage, request: config => config.method === 'post' ? old.promise : undefined })
await retry(first)
let writes = 0
const second = createPage({ storage, scan: () => boundPallet({ palletId: 12, palletCode: '托盘-B', qrToken: 'QR-B' }), request: config => {
if (config.method === 'post' && ++writes > 1) return Promise.reject(new Error('新操作B响应丢失'))
} })
await retry(second)
await second.act('resetUsageOperation'); await second.scan('QR-B')
second.state.form.value.usedPackages = 12; second.state.form.value.fifoSkipReason = 'B原因'
await second.act('submitUsage')
const originalB = second.post().at(-1).data
const before = [...storage.entries()]
assert.equal(JSON.parse(before[0][1]).payload.idempotencyKey, originalB.idempotencyKey)
old.resolve({ data: kind === 'entry' ? { palletId: 11, tunnelId: 31, traceNo: 'TRACE-A' } : { usageId: 71, remainingPackages: 90, flowState: 'AT_SITE_PARTIAL' } })
await flush()
assert.deepEqual([...storage.entries()], before, '旧成功不能清理同范围后来保存的新记录')
assert.deepEqual({ ...first.state.pendingOperation.value.payload }, originalB, '旧页面应恢复新待确认记录')
assert.ok(button(first, '恢复重试'))
const third = createPage({ storage })
await third.scan('QR-B'); third.state.form.value.fifoSkipReason = '不同原因'; await third.act('submitUsage')
assert.equal(third.post().length, 0)
await retry(third)
assert.deepEqual(third.post()[0].data, originalB)
first.close(); second.close(); third.close()
})
for (const kind of ['entry', 'usage']) {
const action = kind === 'entry' ? 'enterTunnel' : 'submitUsage'
for (const outcome of ['reject', 'pending', 'incomplete']) test(`F3 恢复${kind}被FIFO拒绝后推荐${outcome}仍保留已知必填`, async () => {
const storage = new Map(), recommendation = deferred()
await seedUnknown(storage, {}, kind)
const page = createPage({ storage, request: config => {
if (config.method === 'post') return Promise.reject(fifoRejection())
if (config.url.endsWith('/recommendation')) return outcome === 'reject'
? Promise.reject(new Error('推荐服务不可用'))
: outcome === 'incomplete' ? Promise.resolve({ data: { message: '缺少必填标记' } }) : recommendation.promise
} })
await retry(page)
// 同一目标的表单重建不能被视为真实换托盘或换隧道。
page.state.form.value = { ...page.state.form.value }; await flush()
assert.equal(page.state.fifoRecommendation.value?.requiresSkipReason, true)
assert.equal(storage.size, 0)
page.state.form.value.fifoSkipReason = ' '
await page.act(action)
assert.equal(page.requests.filter(item => item.method === 'post').length, 1)
assert.equal(storage.size, 0, '已知必填时空原因不能重新保存待确认')
page.close(); recommendation.resolve({ data: fifoData(false) }); await flush()
})
}
test('F3 恢复拒绝的已知必填仅由有效推荐明确覆盖', async () => {
const storage = new Map(), recommendation = deferred()
await seedUnknown(storage, {}, 'entry')
let rejected = false
const page = createPage({ storage, request: config => {
if (config.method === 'post' && !rejected) { rejected = true; return Promise.reject(fifoRejection()) }
if (config.url.endsWith('/recommendation')) return recommendation.promise
} })
await retry(page)
assert.equal(page.state.fifoRecommendation.value?.requiresSkipReason, true)
recommendation.resolve({ data: fifoData(false) }); await flush()
assert.equal(page.state.fifoRecommendation.value.requiresSkipReason, false)
await page.act('enterTunnel')
assert.equal(page.post('/business/usage/enter-tunnel').length, 2)
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})
test('F3 真正更换隧道可清除原目标必填,旧推荐不能恢复它', async () => {
const storage = new Map(), old = deferred()
await seedUnknown(storage, {}, 'entry')
let rejected = false
const page = createPage({ storage, request: config => {
if (config.method === 'post' && !rejected) { rejected = true; return Promise.reject(fifoRejection()) }
if (config.url.endsWith('/recommendation')) return config.params.tunnelId === 31 ? old.promise : Promise.reject(new Error('新目标建议不可用'))
} })
await retry(page)
assert.equal(page.state.fifoRecommendation.value?.requiresSkipReason, true)
page.state.form.value.tunnelId = 32; await flush()
old.resolve({ data: fifoData(true) }); await flush()
assert.equal(page.state.fifoRecommendation.value, null)
await page.act('enterTunnel')
assert.equal(page.post('/business/usage/enter-tunnel').at(-1).data.tunnelId, 32)
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})