feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选
This commit is contained in:
47
web/test/canvas-connection-tilt.test.ts
Normal file
47
web/test/canvas-connection-tilt.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { canvasConnectionTilt, latchCanvasConnectionApproach } from "../src/lib/canvas/canvas-connection-tilt";
|
||||
|
||||
const node = { position: { x: 100, y: 200 }, width: 400, height: 200 };
|
||||
test("connection approach determines tilt direction and pivot", () => {
|
||||
expect(canvasConnectionTilt(node, { x: 100, y: 300 })).toEqual({ rotateX: 0, rotateY: -5, origin: "0% 50%" });
|
||||
expect(canvasConnectionTilt(node, { x: 500, y: 300 })).toEqual({ rotateX: 0, rotateY: 5, origin: "100% 50%" });
|
||||
expect(canvasConnectionTilt(node, { x: 300, y: 200 })?.rotateX).toBe(5);
|
||||
expect(canvasConnectionTilt(node, { x: 300, y: 400 })?.rotateX).toBe(-5);
|
||||
});
|
||||
test("snap padding is clamped, absent or invalid approach stays flat", () => {
|
||||
expect(canvasConnectionTilt(node, { x: -100, y: 700 })).toEqual({ rotateX: -5, rotateY: -5, origin: "0% 100%" });
|
||||
expect(canvasConnectionTilt(node)).toBeUndefined();
|
||||
expect(canvasConnectionTilt(node, { x: NaN, y: 0 })).toBeUndefined();
|
||||
expect(canvasConnectionTilt({ ...node, width: 0 }, { x: 0, y: 0 })).toBeUndefined();
|
||||
});
|
||||
test("world-space approach is independent of viewport scaling", () => {
|
||||
const point = { x: 150, y: 250 };
|
||||
const result = canvasConnectionTilt(node, point);
|
||||
for (const scale of [0.25, 1, 4]) {
|
||||
const screen = { x: point.x * scale + 30, y: point.y * scale - 70 };
|
||||
expect(canvasConnectionTilt(node, { x: (screen.x - 30) / scale, y: (screen.y + 70) / scale })).toEqual(result);
|
||||
}
|
||||
});
|
||||
test("only connection targets receive approach data; memo tracks it and selection does not activate tilt", () => {
|
||||
const source = readFileSync(new URL("../src/components/canvas/canvas-node.tsx", import.meta.url), "utf8");
|
||||
const layers = readFileSync(new URL("../src/pages/canvas/canvas-project-world-layers.tsx", import.meta.url), "utf8");
|
||||
expect(source).toContain("isConnectionTarget && !reduceMediaEffects && !dragOffset");
|
||||
expect(source).toContain("previous.connectionApproach?.x === next.connectionApproach?.x");
|
||||
expect(source).toContain('data-connection-tilt={connectionTilt ? "true" : undefined}');
|
||||
expect(layers).toContain("props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined");
|
||||
expect(layers).not.toMatch(/connectionApproach=\{[^\n]*mouseWorld/);
|
||||
});
|
||||
|
||||
test("600 pointer frames inside one target retain identical entry data", () => {
|
||||
const entry = latchCanvasConnectionApproach(null, "target", { x: 10, y: 20 });
|
||||
for (let frame = 0; frame < 600; frame++) {
|
||||
expect(latchCanvasConnectionApproach(entry, "target", { x: frame, y: frame })).toBe(entry);
|
||||
}
|
||||
expect(entry?.point).toEqual({ x: 10, y: 20 });
|
||||
const next = latchCanvasConnectionApproach(entry, "other", { x: 100, y: 200 });
|
||||
expect(next).not.toBe(entry);
|
||||
expect(next?.point).toEqual({ x: 100, y: 200 });
|
||||
expect(latchCanvasConnectionApproach(next, null, { x: 0, y: 0 })).toBeNull();
|
||||
expect(latchCanvasConnectionApproach(null, "target", { x: 30, y: 40 })?.point).toEqual({ x: 30, y: 40 });
|
||||
});
|
||||
58
web/test/canvas-image-source.test.ts
Normal file
58
web/test/canvas-image-source.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { createPortraitTextureNode, isCanvasImageSourceNode } from "../src/lib/canvas/canvas-image-source";
|
||||
import { canGenerateImageInPlace } from "../src/lib/canvas/canvas-generation-layout";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
const source: CanvasNodeData = {
|
||||
id: "uploaded", type: CanvasNodeType.Image, title: "原图", position: { x: 10, y: 20 }, width: 300, height: 200,
|
||||
metadata: { content: "original-image", storageKey: "original-key", status: "success" },
|
||||
};
|
||||
const read = (path: string) => readFileSync(resolve(import.meta.dir, "../src", path), "utf8");
|
||||
|
||||
describe("上传图片是输入素材", () => {
|
||||
test("上传与导入图片没有生成框,空节点与生成结果仍可配置", () => {
|
||||
expect(isCanvasImageSourceNode(source)).toBe(true);
|
||||
expect(isCanvasImageSourceNode({ ...source, metadata: {} })).toBe(false);
|
||||
expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, generationType: "edit" } })).toBe(false);
|
||||
expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, taskId: "task" } })).toBe(false);
|
||||
expect(isCanvasImageSourceNode({ ...source, type: CanvasNodeType.Video })).toBe(false);
|
||||
expect(isCanvasImageSourceNode(null)).toBe(false);
|
||||
});
|
||||
test("显式复制/版本生成入口保持可用", () => {
|
||||
expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, generationResultPlacement: "replace-node" } })).toBe(false);
|
||||
expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, copiedFromNodeId: "original" } })).toBe(false);
|
||||
});
|
||||
test("人物质感创建独立编辑节点,不复制原图输出和任务状态", () => {
|
||||
const before = structuredClone(source);
|
||||
const child = createPortraitTextureNode(source, "texture");
|
||||
expect(source).toEqual(before);
|
||||
expect(child.id).toBe("texture");
|
||||
expect(child.position).toEqual({ x: 406, y: 20 });
|
||||
expect(child.metadata?.composerContent).toBe("@图片1");
|
||||
expect(child.metadata?.generationType).toBe("edit");
|
||||
expect(child.metadata?.content).toBeUndefined();
|
||||
expect(child.metadata?.storageKey).toBeUndefined();
|
||||
expect(child.metadata?.taskId).toBeUndefined();
|
||||
expect(isCanvasImageSourceNode(child)).toBe(false);
|
||||
expect(canGenerateImageInPlace(child)).toBe(true);
|
||||
expect(canGenerateImageInPlace(source)).toBe(false);
|
||||
});
|
||||
test("页面和工具入口共享素材判定,人物质感接入原图连线", () => {
|
||||
expect(read("pages/canvas/project.tsx")).toContain("dialogNode && !isCanvasImageSourceNode(dialogNode)");
|
||||
expect(read("lib/canvas/tool-registry/definitions/node-hover-tools.tsx")).toContain("!isCanvasImageSourceNode(ctx.node)");
|
||||
const mediaTools = read("pages/canvas/use-canvas-media-tools.ts");
|
||||
const portrait = mediaTools.slice(mediaTools.indexOf("const openPortraitTextureEditor"), mediaTools.indexOf("const cropImageNode"));
|
||||
expect(portrait).toContain("fromNodeId: node.id, toNodeId: child.id");
|
||||
expect(portrait).toContain("setDialogNodeId(child.id)");
|
||||
expect(portrait).not.toContain("current.map");
|
||||
});
|
||||
});
|
||||
|
||||
test("底部菜单使用画布浮层管理并响应打开、鼠标和键盘交互", () => {
|
||||
const toolbar = read("components/canvas/canvas-toolbar.tsx");
|
||||
expect(toolbar).toContain('useCanvasOverlayLayer("main-toolbar", "var(--z-toolbar)")');
|
||||
expect(toolbar).toContain("if (addOpen || appearanceOpen) bringToFront()");
|
||||
expect(toolbar).toContain("style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}");
|
||||
});
|
||||
140
web/test/canvas-image-upload.test.tsx
Normal file
140
web/test/canvas-image-upload.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
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(<CanvasFileUploadContent node={placeholder()} theme={canvasThemes[mode]} reduceMotion />);
|
||||
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(<CanvasNodeActionContext.Provider value={{ upload: () => {} }}><CanvasFileUploadContent node={interruptFileUpload(placeholder())} theme={canvasThemes[mode]} /></CanvasNodeActionContext.Provider>);
|
||||
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(<CanvasFileUploadContent node={node} theme={canvasThemes.light} />);
|
||||
expect(html).toContain("已上传 50%");
|
||||
expect(html).toContain('opacity:0.55');
|
||||
expect(html).toContain('aria-valuenow="50"');
|
||||
const complete = renderToStaticMarkup(<CanvasFileUploadContent node={{ ...node, metadata: { ...node.metadata, fileUploadProgress: 100 } }} theme={canvasThemes.dark} />);
|
||||
expect(complete).toContain("文件已传输,正在保存与处理");
|
||||
expect(complete).toContain('aria-busy="true"');
|
||||
}
|
||||
});
|
||||
@@ -190,7 +190,8 @@ describe("canvas resource mention slots", () => {
|
||||
|
||||
expect(references.get(target.id)?.map((reference) => reference.nodeId)).toEqual([audio.id]);
|
||||
expect(references.get(config.id)?.map((reference) => reference.nodeId)).toEqual([target.id, audio.id]);
|
||||
expect(references.get(image.id)?.map((reference) => reference.nodeId)).toEqual([image.id]);
|
||||
expect(references.get(image.id)?.map((reference) => reference.nodeId)).toEqual([]);
|
||||
expect(buildNodeMentionReferences(image, nodes, connections)).toEqual([]);
|
||||
});
|
||||
|
||||
test("素材库身份 token 保持稳定", () => {
|
||||
@@ -259,4 +260,3 @@ describe("remove canvas resource mention tokens", () => {
|
||||
expect(nextTarget.metadata?.composerContent).toBe("比较 和 @图片1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
90
web/test/canvas-video-hover-preview.test.ts
Normal file
90
web/test/canvas-video-hover-preview.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||
import { bindCanvasVideoHoverPreview, VIDEO_HOVER_DELAY_MS } from "../src/lib/canvas/canvas-video-hover-preview";
|
||||
|
||||
const wait = (ms = VIDEO_HOVER_DELAY_MS + 30) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
let videos: FakeVideo[];
|
||||
let reduced = false;
|
||||
let interacting = false;
|
||||
let mutation: () => void;
|
||||
let visibility: (entries: { isIntersecting: boolean }[]) => void;
|
||||
const cleanups: (() => void)[] = [];
|
||||
class FakeVideo extends EventTarget {
|
||||
paused = true; currentTime = 0; src = ""; muted = false; playsInline = false; preload = "";
|
||||
className = ""; dataset: Record<string, string> = {}; removed = false; loads = 0;
|
||||
setAttribute() {}
|
||||
removeAttribute() { this.src = ""; }
|
||||
load() { this.loads++; }
|
||||
remove() { this.removed = true; }
|
||||
pause() { this.paused = true; }
|
||||
async play() { this.paused = false; this.dispatchEvent(new Event("playing")); }
|
||||
}
|
||||
class FakeElement extends EventTarget {
|
||||
isConnected = true;
|
||||
closest(selector: string) { return selector.includes('="true"') ? (interacting ? this : null) : this; }
|
||||
getAttribute() { return interacting ? "true" : null; }
|
||||
appendChild(video: FakeVideo) { videos.push(video); }
|
||||
}
|
||||
function stub(key: string, value: unknown) {
|
||||
originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
function setup(resolve: () => Promise<string> = async () => "test.mp4") {
|
||||
const element = new FakeElement();
|
||||
cleanups.push(bindCanvasVideoHoverPreview(element as unknown as HTMLElement, resolve));
|
||||
return element;
|
||||
}
|
||||
function enter(element: FakeElement, pointerType = "mouse", buttons = 0) {
|
||||
element.dispatchEvent(Object.assign(new Event("pointerenter"), { pointerType, buttons }));
|
||||
}
|
||||
beforeEach(() => {
|
||||
videos = []; reduced = false; interacting = false;
|
||||
stub("window", Object.assign(new EventTarget(), { matchMedia: () => ({ matches: reduced }) }));
|
||||
stub("document", Object.assign(new EventTarget(), { hidden: false, querySelectorAll: () => videos.filter((v) => !v.removed), createElement: () => new FakeVideo() }));
|
||||
stub("MutationObserver", class { constructor(callback: () => void) { mutation = callback; } observe() {} disconnect() {} });
|
||||
stub("IntersectionObserver", class { constructor(callback: typeof visibility) { visibility = callback; } observe() {} disconnect() {} });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup());
|
||||
for (const [key, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
originals.clear();
|
||||
});
|
||||
|
||||
test("quick pass, touch, dragging and reduced motion never resolve media", async () => {
|
||||
let calls = 0;
|
||||
const element = setup(async () => { calls++; return "test.mp4"; });
|
||||
enter(element); element.dispatchEvent(new Event("pointerleave"));
|
||||
enter(element, "touch"); enter(element, "mouse", 1);
|
||||
reduced = true; enter(element);
|
||||
await wait();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
test("hover is muted and capped at first three seconds; resources are released", async () => {
|
||||
const element = setup(); enter(element); await wait();
|
||||
const video = videos[0];
|
||||
expect(video.muted).toBe(true); expect(video.playsInline).toBe(true);
|
||||
video.currentTime = 3; video.dispatchEvent(new Event("timeupdate"));
|
||||
expect(video.paused).toBe(true); expect(video.src).toBe(""); expect(video.loads).toBe(1); expect(video.removed).toBe(true);
|
||||
});
|
||||
test("new hover cancels previous decoder and ignores stale URL resolution", async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
const slow = setup(() => new Promise<string>((done) => { resolve = done; }));
|
||||
enter(slow); await wait();
|
||||
const next = setup(); enter(next); resolve("stale.mp4"); await wait();
|
||||
expect(videos.length).toBe(1); expect(videos[0].src).toBe("test.mp4");
|
||||
enter(slow); expect(videos[0].removed).toBe(true);
|
||||
});
|
||||
test("wheel, viewport interaction and leaving viewport cancel playback", async () => {
|
||||
const element = setup(); enter(element); await wait();
|
||||
window.dispatchEvent(new Event("wheel")); expect(videos[0].removed).toBe(true);
|
||||
enter(element); await wait(); interacting = true; mutation(); expect(videos[1].removed).toBe(true);
|
||||
interacting = false; enter(element); await wait(); visibility([{ isIntersecting: false }]); expect(videos[2].removed).toBe(true);
|
||||
});
|
||||
test("manual playback is never competing with hover", async () => {
|
||||
const manual = new FakeVideo(); manual.paused = false; videos.push(manual);
|
||||
let calls = 0; const element = setup(async () => { calls++; return "test.mp4"; });
|
||||
enter(element); await wait(); expect(calls).toBe(0); expect(manual.paused).toBe(false);
|
||||
});
|
||||
21
web/test/chapter-asset-breakdown.test.ts
Normal file
21
web/test/chapter-asset-breakdown.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseChapterAssetBreakdown } from "../src/lib/canvas/chapter-asset-breakdown";
|
||||
|
||||
describe("章节三类资产解析", () => {
|
||||
test("无角色章节仍可提取场景和道具", () => {
|
||||
const result = parseChapterAssetBreakdown(JSON.stringify({ characters: [], scenes: [{ name: "车站", description: "空旷站台", prompt: "夜间站台" }], props: [{ name: "信封", description: "牛皮纸信封", prompt: "褐色信封特写" }] }));
|
||||
expect(result.characters).toEqual([]);
|
||||
expect(result.scenes[0].name).toBe("车站");
|
||||
expect(result.props[0].name).toBe("信封");
|
||||
});
|
||||
test("缺失数组或缺失描述不能静默跳过", () => {
|
||||
expect(() => parseChapterAssetBreakdown('{"characters":[]}')).toThrow("缺少");
|
||||
expect(() => parseChapterAssetBreakdown('{"characters":[],"scenes":[{"name":"车站"}],"props":[]}')).toThrow("description");
|
||||
});
|
||||
test("支持围栏 JSON 和无资产章节", () => {
|
||||
expect(parseChapterAssetBreakdown('```json\n{"characters":[],"scenes":[],"props":[]}\n```')).toEqual({ characters: [], scenes: [], props: [] });
|
||||
});
|
||||
test("忽略正文解释但仍校验完整资产对象", () => {
|
||||
expect(parseChapterAssetBreakdown('提取结果如下:\n{"characters":[],"scenes":[],"props":[]}\n以上为全部结果。')).toEqual({ characters: [], scenes: [], props: [] });
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,14 @@ import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||||
import { onlineToolToOps } from "../src/components/canvas/canvas-assistant-panel";
|
||||
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "../src/lib/generation-task-display";
|
||||
import { generationErrorMessage } from "../src/lib/generation-error";
|
||||
import { generationTaskNodeId } from "../src/lib/canvas/canvas-generation-task-sync";
|
||||
import { generationTaskNodeId, syncGenerationTaskToCanvasStore } from "../src/lib/canvas/canvas-generation-task-sync";
|
||||
|
||||
test("业务项目任务和无节点任务不会读取画布项目", async () => {
|
||||
const task = { id: "chapter-task", type: "canvas_text", status: "succeeded", projectId: "domain-project", clientContext: { domainProjectId: "domain-project", nodeId: "chapter_character_breakdown:chapter" } } as GenerationTask;
|
||||
expect(await syncGenerationTaskToCanvasStore(task)).toBe(false);
|
||||
expect(await syncGenerationTaskToCanvasStore({ ...task, clientContext: undefined, inputJson: JSON.stringify({ metadata: { domainProjectId: "domain-project", nodeId: "chapter-node" } }) })).toBe(false);
|
||||
expect(await syncGenerationTaskToCanvasStore({ ...task, clientContext: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
test("canvas recovery reads node identity from task summaries without full task input", () => {
|
||||
const task: GenerationTask = { id: "task-summary", type: "canvas_image", status: "running", prompt: "", attempts: 1, createdAt: "", updatedAt: "", clientContext: { nodeId: "node-summary" } };
|
||||
|
||||
64
web/test/resource-upload-progress.test.ts
Normal file
64
web/test/resource-upload-progress.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import { apiClient } from "../src/services/api/request";
|
||||
import { uploadResourceFile } from "../src/services/api/resources";
|
||||
|
||||
test("普通 multipart 将传输进度换算为文件字节,总量未知不伪造百分比", async () => {
|
||||
const adapter = apiClient.defaults.adapter;
|
||||
const progress: number[][] = [];
|
||||
try {
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
config.onUploadProgress?.({ loaded: 100, total: undefined } as AxiosProgressEvent);
|
||||
config.onUploadProgress?.({ loaded: 250, total: 1000 } as AxiosProgressEvent);
|
||||
config.onUploadProgress?.({ loaded: 1000, total: 1000 } as AxiosProgressEvent);
|
||||
return { config, status: 200, statusText: "OK", headers: {}, data: { code: 0, data: { resource: { id: "upload-test" } } } };
|
||||
};
|
||||
await uploadResourceFile(new Blob([new Uint8Array(400)]), "file", undefined, (loaded, total) => progress.push([loaded, total]));
|
||||
expect(progress).toEqual([[100, 400], [400, 400]]);
|
||||
} finally {
|
||||
apiClient.defaults.adapter = adapter;
|
||||
}
|
||||
});
|
||||
|
||||
test("分片上传累加片内真实进度,传输 100% 后仍须等待合并响应", async () => {
|
||||
const adapter = apiClient.defaults.adapter;
|
||||
const size = 51 * 1024 * 1024;
|
||||
const chunkSize = 32 * 1024 * 1024;
|
||||
const progress: number[][] = [];
|
||||
let merged = false;
|
||||
try {
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
let data: unknown;
|
||||
if (config.url === "/resources/uploads") data = { uploadId: "session", chunkSize, chunkCount: 2 };
|
||||
else if (config.url?.endsWith("/complete")) {
|
||||
expect(progress.at(-1)).toEqual([size, size]);
|
||||
merged = true;
|
||||
data = { resource: { id: "chunk-test" } };
|
||||
} else {
|
||||
const index = Number(config.url?.split("/").at(-1));
|
||||
const bytes = index === 0 ? chunkSize : size - chunkSize;
|
||||
config.onUploadProgress?.({ loaded: bytes / 2, total: bytes } as AxiosProgressEvent);
|
||||
data = { index };
|
||||
}
|
||||
return { config, status: 200, statusText: "OK", headers: {}, data: { code: 0, data } };
|
||||
};
|
||||
await uploadResourceFile(new Blob([new Uint8Array(size)]), "video", undefined, (loaded, total) => progress.push([loaded, total]));
|
||||
expect(progress).toEqual([[chunkSize / 2, size], [chunkSize, size], [chunkSize + (size - chunkSize) / 2, size], [size, size]]);
|
||||
expect(merged).toBe(true);
|
||||
} finally {
|
||||
apiClient.defaults.adapter = adapter;
|
||||
}
|
||||
});
|
||||
|
||||
test("传输结束不吞掉服务端保存错误", async () => {
|
||||
const adapter = apiClient.defaults.adapter;
|
||||
try {
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
config.onUploadProgress?.({ loaded: 100, total: 100 } as AxiosProgressEvent);
|
||||
return { config, status: 200, statusText: "OK", headers: {}, data: { code: 9, msg: "保存失败" } };
|
||||
};
|
||||
await expect(uploadResourceFile(new Blob(["text"]), "file", undefined, () => {})).rejects.toThrow("保存失败");
|
||||
} finally {
|
||||
apiClient.defaults.adapter = adapter;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user