import { expect, test } from "@playwright/test"; import { canvasAgentOperationRequestSchema, parseCanvasAgentOperationRequestBody, } from "@/lib/canvas/agent-operation-request"; import { applyCanvasOperationPlanToCanvasData, CanvasAgentMutationError, } from "@/lib/canvas/agent-mutations"; const projectId = "11111111-1111-4111-8111-111111111111"; function buildMediaItem(index = 1) { return { mediaItemId: `media-${index}`, type: "image" as const, url: `https://example.com/generated-${index}.png`, assetId: "22222222-2222-4222-8222-222222222222", width: 1024, height: 768, title: `generated-${index}`, prompt: "make the product cleaner", sourceNodeIds: ["image-1"], metadata: { provider: "test", }, }; } function buildPlan(overrides: Record) { return { version: 1, operationId: "message-1:canvas-agent:test", projectId, baseCanvasRevision: "2026-06-22T00:00:00.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", ...overrides, }; } function parsePlan(overrides: Record) { return parseCanvasAgentOperationRequestBody({ operationPlan: buildPlan(overrides), }); } function issuePathsFor(overrides: Record) { const result = canvasAgentOperationRequestSchema.safeParse({ operationPlan: buildPlan(overrides), }); expect(result.success).toBe(false); return result.success ? [] : 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({ operation: "fill_slot", targetSlotId: "slot-1", placement: "viewport-center", preserveOriginal: true, mediaItems: [buildMediaItem(1), buildMediaItem(2)], }); expect(body.operationPlan).toMatchObject({ operation: "fill_slot", targetSlotId: "slot-1", baseCanvasRevision: "2026-06-22T00:00:00.000Z", mediaItems: [ { mediaItemId: "media-1", url: "https://example.com/generated-1.png" }, { mediaItemId: "media-2", url: "https://example.com/generated-2.png" }, ], }); }); test("accepts append revision requests with legacy media payloads", () => { const body = parsePlan({ operation: "append_revision", sourceNodeIds: ["image-1"], targetNodeIds: ["image-1"], media: { type: "image", url: "https://example.com/legacy.png", width: 1200, height: 900, title: "legacy-result", }, }); expect(body.operationPlan).toMatchObject({ operation: "append_revision", media: { url: "https://example.com/legacy.png", }, }); }); test("accepts slot-only, annotation-only, and text-only operations", () => { expect( parsePlan({ operation: "create_agent_slot", targetPosition: { x: 120, y: 160 }, slot: { slotType: "image", label: "主图结果槽", aspectRatio: "4:3", fillPolicy: "replace-empty", acceptedSources: ["text", "image", "annotation"], }, }).operationPlan.operation ).toBe("create_agent_slot"); expect( parsePlan({ operation: "add_annotation", targetNodeIds: ["image-1"], annotations: [ { id: "annotation-1", sourceNodeId: "image-1", type: "box", text: "tighten crop", bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, color: "#ff3366", }, ], }).operationPlan.operation ).toBe("add_annotation"); expect( parsePlan({ operation: "update_text", targetNodeIds: ["text-1"], textContent: "New canvas title", }).operationPlan.operation ).toBe("update_text"); }); test("caps batch media at twenty items", () => { const accepted = parsePlan({ operation: "insert_beside", sourceNodeIds: ["image-1"], placementStrategy: "grid", mediaItems: Array.from({ length: 20 }, (_, index) => buildMediaItem(index + 1)), }); expect(accepted.operationPlan.mediaItems).toHaveLength(20); expect( issuePathsFor({ operation: "insert_beside", sourceNodeIds: ["image-1"], mediaItems: Array.from({ length: 21 }, (_, index) => buildMediaItem(index + 1)), }) ).toContain("operationPlan.mediaItems"); }); test("rejects media operations without generated media", () => { expect( issuePathsFor({ operation: "insert_beside", sourceNodeIds: ["image-1"], placement: "right", }) ).toContain("operationPlan.mediaItems"); }); test("rejects annotation and text operations without required content", () => { expect( issuePathsFor({ operation: "add_annotation", targetNodeIds: ["image-1"], }) ).toContain("operationPlan.annotations"); expect( issuePathsFor({ operation: "update_text", targetNodeIds: ["text-1"], }) ).toContain("operationPlan.textContent"); }); test("rejects invalid media urls", () => { expect( issuePathsFor({ operation: "replace_node_media", targetNodeIds: ["image-1"], mediaItems: [ { ...buildMediaItem(), url: "not-a-url", }, ], }) ).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" ); }); });