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