import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { createImageUploadPlaceholder, createFileUploadPlaceholder, interruptFileUpload, readUploadImageSize, uploadNodeType, uploadPercent, uploadVeilOpacity } from "../src/lib/canvas/canvas-file-upload"; import { CanvasNodeType } from "../src/types/canvas"; import { isCanvasImageSourceNode } from "../src/lib/canvas/canvas-image-source"; import { CanvasFileUploadContent } from "../src/components/canvas/canvas-file-upload-content"; import { CanvasNodeActionContext } from "../src/components/canvas/canvas-node-action-context"; import { canvasThemes } from "../src/lib/canvas-theme"; const file = { name: "photo.png", size: 1234, type: "image/png" }; const placeholder = () => createImageUploadPlaceholder("upload", file, { x: 100, y: 200 }, { width: 1920, height: 1080 }); const read = (path: string) => readFileSync(resolve(import.meta.dir, "../src", path), "utf8"); describe("图片上传占位", () => { for (const [width, height] of [[1920, 1080], [900, 1600], [800, 800], [4000, 500]]) { test(`按真实尺寸保持比例并围绕落点居中 ${width}x${height}`, () => { const node = createImageUploadPlaceholder("upload", file, { x: 100, y: 200 }, { width, height }); expect(node.width / node.height).toBeCloseTo(width / height); expect(node.position.x + node.width / 2).toBe(100); expect(node.position.y + node.height / 2).toBe(200); expect(node.metadata?.naturalWidth).toBe(width); expect(node.metadata?.naturalHeight).toBe(height); expect(node.metadata?.fileUpload).toBe("uploading"); expect(node.metadata?.content).toBeUndefined(); expect(node.metadata?.status).toBeUndefined(); expect(isCanvasImageSourceNode(node)).toBe(true); }); } test("重新打开画布将中断上传转为可重试错误,不修改其他节点", () => { const node = placeholder(); const interrupted = interruptFileUpload(node); expect(interrupted.metadata?.fileUpload).toBe("error"); expect(interrupted.metadata?.errorDetails).toContain("中断"); expect(interrupted.position).toEqual(node.position); expect(node.metadata?.fileUpload).toBe("uploading"); expect(interruptFileUpload(interrupted)).toBe(interrupted); const ready = { ...node, metadata: { content: "image" } }; expect(interruptFileUpload(ready)).toBe(ready); }); test("图片解码成功和失败均释放临时 URL,不伪造尺寸", async () => { const originalImage = globalThis.Image; const create = URL.createObjectURL; const revoke = URL.revokeObjectURL; const revoked: string[] = []; let fail = false; try { URL.createObjectURL = () => "blob:upload-test"; URL.revokeObjectURL = (url) => { revoked.push(url); }; globalThis.Image = class { src = ""; naturalWidth = 1200; naturalHeight = 800; async decode() { if (fail) throw new Error("decode failed"); } } as unknown as typeof Image; const input = new File(["test"], "test.png", { type: "image/png" }); expect(await readUploadImageSize(input)).toEqual({ width: 1200, height: 800 }); fail = true; await expect(readUploadImageSize(input)).rejects.toThrow("decode failed"); expect(revoked).toEqual(["blob:upload-test", "blob:upload-test"]); } finally { globalThis.Image = originalImage; URL.createObjectURL = create; URL.revokeObjectURL = revoke; } }); for (const mode of ["light", "dark"] as const) { test(`${mode}主题包含可访问的上传状态、减弱动效和失败重试`, () => { const html = renderToStaticMarkup(); expect(html).toContain("正在上传图片"); expect(html).toContain('aria-busy="true"'); expect(html).toContain('data-static="true"'); expect(html).toContain(canvasThemes[mode].node.fill); const error = renderToStaticMarkup( {} }}>); expect(error).toContain("重新选择文件"); expect(error).toContain('aria-busy="false"'); expect(error).toContain('data-static="true"'); }); } test("上传先占位再传输,完成替换同一节点,弹窗不等待传输完成", () => { const hook = read("pages/canvas/use-canvas-upload.ts"); const create = hook.slice(hook.indexOf("const createFileNode"), hook.indexOf("const createImageAssetNode")); expect(create.indexOf("createFileUploadPlaceholder")).toBeLessThan(create.indexOf("await uploadImage(file,")); expect(create).toContain("if (!currentNode)"); expect(create).toContain("fileUpload: undefined"); const replace = hook.slice(hook.indexOf('const replaceNodeMedia'), hook.indexOf("const pasteSystemClipboard")); expect(replace).toContain("await createFileNode(file, currentNode.position, nodeId)"); expect(create).toContain("original?.metadata?.content"); expect(read("components/canvas/canvas-upload-modal.tsx")).toContain("onClose();\n await pendingUpload;"); expect(read("styles/globals.css")).toContain("prefers-reduced-motion: reduce"); }); }); test("真实进度与遮罩浓度反向关联,不生成未知进度", () => { expect(uploadPercent(20, 100)).toBe(20); expect(uploadPercent(150, 100)).toBe(100); expect(uploadPercent(0, 0)).toBeUndefined(); expect(uploadPercent(NaN, 100)).toBeUndefined(); expect(uploadVeilOpacity(0)).toBe(1); expect(uploadVeilOpacity(50)).toBeCloseTo(0.55); expect(uploadVeilOpacity(100)).toBeCloseTo(0.1); }); test("统一识别图片、视频、音频、纯文本文件,不误收 PDF 或 Word", () => { for (const [name, type, expected] of [ ["image.png", "image/png", CanvasNodeType.Image], ["video.mp4", "video/mp4", CanvasNodeType.Video], ["sound.ogg", "audio/ogg", CanvasNodeType.Audio], ["sound.wav", "", CanvasNodeType.Audio], ["note.md", "", CanvasNodeType.Text], ["note.txt", "text/plain", CanvasNodeType.Text], ["document.pdf", "application/pdf", null], ["document.docx", "", null], ] as const) expect(uploadNodeType({ name, type })).toBe(expected); }); test("音频与文本在传输前就有独立占位,可重试且不带模型生成状态", async () => { for (const [name, type, expected] of [["voice.mp3", "audio/mpeg", CanvasNodeType.Audio], ["note.md", "text/markdown", CanvasNodeType.Text]] as const) { const node = await createFileUploadPlaceholder("file", new File(["content"], name, { type }), { x: 0, y: 0 }); expect(node.type).toBe(expected); expect(node.metadata?.fileUpload).toBe("uploading"); expect(node.metadata?.content).toBeUndefined(); expect(node.metadata?.status).toBeUndefined(); expect(interruptFileUpload(node).metadata?.fileUpload).toBe("error"); } }); test("所有资源渲染真实百分比,100% 仍等待保存,磨砂渐淡但文案不淡出", () => { for (const type of [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text]) { const node = { ...placeholder(), type, metadata: { fileUpload: "uploading" as const, fileUploadProgress: 50 } }; const html = renderToStaticMarkup(); expect(html).toContain("已上传 50%"); expect(html).toContain('opacity:0.55'); expect(html).toContain('aria-valuenow="50"'); const complete = renderToStaticMarkup(); expect(complete).toContain("文件已传输,正在保存与处理"); expect(complete).toContain('aria-busy="true"'); } });