Files
pj_admin_cloud/tests/batch-export.test.cjs
T
dzc 530cfcd939
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
111
2026-10-21 19:40:17 +08:00

164 lines
9.0 KiB
JavaScript

const { test } = require("node:test")
const assert = require("node:assert/strict")
const fs = require("node:fs")
const path = require("node:path")
const ts = require("typescript")
const vue = require("vue")
const { parse, compileScript } = require("@vue/compiler-sfc")
const root = path.join(__dirname, "../src")
const flush = () => new Promise(setImmediate)
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
function load(source, mocks, globals = {}) {
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 } }).outputText
const module = { exports: {} }
const names = Object.keys(globals)
new Function("module", "exports", "require", ...names, compiled)(module, module.exports, key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
return module.exports
}
function component(query) {
let unmount
let now = 0
let timerId = 0
const timers = new Map()
const events = []
const requests = []
let exposed
const source = compileScript(parse(fs.readFileSync(path.join(root, "components/AsyncTaskProgress.vue"), "utf8")).descriptor, { id: "test" }).content
const mod = load(source, {
vue: { ...vue, onBeforeUnmount: fn => { unmount = fn } },
"/@/api/asyncTask": { getAsyncTaskProgress: async (url, params) => { requests.push({ url, params }); return query(requests.length) } },
"/@/utils/exportDownload": { downloadExportFile() {} },
"/@/hooks/message": { useMessage: () => ({ error() {} }) },
}, {
setTimeout: fn => { timers.set(++timerId, fn); return timerId },
clearTimeout: id => timers.delete(id),
Date: { now: () => now },
})
const state = mod.default.setup({ config: { progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:xf", pollTimeoutMs: 1000 } }, {
expose: value => { exposed = value }, emit: (...args) => events.push(args),
})
return { state, exposed, events, requests, timers, unmount: () => unmount(), setNow: value => { now = value },
async tick() { const entry = timers.entries().next().value; assert.ok(entry, "下一次轮询已安排"); timers.delete(entry[0]); entry[1](); await flush() } }
}
const initial = { taskId: "a".repeat(32), redisKey: "wallet:export:xf", taskName: "消费记录", status: "PENDING", total: 1, completed: 0, percentage: 0, message: "等待执行", finished: false, success: false }
const success = { ...initial, status: "SUCCESS", completed: 1, percentage: 100, finished: true, success: true, resultFileUrl: "/admin/sys-file/oss/file?fileName=test.xlsx", resultFileName: "消费记录.xlsx" }
test("提交互斥且冻结嵌套筛选,列表分页不限制导出", async () => {
const submitted = deferred()
const calls = []
const ui = []
const downloads = []
const mod = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
vue,
"/@/components/AsyncTaskProgress.vue": {},
"/@/hooks/message": { useMessage: () => ({ error: value => ui.push(value) }) },
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
})
const task = mod.useBatchExport({ redisKey: initial.redisKey, progressUrl: "/wallet/async-task/progress", taskName: "导出", fileName: "回退.xlsx", submitTask: async query => { calls.push(query); return submitted.promise } })
task.exportProgressRef.value = { startSubmitting: () => ui.push("submit"), start: () => ui.push("start"), show: () => ui.push("show") }
const query = vue.reactive({ current: 8, size: 10, userIds: ["1"], range: { from: "2026-01-01" }, descs: ["create_time"] })
const pending = task.submitExport(query)
await task.submitExport(query)
query.userIds.push("2"); query.range.from = "2026-02-01"
assert.equal(calls.length, 1)
assert.deepEqual(calls[0], { userIds: ["1"], range: { from: "2026-01-01" }, descs: ["create_time"] })
assert.deepEqual(ui, ["submit", "show"])
submitted.resolve({ data: initial }); await pending
assert.equal(task.exportSubmitting.value, true)
task.handleExportSuccess(success)
assert.equal(task.exportSubmitting.value, false)
assert.deepEqual(downloads[0], [success.resultFileUrl, success.resultFileName])
})
test("提交失败恢复按钮状态并展示受控错误", async () => {
const errors = []
const mod = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
vue, "/@/components/AsyncTaskProgress.vue": {},
"/@/hooks/message": { useMessage: () => ({ error: message => errors.push(message) }) },
"/@/utils/exportDownload": {},
})
const task = mod.useBatchExport({ submitTask: async () => { throw { msg: "任务繁忙" } } })
task.exportProgressRef.value = { startSubmitting() {}, submissionFailed: message => errors.push(message) }
await task.submitExport({})
assert.equal(task.exportSubmitting.value, false)
assert.deepEqual(errors, ["任务繁忙", "任务繁忙"])
})
test("慢查询不重叠,成功只通知一次且停止轮询", async () => {
const pending = deferred()
const task = component(async count => count === 1 ? pending.promise : { data: success })
task.exposed.startSubmitting("导出")
assert.equal(task.state.canClose.value, false)
task.exposed.start(initial)
await flush()
assert.equal(task.requests.length, 1)
assert.equal(task.timers.size, 0)
pending.resolve({ data: { ...initial, status: "RUNNING", total: 10, completed: 1, percentage: 10 } }); await flush()
assert.equal(task.timers.size, 1)
await task.tick()
assert.equal(task.events.length, 1)
assert.equal(task.events[0][0], "success")
assert.equal(task.timers.size, 0)
assert.equal(task.state.canClose.value, true)
})
test("连续五次查询失败暂停,重新查询原任务后仍可下载", async () => {
const task = component(async count => { if (count <= 5) throw { msg: "网络中断" }; return { data: success } })
task.exposed.start(initial); await flush()
for (let i = 0; i < 4; i++) await task.tick()
assert.equal(task.requests.length, 5)
assert.equal(task.state.state.polling, false)
assert.equal(task.state.state.queryError, "网络中断")
task.state.state.visible = false; task.exposed.show()
assert.equal(task.state.state.visible, true)
task.state.retry(); await flush()
assert.equal(task.requests.length, 6)
assert.ok(task.requests.every(item => item.params.taskId === initial.taskId))
assert.equal(task.events[0][0], "success")
})
test("卸载或新任务替换后忽略旧请求响应", async () => {
const pending = deferred()
const task = component(async () => pending.promise)
task.exposed.start(initial); await flush(); task.unmount()
pending.resolve({ data: success }); await flush()
assert.equal(task.timers.size, 0)
assert.equal(task.events.length, 0)
const old = deferred()
const replacement = component(async count => count === 1 ? old.promise : { data: { ...initial, taskId: "b".repeat(32) } })
replacement.exposed.start(initial)
replacement.exposed.start({ ...initial, taskId: "b".repeat(32) }); await flush()
old.resolve({ data: success }); await flush()
assert.equal(replacement.state.state.progress.taskId, "b".repeat(32))
assert.equal(replacement.events.length, 0)
})
test("轮询超时只暂停跟踪,后台失败才发出 failure", async () => {
const task = component(async () => ({ data: initial }))
task.exposed.start(initial); await flush(); task.setNow(1001)
await task.tick()
assert.equal(task.requests.length, 1)
assert.match(task.state.state.queryError, /后台任务可能仍在运行/)
assert.equal(task.events.length, 0)
const failed = component(async () => ({ data: { ...initial, finished: true, success: false, status: "FAILED", message: "导出失败" } }))
failed.exposed.start(initial); await flush()
assert.equal(failed.events[0][0], "failure")
assert.equal(failed.state.canDownload.value, false)
})
test("大文件下载使用原生附件地址并适配单体路径,不创建 Blob", () => {
const links = []
const source = fs.readFileSync(path.join(root, "utils/exportDownload.ts"), "utf8").replaceAll("import.meta.env", '({ VITE_API_URL: "/api" })')
const mod = load(source, { "/@/utils/other": { default: { adaptationUrl: path => path.replace("/hard/", "/admin/") } } }, {
document: { createElement: () => { const link = { style: {}, click() {}, remove() {} }; links.push(link); return link }, body: { appendChild() {} } },
Blob: class { constructor() { throw new Error("不能将整个大文件读入 Blob") } },
})
mod.downloadExportFile("/hard/file?id=large", "充值/扣款.xlsx")
assert.equal(links[0].href, "/api/admin/file?id=large")
assert.equal(links[0].download, "充值_扣款.xlsx")
mod.downloadExportFile("https://files.example.com/test", "test.xlsx")
assert.equal(links[1].href, "https://files.example.com/test")
})