更新登录页及优化相关功能
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import vm from 'node:vm'
|
||||
import { test } from 'node:test'
|
||||
import { computed, reactive, ref, toRefs, watch, nextTick } from 'vue'
|
||||
|
||||
// 执行真实页面的 setup 与提交方法;只替代网络和运行平台边界。
|
||||
async function fillingPage(platform) {
|
||||
const source = await readFile(new URL(platform === 'pc'
|
||||
? '../src/views/business/filling/index.vue'
|
||||
: '../../shierfeng-fastapi-app/src/pages/business/filling/index.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script\b[^>]*>([\s\S]*?)<\/script>/)[1]
|
||||
.replace(/^import[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm, '')
|
||||
const submissions = []
|
||||
let key = 0
|
||||
const proxy = {
|
||||
$auth: { hasPermi: () => true, hasPermiAnd: () => true },
|
||||
$refs: { fillingRef: { validate: callback => callback(true), clearValidate() {} } },
|
||||
$modal: { msgSuccess() {} },
|
||||
resetForm() {},
|
||||
}
|
||||
const context = vm.createContext({
|
||||
computed, reactive, ref, toRefs, watch,
|
||||
getCurrentInstance: () => ({ proxy }),
|
||||
useUserStore: () => ({ admin: true }), useTestBatchStore: () => ({ isTestMode: false }),
|
||||
onShow() {}, createIdempotencyKey: () => `date-command-${++key}`,
|
||||
listFilling: async () => ({ rows: [], total: 0 }),
|
||||
addFilling: async payload => { submissions.push(payload); throw new Error('网络暂不可用') },
|
||||
})
|
||||
vm.runInContext(`${script}\nglobalThis.page = {form, scannedPallet, submitFilling, formError};`, context)
|
||||
const page = context.page
|
||||
const form = platform === 'pc' ? page.form.value : page.form
|
||||
Object.assign(form, { qrToken: 'qr-1', warehouseId: 1, strainId: 1, batchNo: 'B-1', packageSpec: '标准包', initialPackages: 100 })
|
||||
page.scannedPallet.value = { palletId: 1, capacityPackages: 100 }
|
||||
await nextTick()
|
||||
return { page, form, submissions }
|
||||
}
|
||||
|
||||
for (const platform of ['pc', 'app']) {
|
||||
test(`${platform} 装填提交保留来源日期,失败后重试复用幂等键`, async () => {
|
||||
const { page, form, submissions } = await fillingPage(platform)
|
||||
Object.assign(form, { productionDate: '2026-10-01', expiryDate: '2027-03-31' })
|
||||
await nextTick()
|
||||
page.submitFilling()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(submissions.length, 1)
|
||||
assert.equal(submissions[0].productionDate, '2026-10-01')
|
||||
assert.equal(submissions[0].expiryDate, '2027-03-31')
|
||||
page.submitFilling()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(submissions[1].idempotencyKey, submissions[0].idempotencyKey)
|
||||
})
|
||||
|
||||
test(`${platform} 装填日期缺失或倒置时不发送请求`, async () => {
|
||||
const { page, form, submissions } = await fillingPage(platform)
|
||||
for (const dates of [{ productionDate: '', expiryDate: '' }, { productionDate: '2026-10-02', expiryDate: '2026-10-01' }]) {
|
||||
Object.assign(form, dates)
|
||||
page.submitFilling()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(submissions.length, 0)
|
||||
assert.ok(page.formError.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import vm from 'node:vm'
|
||||
import { test } from 'node:test'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { isDuplicateScan, normalizeScanValue } from '../src/utils/palletScan.js'
|
||||
|
||||
test('共享扫码输入允许输入短托盘号且仍拒绝空值', async () => {
|
||||
const source = await readFile(new URL('../src/components/PalletScanInput/index.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script\b[^>]*>([\s\S]*?)<\/script>/)[1]
|
||||
.replace(/^import[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm, '')
|
||||
const events = []
|
||||
const context = vm.createContext({
|
||||
nextTick, ref, watch, onMounted() {},
|
||||
normalizeScanValue, isDuplicateScan,
|
||||
defineProps: definitions => Object.fromEntries(Object.entries(definitions).map(([key, value]) => [key, value.default])),
|
||||
defineEmits: () => (...event) => events.push(event),
|
||||
})
|
||||
vm.runInContext(`${script}\nglobalThis.page = {scanValue, handleSubmit}`, context)
|
||||
context.page.scanValue.value = ' 123 '
|
||||
context.page.handleSubmit()
|
||||
assert.deepEqual(events[0], ['scan', '123'])
|
||||
context.page.scanValue.value = ' '
|
||||
context.page.handleSubmit()
|
||||
assert.equal(events[1][0], 'invalid')
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { test } from 'node:test'
|
||||
|
||||
for (const platform of ['frontend', 'app']) {
|
||||
for (const kind of ['inbound', 'filling', 'shipment', 'usage', 'return']) {
|
||||
test(`${platform}/${kind} 托盘号中的路径和查询字符作为单个标识传输`, async () => {
|
||||
const source = await readFile(new URL(`../../shierfeng-fastapi-${platform}/src/api/business/${kind}.js`, import.meta.url), 'utf8')
|
||||
const requests = []
|
||||
const name = `scan${kind[0].toUpperCase()}${kind.slice(1)}Pallet`
|
||||
const load = new Function('request', `${source.replace(/^import .*$/gm, '').replace(/export /g, '')}; return ${name}`)
|
||||
const scan = load(config => { requests.push(config); return Promise.resolve({}) })
|
||||
await scan('托盘/2026?批#1', { warehouseId: 1, sourceWarehouseId: 1, targetSiteId: 9 })
|
||||
assert.equal(requests[0].url, `/business/${kind}/scan/${encodeURIComponent('托盘/2026?批#1')}`)
|
||||
assert.equal(requests[0].method, 'get')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { parseHTML } from 'linkedom'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import * as Vue from 'vue'
|
||||
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
|
||||
|
||||
const utilityUrl = new URL('../src/utils/shipmentLabelPrint.js', import.meta.url)
|
||||
|
||||
async function printer() {
|
||||
let module
|
||||
try { module = await import(utilityUrl.href) } catch (error) {
|
||||
assert.fail(`缺少可执行的标签打印工具:${error.message}`)
|
||||
}
|
||||
return module.printShipmentLabels
|
||||
}
|
||||
|
||||
function popup() {
|
||||
const { document } = parseHTML('<html><head></head><body></body></html>')
|
||||
const events = new EventTarget()
|
||||
const result = {
|
||||
document, closed: false, printed: 0, focused: false,
|
||||
close() { this.closed = true },
|
||||
focus() { this.focused = true },
|
||||
print() { this.printed += 1 },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const label = {
|
||||
shipmentNo: 'SH-原单号', shipmentStatus: 'CONFIRMED', palletCode: '原托盘-01',
|
||||
customerName: '<script>globalThis.compromised=true</script>', siteName: '现场 & A',
|
||||
strainName: '<img src=x onerror=alert(1)>', batchNo: '<svg/onload=alert(2)>',
|
||||
productionDate: '2026-09-01', expiryDate: null, packageQty: 37,
|
||||
qrCodeDataUrl: 'data:image/png;base64,aGVsbG8='
|
||||
}
|
||||
|
||||
async function loadImages(win) {
|
||||
// 模拟浏览器图片加载事件;标签节点和内容解析使用真实 DOM。
|
||||
for (let i = 0; i < 10 && !win.document.querySelector('img'); i++) await Promise.resolve()
|
||||
for (const img of win.document.querySelectorAll('img')) {
|
||||
Object.defineProperty(img, 'naturalWidth', { value: 100 })
|
||||
img.dispatchEvent(new win.document.defaultView.Event('load'))
|
||||
}
|
||||
}
|
||||
|
||||
test('多托标签保留原始内容且不执行 HTML,二维码加载后才打印并逐托分页', async () => {
|
||||
const print = await printer()
|
||||
const win = popup()
|
||||
const errors = []
|
||||
const pending = print({ openWindow: () => win, loadLabels: async () => [label, { ...label, palletCode: '原托盘-02' }], notifyError: e => errors.push(e) })
|
||||
await Promise.resolve()
|
||||
assert.equal(win.printed, 0)
|
||||
await loadImages(win)
|
||||
assert.equal(await pending, true)
|
||||
assert.equal(win.printed, 1)
|
||||
assert.equal(win.document.querySelectorAll('script, svg').length, 0)
|
||||
assert.equal(win.document.querySelectorAll('img').length, 2)
|
||||
assert.equal(win.document.querySelectorAll('[onerror], [onload]').length, 0)
|
||||
assert.ok(win.document.body.textContent.includes(label.customerName))
|
||||
assert.ok(win.document.body.textContent.includes(label.strainName))
|
||||
assert.ok(win.document.body.textContent.includes(label.batchNo))
|
||||
for (const text of ['原托盘-01', '原托盘-02', 'SH-原单号', '现场 & A', '2026-09-01', '未登记', '37']) {
|
||||
assert.ok(win.document.body.textContent.includes(text), text)
|
||||
}
|
||||
const pages = [...win.document.querySelectorAll('section')]
|
||||
assert.equal(pages.length, 2)
|
||||
assert.equal(pages[0].style.breakAfter, 'page')
|
||||
assert.equal(pages[1].style.breakAfter, 'auto')
|
||||
assert.equal(pages[0].style.breakInside, 'avoid')
|
||||
assert.equal(win.document.querySelector('img').src, label.qrCodeDataUrl)
|
||||
assert.deepEqual(errors, [])
|
||||
win.dispatchEvent(new Event('afterprint'))
|
||||
assert.equal(win.closed, true)
|
||||
})
|
||||
|
||||
test('同步打开窗口以保留用户手势,弹窗被拦截时提示且不请求接口', async () => {
|
||||
const print = await printer()
|
||||
const calls = []
|
||||
const errors = []
|
||||
const pending = print({ openWindow: () => { calls.push('open'); return null }, loadLabels: async () => { calls.push('load'); return [label] }, notifyError: e => errors.push(e) })
|
||||
assert.deepEqual(calls, ['open'])
|
||||
assert.equal(await pending, false)
|
||||
assert.match(errors[0], /弹窗|弹出/)
|
||||
})
|
||||
|
||||
test('接口失败和空数据会关闭空窗口并提示,绝不打印', async () => {
|
||||
const print = await printer()
|
||||
for (const loadLabels of [async () => { throw new Error('network') }, async () => []]) {
|
||||
const win = popup()
|
||||
const errors = []
|
||||
assert.equal(await print({ openWindow: () => win, loadLabels, notifyError: e => errors.push(e) }), false)
|
||||
assert.equal(win.closed, true)
|
||||
assert.equal(win.printed, 0)
|
||||
assert.equal(errors.length, 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('草稿显式标注,旧记录的两个日期显示未登记', async () => {
|
||||
const print = await printer()
|
||||
const win = popup()
|
||||
const pending = print({ openWindow: () => win, loadLabels: async () => [{ ...label, shipmentStatus: 'DRAFT', productionDate: null }], notifyError: assert.fail })
|
||||
await loadImages(win)
|
||||
assert.equal(await pending, true)
|
||||
assert.match(win.document.body.textContent, /草稿/)
|
||||
assert.equal(win.document.body.textContent.match(/未登记/g).length, 2)
|
||||
})
|
||||
|
||||
test('拒绝远程地址或非 PNG 二维码,避免不可信属性载荷', async () => {
|
||||
const print = await printer()
|
||||
for (const qrCodeDataUrl of ['https://attacker.example/track', 'javascript:alert(1)', 'data:image/svg+xml,<svg onload=alert(1)>']) {
|
||||
const win = popup()
|
||||
const errors = []
|
||||
assert.equal(await print({ openWindow: () => win, loadLabels: async () => [{ ...label, qrCodeDataUrl }], notifyError: e => errors.push(e) }), false)
|
||||
assert.equal(win.closed, true)
|
||||
assert.equal(win.printed, 0)
|
||||
assert.equal(errors.length, 1)
|
||||
}
|
||||
})
|
||||
|
||||
test('二维码加载失败会关闭窗口并提示', async () => {
|
||||
const print = await printer()
|
||||
const win = popup()
|
||||
const errors = []
|
||||
const pending = print({ openWindow: () => win, loadLabels: async () => [label], notifyError: e => errors.push(e) })
|
||||
for (let i = 0; i < 10 && !win.document.querySelector('img'); i++) await Promise.resolve()
|
||||
win.document.querySelector('img').dispatchEvent(new win.document.defaultView.Event('error'))
|
||||
assert.equal(await pending, false)
|
||||
assert.equal(win.closed, true)
|
||||
assert.equal(win.printed, 0)
|
||||
assert.match(errors[0], /二维码/)
|
||||
})
|
||||
|
||||
test('异步过程中切换数据批次时丢弃标签并关闭窗口', async () => {
|
||||
const print = await printer()
|
||||
const win = popup()
|
||||
const errors = []
|
||||
let current = true
|
||||
let resolve
|
||||
const pending = print({ openWindow: () => win, loadLabels: () => new Promise(r => { resolve = r }), isCurrent: () => current, notifyError: e => errors.push(e) })
|
||||
current = false
|
||||
resolve([label])
|
||||
assert.equal(await pending, false)
|
||||
assert.equal(win.closed, true)
|
||||
assert.equal(win.printed, 0)
|
||||
assert.equal(win.document.querySelectorAll('img').length, 0)
|
||||
assert.equal(errors.length, 1)
|
||||
})
|
||||
|
||||
test('图片加载期间切换范围或关闭弹窗时不打印', async () => {
|
||||
const print = await printer()
|
||||
for (const change of ['scope', 'close']) {
|
||||
const win = popup()
|
||||
const errors = []
|
||||
let current = true
|
||||
const pending = print({ openWindow: () => win, loadLabels: async () => [label], isCurrent: () => current, notifyError: e => errors.push(e) })
|
||||
await Promise.resolve()
|
||||
if (change === 'scope') current = false
|
||||
else win.close()
|
||||
await loadImages(win)
|
||||
assert.equal(await pending, false)
|
||||
assert.equal(win.printed, 0)
|
||||
assert.equal(win.closed, true)
|
||||
assert.equal(errors.length, 1)
|
||||
}
|
||||
})
|
||||
|
||||
// 执行真实 SFC setup 和 API 封装,仅替换网络、窗口及 UI 提示边界。
|
||||
function bindImports(source) {
|
||||
return source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, path) => {
|
||||
const binding = names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`
|
||||
return `const ${binding} = modules[${JSON.stringify(path)}];`
|
||||
})
|
||||
}
|
||||
|
||||
async function shipmentPage(load) {
|
||||
const source = await readFile(new URL('../src/views/business/shipment/index.vue', import.meta.url), 'utf8')
|
||||
const apiSource = await readFile(new URL('../src/api/business/shipment.js', import.meta.url), 'utf8')
|
||||
const { descriptor } = parse(source)
|
||||
const script = compileScript(descriptor, { id: 'shipment-label-test', genDefaultAs: 'component' })
|
||||
const template = compileTemplate({ source: descriptor.template.content, filename: 'shipment/index.vue', id: 'shipment-label-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||
assert.deepEqual(template.errors, [])
|
||||
const exports = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1])
|
||||
const requests = []
|
||||
const api = new Function('modules', `${bindImports(apiSource).replaceAll('export function ', 'function ')}; return { ${exports.join(',')} };`)({
|
||||
'@/utils/request': { default: config => {
|
||||
requests.push(config)
|
||||
return config.url.endsWith('/labels') ? load(config) : Promise.resolve({ rows: [], total: 0 })
|
||||
} }
|
||||
})
|
||||
const win = popup()
|
||||
const errors = []
|
||||
const batch = { currentBatchId: '', scopeVersion: 0 }
|
||||
const print = await printer()
|
||||
const modules = {
|
||||
'@/components/PalletScanInput': { default: {} },
|
||||
'@/components/TestBatchAdoptionDialog': { default: {} },
|
||||
'@/utils/palletScan': { createIdempotencyKey: () => 'not-used-for-printing' },
|
||||
'@/store/modules/user': { default: () => ({ admin: false }) },
|
||||
'@/store/modules/testBatch': { default: () => batch },
|
||||
'@/utils/testBatchContext': { getStoredTestBatchId: () => batch.currentBatchId },
|
||||
'@/utils/shipmentLabelPrint': { printShipmentLabels: options => print({ ...options, openWindow: () => win }) },
|
||||
'@/api/business/shipment': api
|
||||
}
|
||||
const runtime = { ...Vue, getCurrentInstance: () => ({ proxy: { $auth: { hasPermiAnd: () => true }, $modal: { msgError: e => errors.push(e) } } }) }
|
||||
const factory = new Function('modules', ...Object.keys(runtime), `${bindImports(script.content)}; return component;`)
|
||||
const component = factory(modules, ...Object.values(runtime))
|
||||
const scope = Vue.effectScope()
|
||||
const page = scope.run(() => component.setup({}, { expose() {} }))
|
||||
return { page, win, batch, requests, errors, dispose: () => scope.stop() }
|
||||
}
|
||||
|
||||
test('页面真实处理器调用标签 GET 接口、阻止重复点击并在完成后恢复按钮', async () => {
|
||||
let resolve
|
||||
const context = await shipmentPage(() => new Promise(r => { resolve = r }))
|
||||
try {
|
||||
const pending = context.page.handlePrintLabels({ shipmentId: 12 })
|
||||
assert.equal(context.page.labelLoadingId.value, 12)
|
||||
await context.page.handlePrintLabels({ shipmentId: 13 })
|
||||
const requests = context.requests.filter(request => request.url.endsWith('/labels'))
|
||||
assert.deepEqual(requests, [{ url: '/business/shipment/12/labels', method: 'get' }])
|
||||
resolve({ data: [label] })
|
||||
await loadImages(context.win)
|
||||
await pending
|
||||
assert.equal(context.win.printed, 1)
|
||||
assert.equal(context.page.labelLoadingId.value, null)
|
||||
assert.deepEqual(context.errors, [])
|
||||
} finally { context.dispose() }
|
||||
})
|
||||
|
||||
test('批次切换后又切回原批次,页面仍丢弃旧请求并解除加载状态', async () => {
|
||||
let resolve
|
||||
const context = await shipmentPage(() => new Promise(r => { resolve = r }))
|
||||
try {
|
||||
const pending = context.page.handlePrintLabels({ shipmentId: 12 })
|
||||
context.batch.scopeVersion += 2
|
||||
resolve({ data: [label] })
|
||||
await loadImages(context.win)
|
||||
await pending
|
||||
assert.equal(context.win.printed, 0)
|
||||
assert.equal(context.win.closed, true)
|
||||
assert.equal(context.page.labelLoadingId.value, null)
|
||||
assert.equal(context.errors.length, 1)
|
||||
} finally { context.dispose() }
|
||||
})
|
||||
@@ -0,0 +1,378 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { test } from 'node:test'
|
||||
|
||||
// 两端均编译、挂载真实 SFC,并执行真实 API;仅替换 HTTP、平台生命周期与 UI 宿主。
|
||||
const roots = {
|
||||
pc: new URL('../', import.meta.url),
|
||||
app: new URL('../../shierfeng-fastapi-app/', import.meta.url)
|
||||
}
|
||||
const read = (platform, path) => readFile(new URL(path, roots[platform]), 'utf8').catch(error => {
|
||||
if (error.code === 'ENOENT') assert.fail(`尚未实现:${platform}/${path}`)
|
||||
throw error
|
||||
})
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, path) => {
|
||||
const binding = names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`
|
||||
return `const ${binding} = modules[${JSON.stringify(path)}];`
|
||||
})
|
||||
const item = (palletId = 11) => ({
|
||||
palletId, palletCode: `原托盘-${palletId}`, qrToken: `QR-${palletId}`, activeFillingId: palletId + 100,
|
||||
packageQty: 37, strainName: '原品种', batchNo: '原批号-2026',
|
||||
productionDate: '2026-09-01', expiryDate: '2027-02-28'
|
||||
})
|
||||
const detail = (shipmentId = 7, overrides = {}) => ({
|
||||
shipmentId, shipmentNo: `SH-${shipmentId}`, targetSiteId: 21, sourceWarehouseId: 31,
|
||||
status: 'CONFIRMED', receiptState: 'PENDING', receivedAt: null, items: [item(11), item(12)], ...overrides
|
||||
})
|
||||
|
||||
function hostRenderer(Vue) {
|
||||
const node = (type, text = '') => ({ type, text, children: [], props: {}, style: {}, parent: null })
|
||||
return Vue.createRenderer({
|
||||
createElement: type => node(type), createText: text => node('#text', text), createComment: () => node('#comment'),
|
||||
insert(child, parent, anchor) {
|
||||
if (child.parent) child.parent.children.splice(child.parent.children.indexOf(child), 1)
|
||||
const index = anchor ? parent.children.indexOf(anchor) : -1
|
||||
parent.children.splice(index < 0 ? parent.children.length : index, 0, child)
|
||||
child.parent = parent
|
||||
},
|
||||
remove(child) { child.parent?.children.splice(child.parent.children.indexOf(child), 1); child.parent = null },
|
||||
setText: (el, text) => { el.text = text },
|
||||
setElementText: (el, text) => { el.text = text; el.children = [] },
|
||||
patchProp: (el, key, old, value) => { el.props[key] = value },
|
||||
parentNode: el => el.parent, nextSibling: el => el.parent?.children[el.parent.children.indexOf(el) + 1] || null
|
||||
})
|
||||
}
|
||||
const nodes = root => [root, ...(root.children || []).flatMap(nodes)]
|
||||
const visibleText = root => nodes(root).map(el => el.text || '').join(' ')
|
||||
const confirmButton = page => nodes(page.root).find(el => el.type === 'button' && /确认收货/.test(visibleText(el)))
|
||||
|
||||
async function mount(platform, { kind = 'panel', permissions = ['business:shipment:receive'], request: override } = {}) {
|
||||
const require = createRequire(new URL('package.json', roots[platform]))
|
||||
const Vue = require('vue')
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
|
||||
const calls = [], messages = [], emitted = [], navigations = []
|
||||
const hooks = { onShow: [], onHide: [], onUnload: [], onActivated: [], onDeactivated: [] }
|
||||
const request = config => {
|
||||
calls.push(structuredClone(config))
|
||||
const result = override?.(config)
|
||||
if (result !== undefined) return result
|
||||
if (config.url.endsWith('/list')) return Promise.resolve({ rows: [detail()], total: 1 })
|
||||
if (config.url.includes('/scan/')) return Promise.resolve({ data: item(Number(decodeURIComponent(config.url.split('/scan/')[1]).replace('QR-', ''))) })
|
||||
if (config.url.endsWith('/confirm')) return Promise.resolve({ msg: '收货成功' })
|
||||
if (/\/shipment-receipt\/\d+$/.test(config.url)) return Promise.resolve({ data: detail(Number(config.url.split('/').at(-1))) })
|
||||
if (config.url.endsWith('-options')) return Promise.resolve({ data: [] })
|
||||
throw new Error(`出现意外请求 ${config.method} ${config.url}`)
|
||||
}
|
||||
async function api(path) {
|
||||
const source = await read(platform, path)
|
||||
const names = [...source.matchAll(/export function (\w+)/g)].map(match => match[1])
|
||||
return new Function('modules', `${bindImports(source).replaceAll('export function ', 'function ')}; return {${names.join(',')}};`)({
|
||||
'@/utils/request': { default: request }
|
||||
})
|
||||
}
|
||||
const hasPermi = permission => permissions.includes('*:*:*') || permissions.includes(permission)
|
||||
const auth = { hasPermi, hasPermiOr: values => values.some(hasPermi), hasPermiAnd: values => values.every(hasPermi) }
|
||||
const empty = { render: () => null }
|
||||
const receiptStub = { name: 'ShipmentReceiptPanel', render: () => Vue.h('receipt-panel') }
|
||||
const runtime = {
|
||||
...Vue,
|
||||
// uni-app 原生 input 没有浏览器 DOM;宿主保留值与输入回调边界。
|
||||
vModelText: {
|
||||
mounted(el, { value }, vnode) { el.props.value = value; el.props.onInput = vnode.props['onUpdate:modelValue'] },
|
||||
beforeUpdate(el, { value }, vnode) { el.props.value = value; el.props.onInput = vnode.props['onUpdate:modelValue'] }
|
||||
},
|
||||
defineAsyncComponent: () => receiptStub,
|
||||
onActivated: fn => hooks.onActivated.push(fn), onDeactivated: fn => hooks.onDeactivated.push(fn)
|
||||
}
|
||||
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read(platform, 'src/utils/palletScan.js'))}`)
|
||||
const modules = {
|
||||
vue: runtime,
|
||||
'@dcloudio/uni-app': Object.fromEntries(Object.entries(hooks).map(([name, values]) => [name, fn => values.push(fn)])),
|
||||
'@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) },
|
||||
'@/components/PalletScanInput': { default: empty },
|
||||
'@/components/business/PalletScanInput.vue': { default: empty },
|
||||
'@/components/business/TestBatchBanner.vue': { default: empty },
|
||||
'@/components/business/TestBatchModeSwitch.vue': { default: empty },
|
||||
'@/components/TestBatchAdoptionDialog': { default: empty },
|
||||
'@/components/ShipmentReceiptPanel': { default: receiptStub },
|
||||
'@/components/business/ShipmentReceiptPanel.vue': { default: receiptStub },
|
||||
'@/store/modules/user': { default: () => ({ admin: false }) },
|
||||
'@/store/modules/testBatch': { default: () => ({ isTestMode: false }) },
|
||||
'@/utils/testBatchContext': { getStoredTestBatchId: () => '' },
|
||||
'@/utils/shipmentLabelPrint': { printShipmentLabels() {} }
|
||||
}
|
||||
if (kind === 'panel') modules['@/api/business/shipmentReceipt'] = await api('src/api/business/shipmentReceipt.js')
|
||||
if (kind === 'page') modules['@/api/business/shipment'] = await api('src/api/business/shipment.js')
|
||||
if (kind === 'work') {
|
||||
const source = await read('app', 'src/config/businessMenu.js')
|
||||
modules['@/config/businessMenu'] = new Function(`${source.replaceAll('export ', '')}; return { businessMenuEntries };`)()
|
||||
}
|
||||
const path = kind === 'work' ? 'src/pages/work/index.vue' : kind === 'page'
|
||||
? `src/${platform === 'pc' ? 'views' : 'pages'}/business/shipment/index.vue`
|
||||
: platform === 'pc' ? 'src/components/ShipmentReceiptPanel/index.vue' : 'src/components/business/ShipmentReceiptPanel.vue'
|
||||
const { descriptor } = parse(await read(platform, path))
|
||||
const script = compileScript(descriptor, { id: 'receipt-test', genDefaultAs: 'component' })
|
||||
const template = compileTemplate({ source: descriptor.template.content, filename: path, id: 'receipt-test', compilerOptions: {
|
||||
bindingMetadata: script.bindings, hoistStatic: false, isCustomElement: tag => ['view', 'text', 'scroll-view', 'picker'].includes(tag)
|
||||
} })
|
||||
assert.deepEqual(template.errors, [])
|
||||
const component = new Function('modules', ...Object.keys(runtime), `return (function () { ${bindImports(script.content)}; return component; })();`)(modules, ...Object.values(runtime))
|
||||
component.render = new Function('modules', `${bindImports(template.code).replace('export function render', 'function render')}; return render;`)(modules)
|
||||
let state
|
||||
const setup = component.setup
|
||||
component.setup = (props, ctx) => { state = setup(props, ctx); return state }
|
||||
const root = { children: [] }
|
||||
const app = hostRenderer(Vue).createApp(component, { onReceived: () => emitted.push('received') })
|
||||
app.config.globalProperties.$auth = auth
|
||||
app.config.globalProperties.$modal = Object.fromEntries(['msg', 'msgWarning', 'msgError', 'msgSuccess'].map(name => [name, msg => messages.push(msg)]))
|
||||
app.config.globalProperties.$tab = { navigateTo: path => navigations.push(path) }
|
||||
app.config.globalProperties.parseTime = value => value || '—'
|
||||
for (const directive of ['loading', 'hasPermi']) app.directive(directive, {})
|
||||
// Element Plus 外观替身保留默认/表格作用域插槽,断言实际渲染的文本与按钮。
|
||||
for (const tag of ['el-form', 'el-form-item', 'el-row', 'el-col', 'el-tag', 'el-tabs', 'el-tab-pane', 'el-select', 'el-option', 'el-input', 'el-input-number', 'el-descriptions', 'el-descriptions-item', 'pagination']) {
|
||||
app.component(tag, { setup: (_, { slots }) => () => Vue.h('div', slots.default?.()) })
|
||||
}
|
||||
app.component('el-button', { setup: (_, { slots }) => () => Vue.h('button', slots.default?.()) })
|
||||
app.component('el-alert', { props: ['title'], setup: props => () => Vue.h('div', props.title) })
|
||||
app.component('el-dialog', { props: ['modelValue'], setup: (props, { slots }) => () => props.modelValue ? Vue.h('div', [slots.default?.(), slots.footer?.()]) : null })
|
||||
app.component('el-table', { props: ['data'], setup(props, { slots }) { Vue.provide('rows', Vue.computed(() => props.data || [])); return () => Vue.h('div', slots.default?.()) } })
|
||||
app.component('el-table-column', { props: ['prop'], setup(props, { slots }) { const rows = Vue.inject('rows'); return () => Vue.h('div', rows.value.map(row => slots.default ? slots.default({ row }) : Vue.h('span', String(row[props.prop] ?? '')))) } })
|
||||
app.mount(root)
|
||||
await flush()
|
||||
return {
|
||||
state, calls, messages, emitted, root, navigations,
|
||||
writes: () => calls.filter(call => call.method === 'put'),
|
||||
open: async (id = 7) => { await state.openReceipt({ shipmentId: id }); await flush() },
|
||||
scan: async (value = 'QR-11') => { await state.handleReceiptScan(value); await flush() },
|
||||
lifecycle: async name => { for (const fn of hooks[name]) fn(); await flush() },
|
||||
close: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
for (const platform of ['pc', 'app']) {
|
||||
test(`${platform} 原发货扫码只编码标识路径,保持出库查询参数`, async t => {
|
||||
const page = await mount(platform, { kind: 'page', permissions: ['business:shipment:query', 'business:shipment:add'], request: config => config.url.includes('/scan/') ? Promise.resolve({ data: { ...item(), flowState: 'FILLED_IN_WAREHOUSE', remainingPackages: 37 } }) : undefined }); t.after(page.close)
|
||||
const form = platform === 'pc' ? page.state.form.value : page.state.form
|
||||
Object.assign(form, { sourceWarehouseId: 31, targetSiteId: 21 })
|
||||
await flush()
|
||||
page.state.handleShipmentScan('原托盘 /?#')
|
||||
await flush()
|
||||
const scans = page.calls.filter(call => call.url.includes('/scan/'))
|
||||
assert.deepEqual(scans, [{ url: '/business/shipment/scan/%E5%8E%9F%E6%89%98%E7%9B%98%20%2F%3F%23', method: 'get', params: { sourceWarehouseId: 31, targetSiteId: 21 } }])
|
||||
})
|
||||
|
||||
test(`${platform} receive-only 进入收货页签,不自动调用出库 API`, async t => {
|
||||
const page = await mount(platform, { kind: 'page' }); t.after(page.close)
|
||||
await page.lifecycle('onShow')
|
||||
assert.equal(page.state.activeTab?.value, 'receipt')
|
||||
assert.ok(nodes(page.root).some(node => node.type === 'receipt-panel'))
|
||||
assert.deepEqual(page.calls, [])
|
||||
})
|
||||
|
||||
test(`${platform} 收货列表按服务端状态、单号分页查询,不查询来源仓库权限`, async t => {
|
||||
const page = await mount(platform); t.after(page.close)
|
||||
assert.deepEqual(page.calls, [{ url: '/business/shipment-receipt/list', method: 'get', params: { pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined } }])
|
||||
Object.assign(page.state.queryParams, { receiptState: 'LEGACY', shipmentNo: 'SH-9', pageNum: 3 })
|
||||
await page.state.loadReceipts()
|
||||
assert.deepEqual(page.calls.at(-1).params, { pageNum: 3, pageSize: 10, receiptState: 'LEGACY', shipmentNo: 'SH-9' })
|
||||
assert.equal(page.state.total.value, 1)
|
||||
page.state.queryParams.receiptState = platform === 'pc' ? '' : undefined
|
||||
await page.state.handleQuery()
|
||||
assert.equal(page.calls.at(-1).params.receiptState, undefined, '全部状态必须省略筛选,不能向枚举参数发送空字符串')
|
||||
assert.equal(page.calls.at(-1).params.pageNum, 1)
|
||||
})
|
||||
|
||||
test(`${platform} 无接收权限不能列单、打开、扫码或确认`, async t => {
|
||||
const page = await mount(platform, { permissions: ['business:shipment:list'] }); t.after(page.close)
|
||||
await page.open(); await page.scan(); await page.state.confirmReceipt()
|
||||
assert.deepEqual(page.calls, [])
|
||||
})
|
||||
|
||||
test(`${platform} 首次核对从零开始,扫码保留原品种批号日期包数,全数核对才解锁`, async t => {
|
||||
const page = await mount(platform); t.after(page.close)
|
||||
await page.open()
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [])
|
||||
assert.equal(confirmButton(page).props.disabled, true)
|
||||
await page.state.confirmReceipt()
|
||||
assert.equal(page.writes().length, 0)
|
||||
await page.scan()
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [11])
|
||||
for (const text of ['原托盘-11', '原品种', '原批号-2026', '2026-09-01', '2027-02-28', '37']) assert.ok(visibleText(page.root).includes(text), text)
|
||||
assert.equal(confirmButton(page).props.disabled, true)
|
||||
await page.scan('QR-12')
|
||||
assert.equal(confirmButton(page).props.disabled, false)
|
||||
})
|
||||
|
||||
test(`${platform} 扫码编码路径,错单/旧装填/服务端拒绝不会计入,重复不追加`, async t => {
|
||||
let result = item()
|
||||
const page = await mount(platform, { request: config => config.url.includes('/scan/') ? Promise.resolve({ data: result }) : undefined }); t.after(page.close)
|
||||
await page.open(); await page.scan('托盘 /?#')
|
||||
assert.equal(page.calls.at(-1).url, '/business/shipment-receipt/7/scan/%E6%89%98%E7%9B%98%20%2F%3F%23')
|
||||
await page.scan('QR-11')
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [11])
|
||||
for (const wrong of [item(99), { ...item(12), activeFillingId: 999 }]) {
|
||||
result = wrong; await page.scan('wrong')
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [11])
|
||||
assert.ok(page.state.scanError.value)
|
||||
}
|
||||
})
|
||||
|
||||
test(`${platform} 网络失败重试同键,移除再扫后新键,确认锁阻止重复及修改集合`, async t => {
|
||||
const pending = deferred()
|
||||
let attempts = 0
|
||||
const page = await mount(platform, { request: config => config.method === 'put' ? (++attempts < 3 ? Promise.reject(new Error('网络断开')) : pending.promise) : undefined }); t.after(page.close)
|
||||
await page.open(); await page.scan(); await page.scan('QR-12')
|
||||
await page.state.confirmReceipt(); await page.state.confirmReceipt()
|
||||
assert.equal(page.writes().length, 2)
|
||||
assert.ok(page.state.confirmError.value)
|
||||
assert.equal(page.writes()[0].data.idempotencyKey, page.writes()[1].data.idempotencyKey)
|
||||
page.state.removeScannedPallet(12); await page.scan('QR-12')
|
||||
const confirming = page.state.confirmReceipt()
|
||||
await page.state.confirmReceipt(); page.state.removeScannedPallet(11)
|
||||
await page.scan('QR-11')
|
||||
assert.equal(page.writes().length, 3)
|
||||
assert.notEqual(page.writes()[2].data.idempotencyKey, page.writes()[1].data.idempotencyKey)
|
||||
assert.deepEqual(page.writes()[2].data.scannedPalletIds, [11, 12])
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [11, 12])
|
||||
pending.reject(new Error('仍不可用')); await confirming
|
||||
assert.equal(page.state.confirmLoading.value, false)
|
||||
})
|
||||
|
||||
test(`${platform} 扫码进行中锁住重复请求,服务端拒绝保留已核对集合且允许重试`, async t => {
|
||||
const pending = deferred()
|
||||
let block = false
|
||||
const page = await mount(platform, { request: config => block && config.url.includes('/scan/') ? pending.promise : undefined }); t.after(page.close)
|
||||
await page.open(); await page.scan()
|
||||
block = true
|
||||
const scanning = page.state.handleReceiptScan('QR-12')
|
||||
await page.state.handleReceiptScan('QR-12')
|
||||
page.state.removeScannedPallet(11)
|
||||
await page.state.confirmReceipt()
|
||||
assert.equal(page.calls.filter(call => call.url.includes('/scan/')).length, 2)
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [11])
|
||||
assert.equal(page.writes().length, 0)
|
||||
pending.reject(new Error('托盘状态已变化,拒绝核对')); await scanning
|
||||
assert.match(page.state.scanError.value, /状态已变化/)
|
||||
assert.equal(page.state.scanLoading.value, false)
|
||||
block = false; await page.scan('QR-12')
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [11, 12])
|
||||
})
|
||||
|
||||
test(`${platform} 列表过期响应不覆盖新筛选,详情失败可重试,空明细不可确认`, async t => {
|
||||
const oldList = deferred()
|
||||
let lists = 0, failDetail = true
|
||||
const page = await mount(platform, { request: config => {
|
||||
if (config.url.endsWith('/list') && ++lists === 1) return oldList.promise
|
||||
if (config.url.endsWith('/7')) return failDetail ? Promise.reject(new Error('详情暂不可用')) : Promise.resolve({ data: detail(7, { items: [] }) })
|
||||
} }); t.after(page.close)
|
||||
page.state.queryParams.receiptState = 'RECEIVED'
|
||||
await page.state.handleQuery()
|
||||
oldList.resolve({ rows: [detail(99)], total: 99 }); await flush()
|
||||
assert.equal(page.state.receiptList.value[0].shipmentId, 7)
|
||||
assert.equal(page.state.total.value, 1)
|
||||
await page.open(); assert.ok(page.state.detailError.value)
|
||||
await page.scan(); assert.equal(page.calls.some(call => call.url.includes('/scan/')), false)
|
||||
failDetail = false; await page.state.retryDetail(); await flush()
|
||||
assert.equal(page.state.detailError.value, '')
|
||||
assert.equal(confirmButton(page).props.disabled, true)
|
||||
await page.state.confirmReceipt(); assert.equal(page.writes().length, 0)
|
||||
})
|
||||
|
||||
test(`${platform} 收货成功仅重新读取服务端状态,并通知出库列表刷新`, async t => {
|
||||
let confirmed = false
|
||||
const page = await mount(platform, { request: config => {
|
||||
if (config.method === 'put') { confirmed = true; return Promise.resolve({ msg: '收货成功' }) }
|
||||
if (confirmed && /\/shipment-receipt\/7$/.test(config.url)) return Promise.resolve({ data: detail(7, { receiptState: 'RECEIVED', receivedAt: '2026-10-08 11:22:33' }) })
|
||||
} }); t.after(page.close)
|
||||
await page.open(); await page.scan(); await page.scan('QR-12'); await page.state.confirmReceipt(); await flush()
|
||||
assert.equal(page.state.receiptDetail.value.receiptState, 'RECEIVED')
|
||||
assert.ok(visibleText(page.root).includes('2026-10-08 11:22:33'))
|
||||
assert.deepEqual(page.emitted, ['received'])
|
||||
assert.equal(page.calls.filter(call => call.url.endsWith('/list')).length, 2)
|
||||
await page.state.confirmReceipt()
|
||||
assert.equal(page.writes().length, 1)
|
||||
})
|
||||
|
||||
test(`${platform} 成功后状态刷新失败不会伪造已收货或再次提交`, async t => {
|
||||
let confirmed = false
|
||||
const page = await mount(platform, { request: config => {
|
||||
if (config.method === 'put') { confirmed = true; return Promise.resolve({ msg: '成功' }) }
|
||||
if (confirmed && /\/shipment-receipt\/7$/.test(config.url)) return Promise.reject(new Error('读取失败'))
|
||||
} }); t.after(page.close)
|
||||
await page.open(); await page.scan(); await page.scan('QR-12'); await page.state.confirmReceipt(); await flush()
|
||||
assert.ok(page.state.detailError.value)
|
||||
assert.notEqual(page.state.receiptDetail.value?.receiptState, 'RECEIVED')
|
||||
await page.state.confirmReceipt()
|
||||
assert.equal(page.writes().length, 1)
|
||||
})
|
||||
|
||||
for (const receiptState of ['LEGACY', 'RECEIVED']) {
|
||||
test(`${platform} ${receiptState} 只读,历史记录不伪造收货时间`, async t => {
|
||||
const page = await mount(platform, { request: config => /\/shipment-receipt\/7$/.test(config.url) ? Promise.resolve({ data: detail(7, { receiptState, receivedAt: receiptState === 'LEGACY' ? '错误历史日期' : '2026-10-08' }) }) : undefined }); t.after(page.close)
|
||||
await page.open(); await page.scan(); await page.state.confirmReceipt()
|
||||
assert.equal(page.calls.some(call => call.url.includes('/scan/') || call.method === 'put'), false)
|
||||
assert.ok(visibleText(page.root).includes(receiptState === 'LEGACY' ? '历史未登记收货' : '已收货'))
|
||||
assert.equal(visibleText(page.root).includes('错误历史日期'), false)
|
||||
})
|
||||
}
|
||||
|
||||
test(`${platform} 切换单据丢弃旧详情,关闭丢弃旧扫码,重开从零核对`, async t => {
|
||||
const oldDetail = deferred(), oldScan = deferred()
|
||||
let deferDetail = true
|
||||
const page = await mount(platform, { request: config => {
|
||||
if (deferDetail && config.url.endsWith('/7')) return oldDetail.promise
|
||||
if (config.url.includes('/scan/')) return oldScan.promise
|
||||
} }); t.after(page.close)
|
||||
const opening = page.state.openReceipt({ shipmentId: 7 })
|
||||
await page.open(8); oldDetail.resolve({ data: detail(7) }); await opening
|
||||
assert.equal(page.state.receiptDetail.value.shipmentId, 8)
|
||||
const scanning = page.state.handleReceiptScan('QR-11')
|
||||
page.state.closeReceipt(); deferDetail = false
|
||||
await page.open(7); oldScan.resolve({ data: item() }); await scanning
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [])
|
||||
assert.equal(page.state.scanLoading.value, false)
|
||||
})
|
||||
|
||||
test(`${platform} 旧确认响应不能关闭新单或通知成功,离开页面使请求失效`, async t => {
|
||||
const oldConfirm = deferred(), oldScan = deferred()
|
||||
let deferScan = false
|
||||
const page = await mount(platform, { request: config => {
|
||||
if (config.method === 'put') return oldConfirm.promise
|
||||
if (deferScan && config.url.includes('/scan/')) return oldScan.promise
|
||||
} }); t.after(page.close)
|
||||
await page.open(); await page.scan(); await page.scan('QR-12')
|
||||
const confirming = page.state.confirmReceipt()
|
||||
await page.open(8); oldConfirm.resolve({ msg: '旧单成功' }); await confirming
|
||||
assert.equal(page.state.receiptDetail.value.shipmentId, 8)
|
||||
assert.deepEqual(page.emitted, [])
|
||||
deferScan = true
|
||||
const scanning = page.state.handleReceiptScan('QR-11')
|
||||
await page.lifecycle(platform === 'pc' ? 'onDeactivated' : 'onHide')
|
||||
oldScan.resolve({ data: item() }); await scanning
|
||||
assert.deepEqual(page.state.scannedPalletIds.value, [])
|
||||
})
|
||||
|
||||
test(`${platform} 新发货与收货结果显示服务端状态`, async t => {
|
||||
const page = await mount(platform, { kind: 'page', permissions: ['business:shipment:list'], request: config => config.url.endsWith('/list') ? Promise.resolve({ rows: [detail(), detail(8, { receiptState: 'RECEIVED' }), detail(9, { receiptState: 'LEGACY' })], total: 3 }) : undefined }); t.after(page.close)
|
||||
await page.lifecycle('onShow')
|
||||
const text = visibleText(page.root)
|
||||
for (const label of ['待收货', '已收货', '历史未登记收货']) assert.ok(text.includes(label), label)
|
||||
})
|
||||
}
|
||||
|
||||
test('APP 工作台允许 receive-only 账号从原发货入口进入,未授权入口仍不可见', async t => {
|
||||
const page = await mount('app', { kind: 'work' }); t.after(page.close)
|
||||
const entries = page.state.visibleBusinessEntries.value
|
||||
assert.equal(entries.length, 1)
|
||||
assert.equal(entries[0].path, '/pages/business/shipment/index')
|
||||
page.state.handleBusinessEntry(entries[0])
|
||||
assert.deepEqual(page.navigations, ['/pages/business/shipment/index'])
|
||||
})
|
||||
@@ -0,0 +1,755 @@
|
||||
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()
|
||||
})
|
||||
@@ -30,6 +30,48 @@ test('Web 端统一使用 Sylvan 横版标识、折叠图标和浏览器图标',
|
||||
assert.doesNotMatch(`${fullLogo}\n${markLogo}`, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i)
|
||||
})
|
||||
|
||||
test('PC 登录页在版权信息旁提供开源协议外链', async () => {
|
||||
const [loginPage, router, permission] = await Promise.all([
|
||||
readFile(path.join(frontendRoot, 'src/views/login.vue'), 'utf8'),
|
||||
readFile(path.join(frontendRoot, 'src/router/index.js'), 'utf8'),
|
||||
readFile(path.join(frontendRoot, 'src/permission.js'), 'utf8')
|
||||
])
|
||||
|
||||
assert.match(loginPage, /开源协议/)
|
||||
assert.match(loginPage, /to="\/open-source-notice"/)
|
||||
assert.doesNotMatch(loginPage, /git\.yujiangrubber\.cn\/niuniu\/ShiErFeng/)
|
||||
assert.match(router, /path: '\/open-source-notice'/)
|
||||
assert.match(router, /@\/views\/openSourceNotice\/index/)
|
||||
assert.match(permission, /whiteList = \['\/login', '\/register', '\/open-source-notice'\]/)
|
||||
})
|
||||
|
||||
test('站内开源协议页展示 RuoYi 来源与 MIT 许可全文', async () => {
|
||||
const noticePage = await readFile(path.join(frontendRoot, 'src/views/openSourceNotice/index.vue'), 'utf8')
|
||||
|
||||
assert.match(noticePage, /RuoYi-Vue3-FastAPI/)
|
||||
assert.match(noticePage, /Copyright \(c\) 2018 RuoYi/)
|
||||
assert.match(noticePage, /The MIT License \(MIT\)/)
|
||||
assert.match(noticePage, /自有业务代码、界面设计和文档不因本声明而开放授权/)
|
||||
})
|
||||
|
||||
test('版权归属与 RuoYi 第三方 MIT 许可范围明确', async () => {
|
||||
const [settings, rootLicense, thirdPartyNotices, packageJson] = await Promise.all([
|
||||
readFile(path.join(frontendRoot, 'src/settings.js'), 'utf8'),
|
||||
readFile(path.join(frontendRoot, '..', 'LICENSE'), 'utf8'),
|
||||
readFile(path.join(frontendRoot, '..', 'THIRD-PARTY-NOTICES.md'), 'utf8'),
|
||||
readFile(path.join(frontendRoot, 'package.json'), 'utf8')
|
||||
])
|
||||
|
||||
assert.match(settings, /Copyright © 2024-2026 Shierfeng International Biotechnology Co\., Ltd\. All Rights Reserved\./)
|
||||
assert.match(rootLicense, /Shierfeng International Biotechnology Co\., Ltd\./)
|
||||
assert.match(rootLicense, /All rights reserved\./i)
|
||||
assert.doesNotMatch(rootLicense, /^MIT License$/m)
|
||||
assert.match(thirdPartyNotices, /RuoYi-Vue3-FastAPI/)
|
||||
assert.match(thirdPartyNotices, /Copyright \(c\) 2018 RuoYi/)
|
||||
assert.match(thirdPartyNotices, /The MIT License \(MIT\)/)
|
||||
assert.equal(JSON.parse(packageJson).license, 'UNLICENSED')
|
||||
})
|
||||
|
||||
const expectedRequests = {
|
||||
'src/api/master/strain.js': [
|
||||
{ url: "'/master/strain/list'", method: 'get' },
|
||||
@@ -72,10 +114,16 @@ const expectedRequests = {
|
||||
{ url: "'/master/pallet'", method: 'post' },
|
||||
{ url: "'/master/pallet'", method: 'put' },
|
||||
],
|
||||
'src/api/master/tunnel.js': [
|
||||
{ url: "'/master/tunnel/list'", method: 'get' },
|
||||
{ url: "'/master/tunnel/' + tunnelId", method: 'get' },
|
||||
{ url: "'/master/tunnel'", method: 'post' },
|
||||
{ url: "'/master/tunnel'", method: 'put' },
|
||||
],
|
||||
'src/api/business/inbound.js': [
|
||||
{ url: "'/business/inbound/list'", method: 'get' },
|
||||
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/inbound/' + inboundId", method: 'get' },
|
||||
{ url: "'/business/inbound'", method: 'post' },
|
||||
{ url: "'/business/inbound'", method: 'put' },
|
||||
@@ -84,7 +132,7 @@ const expectedRequests = {
|
||||
'src/api/business/filling.js': [
|
||||
{ url: "'/business/filling/list'", method: 'get' },
|
||||
{ url: "'/business/filling/strain-options'", method: 'get' },
|
||||
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/filling/' + fillingId", method: 'get' },
|
||||
{ url: "'/business/filling'", method: 'post' }
|
||||
],
|
||||
@@ -92,8 +140,9 @@ const expectedRequests = {
|
||||
{ url: "'/business/shipment/list'", method: 'get' },
|
||||
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/shipment/site-options'", method: 'get' },
|
||||
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/shipment/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/shipment/' + shipmentId", method: 'get' },
|
||||
{ url: "'/business/shipment/' + shipmentId + '/labels'", method: 'get' },
|
||||
{ url: "'/business/shipment'", method: 'post' },
|
||||
{ url: "'/business/shipment'", method: 'put' },
|
||||
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' }
|
||||
@@ -101,14 +150,17 @@ const expectedRequests = {
|
||||
'src/api/business/usage.js': [
|
||||
{ url: "'/business/usage/list'", method: 'get' },
|
||||
{ url: "'/business/usage/line-options'", method: 'get' },
|
||||
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/usage/tunnel-options'", method: 'get' },
|
||||
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
|
||||
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/usage-fifo/recommendation'", method: 'get' },
|
||||
{ url: "'/business/usage/' + usageId", method: 'get' },
|
||||
{ url: "'/business/usage'", method: 'post' }
|
||||
],
|
||||
'src/api/business/return.js': [
|
||||
{ url: "'/business/return/list'", method: 'get' },
|
||||
{ url: "'/business/return/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/return/request'", method: 'post' },
|
||||
{ url: "'/business/return/' + returnId", method: 'get' },
|
||||
{ url: "'/business/return/' + returnId + '/confirm'", method: 'put' }
|
||||
@@ -213,9 +265,11 @@ const businessPageContracts = {
|
||||
payloads: {
|
||||
addFilling: [
|
||||
'batchNo',
|
||||
'expiryDate',
|
||||
'idempotencyKey',
|
||||
'initialPackages',
|
||||
'packageSpec',
|
||||
'productionDate',
|
||||
'qrToken',
|
||||
'strainId',
|
||||
'warehouseId'
|
||||
@@ -228,13 +282,14 @@ const businessPageContracts = {
|
||||
'addShipment',
|
||||
'confirmShipment',
|
||||
'getShipment',
|
||||
'getShipmentLabels',
|
||||
'getShipmentSiteOptions',
|
||||
'getShipmentWarehouseOptions',
|
||||
'listShipment',
|
||||
'scanShipmentPallet',
|
||||
'updateShipment'
|
||||
],
|
||||
permissions: ['add', 'confirm', 'edit', 'list', 'query'],
|
||||
permissions: ['add', 'confirm', 'edit', 'list', 'query', 'receive'],
|
||||
payloads: {
|
||||
addShipment: ['palletIds', 'sourceWarehouseId', 'targetSiteId'],
|
||||
updateShipment: ['palletIds', 'shipmentId', 'sourceWarehouseId', 'targetSiteId'],
|
||||
@@ -243,11 +298,10 @@ const businessPageContracts = {
|
||||
},
|
||||
usage: {
|
||||
relativePath: 'src/views/business/usage/index.vue',
|
||||
apiNames: ['addUsage', 'getUsageLineOptions', 'listUsage', 'scanUsagePallet'],
|
||||
apiNames: ['addUsage', 'enterUsageTunnel', 'getUsageFifoRecommendation', 'getUsageLineOptions', 'getUsageTunnelOptions', 'listUsage', 'scanUsagePallet'],
|
||||
permissions: ['add', 'list', 'query'],
|
||||
payloads: {
|
||||
addUsage: ['idempotencyKey', 'lineId', 'qrToken', 'usedPackages']
|
||||
}
|
||||
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
|
||||
payloads: {}
|
||||
},
|
||||
return: {
|
||||
relativePath: 'src/views/business/return/index.vue',
|
||||
@@ -398,6 +452,7 @@ const expectedButtonPermissions = [
|
||||
'business:shipment:add',
|
||||
'business:shipment:edit',
|
||||
'business:shipment:confirm',
|
||||
'business:shipment:receive',
|
||||
'business:usage:query',
|
||||
'business:usage:add',
|
||||
'business:return:query',
|
||||
@@ -1166,8 +1221,8 @@ test('窄屏模式控件仍保留正式退出与测试批次切换入口', async
|
||||
|
||||
test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => {
|
||||
assert.equal(expectedPagePermissions.length, 17)
|
||||
assert.equal(expectedButtonPermissions.length, 37)
|
||||
assert.equal(new Set(expectedPermissions).size, 54)
|
||||
assert.equal(expectedButtonPermissions.length, 38)
|
||||
assert.equal(new Set(expectedPermissions).size, 55)
|
||||
const relativePaths = [
|
||||
...Object.values(masterPageContracts),
|
||||
...Object.values(businessPageContracts),
|
||||
@@ -1467,9 +1522,9 @@ test('首次入库确认失败后取消重试不丢弃原幂等键', async () =>
|
||||
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)|handleConfirmCancellation\(/)
|
||||
})
|
||||
|
||||
test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
|
||||
assert.equal(Object.keys(expectedRequests).length, 17)
|
||||
assert.equal(Object.values(expectedRequests).flat().length, 66)
|
||||
test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
|
||||
assert.equal(Object.keys(expectedRequests).length, 18)
|
||||
assert.equal(Object.values(expectedRequests).flat().length, 74)
|
||||
|
||||
for (const [relativePath, expected] of Object.entries(expectedRequests)) {
|
||||
await t.test(relativePath, async () => {
|
||||
@@ -1482,16 +1537,18 @@ test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
|
||||
|
||||
test('关键扫码、选项、确认与二维码契约被显式覆盖', () => {
|
||||
const requiredRequests = [
|
||||
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
|
||||
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/shipment/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/filling/strain-options'", method: 'get' },
|
||||
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/shipment/site-options'", method: 'get' },
|
||||
{ url: "'/business/usage/line-options'", method: 'get' },
|
||||
{ url: "'/business/usage/tunnel-options'", method: 'get' },
|
||||
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
|
||||
{ url: "'/business/return/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' },
|
||||
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' },
|
||||
@@ -1960,7 +2017,7 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础
|
||||
.map((match) => match[0])
|
||||
assert.ok(businessControls.length > 0, `${resource} 页面缺少业务输入控件`)
|
||||
for (const control of businessControls) {
|
||||
assert.match(control, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务输入`)
|
||||
assert.match(control, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*operationPending[^"]*")/, `${resource} 提交期间必须禁用业务输入`)
|
||||
}
|
||||
const businessActions = [...source.matchAll(/<el-button\b[^>]*class="business-action"[^>]*>/g)]
|
||||
.map((match) => match[0])
|
||||
@@ -2018,7 +2075,7 @@ test('五个业务页面的读取请求逐分支执行 stale guard,并在上
|
||||
},
|
||||
usage: {
|
||||
flows: [
|
||||
['loadLineOptions', ['getUsageLineOptions'], 'isCurrentOptionRequest'],
|
||||
['loadUsageOptions', ['getUsageLineOptions', 'getUsageTunnelOptions'], 'isCurrentOptionRequest'],
|
||||
['handleUsageScan', ['scanUsagePallet'], 'isCurrentScanRequest']
|
||||
],
|
||||
resets: [['resetUsageOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]],
|
||||
@@ -2172,7 +2229,7 @@ test('装填扫码 pending 期间禁用全部业务输入', async () => {
|
||||
for (const field of ['strainId', 'batchNo', 'packageSpec', 'initialPackages']) {
|
||||
const businessControl = source.match(new RegExp(`<[^>]+v-model="form\\.${field}"[^>]*>`))?.[0]
|
||||
assert.ok(businessControl, `缺少装填 ${field} 业务控件`)
|
||||
assert.match(businessControl, /:disabled="[^"]*scanLoading[^"]*"/, `装填扫码 pending 时必须禁用 ${field}`)
|
||||
assert.match(businessControl, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*scanLoading[^"]*")/, `装填扫码 pending 时必须禁用 ${field}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2196,7 +2253,7 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时
|
||||
['strainId', 'undefined'],
|
||||
['batchNo', "''"],
|
||||
['packageSpec', "''"],
|
||||
['initialPackages', 'undefined']
|
||||
['initialPackages', '100']
|
||||
]) {
|
||||
const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`)
|
||||
assert.match(clearPayloadSource, fieldPattern, `装填载荷清理必须重置 ${field}`)
|
||||
@@ -2366,56 +2423,13 @@ test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂
|
||||
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
|
||||
})
|
||||
|
||||
test('使用页面依扫码结果锁定产线并只采用后端返回的余量和状态', async () => {
|
||||
test('使用页面依扫码结果完成入隧、锁定产线并只采用后端返回的余量和状态', async () => {
|
||||
const source = await readFile(path.join(frontendRoot, businessPageContracts.usage.relativePath), 'utf8')
|
||||
|
||||
assertButtonsUsePermission(source, 'submitUsage', 'business:usage:add')
|
||||
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:usage:query', 'business:usage:add'\]\)/)
|
||||
assert.match(source, /pallet\.currentLineId == null/)
|
||||
assert.match(source, /getUsageLineOptions\(pallet\.siteId\)/)
|
||||
assert.match(source, /:disabled="operationPending \|\| lineLocked/)
|
||||
assert.match(source, /validator:\s*validatePositiveInteger/)
|
||||
|
||||
const scanSource = extractNamedFunction(source, 'handleUsageScan')
|
||||
const scanCallIndex = scanSource.indexOf('scanUsagePallet(')
|
||||
assert.ok(scanCallIndex >= 0, '使用扫码必须调用 scanUsagePallet')
|
||||
for (const [snippet, message] of [
|
||||
['clearUsageIdempotencyKey()', '新使用扫码前必须清除旧幂等键'],
|
||||
['scannedPallet.value = null', '新使用扫码前必须清除旧托盘'],
|
||||
['usageResult.value = null', '新使用扫码前必须清除旧成功结果'],
|
||||
['lineOptions.value = []', '新使用扫码前必须清除旧产线选项'],
|
||||
['lineLocked.value = false', '新使用扫码前必须解除旧产线锁定'],
|
||||
["form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }", '新使用扫码前必须清除旧提交 payload']
|
||||
]) {
|
||||
const index = scanSource.indexOf(snippet)
|
||||
assert.ok(index >= 0 && index < scanCallIndex, message)
|
||||
}
|
||||
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
|
||||
assert.match(invalidSource, /clearUsageIdempotencyKey\(\)/)
|
||||
assert.match(invalidSource, /scannedPallet\.value = null/)
|
||||
assert.match(invalidSource, /usageResult\.value = null/)
|
||||
assert.match(invalidSource, /lineOptions\.value = \[\]/)
|
||||
assert.match(invalidSource, /lineLocked\.value = false/)
|
||||
assert.match(invalidSource, /form\.value = \{ qrToken: '', lineId: undefined, usedPackages: undefined \}/)
|
||||
|
||||
const submitSource = extractNamedFunction(source, 'submitUsage')
|
||||
assert.ok(
|
||||
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
|
||||
'使用提交必须在异步校验前加锁'
|
||||
)
|
||||
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
|
||||
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
|
||||
assert.match(submitSource, /const idempotencyKey = ensureUsageIdempotencyKey\(\)/)
|
||||
const successSource = extractPromiseCallback(submitSource, 'then')
|
||||
assert.match(successSource, /const result = response\.data;/)
|
||||
assert.match(successSource, /remainingPackages:\s*result\.remainingPackages/)
|
||||
assert.match(successSource, /flowState:\s*result\.flowState/)
|
||||
assert.doesNotMatch(source, /remainingPackages\s*[-+]=|remainingPackages\s*[-+]\s*form|Math\.(?:max|min)\([^)]*remainingPackages/)
|
||||
assert.match(successSource, /clearUsageIdempotencyKey\(\)/)
|
||||
const catchSource = extractPromiseCallback(submitSource, 'catch')
|
||||
assert.doesNotMatch(catchSource, /clearUsageIdempotencyKey/)
|
||||
assert.match(extractNamedFunction(source, 'resetUsageOperation'), /clearUsageIdempotencyKey\(\)/)
|
||||
assert.match(source, /watch\([\s\S]*?clearUsageIdempotencyKey\(\)/)
|
||||
// 执行真实页面/API行为,避免把“必须每次重扫清空幂等键”的旧实现误当成安全要求。
|
||||
await execFileAsync(process.execPath, ['--test', 'scripts/usage-behavior.test.mjs'], { cwd: frontendRoot })
|
||||
assert.match(source, /@media \(max-width: 767px\)[\s\S]*?min-height:\s*(?:48|52|56)px/)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { test } from 'node:test'
|
||||
import * as Vue from 'vue'
|
||||
import { parse } from '@vue/compiler-sfc'
|
||||
import { compile } from '@vue/compiler-dom'
|
||||
import { renderToString } from '@vue/server-renderer'
|
||||
|
||||
// 单独编译真实日期列和其插槽,验证绑定字段与历史空值展示,不检索源码字符串。
|
||||
async function renderDateColumns(resource, row) {
|
||||
const file = new URL(`../src/views/report/${resource}/index.vue`, import.meta.url)
|
||||
const { descriptor } = parse(await readFile(file, 'utf8'))
|
||||
const { ast } = compile(descriptor.template.content)
|
||||
const columns = []
|
||||
function visit(node) {
|
||||
if (node.tag === 'el-table-column' && node.props.some(prop =>
|
||||
prop.name === 'prop' && ['productionDate', 'expiryDate'].includes(prop.value?.content))) {
|
||||
columns.push(node.loc.source)
|
||||
}
|
||||
for (const child of node.children || []) visit(child)
|
||||
}
|
||||
visit(ast)
|
||||
assert.equal(columns.length, 2, `${resource} 应展示生产日期和有效期至`)
|
||||
const render = new Function('Vue', compile(`<div>${columns.join('')}</div>`, {
|
||||
mode: 'function', prefixIdentifiers: true
|
||||
}).code)(Vue)
|
||||
const app = Vue.createSSRApp({ render })
|
||||
app.component('el-table-column', {
|
||||
props: ['prop', 'label'],
|
||||
setup(props, { slots }) {
|
||||
return () => Vue.h('section', { 'data-column': props.prop, 'aria-label': props.label },
|
||||
slots.default ? slots.default({ row }) : String(row[props.prop] ?? ''))
|
||||
}
|
||||
})
|
||||
return renderToString(app)
|
||||
}
|
||||
|
||||
for (const resource of ['palletInventory', 'consumption', 'return', 'palletTrace']) {
|
||||
test(`${resource} 展示接口的原装填日期,历史 null 为未登记`, async () => {
|
||||
const dated = await renderDateColumns(resource, {
|
||||
productionDate: '2025-12-31', expiryDate: '2026-12-31',
|
||||
occurredAt: '2026-10-08T23:59:59', updatedAt: '2026-10-08T23:59:59'
|
||||
})
|
||||
assert.match(dated, /2025-12-31/)
|
||||
assert.match(dated, /2026-12-31/)
|
||||
assert.doesNotMatch(dated, /2026-10-08|00:00:00/)
|
||||
const legacy = await renderDateColumns(resource, {
|
||||
productionDate: null, expiryDate: null,
|
||||
occurredAt: '2026-10-08T23:59:59', updatedAt: '2026-10-08T23:59:59'
|
||||
})
|
||||
assert.equal((legacy.match(/未登记/g) || []).length, 2)
|
||||
assert.doesNotMatch(legacy, /2026-10-08/)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { test } from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import * as Vue from 'vue'
|
||||
import { parse } from '@vue/compiler-sfc'
|
||||
import { compile } from '@vue/compiler-dom'
|
||||
import { renderToString } from '@vue/server-renderer'
|
||||
|
||||
const reports = {
|
||||
palletInventory: 'listPalletInventory',
|
||||
consumption: 'listConsumptionReport',
|
||||
return: 'listReturnReport',
|
||||
palletTrace: 'listPalletTrace'
|
||||
}
|
||||
|
||||
async function loadPage(resource, rows = []) {
|
||||
const source = await readFile(new URL(`../src/views/report/${resource}/index.vue`, import.meta.url), 'utf8')
|
||||
const { descriptor } = parse(source)
|
||||
const requests = []
|
||||
const context = vm.createContext({
|
||||
ref: Vue.ref, computed: Vue.computed,
|
||||
getCurrentInstance: () => ({ proxy: { addDateRange: query => query, resetForm() {} } }),
|
||||
onBeforeUnmount() {},
|
||||
[reports[resource]]: async query => {
|
||||
requests.push({ ...query })
|
||||
return { rows, total: rows.length }
|
||||
}
|
||||
})
|
||||
// 执行真实页面脚本,仅替换网络和组件生命周期边界。
|
||||
vm.runInContext(`${descriptor.scriptSetup.content.replace(/^import .*$/gm, '')}
|
||||
globalThis.page = { queryParams, reportList, showSearch, loading, loadError, total, dateRange,
|
||||
handleQuery, resetQuery, getList, handleSizeChange, handleCurrentChange,
|
||||
${resource === 'palletInventory' ? 'flowStateOptions, flowStateLabel, currentLocation, idFallback,' : ''}
|
||||
${resource === 'palletTrace' ? 'eventTypeOptions, eventTypeLabel, sourceLabel, flowStateLabel, formatLocation,' : ''}
|
||||
...(typeof traceSummary === 'undefined' ? {} : { traceSummary })
|
||||
}`, context)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
const fields = new Map()
|
||||
const render = new Function('Vue', compile(descriptor.template.content, { mode: 'function', prefixIdentifiers: true }).code)(Vue)
|
||||
async function html() {
|
||||
const app = Vue.createSSRApp({
|
||||
setup: () => ({ ...context.page, parseTime: value => value || '' }),
|
||||
render
|
||||
})
|
||||
app.directive('hasPermi', { getSSRProps: () => ({}) })
|
||||
app.directive('loading', { getSSRProps: () => ({}) })
|
||||
const box = { setup: (_, { slots }) => () => Vue.h('div', slots.default?.()) }
|
||||
for (const name of ['el-form', 'el-row', 'el-select', 'el-option', 'el-button', 'right-toolbar', 'pagination', 'el-date-picker', 'el-empty']) {
|
||||
app.component(name, box)
|
||||
}
|
||||
app.component('el-form-item', {
|
||||
props: ['prop', 'label'],
|
||||
setup(props, { slots }) {
|
||||
Vue.provide('reportField', props.prop)
|
||||
return () => Vue.h('label', { 'data-field': props.prop }, [props.label, slots.default?.()])
|
||||
}
|
||||
})
|
||||
const input = {
|
||||
props: ['modelValue'],
|
||||
setup(props, { attrs }) {
|
||||
fields.set(Vue.inject('reportField'), value => attrs['onUpdate:modelValue'](value))
|
||||
return () => Vue.h('input', { value: props.modelValue })
|
||||
}
|
||||
}
|
||||
app.component('el-input', input)
|
||||
app.component('el-input-number', input)
|
||||
app.component('el-alert', { props: ['title'], setup: props => () => Vue.h('p', props.title) })
|
||||
app.component('el-table', {
|
||||
props: ['data'],
|
||||
setup(props, { slots }) {
|
||||
Vue.provide('reportRows', props.data)
|
||||
return () => Vue.h('div', slots.default?.())
|
||||
}
|
||||
})
|
||||
app.component('el-table-column', {
|
||||
props: ['prop', 'label'],
|
||||
setup(props, { slots }) {
|
||||
const data = Vue.inject('reportRows')
|
||||
return () => Vue.h('section', { 'data-column': props.prop, 'aria-label': props.label },
|
||||
data.map(row => Vue.h('span', slots.default ? slots.default({ row }) : String(row[props.prop] ?? ''))))
|
||||
}
|
||||
})
|
||||
return renderToString(app)
|
||||
}
|
||||
return { page: context.page, requests, fields, html }
|
||||
}
|
||||
|
||||
for (const resource of Object.keys(reports)) {
|
||||
test(`${resource} 页面通过编号、名称和溯源号查询跨托盘明细`, async () => {
|
||||
const report = await loadPage(resource)
|
||||
await report.html()
|
||||
for (const [key, value] of [['tunnelCode', 'T-01'], ['tunnelName', '一号隧道'], ['traceNo', 'TR-MIX']]) {
|
||||
assert.ok(report.fields.has(key), `缺少可操作的 ${key} 输入框`)
|
||||
report.fields.get(key)(value)
|
||||
}
|
||||
if (resource === 'return' || resource === 'palletTrace') {
|
||||
assert.ok(report.fields.has('batchNo'))
|
||||
report.fields.get('batchNo')('B-OLD')
|
||||
}
|
||||
report.page.handleQuery()
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const query = report.requests.at(-1)
|
||||
assert.equal(query.tunnelCode, 'T-01')
|
||||
assert.equal(query.tunnelName, '一号隧道')
|
||||
assert.equal(query.traceNo, 'TR-MIX')
|
||||
assert.equal(query.palletCode, undefined)
|
||||
assert.equal(query.tunnelId, undefined)
|
||||
assert.equal(query.pageNum, 1)
|
||||
})
|
||||
}
|
||||
|
||||
test('消耗页展示隧道且无产线历史记录不显示 #null', async () => {
|
||||
const report = await loadPage('consumption', [{
|
||||
palletCode: 'P-A', tunnelCode: 'T-01', tunnelName: '一号隧道', traceNo: 'TR-MIX',
|
||||
customerId: 1, siteId: 9, strainId: 11,
|
||||
lineId: null, lineName: null, batchNo: 'B-OLD', usedPackages: 30, remainingPackagesAfter: 70
|
||||
}])
|
||||
const html = await report.html()
|
||||
assert.match(html, /T-01/)
|
||||
assert.match(html, /一号隧道/)
|
||||
assert.match(html, /TR-MIX/)
|
||||
assert.doesNotMatch(html, /#null|#undefined/)
|
||||
})
|
||||
|
||||
test('退回页按历史快照展示溯源关系和批次,不需当前库存', async () => {
|
||||
const report = await loadPage('return', [{
|
||||
palletCode: 'P-A', tunnelCode: 'T-OLD', tunnelName: '旧隧道', traceNo: 'TR-OLD',
|
||||
batchNo: 'B-OLD', batchNoSnapshot: 'B-OLD', remainingPackagesSnapshot: 70, status: 'CONFIRMED'
|
||||
}])
|
||||
const html = await report.html()
|
||||
for (const value of ['T-OLD', '旧隧道', 'TR-OLD', 'B-OLD', '70']) assert.ok(html.includes(value), value)
|
||||
})
|
||||
|
||||
test('追溯页跨托盘混批逐笔显示,退回申请和确认不会重复计为使用量', async () => {
|
||||
const common = { tunnelCode: 'T-01', tunnelName: '一号隧道', traceNo: 'TR-MIX', beforeTunnelCode: 'T-01', afterTunnelCode: null }
|
||||
const report = await loadPage('palletTrace', [
|
||||
{ ...common, palletCode: 'P-A', batchNoSnapshot: 'B-A', eventType: 'TUNNEL_ENTERED', usedPackages: 0, returnedPackages: 0 },
|
||||
{ ...common, palletCode: 'P-A', batchNoSnapshot: 'B-A', eventType: 'USAGE_RECORDED', usedPackages: 30, returnedPackages: 0 },
|
||||
{ ...common, palletCode: 'P-B', batchNoSnapshot: 'B-B', eventType: 'USAGE_RECORDED', usedPackages: 20, returnedPackages: 0 },
|
||||
{ ...common, palletCode: 'P-A', batchNoSnapshot: 'B-A', eventType: 'RETURN_REQUESTED', usedPackages: 0, returnedPackages: 0 },
|
||||
{ ...common, palletCode: 'P-A', batchNoSnapshot: 'B-A', eventType: 'RETURN_CONFIRMED', usedPackages: 0, returnedPackages: 70 }
|
||||
])
|
||||
const html = await report.html()
|
||||
assert.match(html, /入隧登记/)
|
||||
assert.doesNotMatch(html, />TUNNEL_ENTERED</)
|
||||
for (const value of ['P-A', 'P-B', 'B-A', 'B-B', 'TR-MIX']) assert.ok(html.includes(value), value)
|
||||
assert.match(html, /本页实际使用 50 包,确认退回 70 包/)
|
||||
})
|
||||
|
||||
test('报表兼容待收货状态与收货确认事件的中文显示', async () => {
|
||||
const inventory = await loadPage('palletInventory', [{ palletCode: 'P-A', flowState: 'IN_TRANSIT' }])
|
||||
const inventoryHtml = await inventory.html()
|
||||
assert.ok(inventoryHtml.includes('待收货'), '库存报表应显示待收货')
|
||||
assert.doesNotMatch(inventoryHtml, />IN_TRANSIT</)
|
||||
|
||||
const trace = await loadPage('palletTrace', [{
|
||||
palletCode: 'P-A', eventType: 'SHIPMENT_RECEIVED', beforeState: 'IN_TRANSIT',
|
||||
afterState: 'AT_SITE_UNUSED', usedPackages: 0, returnedPackages: 0
|
||||
}])
|
||||
const traceHtml = await trace.html()
|
||||
assert.ok(traceHtml.includes('收货确认'), '追溯报表应显示收货确认')
|
||||
assert.ok(traceHtml.includes('待收货'))
|
||||
assert.doesNotMatch(traceHtml, />SHIPMENT_RECEIVED<|>IN_TRANSIT</)
|
||||
assert.match(traceHtml, /本页实际使用 0 包,确认退回 0 包/)
|
||||
})
|
||||
Reference in New Issue
Block a user