test: cover canvas agent mutation contracts
This commit is contained in:
parent
fa9f09e84e
commit
6041442662
@ -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<string, unknown>) }
|
||||
@ -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"
|
||||
|
||||
@ -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<string, unknown>) {
|
||||
: 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user