From 6041442662b621b70b691b0f5456b65fc2d4f5b5 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 22 Jun 2026 22:04:43 +0800 Subject: [PATCH] test: cover canvas agent mutation contracts --- lib/canvas/agent-mutations.ts | 33 +++ tests/routes/canvas-agent-operations.test.ts | 280 +++++++++++++++++++ 2 files changed, 313 insertions(+) diff --git a/lib/canvas/agent-mutations.ts b/lib/canvas/agent-mutations.ts index 92b3bd3a0..1a7a4fdb8 100644 --- a/lib/canvas/agent-mutations.ts +++ b/lib/canvas/agent-mutations.ts @@ -323,6 +323,34 @@ function getOperationTargetNodeIds(plan: CanvasOperationPlan) { ); } +function assertCanvasAgentReferencedNodesExist(params: { + nodes: any[]; + plan: CanvasOperationPlan; + mediaItems?: CanvasMediaItem[]; +}) { + if (params.plan.anchorNodeId && !findNode(params.nodes, params.plan.anchorNodeId)) { + throw new CanvasAgentMutationError( + `锚点节点不存在:${params.plan.anchorNodeId}` + ); + } + + const missingSourceNodeId = (params.plan.sourceNodeIds || []).find( + (nodeId) => !findNode(params.nodes, nodeId) + ); + if (missingSourceNodeId) { + throw new CanvasAgentMutationError(`来源节点不存在:${missingSourceNodeId}`); + } + + const missingMediaSourceNodeId = (params.mediaItems || []) + .flatMap((item) => item.sourceNodeIds || []) + .find((nodeId) => !findNode(params.nodes, nodeId)); + if (missingMediaSourceNodeId) { + throw new CanvasAgentMutationError( + `媒体来源节点不存在:${missingMediaSourceNodeId}` + ); + } +} + function normalizeCanvasMetadata(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } @@ -1110,6 +1138,11 @@ function applyOperationToCanvasData(params: { if (mediaItems.length === 0) { throw new CanvasAgentMutationError("缺少可写回的媒体结果"); } + assertCanvasAgentReferencedNodesExist({ + nodes, + plan: params.plan, + mediaItems, + }); const targetSlot = params.plan.operation === "fill_slot" diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index 724bb9757..5c6944252 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -3,6 +3,10 @@ import { canvasAgentOperationRequestSchema, parseCanvasAgentOperationRequestBody, } from "@/lib/canvas/agent-operation-request"; +import { + applyCanvasOperationPlanToCanvasData, + CanvasAgentMutationError, +} from "@/lib/canvas/agent-mutations"; const projectId = "11111111-1111-4111-8111-111111111111"; @@ -51,6 +55,76 @@ function issuePathsFor(overrides: Record) { : result.error.issues.map((issue) => issue.path.join(".")); } +function buildImageNode(id = "image-1") { + return { + id, + type: "image", + position: { x: 100, y: 120 }, + size: { width: 320, height: 240 }, + data: { + src: `https://cdn.example.com/${id}.png`, + status: "completed", + title: id, + }, + connections: [], + createdAt: "2026-06-22T00:00:00.000Z", + updatedAt: "2026-06-22T00:00:00.000Z", + }; +} + +function buildAgentSlotNode(id = "slot-1") { + return { + id, + type: "agent-slot", + position: { x: 500, y: 120 }, + size: { width: 300, height: 300 }, + data: { + slotType: "image", + status: "empty", + fillPolicy: "replace-empty", + filledNodeIds: [], + }, + connections: [], + createdAt: "2026-06-22T00:00:00.000Z", + updatedAt: "2026-06-22T00:00:00.000Z", + }; +} + +function buildMutationMediaItem( + mediaItemId = "media-result-1", + sourceNodeIds?: string[] +) { + return { + mediaItemId, + type: "image" as const, + url: `https://cdn.example.com/${mediaItemId}.png`, + width: 1024, + height: 768, + title: mediaItemId, + prompt: "make a clean revision", + sourceNodeIds, + }; +} + +function getCanvasData(nodes: any[]) { + return { + nodes, + edges: [], + selectedNodeIds: nodes.length > 0 ? [nodes[0].id] : [], + viewport: { x: 0, y: 0, zoom: 1 }, + }; +} + +function getResultNodes(canvasData: unknown) { + const nodes = (canvasData as { nodes?: any[] }).nodes; + return Array.isArray(nodes) ? nodes : []; +} + +function getResultEdges(canvasData: unknown) { + const edges = (canvasData as { edges?: any[] }).edges; + return Array.isArray(edges) ? edges : []; +} + test.describe("canvas agent operation request schema", () => { test("accepts slot fill requests with batch media and revision guards", () => { const body = parsePlan({ @@ -194,3 +268,209 @@ test.describe("canvas agent operation request schema", () => { ).toContain("operationPlan.mediaItems.0.url"); }); }); + +test.describe("canvas agent operation mutation contract", () => { + test("rejects explicit anchor or source references that are not in canvas data", () => { + expect(() => + applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([]), + plan: { + version: 1, + operationId: "op-missing-anchor", + operation: "insert_beside", + projectId, + anchorNodeId: "missing-anchor", + mediaItems: [buildMutationMediaItem()], + }, + }) + ).toThrow(/锚点节点不存在/); + + expect(() => + applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([buildImageNode()]), + plan: { + version: 1, + operationId: "op-missing-source", + operation: "append_revision", + projectId, + sourceNodeIds: ["missing-source"], + mediaItems: [buildMutationMediaItem()], + }, + }) + ).toThrow(/来源节点不存在/); + + expect(() => + applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([buildImageNode()]), + plan: { + version: 1, + operationId: "op-missing-media-source", + operation: "insert_beside", + projectId, + mediaItems: [ + buildMutationMediaItem("media-result-1", ["missing-media-source"]), + ], + }, + }) + ).toThrow(/媒体来源节点不存在/); + }); + + test("fills an agent slot and returns media item node mappings", () => { + const slot = buildAgentSlotNode(); + const result = applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([slot]), + plan: { + version: 1, + operationId: "op-fill-slot", + operation: "fill_slot", + projectId, + targetSlotId: "slot-1", + mediaItems: [buildMutationMediaItem("media-slot-result")], + }, + }); + + expect(result.createdNodeIds).toHaveLength(1); + expect(result.createdNodeIdsByMediaItemId).toEqual({ + "media-slot-result": result.createdNodeIds[0], + }); + expect(result.updatedNodeIds).toEqual(["slot-1"]); + expect(getResultNodes(result.canvasData).find((node) => node.id === "slot-1")) + .toMatchObject({ + data: { + status: "completed", + filledNodeIds: [result.createdNodeIds[0]], + }, + }); + expect(getResultEdges(result.canvasData)).toEqual([ + expect.objectContaining({ + source: "slot-1", + target: result.createdNodeIds[0], + }), + ]); + }); + + test("creates append revision nodes without overwriting the source", () => { + const source = buildImageNode("image-source"); + const result = applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([source]), + plan: { + version: 1, + operationId: "op-append-revision", + operation: "append_revision", + projectId, + sourceNodeIds: ["image-source"], + anchorNodeId: "image-source", + placement: "right", + mediaItems: [buildMutationMediaItem("media-revision", ["image-source"])], + }, + }); + + const createdNode = getResultNodes(result.canvasData).find( + (node) => node.id === result.createdNodeIds[0] + ); + + expect(result.createdNodeIdsByMediaItemId["media-revision"]).toBe( + result.createdNodeIds[0] + ); + expect(createdNode).toMatchObject({ + type: "image", + data: { + src: "https://cdn.example.com/media-revision.png", + metadata: { + generatedByAgent: true, + canvasAgentOperationId: "op-append-revision", + canvasAgentMediaItemId: "media-revision", + revisionOf: "image-source", + }, + }, + }); + expect( + getResultNodes(result.canvasData).find((node) => node.id === "image-source") + ).toMatchObject({ + data: { src: "https://cdn.example.com/image-source.png" }, + }); + expect(getResultEdges(result.canvasData)).toEqual([ + expect.objectContaining({ + source: "image-source", + target: result.createdNodeIds[0], + type: "reference", + }), + ]); + }); + + test("keeps batch media writes idempotent by operation id", () => { + const anchor = buildImageNode("image-anchor"); + const plan = { + version: 1 as const, + operationId: "op-batch-idempotent", + operation: "insert_beside" as const, + projectId, + anchorNodeId: "image-anchor", + placementStrategy: "grid" as const, + mediaItems: [1, 2, 3].map((index) => + buildMutationMediaItem(`media-batch-${index}`) + ), + }; + + const first = applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([anchor]), + plan, + }); + const retry = applyCanvasOperationPlanToCanvasData({ + canvasData: first.canvasData, + plan, + }); + + expect(first.createdNodeIds).toHaveLength(3); + expect(Object.keys(first.createdNodeIdsByMediaItemId).sort()).toEqual([ + "media-batch-1", + "media-batch-2", + "media-batch-3", + ]); + expect(retry.createdNodeIds).toEqual(first.createdNodeIds); + expect(retry.createdNodeIdsByMediaItemId).toEqual( + first.createdNodeIdsByMediaItemId + ); + expect(getResultNodes(retry.canvasData)).toHaveLength( + getResultNodes(first.canvasData).length + ); + }); + + test("requires explicit confirmation for replace media and rejects stale confirmed replacements", () => { + const target = buildImageNode("image-replace"); + const replacePlan = { + version: 1 as const, + operationId: "op-replace-media", + operation: "replace_node_media" as const, + projectId, + targetNodeIds: ["image-replace"], + mediaItems: [buildMutationMediaItem("media-replacement")], + }; + + expect(() => + applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([target]), + plan: replacePlan, + }) + ).toThrow(/需要用户确认/); + + let caughtError: unknown; + try { + applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([target]), + baseCanvasWasStale: true, + plan: { + ...replacePlan, + confirmed: true, + }, + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(CanvasAgentMutationError); + expect((caughtError as CanvasAgentMutationError).code).toBe( + "STALE_CANVAS_REVISION" + ); + }); +});