fix(canvas): 画布批量生成 - 修复节点丢失、资源引用及刷新状态不一致

This commit is contained in:
ddCat
2026-09-21 21:13:29 +08:00
parent dc1ad68015
commit 6f45240599
26 changed files with 533 additions and 179 deletions

View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
import { selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility";
import { buildCanvasSpatialIndex, canvasNodeBounds } from "@/lib/canvas/canvas-spatial-index";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { markCanvasTaskRecoveryUnconfirmed, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-task-state";
const image = (id: string, x = 0): CanvasNodeData => ({ id, type: CanvasNodeType.Image, title: id, position: { x, y: 0 }, width: 20, height: 20, metadata: { taskId: `task-${id}`, status: "loading" } });
describe("canvas synchronous editor state", () => {
test("commands see previous commands without waiting for React, and execute once", () => {
const ref = { current: [] as string[] };
const snapshots: string[][] = [];
const write = createCanvasStateWriter(ref, (value) => snapshots.push(value));
let calls = 0;
write((current) => { calls++; return [...current, "first"]; });
write((current) => { calls++; return [...current, "second"]; });
expect(calls).toBe(2);
expect(ref.current).toEqual(["first", "second"]);
expect(snapshots).toEqual([["first"], ["first", "second"]]);
});
test("explicit ref writes must still publish to React", () => {
const ref = { current: [] as string[] };
const snapshots: string[][] = [];
const write = createCanvasStateWriter(ref, (value) => snapshots.push(value));
ref.current = ["restored"];
write(ref.current);
expect(snapshots).toEqual([["restored"]]);
});
});
describe("canvas generation node commits", () => {
test("task lookup errors preserve unknown state and cannot undo success or a new binding", () => {
const pending = image("a");
expect(markCanvasTaskRecoveryUnconfirmed(pending, "task-a", "network unavailable").metadata).toMatchObject({ status: "loading", taskId: "task-a", errorDetails: "network unavailable" });
expect(markCanvasTaskRecoveryUnconfirmed(pending, "old-task", "missing")).toBe(pending);
const success: CanvasNodeData = { ...pending, metadata: { ...pending.metadata, status: "success", storageKey: "resource:a" } };
expect(markCanvasTaskRecoveryUnconfirmed(success, "task-a", "missing")).toBe(success);
expect(resetGenerationTaskMetadata({ ...success.metadata, taskStatus: "failed" }, "loading")).toMatchObject({ storageKey: "resource:a", status: "loading" });
expect(resetGenerationTaskMetadata(success.metadata).taskId).toBeUndefined();
});
test("five concurrent results preserve two successes, three failures, and unrelated edits", async () => {
const before = Array.from({ length: 5 }, (_, i) => image(String(i)));
const ref = { current: [...before, image("new")] };
const write = createCanvasStateWriter(ref, () => {});
await Promise.all(before.map(async (node, i) => {
await Promise.resolve();
const result: CanvasNodeData = { ...node, metadata: { ...node.metadata, status: i < 2 ? "success" : "error", storageKey: i < 2 ? `resource:${i}` : undefined } };
write((current) => commitCanvasGenerationResult(current, node, result, `task-${i}`));
}));
expect(ref.current.filter((node) => node.metadata?.status === "success")).toHaveLength(2);
expect(ref.current.filter((node) => node.metadata?.status === "error")).toHaveLength(3);
expect(ref.current.at(-1)?.id).toBe("new");
});
test("keeps movement, manual resize, rename and other node deletion during materialization", () => {
const before = image("a");
const live = { ...before, title: "renamed", position: { x: 300, y: 400 }, width: 600 };
const result: CanvasNodeData = { ...before, width: 100, title: "generated", metadata: { ...before.metadata, status: "success", storageKey: "resource:a" } };
const committed = commitCanvasGenerationResult([live], before, result, "task-a");
expect(committed).toHaveLength(1);
expect(committed[0]).toMatchObject({ title: "renamed", position: live.position, width: 600, metadata: { storageKey: "resource:a" } });
});
test("does not resurrect deleted nodes or overwrite a reset/new task", () => {
const before = image("a");
expect(() => commitCanvasGenerationResult([], before, before, "task-a")).toThrow();
expect(() => commitCanvasGenerationResult([{ ...before, metadata: { taskId: "new" } }], before, before, "task-a")).toThrow();
expect(() => commitCanvasGenerationResult([{ ...before, metadata: {} }], before, before, "task-a")).toThrow();
});
});
describe("canvas viewport selection", () => {
test("hidden and overscan nodes cannot consume the viewport budget", () => {
const nodes = [...Array.from({ length: 400 }, (_, i) => image(`hidden-${i}`)), ...Array.from({ length: 400 }, (_, i) => image(`buffer-${i}`, -100)), ...Array.from({ length: 1000 }, (_, i) => image(`visible-${i}`)), image("forced", 2000)];
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, value: node.id, bounds: canvasNodeBounds(node) })));
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const view = { left: 0, top: 0, right: 100, bottom: 100 };
const retain = { ...view, left: -200, right: 200 };
const selected = selectCanvasVisibleNodes({ index, nodeById, view, enter: retain, retain, hiddenIds: new Set(nodes.filter((node) => node.id.startsWith("hidden")).map((node) => node.id)), retainedIds: new Set(), forcedIds: new Set(["forced"]), budget: 280 });
expect(selected).toHaveLength(1001);
expect(selected.filter((node) => node.id.startsWith("visible"))).toHaveLength(1000);
expect(selected.at(-1)?.id).toBe("forced");
expect(nodes).toHaveLength(1801);
});
});

