Files
ShiErFeng/shierfeng-fastapi-frontend/scripts/usage-behavior.test.mjs
yujiangandClaude Opus 5.5 167df4adb8 Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00

883 lines
52 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 passport = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletPassport.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 masterDisplaySource = await read('utils/masterDisplay.js')
const masterDisplay = new Function('__modules', `${bindImports(masterDisplaySource).replace(/export (function|const) /g, '$1 ')}\nreturn { ${[...masterDisplaySource.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1]).join(',')} };`)({ '@/router': { default: { getRoutes: () => [] } }, '@/utils/palletPassport': passport })
// 看板默认不含隧道和产线,避免看板名称掩盖托盘摘要中的隧道断言
const siteBoard = { siteId: 21, siteCode: 'XC-21', siteName: '一号现场', customerName: '客户A', palletCount: 0, packageSum: 0, unusedPalletCount: 0, partialPalletCount: 0, emptyPalletCount: 0, todayUsageCount: 0, todayUsedPackages: 0, todayByLine: [], tunnelCount: 0, activeTunnelCount: 0, todayFifoSkipCount: 0, weekFifoSkipCount: 0, tunnels: [], lines: [] }
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 === '/business/usage/site-options') return Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }] })
if (config.url === '/business/usage/site-board') return Promise.resolve({ data: siteBoard })
if (config.url === '/business/usage-fifo/queue') return Promise.resolve({ data: { siteId: 21, strains: [] } })
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/ScanFeedback': { default: { name: 'ScanFeedback' } }, '@/components/SfIcon': { default: { name: 'SfIcon' } }, '@/utils/masterDisplay': masterDisplay, '@/utils/palletPassport': passport, '@/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.filter(item => item.url.startsWith('/business/usage/scan/')).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()
})
const twoSites = config => config.url === '/business/usage/site-options'
? Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }, { id: 22, code: 'XC-22', name: '二号现场', customerName: '客户A', tunnelCount: 0, lineCount: 0 }] })
: undefined
test('现场看板默认选第一个可访问现场,只有主动切换才记住,记住的现场用于首次查询', async () => {
const storage = new Map()
const page = createPage({ storage, request: twoSites })
await flush()
assert.deepEqual(page.requests.find(item => item.url === '/business/usage/site-board').params, { siteId: 21 })
assert.deepEqual(page.requests.find(item => item.url === '/business/usage-fifo/queue').params, { siteId: 21 })
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
assert.equal(storage.size, 0, '自动选择不写入本机偏好')
page.state.handleSiteChange(22); await flush()
assert.equal(storage.get('business:usage:site:v1:7'), '22')
assert.deepEqual(page.requests.filter(item => item.url === '/business/usage/site-board').at(-1).params, { siteId: 22 })
page.close()
const again = createPage({ storage, request: twoSites })
assert.equal(again.requests.find(item => item.url === '/business/usage/list').params.siteId, 22, '首次查询直接使用记住的现场')
await flush()
assert.equal(again.state.selectedSiteId.value, 22)
again.close()
const otherUser = createPage({ storage, userId: 8, request: twoSites })
await flush()
assert.equal(otherUser.state.selectedSiteId.value, 21, '偏好按账号隔离')
otherUser.close()
})
test('偏好写入失败不影响切换,读取到已无权访问的现场时回到第一个现场', async () => {
const storage = new Map([['business:usage:site:v1:7', '99']])
const page = createPage({ storage, storageWriteError: true })
assert.equal(page.requests.find(item => item.url === '/business/usage/list').params.siteId, 99)
await flush()
assert.equal(page.state.selectedSiteId.value, 21)
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
page.state.handleSiteChange(21); await flush()
assert.equal(storage.get('business:usage:site:v1:7'), '99')
assert.equal(page.state.storageError.value, '')
page.close()
})
test('隧道卡片切换记录筛选,看板只展示隧道在用情况不展示溯源号', async () => {
const board = { ...siteBoard, tunnelCount: 1, activeTunnelCount: 1, tunnels: [{ tunnelId: 51, tunnelCode: 'KB-01', tunnelName: '看板隧道', inUse: true, palletCount: 2, remainingPackages: 150, startedAt: '2026-10-17T08:00:00', lastUsedAt: '2026-10-17T10:00:00', lastLineName: '看板产线' }] }
const page = createPage({ request: config => config.url === '/business/usage/site-board' ? Promise.resolve({ data: board }) : undefined })
await flush()
assert.match(text(page), /看板隧道/)
assert.doesNotMatch(text(page), /INTERNAL-TRACE|溯源号/)
assert.equal(page.state.tunnelFill(board.tunnels[0]), '75%')
page.state.toggleTunnelFilter(51); await flush()
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, 51)
page.state.toggleTunnelFilter(51); await flush()
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, undefined)
page.close()
})
test('无查询权限不读取看板,无列表权限不读取现场选项', async () => {
const page = createPage({ canOperate: false })
await flush()
assert.equal(page.requests.some(item => ['/business/usage/site-options', '/business/usage/site-board', '/business/usage-fifo/queue'].includes(item.url)), false)
page.state.openWork()
assert.equal(page.state.workOpen.value, false)
page.close()
})
test('作业步骤由扫码、入隧和登记结果推导,快捷包数与预览按当前余量计算', async () => {
const page = createPage()
await flush()
page.state.openWork()
assert.equal(page.state.workOpen.value, true)
assert.equal(page.state.workStep.value, 1)
await page.scan()
assert.equal(page.state.workStep.value, 2)
page.state.selectTunnel(31)
assert.equal(page.state.form.value.tunnelId, 31)
assert.equal(page.state.selectedTunnelName.value, '一号隧道')
await page.act('enterTunnel')
assert.equal(page.state.workStep.value, 3)
page.state.selectTunnel(32)
assert.equal(page.state.form.value.tunnelId, 31, '入隧后不能再切换隧道')
assert.deepEqual(page.state.quickAmounts.value.map(item => item.label), ['10', '20', '50', '全部 100'])
page.state.form.value.usedPackages = 30
assert.deepEqual({ ...page.state.usagePreview.value }, { text: '提交后余 70 包', bad: false })
page.state.form.value.usedPackages = 120
assert.equal(page.state.usagePreview.value.bad, true)
page.state.form.value.usedPackages = 17
const boards = page.requests.filter(item => item.url === '/business/usage/site-board').length
await page.act('submitUsage')
assert.equal(page.state.workStep.value, 4)
assert.equal(page.requests.filter(item => item.url === '/business/usage/site-board').length, boards + 1, '登记成功后刷新看板')
page.state.continueSamePallet(); await flush()
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/QR-001')
page.state.closeWork(); await flush()
assert.equal(page.state.workOpen.value, false)
assert.equal(page.state.scannedPallet.value, null)
page.close()
})
test('先到先用:可一键改用建议托盘,快捷原因写入同一原因字段', 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: 0, requiresSkipReason: true, message: '请填写跳过原因' } })
: undefined })
await page.scan()
assert.equal(page.state.recommendedOther.value, true)
assert.ok(button(page, '改用 较早托盘'))
page.state.pickFifoReason('建议托盘取不出')
assert.equal(page.state.form.value.fifoSkipReason, '建议托盘取不出')
assert.equal(page.state.isReasonActive('建议托盘取不出'), true)
page.state.pickFifoReason('其他')
assert.equal(page.state.form.value.fifoSkipReason, '')
page.state.form.value.fifoSkipReason = '叉车暂时取不出'
assert.equal(page.state.isReasonActive('其他'), true)
page.state.pickFifoReason('其他')
assert.equal(page.state.form.value.fifoSkipReason, '叉车暂时取不出', '自填原因不被「其他」清空')
page.state.useRecommendedPallet(); await flush()
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, `/business/usage/scan/${encodeURIComponent('较早托盘')}`)
page.close()
})