diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index db00ff0e1..b5878891b 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -1004,6 +1004,138 @@ test.describe("canvas agent operation mutation contract", () => { ); }); + test("confirms move-node mutations and keeps retries from moving nodes again", () => { + const first = buildImageNode("image-move-a"); + first.position = { x: 300, y: 100 }; + const second = buildImageNode("image-move-b"); + second.position = { x: 100, y: 100 }; + const unrelated = buildImageNode("image-unrelated"); + unrelated.position = { x: 900, y: 500 }; + const canvasData = getCanvasData([first, second, unrelated]); + const plan: CanvasOperationPlan = { + version: 1, + operationId: "op-move-nodes", + operation: "move_nodes", + projectId, + targetNodeIds: ["image-move-a", "image-move-b"], + targetPosition: { x: 10, y: 20 }, + layoutStrategy: "row", + spacing: 30, + metadata: { + messageId: "assistant-message-move", + chatId: "chat-1", + }, + }; + + expect(() => + applyCanvasOperationPlanToCanvasData({ + canvasData, + plan, + }) + ).toThrow(/需要用户确认/); + + const moved = applyCanvasOperationPlanToCanvasData({ + canvasData, + plan: { + ...plan, + confirmed: true, + metadata: { + ...plan.metadata, + confirmedByUser: true, + }, + }, + }); + const movedNodes = getResultNodes(moved.canvasData); + const movedFirst = movedNodes.find((node) => node.id === "image-move-a"); + const movedSecond = movedNodes.find((node) => node.id === "image-move-b"); + const movedUnrelated = movedNodes.find((node) => node.id === "image-unrelated"); + + expect(moved.createdNodeIds).toEqual([]); + expect(moved.updatedNodeIds).toEqual(["image-move-b", "image-move-a"]); + expect(movedSecond).toMatchObject({ + position: { x: 10, y: 20 }, + data: { + metadata: { + movedByAgent: true, + canvasAgentOperationId: "op-move-nodes", + canvasAgentOperation: "move_nodes", + canvasAgentLayoutStrategy: "row", + previousPosition: { x: 100, y: 100 }, + }, + }, + }); + expect(movedFirst).toMatchObject({ + position: { x: 360, y: 20 }, + data: { + metadata: { + movedByAgent: true, + previousPosition: { x: 300, y: 100 }, + }, + }, + }); + expect(movedUnrelated?.position).toEqual({ x: 900, y: 500 }); + expect((moved.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([ + "image-move-b", + "image-move-a", + ]); + + const retry = applyCanvasOperationPlanToCanvasData({ + canvasData: moved.canvasData, + plan: { + ...plan, + confirmed: true, + metadata: { + ...plan.metadata, + confirmedByUser: true, + }, + }, + }); + const retryNodes = getResultNodes(retry.canvasData); + + expect(new Set(retry.updatedNodeIds)).toEqual( + new Set(["image-move-a", "image-move-b"]) + ); + expect(retryNodes.find((node) => node.id === "image-move-b")?.position).toEqual({ + x: 10, + y: 20, + }); + expect(retryNodes.find((node) => node.id === "image-move-a")?.position).toEqual({ + x: 360, + y: 20, + }); + }); + + test("rejects confirmed stale move-node mutations", () => { + const target = buildImageNode("image-stale-move"); + + let caughtError: unknown; + try { + applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([target]), + baseCanvasWasStale: true, + plan: { + version: 1, + operationId: "op-stale-move-node", + operation: "move_nodes", + projectId, + targetNodeIds: ["image-stale-move"], + targetPosition: { x: 10, y: 20 }, + confirmed: true, + metadata: { + confirmedByUser: true, + }, + }, + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(CanvasAgentMutationError); + expect((caughtError as CanvasAgentMutationError).code).toBe( + "STALE_CANVAS_REVISION" + ); + }); + test("confirms delete-node mutations, removes connected edges, and keeps retries idempotent", () => { const source = buildImageNode("image-source"); const target = buildImageNode("image-delete");