View File

@@ -8,6 +8,10 @@ function node(id: string, x: number, y: number, width = 340, height = 240): Canv
}
describe("findAvailableGenerationGroupPosition", () => {
test("已有存储标识但预览未恢复的媒体不能当作空节点覆盖", () => {
expect(canGenerateImageInPlace({ ...node("stored", 0, 0), metadata: { storageKey: "resource:image" } })).toBe(false);
expect(canGenerateMediaInPlace({ ...node("video", 0, 0), type: CanvasNodeType.Video, metadata: { storageKey: "resource:video" } }, CanvasNodeType.Video)).toBe(false);
});
test("首选位置没有占用时保持原坐标", () => {
expect(findAvailableGenerationGroupPosition([node("source", 0, 0)], { x: 436, y: 0 }, { width: 340, height: 240 })).toEqual({ x: 436, y: 0 });
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { applyGeneratedMediaResultMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { applyGeneratedMediaResultMetadata, applyGenerationTaskResultToNodes, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
function videoNode(assetId: string, storageKey: string): CanvasNodeData {
@@ -16,6 +16,12 @@ function videoNode(assetId: string, storageKey: string): CanvasNodeData {
}
describe("applyGeneratedMediaResultMetadata", () => {
test("明确指定的节点已删除时不能根据 taskId 误更新其他节点", async () => {
const other = { ...videoNode("asset", "video:key"), metadata: { taskId: "task" } };
const applied = await applyGenerationTaskResultToNodes([other], { id: "task", type: "canvas_video", status: "succeeded", prompt: "", attempts: 1, createdAt: "", updatedAt: "" }, "deleted-node");
expect(applied.updated).toBe(false);
expect(applied.nodes).toEqual([other]);
});
test("clears the previous asset binding when a regenerated media result lands", () => {
const node = videoNode("asset-old", "video:old");
const next = applyGeneratedMediaResultMetadata(node, videoMetadata({

View File

@@ -17,6 +17,32 @@ function imageNode(id: string, status: CanvasNodeStatus, metadata: Partial<NonNu
}
describe("canvas image batch retry", () => {
test("旧批次索引不能删除属于其他批次的节点", () => {
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["foreign"] });
const foreign = imageNode("foreign", "loading", { batchRootId: "other-root" });
expect(retireImageBatchChildren(root, [root, foreign], []).removedIds).toEqual([]);
expect(cancelIncompleteImageBatch(root.id, [foreign.id], [root, foreign], []).nodes).toContain(foreign);
});
test("清理和取消只移除无持久资源的占位,不能删除尚未恢复预览的成功图片", () => {
const root = imageNode("root", "loading", { isBatchRoot: true, batchChildIds: ["done", "pending"] });
const done = imageNode("done", "success", { batchRootId: root.id, storageKey: "resource:done", assetId: "asset-done" });
const pending = imageNode("pending", "loading", { batchRootId: root.id });
const nodes = [root, done, pending];
expect(retireImageBatchChildren(root, nodes, []).removedIds).toEqual(["pending"]);
expect(cancelIncompleteImageBatch(root.id, ["done", "pending"], nodes, []).removedIds).toEqual(["pending"]);
expect(reconcileImageBatchRoot(root, nodes).metadata).toMatchObject({ status: "success", storageKey: "resource:done", assetId: "asset-done", primaryImageId: "done" });
});
test("刷新后五张图两成三败,根节点保留成功资源及准确失败数", () => {
const children = Array.from({ length: 5 }, (_, i) => imageNode(`child-${i}`, i < 2 ? "success" : "error", {
batchRootId: "root", ...(i < 2 ? { storageKey: `resource:${i}`, assetId: `asset-${i}` } : { errorDetails: "上游失败" }),
}));
const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: children.map((node) => node.id), taskId: "old-task", taskStatus: "failed" });
const next = reconcileImageBatchRoot(root, [root, ...children]);
expect(next.metadata).toMatchObject({ status: "success", storageKey: "resource:0", batchFailedCount: 3 });
expect(next.metadata.taskId).toBeUndefined();
});
test("只按批次顺序返回属于当前根节点的失败图片", () => {
const root = imageNode("root", "error", { isBatchRoot: true, batchChildIds: ["failed-2", "success", "failed-1", "loading", "foreign"] });
const nodes = [

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { buildNodeGenerationContext } from "../src/components/canvas/canvas-node-generation";
import { buildCanvasResourceReferences } from "../src/lib/canvas/canvas-resource-references";
import { buildCanvasResourceReferences, getGenerationResourceNodes } from "../src/lib/canvas/canvas-resource-references";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas";
function node(id: string, type: CanvasNodeType, content: string): CanvasNodeData {
@@ -33,6 +33,40 @@ function connection(fromNodeId: string): CanvasConnection {
}
describe("canvas node generation position mentions", () => {
test("父图无预览时仍可继承输入,显式子图输入优先且去重", () => {
const a = node("a", CanvasNodeType.Image, "");
a.metadata = { storageKey: "resource:a" };
const b = node("b", CanvasNodeType.Image, "b");
const root = node("root", CanvasNodeType.Image, "");
root.metadata = { isBatchRoot: true };
const child = node("child", CanvasNodeType.Image, "");
child.metadata = { batchRootId: root.id };
const nodes = [a, b, root, child];
const connections = [{ id: "a-root", fromNodeId: a.id, toNodeId: root.id }, { id: "group", fromNodeId: root.id, toNodeId: child.id }];
expect(getGenerationResourceNodes(child.id, nodes, connections).map((item) => item.id)).toEqual(["a"]);
expect(getGenerationResourceNodes(child.id, nodes, [...connections, { id: "b-child", fromNodeId: b.id, toNodeId: child.id }, { id: "b-child-duplicate", fromNodeId: b.id, toNodeId: child.id }]).map((item) => item.id)).toEqual(["b"]);
root.metadata = { isBatchRoot: true, batchRootId: child.id };
child.metadata = { isBatchRoot: true, batchRootId: root.id };
expect(getGenerationResourceNodes(child.id, nodes, [])).toEqual([]);
});
test("批次子图沿父批次继承原始参考图,不把结果分组边当作参考图", () => {
const a = node("a", CanvasNodeType.Image, "data:image/png;base64,a");
const b = node("b", CanvasNodeType.Image, "data:image/png;base64,b");
const root = node("root", CanvasNodeType.Image, "data:image/png;base64,result");
root.metadata = { ...root.metadata, isBatchRoot: true, batchChildIds: ["child"] };
const child = node("child", CanvasNodeType.Image, "");
child.metadata = { batchRootId: root.id, composerContent: "结合 @图片1 和 @图片2" };
const nodes = [a, b, root, child];
const connections = [
{ id: "a-root", fromNodeId: a.id, toNodeId: root.id },
{ id: "b-root", fromNodeId: b.id, toNodeId: root.id },
{ id: "group", fromNodeId: root.id, toNodeId: child.id },
];
const context = buildNodeGenerationContext(child.id, nodes, connections, child.metadata.composerContent!, []);
expect(context.referenceImages.map((image) => image.id)).toEqual([a.id, b.id]);
expect(buildCanvasResourceReferences(nodes, connections, child.id).filter((ref) => ref.active).map((ref) => ref.nodeId)).toEqual([a.id, b.id]);
});
test("Agent 保存的素材引用块在编辑器和再次提交时保持相同编号", () => {
const target = targetNode();
const character = node("character", CanvasNodeType.Image, "data:image/png;base64,a");

View File

@@ -1,8 +1,21 @@
import { expect, test } from "bun:test";
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race";
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race";
type ScenarioResponse<T> = { ok: true; result: T } | { ok: false; error: string };
test("并发生成结果经真实持久化消费链路及重新读取后保留成功、失败和用户编辑", async () => {
type Node = import("../src/types/canvas").CanvasNodeData;
const result = await runScenario<{ edited: boolean; live: Node[]; restored: Node[] }>("canvas-batch-commit-race");
expect(result.edited).toBe(true);
for (const nodes of [result.live, result.restored]) {
expect(nodes).toHaveLength(6);
expect(nodes.filter((node) => node.metadata?.status === "success")).toHaveLength(2);
expect(nodes.filter((node) => node.metadata?.status === "error")).toHaveLength(3);
expect(nodes.find((node) => node.id === "node-0")).toMatchObject({ title: "用户改名", position: { x: 700, y: 500 }, metadata: { assetId: "asset-0" } });
expect(nodes.find((node) => node.id === "node-1")?.metadata?.assetId).toBe("asset-1");
}
});
function runScenario<T>(scenario: Scenario): Promise<T> {
return new Promise<T>((resolve, reject) => {
const worker = new Worker(new URL("./helpers/generation-storage-consistency.worker.ts", import.meta.url).href, { type: "module" });

View File

@@ -4,7 +4,7 @@ import { getActiveUserScope, setActiveUserScope } from "../../src/lib/user-scope
type InstanceHook = (storeName: string, key: string, value: unknown) => Promise<void> | void;
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race";
type Scenario = "image-cleanup" | "scope-cleanup-switch" | "scope-cleanup-late-canvas-reference" | "video-commit-race" | "audio-commit-race" | "canvas-batch-commit-race";
function installStorageHarness() {
const originalCreateInstance = localforage.createInstance.bind(localforage);
@@ -35,6 +35,7 @@ function installStorageHarness() {
localforage.getItem = (async (key: string) => defaultValues.get(key) ?? null) as typeof localforage.getItem;
localforage.setItem = (async (key: string, value: unknown) => {
defaultValues.set(key, value);
await hooks.onSet?.("app_state", key, value);
return value;
}) as typeof localforage.setItem;
localforage.removeItem = (async (key: string) => {
@@ -392,6 +393,49 @@ async function runMediaCommitRace(mediaType: "video" | "audio") {
}
}
async function runCanvasBatchCommitRace() {
const harness = installStorageHarness();
const previousScope = getActiveUserScope();
let unregister: (() => void) | undefined;
try {
setActiveUserScope("canvas-batch-commit-race");
const { useCanvasStore, flushCanvasStorePersistence, CANVAS_STORE_KEY } = await import("../../src/stores/canvas/use-canvas-store");
const { useAssetStore } = await import("../../src/stores/use-asset-store");
const { applyCanvasGenerationTaskNodeEffect, registerCanvasGenerationLiveProject } = await import("../../src/services/canvas-generation-consumer");
const { createCanvasStateWriter } = await import("../../src/lib/canvas/canvas-editor-state");
const { localForageStorageForScope } = await import("../../src/lib/localforage-storage");
const { parseCanvasStorageDocument } = await import("../../src/lib/canvas/canvas-storage-revision");
const { CanvasNodeType } = await import("../../src/types/canvas");
const projectId = useCanvasStore.getState().createProject("batch race");
const nodes = Array.from({ length: 5 }, (_, index) => ({ id: `node-${index}`, type: CanvasNodeType.Image, title: `image-${index}`, position: { x: index * 400, y: 0 }, width: 320, height: 240, metadata: { taskId: `task-${index}`, status: index < 2 ? "loading" as const : "error" as const } }));
const ref: { current: import("../../src/types/canvas").CanvasNodeData[] } = { current: nodes };
const setNodes = createCanvasStateWriter(ref, () => {});
useCanvasStore.getState().updateProject(projectId, { nodes });
await flushCanvasStorePersistence();
useAssetStore.setState({ assets: [0, 1].map((index) => ({ id: `asset-${index}`, kind: "image" as const, title: "generated", coverUrl: "/image.png", tags: [], createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:00Z", data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: 640, height: 480, bytes: 100, mimeType: "image/png" } })) });
unregister = registerCanvasGenerationLiveProject({ scope: getActiveUserScope(), projectId, adapter: { read: () => ({ nodes: ref.current, connections: [], chatSessions: [], activeChatId: null }), write: (state) => setNodes(state.nodes) } });
let edited = false;
harness.hooks.onSet = (storeName, _key, value) => {
if (edited || storeName !== "app_state" || typeof value !== "string" || !value.includes("attach:task-")) return;
edited = true;
setNodes((current) => [...current.map((node) => node.id === "node-0" ? { ...node, title: "用户改名", position: { x: 700, y: 500 } } : node), { ...nodes[0], id: "new-during-save", metadata: {} }]);
};
await Promise.all([0, 1].map((index) => applyCanvasGenerationTaskNodeEffect({
projectId, nodeId: `node-${index}`, nodesRef: ref, setNodes,
task: { id: `task-${index}`, projectId, type: "canvas_image", status: "succeeded", prompt: "test", attempts: 1, createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:01Z", resultJson: "{}" },
output: { outputIndex: 0, mediaType: "image", materializedAssetId: `asset-${index}` }, effectKey: `attach:task-${index}:0`,
})));
useCanvasStore.getState().updateProject(projectId, { nodes: ref.current });
await flushCanvasStorePersistence();
const stored = parseCanvasStorageDocument(await localForageStorageForScope(getActiveUserScope()).getItem(CANVAS_STORE_KEY), []);
return { edited, live: ref.current, restored: stored.state.projects.find((project) => project.id === projectId)?.nodes };
} finally {
unregister?.();
setActiveUserScope(previousScope);
harness.restore();
}
}
self.onmessage = async (event: MessageEvent<Scenario>) => {
try {
const result =
@@ -401,7 +445,9 @@ self.onmessage = async (event: MessageEvent<Scenario>) => {
? await runScopeCleanupAfterSwitch()
: event.data === "scope-cleanup-late-canvas-reference"
? await runScopeCleanupAfterLateCanvasReference()
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
: event.data === "canvas-batch-commit-race"
? await runCanvasBatchCommitRace()
: await runMediaCommitRace(event.data === "audio-commit-race" ? "audio" : "video");
self.postMessage({ ok: true, result });
} catch (error) {
self.postMessage({ ok: false, error: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });