import test from "node:test" import assert from "node:assert/strict" import fs from "node:fs" import {registerHooks} from "node:module" import {compileScript, parse} from "@vue/compiler-sfc" import {createRenderer, nextTick} from "vue" import {ehThumbnailUrl} from "lionwebsite-frontend-shared" // 移动端详情抽屉的“打开/模式”状态只存在于真实组件里,store 单测覆盖不到。 // 这里用编译期 loader hook 直接执行仓库中的 .vue 源码,再挂到最小渲染器上; // 开关与模式都落在普通 DOM 属性上,因此不需要 jsdom/happy-dom。 registerHooks({ load(url, context, nextLoad) { if (!url.endsWith("/TaskDetailSheet.vue")) return nextLoad(url, context) const source = fs.readFileSync(new URL(url), "utf8") const {descriptor, errors} = parse(source, {filename: url}) assert.deepEqual(errors, [], "SFC 解析必须无错") return { format: "module", shortCircuit: true, source: compileScript(descriptor, {id: "sheet-test", inlineTemplate: true, isProd: false}).content, } }, }) const {default: TaskDetailSheet} = await import("../src/components/TaskDetailSheet.vue") const store = (await import("../src/store/index.js")).default // 最小渲染器:只需要能插入节点、读回属性、处理 v-show 的 style。 function fakeElement(tag) { return {nodeType: 1, tag, children: [], parentNode: null, style: {}, attrs: {}, listeners: {}} } function nodeOps() { const remove = node => { const parent = node.parentNode if (!parent) return const index = parent.children.indexOf(node) if (index >= 0) parent.children.splice(index, 1) node.parentNode = null } const insert = (node, parent, anchor = null) => { if (node.parentNode) remove(node) const index = anchor ? parent.children.indexOf(anchor) : -1 if (index >= 0) parent.children.splice(index, 0, node) else parent.children.push(node) node.parentNode = parent } return { insert, remove, createElement: fakeElement, createText: text => ({nodeType: 3, text, parentNode: null}), createComment: text => ({nodeType: 8, text, parentNode: null}), setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.children = [{nodeType: 3, text, parentNode: node}] }, parentNode: node => node.parentNode, nextSibling: node => { const parent = node.parentNode if (!parent) return null const index = parent.children.indexOf(node) return parent.children[index + 1] || null }, querySelector: () => null, setScopeId: (el, id) => { el.scopeId = id }, cloneNode: el => ({...fakeElement(el.tag), attrs: {...el.attrs}}), insertStaticContent: (content, parent, anchor) => { const node = fakeElement("static") insert(node, parent, anchor) return [node, node] }, patchProp: (el, key, previous, next) => { if (key === "style") el.style = next || {} else if (/^on[A-Z]/.test(key)) el.listeners[key] = next else el.attrs[key] = next }, } } function mountSheet() { const app = createRenderer(nodeOps()).createApp(TaskDetailSheet) // 真实入口在 main.js 里全局注册 Element Plus;这里只需要占位节点。 app.component("el-progress", {render: () => null}) const root = fakeElement("root") app.mount(root) return root } function descendants(node, out = []) { for (const child of node.children || []) { if (child.nodeType === 1) { out.push(child) descendants(child, out) } } return out } function findByClass(root, className) { return descendants(root).find(node => node.attrs.class === className) || null } function textOf(root) { const parts = [] const walk = node => { if (node.nodeType === 3) { parts.push(node.text); return } for (const child of node.children || []) walk(child) } walk(root) return parts.join(" ") } function isOpen(root) { const layer = findByClass(root, "sheet-layer") assert.ok(layer, "抽屉根节点必须存在") // v-show 只在关闭时写 display: none。 return layer.style.display !== "none" } const taskGallery = () => ({ gid: 5, name: "本地任务", status: "已提交", pages: 10, proceeding: 0, thumb_link: "", createTime: 1, downloader: 7, }) const remoteGallery = () => ({ gid: 9, name: "远端画廊", link: "https://example.org/g/9/k/", thumbnailUrl: "/t/9.jpg", }) function reset() { store.state.AuthCode = "auth" store.state.userId = 7 store.state.detailGallery = null store.state.chosenGallery = false } test("清空 detailGallery 会关上任务态抽屉", async () => { reset() const root = mountSheet() store.state.detailGallery = taskGallery() await nextTick() assert.equal(isOpen(root), true) assert.match(textOf(root), /本地任务/) store.state.detailGallery = null await nextTick() assert.equal(isOpen(root), false) }) test("清空 chosenGallery 会关上远端态抽屉并复位模式", async () => { reset() const root = mountSheet() store.state.chosenGallery = remoteGallery() await nextTick() assert.equal(isOpen(root), true) assert.match(textOf(root), /远端画廊/) store.state.chosenGallery = false await nextTick() assert.equal(isOpen(root), false) // 模式必须回到 task,否则下一次打开任务会渲染成远端面板。 store.state.detailGallery = taskGallery() await nextTick() assert.equal(isOpen(root), true) assert.match(textOf(root), /删除任务/) }) test("远端态下 detailGallery 变空不得误关抽屉", async () => { reset() const root = mountSheet() store.state.chosenGallery = remoteGallery() await nextTick() assert.equal(isOpen(root), true) // 任务流的 detail 被清空(例如列表刷新),此时屏幕上的是远端画廊,必须保持打开。 store.state.detailGallery = null await nextTick() assert.equal(isOpen(root), true) assert.match(textOf(root), /远端画廊/) }) test("已规范化的 thumb_link 直接使用,不再二次代理", async () => { reset() const root = mountSheet() const proxied = ehThumbnailUrl("/t/9.jpg", "auth") store.state.chosenGallery = { ...remoteGallery(), thumb_link: proxied, thumbnailUrl: "https://example.org/raw.jpg", } await nextTick() const image = descendants(root).find(node => node.tag === "img") assert.ok(image, "远端态必须渲染缩略图") assert.equal(image.attrs.src, proxied) assert.equal(image.attrs.src.split("ehThumbnail").length - 1, 1) // 外层 path 仍是原始图片路径,没有被嵌套成代理 URL。 assert.equal(new URL(image.attrs.src).searchParams.get("path"), "/t/9.jpg") }) test("只有原始上游地址时才在这里代理一次", async () => { reset() const root = mountSheet() store.state.chosenGallery = {...remoteGallery(), thumbnailUrl: "https://example.org/raw.jpg"} await nextTick() const image = descendants(root).find(node => node.tag === "img") assert.ok(image) assert.equal(image.attrs.src, ehThumbnailUrl("https://example.org/raw.jpg", "auth")) assert.equal(new URL(image.attrs.src).searchParams.get("path"), "https://example.org/raw.jpg") })