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('') 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: '', siteName: '现场 & A', strainName: '', batchNo: '', 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,']) { 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() } })