perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent 165b4443ab
commit 58f01d1eb6
107 changed files with 3025 additions and 1089 deletions

View File

@@ -21,12 +21,13 @@ describe("backend API request error semantics", () => {
response: {
status: 429,
data: { code: 42901, data: null, msg: "请求过于频繁,请稍后重试" },
headers: { "retry-after": "60" },
},
};
const thrown = await request(Promise.reject(axiosError)).catch((error) => error);
expect(thrown).toBeInstanceOf(ApiError);
expect(thrown).toMatchObject({ status: 429, code: 42901, message: "请求过于频繁,请稍后重试", retryable: true });
expect(thrown).toMatchObject({ status: 429, code: 42901, message: "请求过于频繁,请稍后重试", retryable: true, retryAfterMs: 60_000 });
expect(thrown.cause).toBe(axiosError);
});

View File

@@ -69,13 +69,16 @@ describe("large canvas media rendering", () => {
expect(globalStylesSource).not.toMatch(/\[data-canvas-viewport-interacting="true"\]\s+\.canvas-node-shell\s*\{[^}]*content-visibility:\s*hidden/);
});
test("lets inactive video nodes hydrate their own first-frame preview", () => {
expect(canvasNodeContentSource).toContain("if (previewUrl || !node.metadata?.content || !updateMetadataRef.current)");
test("keeps inactive video nodes on a viewport-gated static first frame", () => {
const inactivePreviewSource = canvasNodeContentSource.match(/function InactiveVideoPreview[\s\S]*?\n}\n\nfunction VideoPreviewPlayButton/)?.[0] || "";
expect(canvasNodeContentSource).toContain("if (previewUrl || !nearViewport || !node.metadata?.content || !updateMetadataRef.current)");
expect(canvasNodeContentSource).not.toContain("hydrateMediaPreview");
expect(canvasNodeContentSource).toContain("mediaActive || !hasPassivePreview");
expect(canvasNodeContentSource).toContain('muted\n playsInline\n preload="auto"');
expect(canvasNodeContentSource).toContain("onLoadedMetadata={(event) => primePassiveVideoFrame(event.currentTarget)}");
expect(canvasNodeContentSource).toContain("video.currentTime = Math.min(0.001, video.duration)");
expect(inactivePreviewSource).not.toContain("<video");
expect(inactivePreviewSource).toContain("<VideoPreviewPlayButton");
expect(canvasNodeContentSource).toContain("useVideoPlaybackUrl(node, mediaActive)");
expect(canvasNodeContentSource).toContain("onMediaPlayRequest?.(node.id)");
expect(canvasNodeContentSource).toContain('autoPlay preload="metadata"');
expect(canvasNodeContentSource).toContain("scheduleResourceBlobCache(node.metadata?.storageKey || \"\")");
});
test("allows failed or empty first-frame requests to retry", () => {
@@ -174,13 +177,14 @@ describe("video canvas controls", () => {
expect(videoPlayerSource).toContain("VolumeLow: defaultLayoutIcons.MuteButton.Mute");
expect(videoPlayerSource).toContain("VolumeHigh: defaultLayoutIcons.MuteButton.Mute");
expect(videoPlayerSource).toContain("muteButton.disabled = noAudio;");
expect(videoPlayerSource).toContain("(volumeSlider as HTMLElement & { disabled?: boolean }).disabled = noAudio;");
expect(videoPlayerSource).not.toContain("volumeSlider as HTMLElement & { disabled?: boolean }");
expect(videoPlayerSource).toContain('volumeSlider.setAttribute("aria-disabled", String(noAudio))');
expect(videoPlayerSource).toContain('event.target.closest(".vds-volume,.vds-mute-button,.vds-volume-slider")');
expect(videoPlayerSource).toContain("onKeyDown={stopCanvasControlInteraction}");
expect(videoPlayerSource).not.toContain("onPointerDownCapture={stopCanvasControlInteraction}");
expect(videoPlayerSource).not.toContain("onMouseDownCapture={stopCanvasControlInteraction}");
expect(videoPlayerSource).not.toContain("onClickCapture={stopCanvasControlClick}");
expect(canvasNodeContentSource).toContain("hasAudio={inferVideoHasAudio(node.metadata)} autoPlay={mediaActive}");
expect(canvasNodeContentSource).toContain('hasAudio={inferVideoHasAudio(node.metadata)} autoPlay preload="metadata"');
expect(canvasNodeContentSource).toContain('if (["false", "0", "off", "no", "disabled"].includes(value || "")) return false;');
});
@@ -225,6 +229,8 @@ describe("video canvas controls", () => {
});
test("keeps the first activation click from being treated as a node drag", () => {
expect(canvasNodeContentSource).toContain("onPointerDown={(event) => event.stopPropagation()}");
expect(canvasNodeContentSource).toContain("onMouseDown={(event) => event.stopPropagation()}");
expect(videoPlayerSource).toContain("onClick={stopCanvasControlClick}");
expect(videoPlayerSource).toContain('event.target.closest(".vds-controls,.vds-menu-items")');
expect(videoPlayerSource).toContain("event.nativeEvent?.composedPath?.()");

View File

@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test";
import { buildImageToolbarTools } from "@/components/canvas/canvas-image-toolbar-tools";
import { resolveNodeToolbarPlacement, resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
function createNodeContext(node: CanvasNodeData): ToolContext {
return {
selectedCount: 0,
selectedNodeTypes: new Set(),
selectedVideoCount: 0,
canvasTool: "move",
workspaceMode: "professional",
isProjectLinked: false,
canUndo: false,
canRedo: false,
node,
nodeMetadata: node.metadata,
extractingVideoFrames: false,
extractingAudio: false,
trimmingVideo: false,
mergingVideos: false,
addPanelOpen: false,
appearancePanelOpen: false,
settingsPanelOpen: false,
handlers: {} as ToolbarHandlers,
};
}
describe("canvas node toolbar model", () => {
test("preserves empty, simple and busy video states", () => {
const ctx = createNodeContext({ id: "video", type: CanvasNodeType.Video, title: "视频", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: {} });
const emptyTools = resolveToolbarTools("node-hover", ctx, null);
expect(emptyTools.some((tool) => tool.id === "extractAudio")).toBe(false);
expect(resolveNodeToolbarPlacement(emptyTools.find((tool) => tool.id === "uploadVideo")!, ctx).group).toBe("primary");
ctx.nodeMetadata = { content: "video.mp4" };
ctx.extractingAudio = true;
const audio = resolveToolbarTools("node-hover", ctx, null).find((tool) => tool.id === "extractAudio")!;
expect(audio.disabled?.(ctx)).toBe(true);
ctx.workspaceMode = "simple";
expect(resolveToolbarTools("node-hover", ctx, null).some((tool) => tool.id === "extractAudio")).toBe(false);
});
test("image menu actions remain lazy and keep the original node", () => {
const node: CanvasNodeData = { id: "image", type: CanvasNodeType.Image, title: "图片", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "image.png", freeResize: true } };
const received: CanvasNodeData[] = [];
const tools = buildImageToolbarTools(node, { onCrop: (target: CanvasNodeData) => received.push(target) } as never);
expect(received).toEqual([]);
tools.find((tool) => tool.id === "crop")!.onClick();
expect(received).toEqual([node]);
expect(tools.find((tool) => tool.id === "resize")?.active).toBe(false);
});
test("keeps video primary actions compact and moves secondary processing into a menu", () => {
const ctx = createNodeContext({ id: "video", type: CanvasNodeType.Video, title: "视频", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "video.mp4" } });
const tools = resolveToolbarTools("node-hover", ctx, null);
const groups = new Map(tools.map((tool) => [tool.id, resolveNodeToolbarPlacement(tool, ctx).group]));
expect([...groups].filter(([, group]) => group === "primary").map(([id]) => id)).toEqual(["trimRegenerate", "subtitles"]);
expect([...groups].filter(([, group]) => group === "process").map(([id]) => id)).toEqual(["extractFrames", "extractAudio"]);
expect(groups.get("download")).toBe("utility");
expect(groups.get("timeline")).toBe("workspace");
expect(groups.get("uploadVideo")).toBe("more");
expect(groups.get("delete")).toBe("more");
expect(groups.get("node-lock")).toBe("more");
});
test("uses the redesigned video terminology from the registry", () => {
const ctx = createNodeContext({ id: "video", type: CanvasNodeType.Video, title: "视频", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "video.mp4" } });
const tools = new Map(resolveToolbarTools("node-hover", ctx, null).map((tool) => [tool.id, tool]));
const displayLabel = (id: string) => {
const value = tools.get(id)?.displayLabel;
return typeof value === "function" ? value(ctx) : value;
};
expect(displayLabel("subtitles")).toBe("字幕");
expect(displayLabel("timeline")).toBe("进入剪辑");
expect(displayLabel("extractFrames")).toBe("提取画面");
expect(displayLabel("extractAudio")).toBe("提取音频");
});
test("image-only tools carry presentation metadata instead of relying on component ID lists", () => {
const node: CanvasNodeData = { id: "image", type: CanvasNodeType.Image, title: "图片", position: { x: 0, y: 0 }, width: 320, height: 180, metadata: { content: "image.png" } };
const tools = buildImageToolbarTools(node, {} as never);
expect(tools.find((tool) => tool.id === "angle")?.group).toBe("viewpoint");
expect(tools.find((tool) => tool.id === "maskEdit")?.group).toBe("primary");
expect(tools.some((tool) => tool.id === "superResolve")).toBe(false);
expect(tools.find((tool) => tool.id === "emotion")?.group).toBe("portrait");
expect(tools.find((tool) => tool.id === "upscale")?.description).toContain("不是 AI 超分");
expect(tools.find((tool) => tool.id === "crop")?.section).toBe("构图与尺寸");
expect(tools.find((tool) => tool.id === "resize")?.active).toBe(true);
expect(tools.find((tool) => tool.id === "copyPrompt")?.group).toBe("more");
});
});

View File

@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test";
import { applyCanvasSelectionStrategy, canvasSelectionHitsBounds, createCanvasSelectionBounds, createCanvasSelectionSpatialIndexCache, resolveCanvasPointerIntent, resolveCanvasSelectionHitMode, resolveCanvasSelectionPreviewDelta, resolveCanvasSelectionStrategy } from "@/lib/canvas/canvas-selection";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
describe("canvas selection semantics", () => {
test("maps modifiers to replace, add, toggle, and subtract strategies", () => {
expect(resolveCanvasSelectionStrategy({ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })).toBe("replace");
expect(resolveCanvasSelectionStrategy({ altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBe("add");
expect(resolveCanvasSelectionStrategy({ altKey: false, ctrlKey: true, metaKey: false, shiftKey: false })).toBe("toggle");
expect(resolveCanvasSelectionStrategy({ altKey: false, ctrlKey: false, metaKey: true, shiftKey: false })).toBe("toggle");
expect(resolveCanvasSelectionStrategy({ altKey: true, ctrlKey: true, metaKey: true, shiftKey: true })).toBe("subtract");
});
test("uses containment from left to right and intersection from right to left", () => {
expect(resolveCanvasSelectionHitMode(0, 100)).toBe("contain");
expect(resolveCanvasSelectionHitMode(100, 0)).toBe("intersect");
expect(createCanvasSelectionBounds(100, 80, 0, 20)).toEqual({ left: 0, top: 20, right: 100, bottom: 80 });
});
test("distinguishes fully contained nodes from partially intersecting nodes", () => {
const selection = { left: 0, top: 0, right: 100, bottom: 100 };
const contained = { left: 10, top: 10, right: 90, bottom: 90 };
const partial = { left: 80, top: 80, right: 120, bottom: 120 };
expect(canvasSelectionHitsBounds(selection, contained, "contain")).toBe(true);
expect(canvasSelectionHitsBounds(selection, partial, "contain")).toBe(false);
expect(canvasSelectionHitsBounds(selection, partial, "intersect")).toBe(true);
});
test("applies all four selection strategies against the gesture-start snapshot", () => {
const initial = ["a", "b"];
const hits = ["b", "c"];
expect([...applyCanvasSelectionStrategy(initial, hits, "replace")]).toEqual(["b", "c"]);
expect([...applyCanvasSelectionStrategy(initial, hits, "add")]).toEqual(["a", "b", "c"]);
expect([...applyCanvasSelectionStrategy(initial, hits, "toggle")]).toEqual(["a", "c"]);
expect([...applyCanvasSelectionStrategy(initial, hits, "subtract")]).toEqual(["a"]);
});
test("derives transient include/remove markers without cloning the full selection", () => {
const initial = new Set(["a", "b"]);
const hits = new Set(["b", "c"]);
expect(resolveCanvasSelectionPreviewDelta(initial, hits, "replace")).toEqual({ includeNodeIds: new Set(["c"]), removeNodeIds: new Set(["a"]) });
expect(resolveCanvasSelectionPreviewDelta(initial, hits, "add")).toEqual({ includeNodeIds: new Set(["c"]), removeNodeIds: new Set() });
expect(resolveCanvasSelectionPreviewDelta(initial, hits, "toggle")).toEqual({ includeNodeIds: new Set(["c"]), removeNodeIds: new Set(["b"]) });
expect(resolveCanvasSelectionPreviewDelta(initial, hits, "subtract")).toEqual({ includeNodeIds: new Set(), removeNodeIds: new Set(["b"]) });
});
test("routes default mouse selection without stealing trackpad or temporary hand gestures", () => {
const pointer = { altKey: false, background: true, boxSelectEnabled: true, button: 0, ctrlKey: false, metaKey: false, pointerType: "mouse", shiftKey: false, spacePressed: false };
expect(resolveCanvasPointerIntent(pointer)).toBe("select");
expect(resolveCanvasPointerIntent({ ...pointer, spacePressed: true })).toBe("pan");
expect(resolveCanvasPointerIntent({ ...pointer, button: 1 })).toBe("pan");
expect(resolveCanvasPointerIntent({ ...pointer, pointerType: "touch" })).toBe("pan");
expect(resolveCanvasPointerIntent({ ...pointer, background: false })).toBe("ignore");
expect(resolveCanvasPointerIntent({ ...pointer, background: false, spacePressed: true })).toBe("pan");
expect(resolveCanvasPointerIntent({ ...pointer, background: false, button: 1 })).toBe("pan");
expect(resolveCanvasPointerIntent({ ...pointer, boxSelectEnabled: false })).toBe("pan");
expect(resolveCanvasPointerIntent({ ...pointer, boxSelectEnabled: false, shiftKey: true })).toBe("select");
});
test("reuses the selectable-node spatial index until the node array changes", () => {
const nodes: CanvasNodeData[] = [
node("batch-root", 0, 0, { isBatchRoot: true, imageBatchExpanded: false }),
node("batch-child", 20, 20, { batchRootId: "batch-root" }),
{ ...node("frame", 300, 0), type: CanvasNodeType.Frame, metadata: { frame: { collapsed: true, expandedWidth: 160, expandedHeight: 90 } } },
{ ...node("frame-child", 320, 20), parentId: "frame" },
node("visible", 600, 0),
];
const cache = createCanvasSelectionSpatialIndexCache();
const first = cache.get(nodes);
expect(cache.get(nodes)).toBe(first);
expect(first.query({ left: -10, top: -10, right: 1000, bottom: 300 }).map((item) => item.id)).toEqual(["batch-root", "frame", "visible"]);
expect(cache.get([...nodes])).not.toBe(first);
});
test("uses region selection as the page default and commits selection outside pointer-move", async () => {
const projectSource = await Bun.file(new URL("../src/pages/canvas/project.tsx", import.meta.url)).text();
const controllerSource = await Bun.file(new URL("../src/pages/canvas/use-canvas-selection-controller.ts", import.meta.url)).text();
const canvasSource = await Bun.file(new URL("../src/components/canvas/infinite-canvas.tsx", import.meta.url)).text();
const graphicsSource = await Bun.file(new URL("../src/components/canvas/canvas-leafer-graphics-layer.tsx", import.meta.url)).text();
const globalStyles = await Bun.file(new URL("../src/styles/globals.css", import.meta.url)).text();
expect(projectSource).toContain('useState<CanvasToolMode>("box-select")');
const pointerMoveBody = controllerSource.slice(controllerSource.indexOf("const handlePointerMove"), controllerSource.indexOf("const finishSelection"));
expect(pointerMoveBody).not.toContain("setSelectedNodeIds(");
expect(controllerSource).toContain("updateSelectionPreview(screenToCanvas(clientX, clientY), true)");
expect(canvasSource).toContain('"canvas-cursor-select"');
expect(canvasSource).not.toContain('"cursor-crosshair"');
expect(graphicsSource).toContain('fill: "transparent"');
expect(graphicsSource).toContain('dashPattern: [4 / scale, 4 / scale]');
expect(globalStyles).toContain(".canvas-cursor-select");
expect(globalStyles).not.toContain("filter='drop-shadow");
});
});
function node(id: string, x: number, y: number, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {
return {
id,
type: CanvasNodeType.Image,
title: id,
position: { x, y },
width: 160,
height: 90,
metadata,
};
}

View File

@@ -8,6 +8,15 @@ describe("canvas shortcuts", () => {
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "focus")?.keys).toEqual([["Shift", "Ctrl / Cmd", "F"]]);
});
test("documents default region selection and trackpad-friendly panning", () => {
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "box-select")?.keys[0]).toEqual(["空白处左键拖动"]);
expect(CANVAS_SHORTCUTS.find((shortcut) => shortcut.id === "pan")?.keys).toEqual([
["触控板双指"],
["Space", "左键拖动"],
["中键拖动"],
]);
});
test("searches titles, descriptions, keys and keywords", () => {
expect(filterCanvasShortcuts("粘贴").map((shortcut) => shortcut.id)).toContain("paste");
expect(filterCanvasShortcuts("缩放").map((shortcut) => shortcut.id)).toEqual(expect.arrayContaining(["zoom-wheel", "zoom-controls", "zoom-presets"]));

View File

@@ -277,6 +277,52 @@ test("a selected Dreamina local model never creates a Backend task", async () =>
expect(backendCalls).toBe(0);
});
test("backend text generation forwards streaming callbacks and thinking options", async () => {
let createdInput: Parameters<NonNullable<Parameters<typeof runBackendGenerationTask>[1]>["createTask"]>[0] | undefined;
let streamedText = "";
const running: GenerationTask = {
id: "text-stream-task-0001",
type: "canvas_text",
status: "running",
prompt: "写一个开场",
attempts: 1,
createdAt: "2026-09-04T00:00:00.000Z",
updatedAt: "2026-09-04T00:00:00.000Z",
};
const result = await runBackendGenerationTask(
{
mode: "text",
prompt: running.prompt,
config: backendModelConfig("reasoning-text-model"),
streamText: true,
enableThinking: true,
onTextDelta: (value) => {
streamedText = value;
},
},
{
createTask: async (input) => {
createdInput = input;
return running;
},
waitTask: async (_id, options) => {
options?.onTextDelta?.("实时正文");
return { ...running, status: "succeeded", resultJson: JSON.stringify({ mode: "text", text: "完整正文", reasoning: "思考摘要" }) };
},
runLocal: async () => {
throw new Error("must not use local Runtime");
},
createId: () => "unused-text-id",
now: () => "2026-09-04T00:00:00.000Z",
},
);
expect(createdInput?.input?.textOptions).toEqual({ stream: true, thinking: true });
expect(streamedText).toBe("实时正文");
expect(result).toEqual({ mode: "text", text: "完整正文", reasoning: "思考摘要" });
});
test("the shared local generation entry projects pre-receipt work as submitting without fake progress", async () => {
const updates: GenerationTask[] = [];
let release!: () => void;

View File

@@ -8,7 +8,8 @@ import { applyCanvasGenerationTaskNodeEffect, persistCanvasAgentGenerationContin
import { canvasCinematicContinuationEntryAdapters } from "../src/components/canvas/canvas-assistant-panel";
import { applyGenerationConsumerEffect, generationEffectApplied } from "../src/services/generation-consumer-dedupe";
import { createProviderNeutralGenerationTaskEffectStore } from "../src/services/provider-neutral-generation-effects";
import { consumeGenerationTaskAgent, consumeGenerationTaskMessage, consumeGenerationTaskNode, materializeGenerationTaskAssets } from "../src/services/project-asset-sync";
import { consumeGenerationTaskAgent, consumeGenerationTaskMessage, consumeGenerationTaskNode, materializeGenerationTaskAssets, retryCanvasAssetSyncAfterRateLimit } from "../src/services/project-asset-sync";
import { ApiError } from "../src/services/api/request";
import { flushCanvasStorePersistence, useCanvasStore, withCanvasStorePersistenceSuppressed, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
import { flushAssetStorePersistence, useAssetStore, type NewAsset } from "../src/stores/use-asset-store";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasNodeData } from "../src/types/canvas";
@@ -42,6 +43,56 @@ function createEffectStore(): GenerationTaskEffectStore {
};
}
test("automatic canvas asset sync honors Retry-After before retrying a rate limit", async () => {
const delays: number[] = [];
let attempts = 0;
const result = await retryCanvasAssetSyncAfterRateLimit(
async () => {
attempts += 1;
if (attempts === 1) throw new ApiError("请求过于频繁,请稍后再试", { status: 429, retryAfterMs: 60_000 });
return "synced";
},
{
wait: async (delayMs) => {
delays.push(delayMs);
},
},
);
expect(result).toBe("synced");
expect(attempts).toBe(2);
expect(delays).toEqual([60_000]);
});
test("automatic canvas asset sync does not retry non-rate-limit failures", async () => {
let attempts = 0;
await expect(
retryCanvasAssetSyncAfterRateLimit(async () => {
attempts += 1;
throw new ApiError("素材校验失败", { status: 400 });
}),
).rejects.toThrow("素材校验失败");
expect(attempts).toBe(1);
});
test("automatic canvas asset sync aborts promptly while waiting for the rate-limit window", async () => {
const controller = new AbortController();
const syncing = retryCanvasAssetSyncAfterRateLimit(
async () => {
throw new ApiError("请求过于频繁,请稍后再试", { status: 429, retryAfterMs: 60_000 });
},
{ signal: controller.signal },
);
await Promise.resolve();
controller.abort();
await expect(syncing).rejects.toMatchObject({ name: "AbortError" });
});
type MetaImageHarnessImage = {
naturalWidth: number;
naturalHeight: number;
@@ -3075,7 +3126,8 @@ describe("generation task materializer", () => {
test("generic task materialization never treats a local Canvas id as a backend project id", async () => {
const source = await Bun.file(new URL("../src/services/project-asset-sync.ts", import.meta.url)).text();
expect(source).not.toContain("if (input.task.projectId) await syncAssetToProject(assetId, input.task.projectId");
expect(source).toContain("if (!options.domainProjectId) return");
expect(source).toContain("if (!options.domainProjectId) {");
expect(source).toContain("linkedToProject: false");
expect(source).toContain("await syncAssetToProject(asset.id, options.domainProjectId");
});

View File

@@ -14,7 +14,7 @@ import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore, type Asset,
import { withGenerationAssetStorageLock } from "../src/services/generation-asset-repository";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore, withCanvasStorePersistenceLock, withCanvasStorePersistenceSuppressed, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
import { deleteAssetWithRemoteSync, deleteCanvasProjectsWithRemoteSync, installRemoteUserDataAutoSync, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData, withRemoteUserDataSyncExclusive } from "../src/services/user-data-sync";
import { deleteAssetWithRemoteSync, deleteCanvasProjectsWithRemoteSync, initializeRemoteUserDataSession, loadCanvasProjectForEditing, loadAssetLibraryPage, installRemoteUserDataAutoSync, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData, withRemoteUserDataSyncExclusive } from "../src/services/user-data-sync";
import { apiClient } from "../src/services/api/request";
import { useUserStore } from "../src/stores/use-user-store";
import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "../src/stores/canvas/use-canvas-history-store";
@@ -4404,21 +4404,75 @@ test("failed remote baseline cannot upload stale local cache", async () => {
}
});
test("user session stays unhydrated until the remote baseline is durable", async () => {
test("incremental sessions leave cached entities untouched and fetch only the opened canvas", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
Object.defineProperty(globalThis, "window", { configurable: true, value: { setTimeout: () => 1, clearTimeout: () => undefined, localStorage: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined } } });
const previousAdapter = apiClient.defaults.adapter;
const previousProjects = useCanvasStore.getState().projects;
const previousAssets = useAssetStore.getState().assets;
const requests: string[] = [];
const cached = storedCanvasProject("cached-unopened", "Cached");
const remote = storedCanvasProject("remote-opened", "Remote");
apiClient.defaults.adapter = async (config) => {
requests.push(`${config.method} ${config.url}`);
if (config.url !== "/canvas-projects/remote-opened") throw new Error("unexpected request");
return { data: { code: 0, data: { project: remote }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
};
try {
useCanvasStore.setState({ projects: [cached] });
useAssetStore.setState({ assets: [] });
await initializeRemoteUserDataSession("incremental-owner");
await saveRemoteUserDataNow();
expect(requests).toEqual([]);
expect((await loadCanvasProjectForEditing(remote.id))?.title).toBe("Remote");
await loadCanvasProjectForEditing(remote.id);
expect(requests).toEqual(["get /canvas-projects/remote-opened"]);
expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual([cached.id, remote.id]);
useCanvasStore.setState({ projects: [remote] });
await saveRemoteUserDataNow();
expect(requests).toEqual(["get /canvas-projects/remote-opened"]);
} finally {
resetRemoteUserDataSync();
apiClient.defaults.adapter = previousAdapter;
useCanvasStore.setState({ projects: previousProjects });
useAssetStore.setState({ assets: previousAssets });
if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window;
else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow });
}
});
test("an asset page arriving after account switch cannot populate the new account", async () => {
const previousAdapter = apiClient.defaults.adapter;
const previousAssets = useAssetStore.getState().assets;
let release!: () => void;
const waiting = new Promise<void>((resolve) => { release = resolve; });
apiClient.defaults.adapter = async (config) => {
await waiting;
return { data: { code: 0, data: { assets: [], page: 1, pageSize: 40, total: 0, hasMore: false }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
};
try {
await initializeRemoteUserDataSession("account-A");
const request = loadAssetLibraryPage({ page: 1, pageSize: 40 }).catch((error: unknown) => error);
await initializeRemoteUserDataSession("account-B");
release();
expect(String(await request)).toContain("账号已切换");
expect(useAssetStore.getState().assets).toEqual(previousAssets);
} finally {
release();
resetRemoteUserDataSync();
apiClient.defaults.adapter = previousAdapter;
useAssetStore.setState({ assets: previousAssets });
}
});
test("user session initializes without downloading the full remote snapshot", async () => {
const originalWindow = (globalThis as { window?: unknown }).window;
const originalGetItem = localforage.getItem.bind(localforage);
const originalSetItem = localforage.setItem.bind(localforage);
const previousAdapter = apiClient.defaults.adapter;
const previousUserState = useUserStore.getState();
const localValues = new Map<string, string>();
let releaseSnapshot!: () => void;
const snapshotReleased = new Promise<void>((resolve) => {
releaseSnapshot = resolve;
});
let snapshotStartedResolve!: () => void;
const snapshotStarted = new Promise<void>((resolve) => {
snapshotStartedResolve = resolve;
});
let snapshotRequests = 0;
const localStorageValues = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
configurable: true,
@@ -4443,8 +4497,7 @@ test("user session stays unhydrated until the remote baseline is durable", async
return { data: { code: 0, data: { source: "frontend", models: [] }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
}
if (url.includes("user-data/snapshot")) {
snapshotStartedResolve();
await snapshotReleased;
snapshotRequests += 1;
return { data: { code: 0, data: { projects: [], assets: [] }, msg: "" }, status: 200, statusText: "OK", headers: {}, config };
}
throw new Error(`unexpected request: ${String(config.method || "get")} ${url}`);
@@ -4465,13 +4518,11 @@ test("user session stays unhydrated until the remote baseline is durable", async
updatedAt: "2026-08-25T00:00:00.000Z",
},
});
await snapshotStarted;
expect(useUserStore.getState().hydrated).toBe(false);
releaseSnapshot();
await applying;
expect(useUserStore.getState().hydrated).toBe(true);
expect(snapshotRequests).toBe(0);
} finally {
releaseSnapshot();
await applying?.catch(() => undefined);
resetRemoteUserDataSync();
useUserStore.setState(previousUserState);

View File

@@ -24,6 +24,8 @@ describe("workspace route loading", () => {
for (const route of ["projects", "canvas", "assets", "wallet", "create"]) {
expect(modules).toContain(`${route}: () => import`);
}
expect(modules).toContain('projectDetail: () => import("@/pages/projects/detail")');
expect(modules).toContain('slug === "projects" && segments.length > 1');
expect(navigation).toContain("onPointerEnter={() => preloadWorkspaceRoute(linkTo)}");
expect(navigation).toContain("onPointerDown={() => preloadWorkspaceRoute(linkTo)}");
expect(navigation).toContain("onFocus={() => preloadWorkspaceRoute(linkTo)}");
@@ -56,6 +58,41 @@ describe("workspace route loading", () => {
expect(canvasCard).toContain("正在打开");
});
test("loads only the active project detail view and keeps canvas-only state out of the global layout", () => {
const detail = source("../src/pages/projects/detail.tsx");
const layout = source("../src/layouts/user-layout.tsx");
const canvas = source("../src/pages/canvas/index.tsx");
for (const view of ["assets", "canvases", "chapters", "overview", "settings", "workflow", "editor"]) {
expect(detail).toContain(`lazy(() => import("./detail/${view}"))`);
}
expect(layout).not.toContain("useCanvasUiStore");
expect(layout).not.toContain("CanvasDeleteProjectsDialog");
expect(canvas).toContain("deleteDialogOpen ? <Suspense");
});
test("does not poll wallet balance from permanent workspace chrome", () => {
const wallet = source("../src/hooks/use-wallet-balance.ts");
expect(wallet).not.toContain("refetchInterval:");
expect(wallet).toContain("wallet:updated");
expect(wallet).toContain("WALLET_STALE_TIME_MS");
});
test("defers modal-only markdown and canvas creation runtimes until interaction", () => {
const changelogButton = source("../src/components/layout/app-changelog-modal.tsx");
const announcements = source("../src/components/layout/system-announcement-center.tsx");
const projectDetail = source("../src/pages/projects/detail.tsx");
const workflow = source("../src/pages/projects/detail/workflow-production-workbench.tsx");
expect(changelogButton).toContain('lazy(() => import("@/components/layout/app-changelog-dialog")');
expect(changelogButton).not.toContain('from "react-markdown"');
expect(announcements).toContain('lazy(() => import("@/components/ui/aceternity/announcement-timeline-modal")');
expect(projectDetail).toContain('import("@/services/user-data-sync")');
expect(projectDetail).not.toContain('import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync"');
expect(workflow).not.toContain('from "@/lib/video-poster"');
expect(workflow).toContain('if (playing) return <video');
});
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");