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() })