fix(canvas): 画布同步 - 素材缺失时可强制覆盖并恢复文本与连线顺序
云端缺少素材时可用本地版强制覆盖,只清失效媒体引用。文本节点不再把资源地址当正文,合并时保留引用槽位顺序。
This commit is contained in:
@@ -44,6 +44,32 @@ test("new nodes and reference connections are applied atomically", () => {
|
||||
expect(result.connections).toEqual([edge]);
|
||||
});
|
||||
|
||||
test("Agent refresh applies server reference order when local connections were untouched", () => {
|
||||
const imageOne = { id: "image-one", fromNodeId: "image-one", toNodeId: node.id };
|
||||
const imageTwo = { id: "image-two", fromNodeId: "image-two", toNodeId: node.id };
|
||||
const characterOne = { id: "character-one", fromNodeId: "character-one", toNodeId: node.id };
|
||||
const characterTwo = { id: "character-two", fromNodeId: "character-two", toNodeId: node.id };
|
||||
const previous = { ...project, connections: [characterOne, characterTwo, imageOne, imageTwo] };
|
||||
const incoming = { ...previous, revision: 2, connections: [imageOne, imageTwo, characterOne, characterTwo] };
|
||||
const merged = mergeAgentCanvasEditor(previous, incoming, previous.nodes, previous.connections, previous);
|
||||
|
||||
expect(merged.connections.map((connection) => connection.id)).toEqual(["image-one", "image-two", "character-one", "character-two"]);
|
||||
});
|
||||
|
||||
test("Agent refresh preserves moving the first reference to the fifth position locally", () => {
|
||||
const roleOne = { id: "role-one", fromNodeId: "role-one", toNodeId: node.id };
|
||||
const imageTwo = { id: "image-two", fromNodeId: "image-two", toNodeId: node.id };
|
||||
const roleThree = { id: "role-three", fromNodeId: "role-three", toNodeId: node.id };
|
||||
const imageFour = { id: "image-four", fromNodeId: "image-four", toNodeId: node.id };
|
||||
const imageFive = { id: "image-five", fromNodeId: "image-five", toNodeId: node.id };
|
||||
const previous = { ...project, connections: [roleOne, imageTwo, roleThree, imageFour, imageFive] };
|
||||
const incoming = { ...previous, revision: 2, connections: [imageFive, roleOne, imageTwo, roleThree, imageFour] };
|
||||
const local = { ...previous, connections: [imageTwo, roleThree, imageFour, imageFive, roleOne] };
|
||||
const merged = mergeAgentCanvasEditor(previous, incoming, local.nodes, local.connections, local);
|
||||
|
||||
expect(merged.connections.map((connection) => connection.id)).toEqual(["image-two", "role-three", "image-four", "image-five", "role-one"]);
|
||||
});
|
||||
|
||||
test("deleted nodes and a newer task binding are not overwritten", () => {
|
||||
expect(() => applyAgentCanvasPatch({ ...project, nodes: [] }, patch)).toThrow("冲突");
|
||||
// A replacement task can have the same running status; its task identity is a fence.
|
||||
|
||||
33
web/test/canvas-missing-resources.test.ts
Normal file
33
web/test/canvas-missing-resources.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { canvasResourceIds, clearMissingCanvasResources } from "@/services/canvas-missing-resources";
|
||||
|
||||
describe("canvas missing resource overwrite", () => {
|
||||
test("collects nested resource locators without treating prompt text as media", () => {
|
||||
const value = {
|
||||
nodes: [{ id: "node-1", metadata: { storageKey: "resource:gone", prompt: "resource:keep-as-text", videoPreview: { url: "/api/resources/poster/file" } } }],
|
||||
};
|
||||
expect(canvasResourceIds(value)).toEqual(new Set(["gone", "poster"]));
|
||||
});
|
||||
|
||||
test("removes only invalid media fields and asset binding while keeping node content", () => {
|
||||
const value = {
|
||||
id: "canvas",
|
||||
nodes: [{ id: "node-1", type: "image", metadata: { assetId: "asset-1", storageKey: "resource:gone", content: "/api/resources/gone/file", prompt: "keep this prompt", status: "success" } }],
|
||||
timeline: { clips: [{ id: "clip", directMedia: { kind: "image", assetId: "asset-1", storageKey: "resource:gone", url: "/api/resources/gone/file" } }] },
|
||||
};
|
||||
const repaired = clearMissingCanvasResources(value, new Set(["gone"]));
|
||||
expect(repaired).toEqual({
|
||||
id: "canvas",
|
||||
nodes: [{ id: "node-1", type: "image", metadata: { prompt: "keep this prompt", status: "idle" } }],
|
||||
timeline: { clips: [{ id: "clip", directMedia: { kind: "image" } }] },
|
||||
});
|
||||
});
|
||||
|
||||
test("server reported locator fields are also cleared during the fallback", () => {
|
||||
const value = { nodes: [{ id: "node-1", metadata: { assetId: "asset-1", futureLocator: "resource:gone", prompt: "keep" } }] };
|
||||
expect(clearMissingCanvasResources(value, new Set(["gone"]), "", true)).toEqual({
|
||||
nodes: [{ id: "node-1", metadata: { assetId: "asset-1", prompt: "keep" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
67
web/test/user-data-sync-media.test.ts
Normal file
67
web/test/user-data-sync-media.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { ensureRemoteResourceReferences, restoreRemoteTextNodeContents } from "@/services/user-data-sync-media";
|
||||
|
||||
describe("画布资源引用同步", () => {
|
||||
test("文本节点保留正文,只同步资源定位键", async () => {
|
||||
const node = {
|
||||
type: "text",
|
||||
metadata: {
|
||||
content: "这是上传文件的正文",
|
||||
prompt: "这是上传文件的正文",
|
||||
storageKey: "resource:text-file",
|
||||
mimeType: "text/plain",
|
||||
},
|
||||
};
|
||||
|
||||
const synced = await ensureRemoteResourceReferences(node);
|
||||
|
||||
expect(synced.metadata.content).toBe(node.metadata.content);
|
||||
expect(synced.metadata.prompt).toBe(node.metadata.prompt);
|
||||
expect(synced.metadata.storageKey).toBe("resource:text-file");
|
||||
});
|
||||
|
||||
test("媒体节点仍将展示字段转换为资源地址", async () => {
|
||||
const node = {
|
||||
type: "image",
|
||||
metadata: {
|
||||
content: "blob:image",
|
||||
storageKey: "resource:image-file",
|
||||
},
|
||||
};
|
||||
|
||||
const synced = await ensureRemoteResourceReferences(node);
|
||||
|
||||
expect(synced.metadata.content).toContain("/resources/image-file/file");
|
||||
});
|
||||
|
||||
test("加载旧画布时恢复已被保存成资源 URL 的文本正文", async () => {
|
||||
const project = {
|
||||
nodes: [
|
||||
{
|
||||
type: "text",
|
||||
metadata: {
|
||||
content: "/api/resources/text-file/file",
|
||||
prompt: "上传文件的原始正文",
|
||||
storageKey: "resource:text-file",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const restored = await restoreRemoteTextNodeContents(project, async () => null);
|
||||
|
||||
expect(restored.nodes[0].metadata.content).toBe("上传文件的原始正文");
|
||||
});
|
||||
|
||||
test("没有可恢复的 prompt 时从资源文件读取正文", async () => {
|
||||
const project = {
|
||||
nodes: [{ type: "text", metadata: { content: "/api/resources/text-file/file", storageKey: "resource:text-file" } }],
|
||||
};
|
||||
|
||||
const restored = await restoreRemoteTextNodeContents(project, async () => "从资源恢复的正文");
|
||||
|
||||
expect(restored.nodes[0].metadata.content).toBe("从资源恢复的正文");
|
||||
expect(restored.nodes[0].metadata.prompt).toBe("从资源恢复的正文");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user