477 lines
13 KiB
TypeScript
477 lines
13 KiB
TypeScript
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<string, unknown>) {
|
|
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<string, unknown>) {
|
|
return parseCanvasAgentOperationRequestBody({
|
|
operationPlan: buildPlan(overrides),
|
|
});
|
|
}
|
|
|
|
function issuePathsFor(overrides: Record<string, unknown>) {
|
|
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"
|
|
);
|
|
});
|
|
});
|