更新登录页及优化相关功能
This commit is contained in:
@@ -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() }
|
||||
})
|
||||
Reference in New Issue
Block a user