feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验
统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
37
web/test/asset-remote-sync.test.ts
Normal file
37
web/test/asset-remote-sync.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { assetForRemoteSync } from "@/lib/asset-remote-sync";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
function imageAsset(primaryVersionId?: string): Asset {
|
||||
return {
|
||||
id: "asset-1",
|
||||
kind: "image",
|
||||
title: "测试素材",
|
||||
coverUrl: "opaque://asset",
|
||||
tags: [],
|
||||
primaryVersionId,
|
||||
createdAt: "2026-09-01T00:00:00.000Z",
|
||||
updatedAt: "2026-09-01T00:00:00.000Z",
|
||||
data: { dataUrl: "opaque://asset", width: 1, height: 1, bytes: 1, mimeType: "image/png" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("asset remote sync", () => {
|
||||
test("removes an invalid historical primary version id only from the remote payload", () => {
|
||||
const source = imageAsset("generation-result:" + "x".repeat(40));
|
||||
|
||||
const remote = assetForRemoteSync(source);
|
||||
|
||||
expect(remote.primaryVersionId).toBeUndefined();
|
||||
expect(source.primaryVersionId).toBe("generation-result:" + "x".repeat(40));
|
||||
expect(remote).not.toBe(source);
|
||||
});
|
||||
|
||||
test("preserves a valid backend version uuid", () => {
|
||||
const source = imageAsset("123e4567-e89b-12d3-a456-426614174000");
|
||||
|
||||
expect(assetForRemoteSync(source)).toBe(source);
|
||||
expect(source.primaryVersionId).toBe("123e4567-e89b-12d3-a456-426614174000");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { applyFrameDrop, canFolderContain, canLinkedFolderArchive, findFrameDropTarget, isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
|
||||
import { applyFrameDrop, buildCanvasFrameDropIndex, canFolderContain, canLinkedFolderArchive, findFrameDropTarget, findFrameDropTargetFromIndex, isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
|
||||
import { resolveCanvasFolderTheme, resolveCanvasFolderThemeCover } from "@/lib/canvas/canvas-folder-theme";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
@@ -66,6 +66,16 @@ describe("canvas folders", () => {
|
||||
expect(nextFolder.metadata?.frame?.expandedHeight).toBeGreaterThan(dragged.height);
|
||||
});
|
||||
|
||||
test("空间索引画框落点与常规落点一致", () => {
|
||||
const target = folder("folder");
|
||||
const dragged = image("image");
|
||||
const nodes = [target, dragged];
|
||||
const index = buildCanvasFrameDropIndex(nodes);
|
||||
|
||||
expect(findFrameDropTargetFromIndex(index, [dragged], new Set([dragged.id]), { x: 0, y: 0 })).toBe(findFrameDropTarget(nodes, new Set([dragged.id])));
|
||||
expect(findFrameDropTargetFromIndex(index, [dragged], new Set([dragged.id]), { x: 400, y: 400 })).toBeNull();
|
||||
});
|
||||
|
||||
test("素材库链接文件夹只接收可归档的真实内容节点", () => {
|
||||
const target = folder("linked", true);
|
||||
const ready = image("ready");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationGroupSize } from "../src/lib/canvas/canvas-generation-layout";
|
||||
import { canGenerateImageInPlace, canGenerateMediaInPlace, findAvailableGenerationGroupPosition, imageGenerationGroupSize } from "../src/lib/canvas/canvas-generation-layout";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||||
|
||||
function node(id: string, x: number, y: number, width = 340, height = 240): CanvasNodeData {
|
||||
@@ -28,9 +28,17 @@ describe("findAvailableGenerationGroupPosition", () => {
|
||||
});
|
||||
|
||||
describe("canGenerateImageInPlace", () => {
|
||||
test("只有空图片节点复用原节点", () => {
|
||||
test("空图片和显式复制节点复用原节点", () => {
|
||||
expect(canGenerateImageInPlace(node("empty", 0, 0))).toBe(true);
|
||||
expect(canGenerateImageInPlace({ ...node("result", 0, 0), metadata: { content: "image-url" } })).toBe(false);
|
||||
expect(canGenerateImageInPlace({ ...node("copy", 0, 0), metadata: { content: "image-url", generationResultPlacement: "replace-node" } })).toBe(true);
|
||||
expect(canGenerateImageInPlace({ ...node("copy", 0, 0), metadata: { content: "image-url", generationResultPlacement: "new-version", copiedFromNodeId: "source" } })).toBe(false);
|
||||
expect(canGenerateImageInPlace({ ...node("text", 0, 0), type: CanvasNodeType.Text })).toBe(false);
|
||||
});
|
||||
|
||||
test("视频复制节点与图片使用相同的结果落点规则", () => {
|
||||
const video = { ...node("video-copy", 0, 0), type: CanvasNodeType.Video, metadata: { content: "video-url", generationResultPlacement: "replace-node" as const } };
|
||||
expect(canGenerateMediaInPlace(video, CanvasNodeType.Video)).toBe(true);
|
||||
expect(canGenerateMediaInPlace({ ...video, metadata: { ...video.metadata, generationResultPlacement: "new-version" } }, CanvasNodeType.Video)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { resolveActiveCanvasMediaNodeId } from "../src/lib/canvas/canvas-performance-mode";
|
||||
import { canvasNodeRenderPadding, resolveActiveCanvasMediaNodeId } from "../src/lib/canvas/canvas-performance-mode";
|
||||
import { videoMetadata } from "../src/lib/canvas/canvas-generation-task-sync";
|
||||
import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "../src/lib/canvas/canvas-media-preview";
|
||||
import { collectImageStorageKeys } from "../src/services/image-storage";
|
||||
@@ -15,6 +15,8 @@ const canvasAudioPlayerSource = readFileSync(resolve(import.meta.dir, "../src/co
|
||||
const canvasMentionSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-resource-mention-textarea.tsx"), "utf8");
|
||||
const canvasNodeSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node.tsx"), "utf8");
|
||||
const videoPlayerSource = readFileSync(resolve(import.meta.dir, "../src/components/video-player.tsx"), "utf8");
|
||||
const canvasProjectSource = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/project.tsx"), "utf8");
|
||||
const globalStylesSource = readFileSync(resolve(import.meta.dir, "../src/styles/globals.css"), "utf8");
|
||||
|
||||
function node(id: string, type: CanvasNodeType): CanvasNodeData {
|
||||
return { id, type, title: id, position: { x: 0, y: 0 }, width: 320, height: 180, metadata: {} };
|
||||
@@ -44,6 +46,29 @@ describe("canvas dimension header rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("large canvas media rendering", () => {
|
||||
test("keeps rendered nodes mounted longer than newly entering nodes", () => {
|
||||
expect(canvasNodeRenderPadding(true, false)).toBe(128);
|
||||
expect(canvasNodeRenderPadding(true, true)).toBe(640);
|
||||
expect(canvasNodeRenderPadding(false, true)).toBeGreaterThan(canvasNodeRenderPadding(false, false));
|
||||
});
|
||||
|
||||
test("does not eagerly load or resize LibTV thumbnails", () => {
|
||||
expect(canvasNodeContentSource).toContain('loading="lazy"');
|
||||
expect(canvasNodeContentSource).not.toContain('importedFromLibTV ? "eager"');
|
||||
expect(canvasNodeContentSource).toContain("if (importedFromLibTV) return;");
|
||||
});
|
||||
|
||||
test("keeps canvas node action context stable across viewport renders", () => {
|
||||
expect(canvasProjectSource).toContain("const canvasNodeActions = useMemo<CanvasNodeActionContextValue>");
|
||||
expect(canvasProjectSource).toContain("<CanvasNodeActionContext.Provider value={canvasNodeActions}>");
|
||||
});
|
||||
|
||||
test("keeps node shells visible while panning and zooming", () => {
|
||||
expect(globalStylesSource).not.toMatch(/\[data-canvas-viewport-interacting="true"\]\s+\.canvas-node-shell\s*\{[^}]*content-visibility:\s*hidden/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audio canvas interaction", () => {
|
||||
test("uses a lightweight player without a native audio element or inactive mask", () => {
|
||||
expect(canvasNodeContentSource).toContain("<CanvasAudioPlayer node={node} theme={theme} />");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
|
||||
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
|
||||
import { buildNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
describe("canvas node copy title", () => {
|
||||
test("首次复制使用 copy1,后续按已有最大序号递增", () => {
|
||||
@@ -25,3 +27,57 @@ describe("canvas node copy title", () => {
|
||||
expect([first, second]).toEqual(["节点_copy2", "节点_copy3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas generation copy metadata", () => {
|
||||
test("媒体副本保留提示词与参考字段,并明确原地回填生成结果", () => {
|
||||
const source: CanvasNodeData = {
|
||||
id: "source",
|
||||
type: CanvasNodeType.Video,
|
||||
title: "镜头",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 340,
|
||||
height: 240,
|
||||
metadata: {
|
||||
content: "video-url",
|
||||
prompt: "相同提示词",
|
||||
videoStartFrameNodeId: "reference-start",
|
||||
videoEndFrameNodeId: "reference-end",
|
||||
generationResultPlacement: "new-version",
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = isolateCopiedNodeMetadata(source, new Map([[source.id, "copy"]]));
|
||||
|
||||
expect(metadata.prompt).toBe("相同提示词");
|
||||
expect(metadata.videoStartFrameNodeId).toBe("reference-start");
|
||||
expect(metadata.videoEndFrameNodeId).toBe("reference-end");
|
||||
expect(metadata.copiedFromNodeId).toBe("source");
|
||||
expect(metadata.generationResultPlacement).toBe("replace-node");
|
||||
});
|
||||
|
||||
test("复制到副本的入边继续解析为同一张参考图", () => {
|
||||
const reference: CanvasNodeData = {
|
||||
id: "reference",
|
||||
type: CanvasNodeType.Image,
|
||||
title: "角色参考",
|
||||
position: { x: 0, y: 0 },
|
||||
width: 340,
|
||||
height: 240,
|
||||
metadata: { content: "reference-url", mimeType: "image/png" },
|
||||
};
|
||||
const copy: CanvasNodeData = {
|
||||
id: "copy",
|
||||
type: CanvasNodeType.Image,
|
||||
title: "结果_copy1",
|
||||
position: { x: 400, y: 0 },
|
||||
width: 340,
|
||||
height: 240,
|
||||
metadata: { content: "old-result", prompt: "保持角色一致", copiedFromNodeId: "source", generationResultPlacement: "replace-node" },
|
||||
};
|
||||
|
||||
const context = buildNodeGenerationContext(copy.id, [reference, copy], [{ id: "copy-reference", fromNodeId: reference.id, toNodeId: copy.id }], copy.metadata!.prompt!, []);
|
||||
|
||||
expect(context.prompt).toBe("保持角色一致");
|
||||
expect(context.referenceImages.map((image) => image.id)).toEqual([reference.id]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
|
||||
import { normalizeCanvasNodeTimestamps, stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { normalizeCanvasNodeTimestamps, stampCanvasNodeChanges, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
function node(id: string, patch: Partial<CanvasNodeData> = {}): CanvasNodeData {
|
||||
@@ -48,6 +48,21 @@ describe("canvas node timestamps", () => {
|
||||
const updated = stampCanvasNodeChanges([previous], [hydrated], "2026-08-28T02:00:00.000Z");
|
||||
expect(updated[0]?.updatedAt).toBe("2026-08-28T01:00:00.000Z");
|
||||
});
|
||||
|
||||
test("batches media metadata updates while preserving untouched node references", () => {
|
||||
const first = node("first");
|
||||
const second = node("second");
|
||||
const nodes = [first, second];
|
||||
const next = updateCanvasNodes(nodes, new Map([
|
||||
["first", (current) => ({ ...current, metadata: { ...current.metadata, naturalWidth: 1920 } })],
|
||||
["second", (current) => ({ ...current, metadata: { ...current.metadata, naturalHeight: 1080 } })],
|
||||
]), "2026-08-28T03:00:00.000Z");
|
||||
expect(next).not.toBe(nodes);
|
||||
expect(next[0]).not.toBe(first);
|
||||
expect(next[1]).not.toBe(second);
|
||||
expect(next[0].metadata?.naturalWidth).toBe(1920);
|
||||
expect(next[1].metadata?.naturalHeight).toBe(1080);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canvas node search", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildAssetMentionReferences, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { buildAssetMentionReferences, buildCanvasNodeMentionReferenceMap, buildNodeMentionReferences, buildOrderedCanvasResourceReferences, canvasResourceMentionToken, collectUpstreamVideoNodes } from "../src/lib/canvas/canvas-resource-references";
|
||||
import { canvasNodeToAsset } from "../src/lib/canvas/canvas-node-asset";
|
||||
import { buildNodeGenerationInputs } from "../src/components/canvas/canvas-node-generation";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
|
||||
@@ -179,6 +179,20 @@ describe("canvas resource mention slots", () => {
|
||||
expect(buildNodeMentionReferences(target, nodes, connections).map((reference) => reference.label)).toEqual(["图片1", "音频1", "图片2", "文本1"]);
|
||||
});
|
||||
|
||||
test("批量索引保持直接引用和配置节点引用语义", () => {
|
||||
const image = imageNode("image-a");
|
||||
const audio = audioNode("audio-a");
|
||||
const target = videoNode("target");
|
||||
const config: CanvasNodeData = { id: "config", type: CanvasNodeType.Config, title: "config", position: { x: 0, y: 0 }, width: 100, height: 100, metadata: {} };
|
||||
const nodes = [image, audio, target, config];
|
||||
const connections = [connection(image.id, target.id), connection(target.id, config.id), connection(audio.id, config.id)];
|
||||
const references = buildCanvasNodeMentionReferenceMap(nodes, connections);
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
test("素材库身份 token 保持稳定", () => {
|
||||
expect(canvasResourceMentionToken({
|
||||
id: "asset:asset-a",
|
||||
|
||||
79
web/test/canvas-spatial-index.test.ts
Normal file
79
web/test/canvas-spatial-index.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { buildCanvasSpatialIndex, canvasNodeBounds } from "@/lib/canvas/canvas-spatial-index";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
describe("canvas spatial index", () => {
|
||||
test("queries intersecting entries across buckets in source order", () => {
|
||||
const index = buildCanvasSpatialIndex([
|
||||
{ id: "far", bounds: { left: 2048, top: 0, right: 2200, bottom: 120 }, value: "far" },
|
||||
{ id: "near", bounds: { left: 900, top: 40, right: 1100, bottom: 160 }, value: "near" },
|
||||
{ id: "cross", bounds: { left: 1000, top: -40, right: 2100, bottom: 40 }, value: "cross" },
|
||||
], 1024);
|
||||
|
||||
expect(index.query({ left: 950, top: 0, right: 1150, bottom: 100 })).toEqual(["near", "cross"]);
|
||||
expect(index.query({ left: 2100, top: 0, right: 2200, bottom: 100 })).toEqual(["far"]);
|
||||
});
|
||||
|
||||
test("does not return edge-touching or invalid entries", () => {
|
||||
const index = buildCanvasSpatialIndex([
|
||||
{ id: "touching", bounds: { left: 0, top: 0, right: 10, bottom: 10 }, value: "touching" },
|
||||
{ id: "invalid", bounds: { left: 0, top: 0, right: 0, bottom: 10 }, value: "invalid" },
|
||||
]);
|
||||
|
||||
expect(index.query({ left: 10, top: 0, right: 20, bottom: 10 })).toEqual([]);
|
||||
expect(index.query({ left: 1, top: 1, right: 2, bottom: 2 })).toEqual(["touching"]);
|
||||
});
|
||||
|
||||
test("builds bounds from canvas node geometry", () => {
|
||||
expect(canvasNodeBounds({ position: { x: -20, y: 30 }, width: 120, height: 80 })).toEqual({ left: -20, top: 30, right: 100, bottom: 110 });
|
||||
});
|
||||
|
||||
test("keeps very large entries out of the bucket build loop", () => {
|
||||
const index = buildCanvasSpatialIndex([
|
||||
{ id: "large", bounds: { left: -100_000, top: -100_000, right: 100_000, bottom: 100_000 }, value: "large" },
|
||||
{ id: "small", bounds: { left: 20_000, top: 20_000, right: 20_100, bottom: 20_100 }, value: "small" },
|
||||
], 100);
|
||||
|
||||
expect(index.query({ left: 0, top: 0, right: 10, bottom: 10 })).toEqual(["large"]);
|
||||
expect(index.query({ left: 20_000, top: 20_000, right: 20_050, bottom: 20_050 })).toEqual(["large", "small"]);
|
||||
});
|
||||
|
||||
test("supports a bounded query for dense buckets", () => {
|
||||
const index = buildCanvasSpatialIndex(Array.from({ length: 10 }, (_, index) => ({
|
||||
id: `node-${index}`,
|
||||
bounds: { left: index * 2, top: 0, right: index * 2 + 1, bottom: 1 },
|
||||
value: index,
|
||||
})));
|
||||
|
||||
expect(index.query({ left: 0, top: 0, right: 100, bottom: 100 }, 3)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("keeps the target 50k canvas mix query-bounded", () => {
|
||||
const nodes = buildLargeCanvasFixture();
|
||||
const counts = nodes.reduce<Record<string, number>>((result, node) => {
|
||||
result[node.type] = (result[node.type] || 0) + 1;
|
||||
return result;
|
||||
}, {});
|
||||
expect(counts[CanvasNodeType.Image]).toBe(35000);
|
||||
expect(counts[CanvasNodeType.Video]).toBe(14000);
|
||||
expect(counts[CanvasNodeType.Text]).toBe(1000);
|
||||
|
||||
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node.id })));
|
||||
const visible = index.query({ left: 0, top: 0, right: 1600, bottom: 900 }, 720);
|
||||
expect(visible.length).toBeLessThanOrEqual(720);
|
||||
expect(visible.every((id) => id.startsWith("node"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function buildLargeCanvasFixture(): CanvasNodeData[] {
|
||||
return Array.from({ length: 50000 }, (_, index) => ({
|
||||
id: `node${index}`,
|
||||
type: index < 35000 ? CanvasNodeType.Image : index < 49000 ? CanvasNodeType.Video : CanvasNodeType.Text,
|
||||
title: `节点 ${index}`,
|
||||
position: { x: (index % 250) * 360, y: Math.floor(index / 250) * 220 },
|
||||
width: 320,
|
||||
height: 180,
|
||||
metadata: {},
|
||||
}));
|
||||
}
|
||||
@@ -29,6 +29,33 @@ describe("workspace route loading", () => {
|
||||
expect(navigation).toContain("onFocus={() => preloadWorkspaceRoute(linkTo)}");
|
||||
});
|
||||
|
||||
test("uses the creation page as the root entry without legacy home or create routes", () => {
|
||||
const router = source("../src/router.tsx");
|
||||
const navigation = source("../src/components/layout/workspace-sidebar-nav.tsx");
|
||||
|
||||
expect(router).toContain('{ path: "/", element: <RequireAuth>{deferred(<CreatePage />)}</RequireAuth> }');
|
||||
expect(router).not.toContain('path: "/create"');
|
||||
expect(router).not.toContain('path: "/home"');
|
||||
expect(router).not.toContain("HomePage");
|
||||
expect(navigation).toContain('{ id: "home", title: "首页", icon: Home, to: "/" }');
|
||||
expect(navigation).not.toContain('to: "/create"');
|
||||
expect(navigation).not.toContain('to: "/home"');
|
||||
});
|
||||
|
||||
test("preloads canvas detail and paints opening feedback before navigation", () => {
|
||||
const modules = source("../src/lib/workspace-route-modules.ts");
|
||||
const router = source("../src/router.tsx");
|
||||
const canvasLibrary = source("../src/pages/canvas/index.tsx");
|
||||
const canvasCard = source("../src/components/canvas/canvas-folder-card.tsx");
|
||||
|
||||
expect(modules).toContain('loadCanvasProjectPage = () => import("@/pages/canvas/project")');
|
||||
expect(router).toContain("lazy(loadCanvasProjectPage)");
|
||||
expect(canvasLibrary).toContain("setOpeningProjectId(id)");
|
||||
expect(canvasLibrary).toContain("window.requestAnimationFrame(() => navigate(");
|
||||
expect(canvasCard).toContain("onPointerEnter={onPrefetch}");
|
||||
expect(canvasCard).toContain("正在打开");
|
||||
});
|
||||
|
||||
test("uses a quiet workspace skeleton for initial hydration", () => {
|
||||
const loader = source("../src/components/ui/aceternity/full-screen-loader.tsx");
|
||||
const css = source("../src/styles/globals.css");
|
||||
|
||||
Reference in New Issue
Block a user