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

248 lines
11 KiB
JavaScript

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