3909 lines
117 KiB
TypeScript
3909 lines
117 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import {
|
||
collectMessageImageUrls,
|
||
extractAssistantImageUrls,
|
||
extractVideoUrls,
|
||
normalizeMediaUrl,
|
||
stripMediaMarkup,
|
||
} from "@/components/canvas/chat-panel/canvas-chat-media";
|
||
import {
|
||
getCanvasMediaPayloadHandledItemIds,
|
||
getCanvasOperationHandledMediaItemIds,
|
||
shouldRetryCanvasViewOperationAfterRefresh,
|
||
shouldSkipCanvasMediaPayloadForHandledItems,
|
||
} from "@/components/canvas/chat-panel/canvas-agent-stream";
|
||
import {
|
||
mergePendingCanvasAgentTargetNodeIds,
|
||
resolvePendingCanvasAgentTargetNode,
|
||
} from "@/components/canvas/chat-panel/canvas-agent-targets";
|
||
import {
|
||
consumeCanvasAgentAnnotationEditRequest,
|
||
consumeCanvasAgentSlotFillRequest,
|
||
getPendingCanvasAgentAnnotationEditRequest,
|
||
getPendingCanvasAgentSlotFillRequest,
|
||
storeCanvasAgentAnnotationEditRequest,
|
||
storeCanvasAgentSlotFillRequest,
|
||
type CanvasAgentPendingRequestWindow,
|
||
} from "@/lib/canvas/agent-pending-requests";
|
||
import {
|
||
writeCanvasMediaChunk,
|
||
writeCanvasOperationChunk,
|
||
} from "@/app/(chat)/api/chat/_lib/stream";
|
||
import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context";
|
||
import {
|
||
buildCanvasAgentAnnotationEditPrompt,
|
||
hasCanvasAgentAnnotationEditIntent,
|
||
} from "@/lib/canvas/agent-annotation-prompt";
|
||
import {
|
||
defaultCanvasToolbarConfig,
|
||
getCanvasToolbarItem,
|
||
} from "@/lib/canvas-toolbar-config";
|
||
import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification";
|
||
import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing";
|
||
import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent";
|
||
import {
|
||
isAgentModeScopedChatId,
|
||
isScopedEmptyHistoryRequest,
|
||
isValidMessagesChatId,
|
||
normalizeMessagesChatId,
|
||
} from "@/lib/chat/messages-route-access";
|
||
import {
|
||
CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE,
|
||
CANVAS_NODE_NOTE_ANNOTATION_SOURCE,
|
||
CANVAS_VISUAL_ANNOTATION_SOURCE,
|
||
appendCanvasVisualAnnotationsMetadata,
|
||
syncCanvasLocalEditRegionAnnotationsMetadata,
|
||
syncCanvasNodeNoteAnnotationMetadata,
|
||
} from "@/lib/canvas/agent-annotation-metadata";
|
||
import {
|
||
buildPendingCanvasOperation,
|
||
extractPendingCanvasOperationFromWorkingState,
|
||
isCanvasOperationCancelText,
|
||
isCanvasOperationConfirmText,
|
||
withConfirmedCanvasOperationPlan,
|
||
} from "@/lib/canvas/agent-confirmation";
|
||
import { shouldRejectStaleCanvasAgentHighRiskMutation } from "@/lib/canvas/agent-risk";
|
||
import {
|
||
buildCanvasAgentSlotCreationPlan,
|
||
normalizeCanvasAgentModelPlannerDecision,
|
||
resolveCanvasAgentSlotCreationIntent,
|
||
shouldConsiderCanvasAgentModelPlanner,
|
||
} from "@/lib/canvas/agent-model-planner";
|
||
import { buildCanvasAgentExplicitOperationPlan } from "@/lib/canvas/agent-explicit-operations";
|
||
import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback";
|
||
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
|
||
import { buildCanvasAgentResultViewOperation } from "@/lib/canvas/agent-result-view";
|
||
import {
|
||
buildCanvasAgentComparisonViewPlan,
|
||
buildCanvasAgentLatestResultViewPlan,
|
||
hasCanvasAgentComparisonViewIntent,
|
||
hasCanvasAgentLatestResultViewIntent,
|
||
resolveCanvasAgentComparisonNodeIds,
|
||
} from "@/lib/canvas/agent-view-intent";
|
||
import {
|
||
buildCanvasAgentPanViewPlan,
|
||
hasCanvasAgentPanViewIntent,
|
||
} from "@/lib/canvas/agent-view-pan";
|
||
import {
|
||
computeCanvasAgentPanViewport,
|
||
resolveCanvasViewOperation,
|
||
resolveCanvasViewOperationTargetNodeIds,
|
||
} from "@/lib/canvas/agent-view-operations";
|
||
import {
|
||
easeOutCanvasAgentViewProgress,
|
||
interpolateCanvasAgentViewport,
|
||
normalizeCanvasAgentViewAnimationDuration,
|
||
} from "@/lib/canvas/agent-view-animation";
|
||
import {
|
||
CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS,
|
||
shouldDeferCanvasAgentViewOperation,
|
||
} from "@/lib/canvas/agent-view-interaction-guard";
|
||
import {
|
||
getCanvasAgentOperationErrorMessage,
|
||
normalizeCanvasAgentOperationErrorCode,
|
||
} from "@/lib/canvas/agent-operation-errors";
|
||
import {
|
||
buildStaleCanvasSaveConflictPayload,
|
||
isStaleCanvasSaveBaseRevision,
|
||
parseCanvasRevisionDate,
|
||
} from "@/lib/canvas/canvas-save-conflict";
|
||
import {
|
||
applyCanvasOperationPlanToCanvasData,
|
||
CanvasAgentMutationError,
|
||
} from "@/lib/canvas/agent-mutations";
|
||
import {
|
||
appendCanvasAgentOperationAuditIfMissing,
|
||
buildIdempotentCanvasAgentAuditResult,
|
||
findCanvasAgentOperationAudit,
|
||
} from "@/lib/canvas/agent-operation-audit";
|
||
import {
|
||
canApplyCanvasAgentMutations,
|
||
getCanvasAgentFeatureFlags,
|
||
normalizeCanvasAgentBoolean,
|
||
normalizeCanvasAgentWritebackMode,
|
||
shouldStreamCanvasAgentShadowOperations,
|
||
} from "@/lib/canvas/agent-feature-flags";
|
||
import { useCanvasStore } from "@/store/canvas";
|
||
import {
|
||
buildDetailResultMarkdown,
|
||
buildDetailReviewAppendMarkdown,
|
||
buildDetailReviewPlans,
|
||
} from "@/components/canvas/chat-panel/canvas-detail-markdown";
|
||
import {
|
||
DETAIL_BUNDLE_SLOT_INDEXES,
|
||
DETAIL_GENERATION_MODE_OPTIONS,
|
||
DETAIL_MODEL_SLOT_INDEX,
|
||
DETAIL_PRODUCT_SLOT_INDEXES,
|
||
DETAIL_SLOT_CONFIGS,
|
||
DETAIL_TARGET_HEIGHT,
|
||
DETAIL_TARGET_WIDTH,
|
||
extractDetailFrameCount,
|
||
extractFirstNumber,
|
||
extractFontWeight,
|
||
normalizeDetailGenerationMode,
|
||
sanitizeDetailImageModelPrompt,
|
||
splitDetailKeywords,
|
||
} from "@/components/canvas/chat-panel/canvas-detail-config";
|
||
import {
|
||
createDetailArchiveSnapshot,
|
||
patchDetailArchiveList,
|
||
prependDetailArchive,
|
||
replaceSavedDetailArchive,
|
||
sanitizeDetailArchiveList,
|
||
} from "@/components/canvas/chat-panel/canvas-detail-archive";
|
||
import {
|
||
applyDetailSlotAttachment,
|
||
fillDetailSlotAttachments,
|
||
getDetailSlotTargetIndexes,
|
||
} from "@/components/canvas/chat-panel/canvas-detail-slots";
|
||
import {
|
||
buildCanvasUploadedImageAttachment,
|
||
CANVAS_IMAGE_UPLOAD_MAX_BYTES,
|
||
getCanvasUploadImageTitle,
|
||
normalizeUploadedAssetId,
|
||
validateCanvasImageUploadFile,
|
||
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
|
||
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
|
||
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
|
||
import type { ChatMessage } from "@/lib/types";
|
||
|
||
test.describe("canvas chat pure helpers", () => {
|
||
test("infers explicit agent image counts from user text", () => {
|
||
expect(inferAgentImageCountFromText("再出三版")).toBe(3);
|
||
expect(inferAgentImageCountFromText("基于这张图再来 3 张")).toBe(3);
|
||
expect(inferAgentImageCountFromText("生成四张主图")).toBe(4);
|
||
expect(inferAgentImageCountFromText("多来两张")).toBe(2);
|
||
expect(inferAgentImageCountFromText("生成16:9横版海报")).toBeNull();
|
||
expect(inferAgentImageCountFromText("提升到2K清晰度")).toBeNull();
|
||
expect(inferAgentImageCountFromText("再出30张", 12)).toBe(12);
|
||
});
|
||
|
||
test("keeps canvas message empty-history access scoped and explicit", () => {
|
||
const canvasChatId = "11111111-1111-4111-8111-111111111111";
|
||
const agentScopedChatId = "11111111-1111-4111-8111-a93e17a60001";
|
||
|
||
expect(normalizeMessagesChatId(`canvas-${canvasChatId}`)).toBe(
|
||
canvasChatId
|
||
);
|
||
expect(normalizeMessagesChatId("canvas-not-a-uuid")).toBe(
|
||
"canvas-not-a-uuid"
|
||
);
|
||
expect(isValidMessagesChatId(canvasChatId)).toBe(true);
|
||
expect(isValidMessagesChatId("canvas-not-a-uuid")).toBe(false);
|
||
expect(isScopedEmptyHistoryRequest({ chatId: canvasChatId })).toBe(false);
|
||
expect(
|
||
isScopedEmptyHistoryRequest({ chatId: canvasChatId, scope: "canvas" })
|
||
).toBe(true);
|
||
expect(
|
||
isScopedEmptyHistoryRequest({ chatId: canvasChatId, scope: " AGENT " })
|
||
).toBe(true);
|
||
expect(
|
||
isScopedEmptyHistoryRequest({
|
||
chatId: "canvas-not-a-uuid",
|
||
scope: "canvas",
|
||
})
|
||
).toBe(false);
|
||
expect(isAgentModeScopedChatId(agentScopedChatId)).toBe(true);
|
||
expect(isAgentModeScopedChatId(canvasChatId)).toBe(false);
|
||
});
|
||
|
||
test("rejects stale autosave bases without blocking fresh or missing revisions", () => {
|
||
const currentUpdatedAt = new Date("2026-06-22T00:00:05.500Z");
|
||
|
||
expect(parseCanvasRevisionDate("not-a-date")).toBeNull();
|
||
expect(
|
||
isStaleCanvasSaveBaseRevision({
|
||
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
|
||
currentUpdatedAt,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
isStaleCanvasSaveBaseRevision({
|
||
baseCanvasUpdatedAt: "2026-06-22T00:00:05.000Z",
|
||
currentUpdatedAt,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
isStaleCanvasSaveBaseRevision({
|
||
baseCanvasUpdatedAt: "2026-06-22T00:00:05.500Z",
|
||
currentUpdatedAt,
|
||
})
|
||
).toBe(false);
|
||
expect(
|
||
isStaleCanvasSaveBaseRevision({
|
||
currentUpdatedAt,
|
||
})
|
||
).toBe(false);
|
||
expect(buildStaleCanvasSaveConflictPayload(currentUpdatedAt)).toMatchObject({
|
||
success: false,
|
||
code: "STALE_CANVAS_REVISION",
|
||
canvasUpdatedAt: "2026-06-22T00:00:05.500Z",
|
||
});
|
||
});
|
||
|
||
const plannerContext: CanvasAgentContext = {
|
||
version: 1,
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
canvasRevision: "2026-06-22T00:00:00.000Z",
|
||
canvasUpdatedAt: "2026-06-22T00:00:00.000Z",
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
selectedNodeIds: ["image-1", "text-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "image-1",
|
||
type: "image",
|
||
title: "主图",
|
||
x: 10,
|
||
y: 20,
|
||
width: 300,
|
||
height: 240,
|
||
isSelected: true,
|
||
},
|
||
{
|
||
nodeId: "text-1",
|
||
type: "text",
|
||
title: "旧标题",
|
||
x: 40,
|
||
y: 80,
|
||
width: 240,
|
||
height: 80,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
candidateNodes: [],
|
||
slots: [],
|
||
annotations: [],
|
||
focusPolicy: "selected",
|
||
};
|
||
|
||
test("keeps canvas model planner scoped to low-risk canvas operations", () => {
|
||
expect(
|
||
shouldConsiderCanvasAgentModelPlanner({
|
||
userText: "把选中的文案润色得更高级",
|
||
context: plannerContext,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldConsiderCanvasAgentModelPlanner({
|
||
userText: "给这张图换成夏天户外背景,新图放右边",
|
||
context: plannerContext,
|
||
})
|
||
).toBe(false);
|
||
expect(
|
||
shouldConsiderCanvasAgentModelPlanner({
|
||
userText: "新建一个视频槽位",
|
||
context: plannerContext,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldConsiderCanvasAgentModelPlanner({
|
||
userText: "对比原图和新图",
|
||
context: plannerContext,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldConsiderCanvasAgentModelPlanner({
|
||
userText: "清空选中的节点",
|
||
context: plannerContext,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldConsiderCanvasAgentModelPlanner({
|
||
userText: "向右移动视角一点",
|
||
context: plannerContext,
|
||
})
|
||
).toBe(true);
|
||
});
|
||
|
||
test("treats selected agent slots as canvas routing context", () => {
|
||
expect(
|
||
hasCanvasAgentRoutingContext({
|
||
hasCanvasSelection: false,
|
||
targetResolutionMode: "none",
|
||
mediaSource: "none",
|
||
selectedImageCount: 0,
|
||
selectedSlotCount: 1,
|
||
hasSelectedAgentSlot: true,
|
||
})
|
||
).toBe(true);
|
||
|
||
expect(
|
||
hasCanvasAgentRoutingContext({
|
||
hasCanvasSelection: false,
|
||
targetResolutionMode: "none",
|
||
mediaSource: "none",
|
||
selectedImageCount: 0,
|
||
selectedSlotCount: 0,
|
||
hasSelectedAgentSlot: false,
|
||
})
|
||
).toBe(false);
|
||
});
|
||
|
||
test("normalizes model-planned empty slot creation", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "create_agent_slot",
|
||
slotType: "video",
|
||
slotLabel: "片头视频槽位",
|
||
},
|
||
context: {
|
||
...plannerContext,
|
||
viewport: { x: -100, y: -50, zoom: 2, width: 1000, height: 800 },
|
||
},
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "新建一个视频槽位",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("operation");
|
||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||
operation: "create_agent_slot",
|
||
targetPosition: { x: 120, y: 115 },
|
||
slot: {
|
||
slotType: "video",
|
||
label: "片头视频槽位",
|
||
aspectRatio: "16:9",
|
||
fillPolicy: "replace-empty",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("builds direct-media slot creation plans for combined slot requests", () => {
|
||
const intent = resolveCanvasAgentSlotCreationIntent({
|
||
userText: "新建一个 9:16 视频槽位并生成片头",
|
||
});
|
||
expect(intent).toMatchObject({
|
||
shouldCreate: true,
|
||
combinedWithMediaExecution: true,
|
||
slotType: "video",
|
||
aspectRatio: "9:16",
|
||
});
|
||
|
||
const plan = buildCanvasAgentSlotCreationPlan({
|
||
context: {
|
||
...plannerContext,
|
||
viewport: { x: 0, y: 0, zoom: 1, width: 1000, height: 800 },
|
||
},
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "新建一个 9:16 视频槽位并生成片头",
|
||
operationId: "message-1:agent-generate-video:create-slot",
|
||
});
|
||
|
||
expect(plan).toMatchObject({
|
||
operationId: "message-1:agent-generate-video:create-slot",
|
||
operation: "create_agent_slot",
|
||
targetPosition: { x: 320, y: 290 },
|
||
slot: {
|
||
slotType: "video",
|
||
aspectRatio: "9:16",
|
||
fillPolicy: "replace-empty",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("leaves combined slot creation and media generation to media executors", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "create_agent_slot",
|
||
slotType: "video",
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "创建一个视频槽位并生成结果",
|
||
});
|
||
|
||
expect(outcome).toMatchObject({
|
||
kind: "none",
|
||
reason: "slot_creation_combined_with_media_execution",
|
||
});
|
||
});
|
||
|
||
test("plans direct writeback as a slot fill when an agent slot is selected", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: ["slot-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "slot-1",
|
||
type: "agent-slot",
|
||
title: "主图槽位",
|
||
x: 100,
|
||
y: 120,
|
||
width: 320,
|
||
height: 240,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
slots: [
|
||
{
|
||
nodeId: "slot-1",
|
||
slotType: "image",
|
||
label: "主图槽位",
|
||
x: 100,
|
||
y: 120,
|
||
width: 320,
|
||
height: 240,
|
||
aspectRatio: "4:3",
|
||
isSelected: true,
|
||
fillPolicy: "replace-empty",
|
||
},
|
||
],
|
||
};
|
||
|
||
const result = buildCanvasAgentDirectWritebackPlan({
|
||
projectId: plannerContext.projectId || "",
|
||
chatId: "chat-1",
|
||
assistantMessageId: "assistant-1",
|
||
operationLabel: "agent-generate-image",
|
||
resultType: "image",
|
||
resultUrls: ["https://cdn.example.com/slot.png"],
|
||
prompt: "生成主图",
|
||
mode: "generate",
|
||
context,
|
||
model: "image-model",
|
||
});
|
||
|
||
expect(result).toMatchObject({
|
||
operationId: "assistant-1:agent-generate-image",
|
||
operation: "fill_slot",
|
||
targetSlotId: "slot-1",
|
||
shouldFillSlot: true,
|
||
mediaItemIds: ["assistant-1:agent-generate-image:1"],
|
||
plan: {
|
||
operation: "fill_slot",
|
||
targetSlotId: "slot-1",
|
||
anchorNodeId: undefined,
|
||
placementStrategy: "single",
|
||
sourceNodeIds: ["slot-1"],
|
||
},
|
||
});
|
||
expect(result?.mediaItems[0]).toMatchObject({
|
||
mediaItemId: "assistant-1:agent-generate-image:1",
|
||
type: "image",
|
||
url: "https://cdn.example.com/slot.png",
|
||
prompt: "生成主图",
|
||
sourceNodeIds: ["slot-1"],
|
||
metadata: {
|
||
messageId: "assistant-1",
|
||
chatId: "chat-1",
|
||
model: "image-model",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("plans direct image edits as append revisions anchored to selected media", () => {
|
||
const result = buildCanvasAgentDirectWritebackPlan({
|
||
projectId: plannerContext.projectId || "",
|
||
chatId: "chat-1",
|
||
assistantMessageId: "assistant-2",
|
||
operationLabel: "agent-edit-image",
|
||
resultType: "image",
|
||
resultUrls: [
|
||
"relative.png",
|
||
"https://cdn.example.com/revision.png",
|
||
],
|
||
prompt: "",
|
||
requestTextContent: "把选中图片改成黄昏光线",
|
||
mode: "edit",
|
||
context: plannerContext,
|
||
targetRefs: [{ nodeId: "image-1" }],
|
||
sourceImageUrls: ["https://cdn.example.com/source.png"],
|
||
assetIdsByUrl: {
|
||
"https://cdn.example.com/revision.png": "asset-1",
|
||
},
|
||
});
|
||
|
||
expect(result).toMatchObject({
|
||
operation: "append_revision",
|
||
shouldFillSlot: false,
|
||
mediaItemIds: ["assistant-2:agent-edit-image:1"],
|
||
plan: {
|
||
operation: "append_revision",
|
||
anchorNodeId: "image-1",
|
||
placementStrategy: "single",
|
||
metadata: {
|
||
prompt: "把选中图片改成黄昏光线",
|
||
},
|
||
},
|
||
});
|
||
expect(result?.mediaItems[0]).toMatchObject({
|
||
assetId: "asset-1",
|
||
sourceNodeIds: ["image-1"],
|
||
metadata: {
|
||
sourceImageUrls: ["https://cdn.example.com/source.png"],
|
||
},
|
||
});
|
||
});
|
||
|
||
test("anchors direct image edits to resolved target refs before fallback selection", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: [],
|
||
selectedNodes: [],
|
||
candidateNodes: [
|
||
{
|
||
nodeId: "image-left",
|
||
type: "image",
|
||
title: "左图",
|
||
x: 0,
|
||
y: 0,
|
||
width: 240,
|
||
height: 180,
|
||
},
|
||
{
|
||
nodeId: "image-right",
|
||
type: "image",
|
||
title: "右图",
|
||
x: 420,
|
||
y: 0,
|
||
width: 240,
|
||
height: 180,
|
||
},
|
||
],
|
||
focusPolicy: "auto",
|
||
};
|
||
|
||
const result = buildCanvasAgentDirectWritebackPlan({
|
||
projectId: plannerContext.projectId || "",
|
||
chatId: "chat-1",
|
||
assistantMessageId: "assistant-targeted",
|
||
operationLabel: "agent-edit-image",
|
||
resultType: "image",
|
||
resultUrls: ["https://cdn.example.com/right-revision.png"],
|
||
prompt: "把右边那张背景换成白色",
|
||
mode: "edit",
|
||
context,
|
||
targetRefs: [{ nodeId: " image-right " }],
|
||
sourceImageUrls: ["https://cdn.example.com/right-source.png"],
|
||
});
|
||
|
||
expect(result).toMatchObject({
|
||
operation: "append_revision",
|
||
plan: {
|
||
operation: "append_revision",
|
||
anchorNodeId: "image-right",
|
||
sourceNodeIds: ["image-right"],
|
||
},
|
||
});
|
||
expect(result?.selectedMediaNodes.map((node) => node.nodeId)).toEqual([
|
||
"image-right",
|
||
]);
|
||
expect(result?.mediaItems[0]).toMatchObject({
|
||
sourceNodeIds: ["image-right"],
|
||
metadata: {
|
||
sourceImageUrls: ["https://cdn.example.com/right-source.png"],
|
||
},
|
||
});
|
||
});
|
||
|
||
test("records annotation trace metadata for direct annotation edits", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
annotations: [
|
||
{
|
||
id: "annotation-keep-bg",
|
||
sourceNodeId: "image-1",
|
||
type: "text-note",
|
||
text: "背景换成浅灰",
|
||
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
|
||
},
|
||
{
|
||
id: "annotation-other",
|
||
sourceNodeId: "image-other",
|
||
type: "text-note",
|
||
text: "不应进入 image-1 的结果 metadata",
|
||
},
|
||
],
|
||
};
|
||
|
||
const result = buildCanvasAgentDirectWritebackPlan({
|
||
projectId: plannerContext.projectId || "",
|
||
chatId: "chat-1",
|
||
assistantMessageId: "assistant-annotation-edit",
|
||
operationLabel: "agent-edit-image",
|
||
resultType: "image",
|
||
resultUrls: ["https://cdn.example.com/annotation-edit.png"],
|
||
prompt: "按批注修改这张图",
|
||
requestTextContent: "按批注修改这张图",
|
||
mode: "edit",
|
||
context,
|
||
targetRefs: [{ nodeId: "image-1" }],
|
||
});
|
||
|
||
expect(result?.mediaItems[0]?.metadata).toMatchObject({
|
||
canvasAgentAnnotationIds: ["annotation-keep-bg"],
|
||
canvasAgentAnnotationCount: 1,
|
||
});
|
||
expect(
|
||
String(result?.mediaItems[0]?.metadata?.canvasAgentAnnotationBrief || "")
|
||
).toContain("背景换成浅灰");
|
||
expect(
|
||
String(result?.mediaItems[0]?.metadata?.canvasAgentAnnotationBrief || "")
|
||
).not.toContain("annotation-other");
|
||
});
|
||
|
||
test("plans direct batch generations beside each matched source", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: ["image-a", "image-b"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "image-a",
|
||
type: "image",
|
||
title: "A",
|
||
x: 0,
|
||
y: 0,
|
||
width: 200,
|
||
height: 160,
|
||
isSelected: true,
|
||
},
|
||
{
|
||
nodeId: "image-b",
|
||
type: "image",
|
||
title: "B",
|
||
x: 260,
|
||
y: 0,
|
||
width: 200,
|
||
height: 160,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
};
|
||
|
||
const result = buildCanvasAgentDirectWritebackPlan({
|
||
projectId: plannerContext.projectId || "",
|
||
chatId: "chat-1",
|
||
assistantMessageId: "assistant-3",
|
||
operationLabel: "agent-generate-image",
|
||
resultType: "image",
|
||
resultUrls: [
|
||
"https://cdn.example.com/a.png",
|
||
"https://cdn.example.com/b.png",
|
||
],
|
||
prompt: "分别生成两个变体",
|
||
mode: "generate",
|
||
context,
|
||
targetRefs: [{ nodeId: "image-a" }, { nodeId: "image-b" }],
|
||
});
|
||
|
||
expect(result).toMatchObject({
|
||
operation: "insert_beside",
|
||
shouldFillSlot: false,
|
||
plan: {
|
||
operation: "insert_beside",
|
||
anchorNodeId: "image-a",
|
||
placementStrategy: "beside_each_source",
|
||
sourceNodeIds: ["image-a", "image-b"],
|
||
},
|
||
});
|
||
expect(result?.mediaItems.map((item) => item.sourceNodeIds)).toEqual([
|
||
["image-a"],
|
||
["image-b"],
|
||
]);
|
||
});
|
||
|
||
test("plans direct generations into the current viewport when no anchor exists", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: [],
|
||
selectedNodes: [],
|
||
candidateNodes: [],
|
||
slots: [],
|
||
annotations: [],
|
||
viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 800 },
|
||
focusPolicy: "auto",
|
||
};
|
||
|
||
const result = buildCanvasAgentDirectWritebackPlan({
|
||
projectId: plannerContext.projectId || "",
|
||
chatId: "chat-1",
|
||
assistantMessageId: "assistant-no-anchor",
|
||
operationLabel: "agent-generate-image",
|
||
resultType: "image",
|
||
resultUrls: ["https://cdn.example.com/no-anchor.png"],
|
||
prompt: "生成一张主图",
|
||
mode: "generate",
|
||
context,
|
||
});
|
||
|
||
expect(result).toMatchObject({
|
||
operation: "insert_beside",
|
||
shouldFillSlot: false,
|
||
plan: {
|
||
operation: "insert_beside",
|
||
anchorNodeId: undefined,
|
||
placement: "viewport-center",
|
||
placementStrategy: "single",
|
||
sourceNodeIds: [],
|
||
metadata: {
|
||
canvasAgentViewport: {
|
||
x: -200,
|
||
y: -100,
|
||
zoom: 1,
|
||
width: 1000,
|
||
height: 800,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
expect(result?.mediaItems[0]?.sourceNodeIds).toEqual([]);
|
||
});
|
||
|
||
test("does not build direct writeback plans without valid result urls", () => {
|
||
expect(
|
||
buildCanvasAgentDirectWritebackPlan({
|
||
projectId: plannerContext.projectId || "",
|
||
chatId: "chat-1",
|
||
assistantMessageId: "assistant-4",
|
||
operationLabel: "agent-generate-image",
|
||
resultType: "image",
|
||
resultUrls: ["", "blob:local", "/relative.png"],
|
||
prompt: "生成图片",
|
||
mode: "generate",
|
||
context: plannerContext,
|
||
})
|
||
).toBeNull();
|
||
});
|
||
|
||
test("normalizes model-planned focus to known canvas nodes only", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "focus_nodes",
|
||
nodeIds: ["missing-node", "image-1"],
|
||
reply: "已聚焦主图。",
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "帮我定位到主图",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("view");
|
||
expect(outcome.kind === "view" ? outcome.viewPlan.nodeIds : []).toEqual([
|
||
"image-1",
|
||
]);
|
||
});
|
||
|
||
test("normalizes model-planned text updates to selected text nodes", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "update_text",
|
||
nodeIds: ["image-1", "text-1"],
|
||
textContent: "轻盈强吸力,家务更省心",
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "把选中文案改得更高级",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("operation");
|
||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||
operation: "update_text",
|
||
targetNodeIds: ["text-1"],
|
||
textContent: "轻盈强吸力,家务更省心",
|
||
});
|
||
});
|
||
|
||
test("normalizes model-planned text creation into new canvas text nodes", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: [],
|
||
selectedNodes: [],
|
||
viewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
|
||
focusPolicy: "auto",
|
||
};
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "create_text_node",
|
||
textContent: "618 限时优惠,新品主推",
|
||
},
|
||
context,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "在画布上添加一段促销文案:618 限时优惠,新品主推",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("operation");
|
||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||
operation: "create_text_node",
|
||
targetPosition: { x: 440, y: 340 },
|
||
textContent: "618 限时优惠,新品主推",
|
||
preserveOriginal: true,
|
||
metadata: {
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
planner: "model",
|
||
canvasAgentViewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
|
||
},
|
||
});
|
||
});
|
||
|
||
test("turns new-text update decisions into text creation when no text node is targeted", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: ["image-1"],
|
||
selectedNodes: [plannerContext.selectedNodes[0]],
|
||
viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 700 },
|
||
focusPolicy: "selected",
|
||
};
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "update_text",
|
||
nodeIds: ["image-1"],
|
||
textContent: "新品上市",
|
||
},
|
||
context,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-2",
|
||
chatId: "chat-1",
|
||
userText: "在画布上添加文字:新品上市",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("operation");
|
||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||
operation: "create_text_node",
|
||
targetPosition: { x: 540, y: 390 },
|
||
textContent: "新品上市",
|
||
metadata: {
|
||
normalizedFromAction: "update_text",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("normalizes model-planned node renames without requiring text nodes", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "rename_node",
|
||
nodeIds: ["image-1"],
|
||
textContent: "白底主图 A",
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "把选中图片标题改成白底主图 A",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("operation");
|
||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||
operation: "rename_node",
|
||
targetNodeIds: ["image-1"],
|
||
textContent: "白底主图 A",
|
||
metadata: {
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
planner: "model",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("normalizes model-planned image notes as note updates", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "update_note",
|
||
nodeIds: ["image-1", "text-1"],
|
||
textContent: "保留主体轮廓,背景可以更干净",
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "给选中图片备注:保留主体轮廓,背景可以更干净",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("operation");
|
||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||
operation: "update_note",
|
||
targetNodeIds: ["image-1"],
|
||
textContent: "保留主体轮廓,背景可以更干净",
|
||
metadata: {
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
planner: "model",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("builds deterministic text creation plans from explicit canvas prompts", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: [],
|
||
selectedNodes: [],
|
||
viewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
|
||
focusPolicy: "auto",
|
||
};
|
||
const outcome = buildCanvasAgentExplicitOperationPlan({
|
||
context,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "在画布上添加文字:新品上市",
|
||
});
|
||
|
||
expect(outcome?.operationPlan).toMatchObject({
|
||
operation: "create_text_node",
|
||
targetPosition: { x: 440, y: 340 },
|
||
textContent: "新品上市",
|
||
preserveOriginal: true,
|
||
metadata: {
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
planner: "deterministic",
|
||
canvasAgentViewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 },
|
||
},
|
||
});
|
||
});
|
||
|
||
test("builds deterministic agent slot creation plans from explicit canvas prompts", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
viewport: { x: 0, y: 0, zoom: 1, width: 1000, height: 800 },
|
||
focusPolicy: "auto",
|
||
};
|
||
const outcome = buildCanvasAgentExplicitOperationPlan({
|
||
context,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "新建一个 9:16 视频槽位",
|
||
});
|
||
|
||
expect(outcome?.operationPlan).toMatchObject({
|
||
operation: "create_agent_slot",
|
||
targetPosition: { x: 320, y: 290 },
|
||
slot: {
|
||
slotType: "video",
|
||
label: "视频槽位",
|
||
aspectRatio: "9:16",
|
||
fillPolicy: "replace-empty",
|
||
acceptedSources: ["text", "image", "selection", "annotation"],
|
||
},
|
||
preserveOriginal: true,
|
||
metadata: {
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
planner: "deterministic",
|
||
},
|
||
});
|
||
expect(outcome?.reply).toContain("视频槽位");
|
||
});
|
||
|
||
test("builds deterministic node rename plans without treating titles as image edits", () => {
|
||
const outcome = buildCanvasAgentExplicitOperationPlan({
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "把选中图片改名为白底主图 A",
|
||
});
|
||
|
||
expect(outcome?.operationPlan).toMatchObject({
|
||
operation: "rename_node",
|
||
targetNodeIds: ["image-1"],
|
||
textContent: "白底主图 A",
|
||
metadata: {
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
planner: "deterministic",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("builds deterministic note updates for selected media nodes only", () => {
|
||
const outcome = buildCanvasAgentExplicitOperationPlan({
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "给选中图片备注:保留主体轮廓",
|
||
});
|
||
|
||
expect(outcome?.operationPlan).toMatchObject({
|
||
operation: "update_note",
|
||
targetNodeIds: ["image-1"],
|
||
textContent: "保留主体轮廓",
|
||
metadata: {
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
planner: "deterministic",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("builds deterministic annotation additions for selected media nodes only", () => {
|
||
const outcome = buildCanvasAgentExplicitOperationPlan({
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "给选中图片加批注:这里需要重点保留产品轮廓",
|
||
});
|
||
|
||
expect(outcome?.operationPlan).toMatchObject({
|
||
operation: "add_annotation",
|
||
targetNodeIds: ["image-1"],
|
||
textContent: "这里需要重点保留产品轮廓",
|
||
annotations: [
|
||
{
|
||
type: "text-note",
|
||
text: "这里需要重点保留产品轮廓",
|
||
color: "#f59e0b",
|
||
},
|
||
],
|
||
metadata: {
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
planner: "deterministic",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("does not route media generation or note-driven image edits as explicit canvas operations", () => {
|
||
expect(
|
||
buildCanvasAgentExplicitOperationPlan({
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "按这个备注修改主图",
|
||
})
|
||
).toBeNull();
|
||
|
||
expect(
|
||
buildCanvasAgentExplicitOperationPlan({
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "生成一张图,标题文字:新品上市",
|
||
})
|
||
).toBeNull();
|
||
|
||
expect(
|
||
buildCanvasAgentExplicitOperationPlan({
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "新建一个视频槽位并生成片头",
|
||
})
|
||
).toBeNull();
|
||
|
||
expect(
|
||
buildCanvasAgentExplicitOperationPlan({
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "按这张图上的批注修改",
|
||
})
|
||
).toBeNull();
|
||
});
|
||
|
||
test("normalizes model-planned annotations to selected media nodes", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "add_annotation",
|
||
nodeIds: ["image-1", "text-1"],
|
||
textContent: "这里需要重点保留产品轮廓",
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "给选中图片加备注:这里需要重点保留产品轮廓",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("operation");
|
||
expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({
|
||
operation: "add_annotation",
|
||
targetNodeIds: ["image-1"],
|
||
textContent: "这里需要重点保留产品轮廓",
|
||
annotations: [
|
||
{
|
||
type: "text-note",
|
||
text: "这里需要重点保留产品轮廓",
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
test("turns destructive model-planned canvas requests into confirmation", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "none",
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "删除选中的这两项",
|
||
});
|
||
|
||
expect(outcome).toMatchObject({
|
||
kind: "confirmation_required",
|
||
riskOperation: "delete_nodes",
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
});
|
||
|
||
const clearOutcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "none",
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "清除选中的对象",
|
||
});
|
||
|
||
expect(clearOutcome).toMatchObject({
|
||
kind: "confirmation_required",
|
||
riskOperation: "delete_nodes",
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
});
|
||
});
|
||
|
||
test("builds pending move-node operation from model-planned high-risk layout", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "confirmation_required",
|
||
riskOperation: "move_nodes",
|
||
nodeIds: ["image-1", "text-1"],
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "把选中的两项整理成一行",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("confirmation_required");
|
||
expect(
|
||
outcome.kind === "confirmation_required" ? outcome.operationPlan : null
|
||
).toMatchObject({
|
||
operation: "move_nodes",
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
layoutStrategy: "row",
|
||
});
|
||
expect(
|
||
outcome.kind === "confirmation_required" ? outcome.operationPlan?.confirmed : true
|
||
).toBeUndefined();
|
||
});
|
||
|
||
test("keeps viewport-centering move requests as view operations", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "focus_nodes",
|
||
nodeIds: ["image-1"],
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "把选中的图移动到视野中央",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("view");
|
||
expect(outcome.kind === "view" ? outcome.viewPlan : null).toMatchObject({
|
||
operation: "focus_selection",
|
||
nodeIds: ["image-1"],
|
||
anchorNodeId: "image-1",
|
||
persist: false,
|
||
});
|
||
});
|
||
|
||
test("builds pending delete-node operation from model-planned destructive request", () => {
|
||
const outcome = normalizeCanvasAgentModelPlannerDecision({
|
||
decision: {
|
||
action: "confirmation_required",
|
||
riskOperation: "delete_nodes",
|
||
nodeIds: ["image-1", "text-1"],
|
||
},
|
||
context: plannerContext,
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
chatId: "chat-1",
|
||
userText: "删除选中的两项",
|
||
});
|
||
|
||
expect(outcome.kind).toBe("confirmation_required");
|
||
expect(
|
||
outcome.kind === "confirmation_required" ? outcome.operationPlan : null
|
||
).toMatchObject({
|
||
operation: "delete_nodes",
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
metadata: {
|
||
pendingConfirmation: true,
|
||
riskOperation: "delete_nodes",
|
||
},
|
||
});
|
||
expect(
|
||
outcome.kind === "confirmation_required" ? outcome.operationPlan?.confirmed : true
|
||
).toBeUndefined();
|
||
});
|
||
|
||
test("extracts and confirms pending canvas operations from working state", () => {
|
||
const pending = buildPendingCanvasOperation({
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
userText: "把选中的两项整理成一行",
|
||
riskOperation: "move_nodes",
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
reply: "移动节点需要确认。",
|
||
operationPlan: {
|
||
version: 1,
|
||
operationId: "message-1:canvas-move",
|
||
operation: "move_nodes",
|
||
projectId: plannerContext.projectId || "",
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
layoutStrategy: "row",
|
||
metadata: {
|
||
pendingConfirmation: true,
|
||
},
|
||
},
|
||
});
|
||
|
||
const extracted = extractPendingCanvasOperationFromWorkingState({
|
||
lastTurnSummary: {
|
||
pendingCanvasOperation: pending,
|
||
},
|
||
});
|
||
expect(extracted?.operationPlan?.operation).toBe("move_nodes");
|
||
expect(isCanvasOperationConfirmText("确认执行")).toBe(true);
|
||
expect(isCanvasOperationCancelText("先不执行了")).toBe(true);
|
||
|
||
const confirmedPlan = extracted
|
||
? withConfirmedCanvasOperationPlan(extracted, "message-2")
|
||
: null;
|
||
expect(confirmedPlan).toMatchObject({
|
||
operation: "move_nodes",
|
||
confirmed: true,
|
||
metadata: {
|
||
confirmedByUser: true,
|
||
confirmedByMessageId: "message-2",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("confirms pending replace-node-media operations without executing directly", () => {
|
||
const pending = buildPendingCanvasOperation({
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
userText: "直接替换原图",
|
||
riskOperation: "replace_node_media",
|
||
targetNodeIds: ["image-1"],
|
||
reply: "替换原图需要确认。",
|
||
operationPlan: {
|
||
version: 1,
|
||
operationId: "op-replace-confirmed",
|
||
operation: "replace_node_media",
|
||
projectId: plannerContext.projectId || "",
|
||
targetNodeIds: ["image-1"],
|
||
mediaItems: [
|
||
{
|
||
mediaItemId: "media-1",
|
||
type: "image",
|
||
url: "https://cdn.example.com/new.png",
|
||
assetId: "asset-new",
|
||
title: "新图",
|
||
prompt: "new prompt",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
expect(pending.operationPlan?.confirmed).toBeUndefined();
|
||
const confirmedPlan = withConfirmedCanvasOperationPlan(pending, "message-2");
|
||
expect(confirmedPlan).toMatchObject({
|
||
operation: "replace_node_media",
|
||
confirmed: true,
|
||
targetNodeIds: ["image-1"],
|
||
mediaItems: [
|
||
{
|
||
mediaItemId: "media-1",
|
||
url: "https://cdn.example.com/new.png",
|
||
},
|
||
],
|
||
metadata: {
|
||
confirmedByUser: true,
|
||
confirmedByMessageId: "message-2",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("confirms pending delete-node operations without executing directly", () => {
|
||
const pending = buildPendingCanvasOperation({
|
||
projectId: plannerContext.projectId || "",
|
||
messageId: "message-1",
|
||
userText: "删除选中的两项",
|
||
riskOperation: "delete_nodes",
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
reply: "删除节点需要确认。",
|
||
operationPlan: {
|
||
version: 1,
|
||
operationId: "message-1:canvas-delete",
|
||
operation: "delete_nodes",
|
||
projectId: plannerContext.projectId || "",
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
metadata: {
|
||
pendingConfirmation: true,
|
||
},
|
||
},
|
||
});
|
||
|
||
const confirmedPlan = withConfirmedCanvasOperationPlan(pending, "message-2");
|
||
expect(confirmedPlan).toMatchObject({
|
||
operation: "delete_nodes",
|
||
confirmed: true,
|
||
targetNodeIds: ["image-1", "text-1"],
|
||
metadata: {
|
||
confirmedByUser: true,
|
||
confirmedByMessageId: "message-2",
|
||
},
|
||
});
|
||
});
|
||
|
||
test("rejects confirmed stale high-risk canvas mutations", () => {
|
||
expect(
|
||
shouldRejectStaleCanvasAgentHighRiskMutation({
|
||
operation: "replace_node_media",
|
||
confirmed: false,
|
||
baseCanvasWasStale: true,
|
||
})
|
||
).toBe(false);
|
||
expect(
|
||
shouldRejectStaleCanvasAgentHighRiskMutation({
|
||
operation: "replace_node_media",
|
||
confirmed: true,
|
||
baseCanvasWasStale: true,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldRejectStaleCanvasAgentHighRiskMutation({
|
||
operation: "delete_nodes",
|
||
metadata: { confirmedByUser: true },
|
||
baseCanvasWasStale: true,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldRejectStaleCanvasAgentHighRiskMutation({
|
||
operation: "insert_beside",
|
||
confirmed: true,
|
||
baseCanvasWasStale: true,
|
||
})
|
||
).toBe(false);
|
||
expect(
|
||
shouldRejectStaleCanvasAgentHighRiskMutation({
|
||
operation: "move_nodes",
|
||
confirmed: true,
|
||
baseCanvasWasStale: false,
|
||
})
|
||
).toBe(false);
|
||
});
|
||
|
||
test("normalizes canvas operation error messages for stream feedback", () => {
|
||
expect(normalizeCanvasAgentOperationErrorCode("STALE_CANVAS_REVISION")).toBe(
|
||
"STALE_CANVAS_REVISION"
|
||
);
|
||
expect(normalizeCanvasAgentOperationErrorCode("unknown")).toBe(
|
||
"CANVAS_AGENT_WRITEBACK_FAILED"
|
||
);
|
||
expect(
|
||
normalizeCanvasAgentOperationErrorCode("CANVAS_AGENT_WRITEBACK_DISABLED")
|
||
).toBe("CANVAS_AGENT_WRITEBACK_DISABLED");
|
||
expect(
|
||
getCanvasAgentOperationErrorMessage({
|
||
code: "STALE_CANVAS_REVISION",
|
||
})
|
||
).toContain("刷新画布后重新确认");
|
||
expect(
|
||
getCanvasAgentOperationErrorMessage({
|
||
code: "CANVAS_AGENT_CONFIRMATION_REQUIRED",
|
||
})
|
||
).toContain("需要确认");
|
||
expect(
|
||
getCanvasAgentOperationErrorMessage({
|
||
code: "CANVAS_AGENT_CONFLICT",
|
||
})
|
||
).toContain("目标状态有冲突");
|
||
expect(
|
||
getCanvasAgentOperationErrorMessage({
|
||
code: "CANVAS_AGENT_WRITEBACK_DISABLED",
|
||
})
|
||
).toContain("灰度保护");
|
||
expect(
|
||
getCanvasAgentOperationErrorMessage({
|
||
code: "CANVAS_AGENT_WRITEBACK_FAILED",
|
||
fallbackMessage: "写回接口失败",
|
||
})
|
||
).toBe("写回接口失败");
|
||
});
|
||
|
||
test("normalizes canvas agent rollout flags and writeback modes", () => {
|
||
expect(normalizeCanvasAgentBoolean("true", false)).toBe(true);
|
||
expect(normalizeCanvasAgentBoolean("off", true)).toBe(false);
|
||
expect(normalizeCanvasAgentBoolean("surprise", true)).toBe(true);
|
||
expect(normalizeCanvasAgentWritebackMode("operation_shadow")).toBe(
|
||
"operation_shadow"
|
||
);
|
||
expect(normalizeCanvasAgentWritebackMode("nope")).toBe(
|
||
"operation_authoritative"
|
||
);
|
||
|
||
const defaults = getCanvasAgentFeatureFlags({});
|
||
expect(defaults).toMatchObject({
|
||
operationProtocolEnabled: true,
|
||
writebackMode: "operation_authoritative",
|
||
slotEnabled: true,
|
||
viewOperationEnabled: true,
|
||
annotationEditEnabled: true,
|
||
});
|
||
expect(canApplyCanvasAgentMutations(defaults)).toBe(true);
|
||
|
||
const shadow = getCanvasAgentFeatureFlags({
|
||
CANVAS_AGENT_WRITEBACK_MODE: "operation_shadow",
|
||
CANVAS_AGENT_SLOT_ENABLED: "0",
|
||
CANVAS_AGENT_VIEW_OPERATION_ENABLED: "false",
|
||
CANVAS_AGENT_ANNOTATION_EDIT_ENABLED: "off",
|
||
});
|
||
expect(shadow).toMatchObject({
|
||
writebackMode: "operation_shadow",
|
||
slotEnabled: false,
|
||
viewOperationEnabled: false,
|
||
annotationEditEnabled: false,
|
||
});
|
||
expect(canApplyCanvasAgentMutations(shadow)).toBe(false);
|
||
expect(shouldStreamCanvasAgentShadowOperations(shadow)).toBe(true);
|
||
|
||
const disabledProtocol = getCanvasAgentFeatureFlags({
|
||
CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED: "disabled",
|
||
CANVAS_AGENT_WRITEBACK_MODE: "operation_authoritative",
|
||
});
|
||
expect(canApplyCanvasAgentMutations(disabledProtocol)).toBe(false);
|
||
expect(shouldStreamCanvasAgentShadowOperations(disabledProtocol)).toBe(false);
|
||
});
|
||
|
||
test("builds idempotent canvas operation audit results", () => {
|
||
const canvasData = {
|
||
metadata: {
|
||
canvasAgentOperationAudit: [
|
||
{
|
||
operationId: " op-1 ",
|
||
operation: "link_nodes",
|
||
createdNodeIds: [" node-1 ", ""],
|
||
createdNodeIdsByMediaItemId: {
|
||
" media-1 ": " node-1 ",
|
||
"": "ignored",
|
||
"media-2": "",
|
||
},
|
||
updatedNodeIds: [" node-a ", "node-b"],
|
||
createdEdgeIds: [" edge-1 "],
|
||
},
|
||
],
|
||
},
|
||
};
|
||
|
||
const audit = findCanvasAgentOperationAudit(canvasData, "op-1");
|
||
expect(audit).toBeTruthy();
|
||
expect(
|
||
buildIdempotentCanvasAgentAuditResult({
|
||
canvasData,
|
||
audit: audit || {},
|
||
})
|
||
).toMatchObject({
|
||
canvasData,
|
||
createdNodeIds: ["node-1"],
|
||
createdNodeIdsByMediaItemId: { "media-1": "node-1" },
|
||
updatedNodeIds: ["node-a", "node-b"],
|
||
createdEdgeIds: ["edge-1"],
|
||
});
|
||
|
||
const unchanged = appendCanvasAgentOperationAuditIfMissing({
|
||
canvasData,
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-1",
|
||
operation: "link_nodes",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
sourceNodeIds: ["node-a", "node-b"],
|
||
},
|
||
result: {
|
||
createdNodeIds: [],
|
||
createdNodeIdsByMediaItemId: {},
|
||
updatedNodeIds: ["different"],
|
||
createdEdgeIds: ["different"],
|
||
},
|
||
});
|
||
expect(unchanged).toBe(canvasData);
|
||
|
||
const appended = appendCanvasAgentOperationAuditIfMissing({
|
||
canvasData,
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-2",
|
||
operation: "add_annotation",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
targetNodeIds: ["node-a"],
|
||
},
|
||
result: {
|
||
createdNodeIds: [],
|
||
createdNodeIdsByMediaItemId: {},
|
||
updatedNodeIds: ["node-a"],
|
||
createdEdgeIds: [],
|
||
},
|
||
});
|
||
expect(
|
||
findCanvasAgentOperationAudit(appended, "op-2")
|
||
).toMatchObject({
|
||
operationId: "op-2",
|
||
operation: "add_annotation",
|
||
updatedNodeIds: ["node-a"],
|
||
});
|
||
});
|
||
|
||
test("applies append revision canvas mutations without overwriting source nodes", () => {
|
||
const sourceNode = {
|
||
id: "image-1",
|
||
type: "image",
|
||
position: { x: 100, y: 120 },
|
||
size: { width: 320, height: 240 },
|
||
data: {
|
||
src: "https://cdn.example.com/source.png",
|
||
status: "completed",
|
||
title: "原图",
|
||
},
|
||
connections: [],
|
||
createdAt: "2026-06-22T00:00:00.000Z",
|
||
updatedAt: "2026-06-22T00:00:00.000Z",
|
||
};
|
||
|
||
const result = applyCanvasOperationPlanToCanvasData({
|
||
canvasData: {
|
||
nodes: [sourceNode],
|
||
edges: [],
|
||
selectedNodeIds: ["image-1"],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
},
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-append-1",
|
||
operation: "append_revision",
|
||
projectId: plannerContext.projectId || "",
|
||
sourceNodeIds: ["image-1"],
|
||
anchorNodeId: "image-1",
|
||
placement: "right",
|
||
mediaItems: [
|
||
{
|
||
mediaItemId: "media-append-1",
|
||
type: "image",
|
||
url: "https://cdn.example.com/revision.png",
|
||
title: "白底版本",
|
||
prompt: "换成白底",
|
||
metadata: {
|
||
messageId: "assistant-append-1",
|
||
model: "test-image-model",
|
||
},
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
expect(result.createdNodeIds).toHaveLength(1);
|
||
const resultCanvasData = result.canvasData as { nodes?: any[]; edges?: any[] };
|
||
expect(result.createdNodeIdsByMediaItemId["media-append-1"]).toBe(
|
||
result.createdNodeIds[0]
|
||
);
|
||
|
||
const createdNode = resultCanvasData.nodes?.find(
|
||
(node) => node.id === result.createdNodeIds[0]
|
||
);
|
||
expect(createdNode).toMatchObject({
|
||
type: "image",
|
||
data: {
|
||
src: "https://cdn.example.com/revision.png",
|
||
metadata: {
|
||
generatedByAgent: true,
|
||
canvasAgentOperationId: "op-append-1",
|
||
canvasAgentMediaItemId: "media-append-1",
|
||
revisionOf: "image-1",
|
||
sourceNodeId: "image-1",
|
||
sourceNodeIds: ["image-1"],
|
||
prompt: "换成白底",
|
||
model: "test-image-model",
|
||
messageId: "assistant-append-1",
|
||
sourceMessageId: "assistant-append-1",
|
||
sourceImageKey: "https://cdn.example.com/revision.png",
|
||
},
|
||
},
|
||
});
|
||
expect(createdNode?.position.x).toBeGreaterThan(sourceNode.position.x);
|
||
expect(resultCanvasData.nodes?.find((node) => node.id === "image-1")).toMatchObject({
|
||
data: { src: "https://cdn.example.com/source.png" },
|
||
});
|
||
expect(resultCanvasData.edges).toEqual([
|
||
expect.objectContaining({
|
||
source: "image-1",
|
||
target: result.createdNodeIds[0],
|
||
type: "reference",
|
||
}),
|
||
]);
|
||
});
|
||
|
||
test("fills agent slots and keeps operation retries idempotent", () => {
|
||
const slotNode = {
|
||
id: "slot-1",
|
||
type: "agent-slot",
|
||
position: { x: 400, y: 80 },
|
||
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",
|
||
};
|
||
const plan = {
|
||
version: 1 as const,
|
||
operationId: "op-fill-slot-1",
|
||
operation: "fill_slot" as const,
|
||
projectId: plannerContext.projectId || "",
|
||
targetSlotId: "slot-1",
|
||
mediaItems: [
|
||
{
|
||
mediaItemId: "media-slot-1",
|
||
type: "image" as const,
|
||
url: "https://cdn.example.com/slot-result.png",
|
||
},
|
||
],
|
||
};
|
||
|
||
const first = applyCanvasOperationPlanToCanvasData({
|
||
canvasData: {
|
||
nodes: [slotNode],
|
||
edges: [],
|
||
selectedNodeIds: ["slot-1"],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
},
|
||
plan,
|
||
});
|
||
|
||
expect(first.createdNodeIds).toHaveLength(1);
|
||
const firstCanvasData = first.canvasData as { nodes?: any[]; edges?: any[] };
|
||
expect(first.updatedNodeIds).toEqual(["slot-1"]);
|
||
expect(first.createdNodeIdsByMediaItemId).toEqual({
|
||
"media-slot-1": first.createdNodeIds[0],
|
||
});
|
||
expect(firstCanvasData.nodes?.find((node) => node.id === "slot-1")).toMatchObject({
|
||
data: {
|
||
status: "completed",
|
||
filledNodeIds: [first.createdNodeIds[0]],
|
||
},
|
||
});
|
||
expect(firstCanvasData.edges).toEqual([
|
||
expect.objectContaining({
|
||
source: "slot-1",
|
||
target: first.createdNodeIds[0],
|
||
}),
|
||
]);
|
||
|
||
const retry = applyCanvasOperationPlanToCanvasData({
|
||
canvasData: first.canvasData,
|
||
plan,
|
||
});
|
||
|
||
expect(retry.createdNodeIds).toEqual(first.createdNodeIds);
|
||
expect(retry.createdNodeIdsByMediaItemId).toEqual(
|
||
first.createdNodeIdsByMediaItemId
|
||
);
|
||
expect((retry.canvasData as { nodes?: any[] }).nodes).toHaveLength(
|
||
firstCanvasData.nodes?.length || 0
|
||
);
|
||
});
|
||
|
||
test("applies batch media mutations with stable media item mappings", () => {
|
||
const anchorNode = {
|
||
id: "image-batch-source",
|
||
type: "image",
|
||
position: { x: 0, y: 0 },
|
||
size: { width: 200, height: 200 },
|
||
data: { src: "https://cdn.example.com/source.png", status: "completed" },
|
||
connections: [],
|
||
createdAt: "2026-06-22T00:00:00.000Z",
|
||
updatedAt: "2026-06-22T00:00:00.000Z",
|
||
};
|
||
|
||
const result = applyCanvasOperationPlanToCanvasData({
|
||
canvasData: {
|
||
nodes: [anchorNode],
|
||
edges: [],
|
||
selectedNodeIds: ["image-batch-source"],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
},
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-batch-1",
|
||
operation: "insert_beside",
|
||
projectId: plannerContext.projectId || "",
|
||
anchorNodeId: "image-batch-source",
|
||
placementStrategy: "grid",
|
||
mediaItems: [1, 2, 3].map((index) => ({
|
||
mediaItemId: `media-batch-${index}`,
|
||
type: "image" as const,
|
||
url: `https://cdn.example.com/result-${index}.png`,
|
||
})),
|
||
},
|
||
});
|
||
|
||
expect(result.createdNodeIds).toHaveLength(3);
|
||
const resultCanvasData = result.canvasData as { nodes?: any[]; edges?: any[] };
|
||
expect(Object.keys(result.createdNodeIdsByMediaItemId).sort()).toEqual([
|
||
"media-batch-1",
|
||
"media-batch-2",
|
||
"media-batch-3",
|
||
]);
|
||
expect(resultCanvasData.nodes).toHaveLength(4);
|
||
expect(resultCanvasData.edges).toHaveLength(3);
|
||
});
|
||
|
||
test("rejects stale confirmed high-risk media replacement mutations", () => {
|
||
const targetNode = {
|
||
id: "image-replace-1",
|
||
type: "image",
|
||
position: { x: 0, y: 0 },
|
||
size: { width: 200, height: 200 },
|
||
data: { src: "https://cdn.example.com/source.png", status: "completed" },
|
||
connections: [],
|
||
createdAt: "2026-06-22T00:00:00.000Z",
|
||
updatedAt: "2026-06-22T00:00:00.000Z",
|
||
};
|
||
|
||
expect(() =>
|
||
applyCanvasOperationPlanToCanvasData({
|
||
canvasData: {
|
||
nodes: [targetNode],
|
||
edges: [],
|
||
selectedNodeIds: ["image-replace-1"],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
},
|
||
baseCanvasWasStale: true,
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-replace-1",
|
||
operation: "replace_node_media",
|
||
projectId: plannerContext.projectId || "",
|
||
targetNodeIds: ["image-replace-1"],
|
||
confirmed: true,
|
||
mediaItems: [
|
||
{
|
||
mediaItemId: "media-replace-1",
|
||
type: "image",
|
||
url: "https://cdn.example.com/replacement.png",
|
||
},
|
||
],
|
||
},
|
||
})
|
||
).toThrow(CanvasAgentMutationError);
|
||
});
|
||
|
||
test("keeps failed canvas operations eligible for canvasMedia fallback", () => {
|
||
const handled = new Set<string>();
|
||
|
||
const failedHandledIds = getCanvasOperationHandledMediaItemIds({
|
||
status: "failed",
|
||
operationId: "op-1",
|
||
operation: "insert_beside",
|
||
handledCanvasMediaItemIds: ["media-1"],
|
||
mediaItemIds: ["media-1"],
|
||
fallbackToCanvasMedia: true,
|
||
});
|
||
failedHandledIds.forEach((id) => handled.add(id));
|
||
|
||
expect(failedHandledIds).toEqual([]);
|
||
expect(
|
||
shouldSkipCanvasMediaPayloadForHandledItems({
|
||
payload: {
|
||
mediaItemIds: ["media-1"],
|
||
imageUrls: ["https://cdn.example.com/a.png"],
|
||
},
|
||
handledCanvasMediaItemIds: handled,
|
||
})
|
||
).toBe(false);
|
||
|
||
const appliedHandledIds = getCanvasOperationHandledMediaItemIds({
|
||
status: "applied",
|
||
operationId: "op-2",
|
||
operation: "insert_beside",
|
||
handledCanvasMediaItemIds: ["media-2"],
|
||
});
|
||
appliedHandledIds.forEach((id) => handled.add(id));
|
||
|
||
expect(appliedHandledIds).toEqual(["media-2"]);
|
||
expect(
|
||
shouldSkipCanvasMediaPayloadForHandledItems({
|
||
payload: {
|
||
mediaItemIds: ["media-2"],
|
||
imageUrls: ["https://cdn.example.com/b.png"],
|
||
},
|
||
handledCanvasMediaItemIds: handled,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldSkipCanvasMediaPayloadForHandledItems({
|
||
payload: {
|
||
mediaItemIds: ["media-2", "media-3"],
|
||
imageUrls: ["https://cdn.example.com/b.png", "https://cdn.example.com/c.png"],
|
||
},
|
||
handledCanvasMediaItemIds: handled,
|
||
})
|
||
).toBe(false);
|
||
|
||
const shadowHandledIds = getCanvasOperationHandledMediaItemIds({
|
||
status: "shadowed",
|
||
operationId: "op-shadow",
|
||
operation: "insert_beside",
|
||
handledCanvasMediaItemIds: ["media-shadow"],
|
||
mediaItemIds: ["media-shadow"],
|
||
fallbackToCanvasMedia: true,
|
||
});
|
||
shadowHandledIds.forEach((id) => handled.add(id));
|
||
|
||
expect(shadowHandledIds).toEqual([]);
|
||
expect(
|
||
shouldSkipCanvasMediaPayloadForHandledItems({
|
||
payload: {
|
||
mediaItemIds: ["media-shadow"],
|
||
imageUrls: ["https://cdn.example.com/shadow.png"],
|
||
},
|
||
handledCanvasMediaItemIds: handled,
|
||
})
|
||
).toBe(false);
|
||
|
||
expect(
|
||
shouldSkipCanvasMediaPayloadForHandledItems({
|
||
payload: {
|
||
mediaItemIds: ["media-pending-replace"],
|
||
imageUrls: ["https://cdn.example.com/pending-replace.png"],
|
||
},
|
||
handledCanvasMediaItemIds: handled,
|
||
})
|
||
).toBe(false);
|
||
|
||
expect(
|
||
getCanvasMediaPayloadHandledItemIds({
|
||
handledCanvasMediaItemIds: [" media-4 ", "", "media-5"],
|
||
})
|
||
).toEqual(["media-4", "media-5"]);
|
||
});
|
||
|
||
test("writes applied, failed, and shadowed canvas operation stream chunks", () => {
|
||
const chunks: Array<{ type: string; data: string }> = [];
|
||
const writer = {
|
||
write(chunk: { type: string; data: string }) {
|
||
chunks.push(chunk);
|
||
},
|
||
};
|
||
|
||
writeCanvasOperationChunk(
|
||
writer,
|
||
{
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
operationId: "op-applied",
|
||
operation: "insert_beside",
|
||
createdNodeIds: ["node-1"],
|
||
createdNodeIdsByMediaItemId: { "media-1": "node-1" },
|
||
updatedNodeIds: [],
|
||
createdEdgeIds: ["edge-1"],
|
||
canvasRevision: "2026-06-22T00:00:00.000Z",
|
||
anchorNodeId: "anchor-1",
|
||
handledCanvasMediaItemIds: [" media-1 ", ""],
|
||
mediaItemIds: ["media-1"],
|
||
message: "已写回画布",
|
||
},
|
||
"test-applied"
|
||
);
|
||
writeCanvasOperationChunk(
|
||
writer,
|
||
{
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
operationId: "op-failed",
|
||
operation: "insert_beside",
|
||
status: "failed",
|
||
createdNodeIds: [],
|
||
updatedNodeIds: [],
|
||
createdEdgeIds: [],
|
||
handledCanvasMediaItemIds: ["should-not-be-used"],
|
||
mediaItemIds: [" media-2 ", ""],
|
||
errorCode: "STALE_CANVAS_REVISION",
|
||
errorMessage: "画布已更新",
|
||
fallbackToCanvasMedia: true,
|
||
},
|
||
"test-failed"
|
||
);
|
||
writeCanvasOperationChunk(
|
||
writer,
|
||
{
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
operationId: "op-shadowed",
|
||
operation: "insert_beside",
|
||
status: "shadowed",
|
||
createdNodeIds: [],
|
||
updatedNodeIds: [],
|
||
createdEdgeIds: [],
|
||
handledCanvasMediaItemIds: ["should-not-be-used"],
|
||
mediaItemIds: [" media-3 ", ""],
|
||
fallbackToCanvasMedia: true,
|
||
},
|
||
"test-shadowed"
|
||
);
|
||
|
||
expect(chunks).toHaveLength(3);
|
||
expect(chunks[0].type).toBe("data-canvasOperation");
|
||
expect(JSON.parse(chunks[0].data)).toMatchObject({
|
||
status: "applied",
|
||
operationId: "op-applied",
|
||
createdNodeIds: ["node-1"],
|
||
handledCanvasMediaItemIds: ["media-1"],
|
||
mediaItemIds: ["media-1"],
|
||
});
|
||
expect(JSON.parse(chunks[1].data)).toMatchObject({
|
||
status: "failed",
|
||
operationId: "op-failed",
|
||
handledCanvasMediaItemIds: [],
|
||
mediaItemIds: ["media-2"],
|
||
errorCode: "STALE_CANVAS_REVISION",
|
||
errorMessage: "画布已更新",
|
||
fallbackToCanvasMedia: true,
|
||
});
|
||
expect(JSON.parse(chunks[2].data)).toMatchObject({
|
||
status: "shadowed",
|
||
operationId: "op-shadowed",
|
||
handledCanvasMediaItemIds: [],
|
||
mediaItemIds: ["media-3"],
|
||
fallbackToCanvasMedia: true,
|
||
});
|
||
});
|
||
|
||
test("writes normalized canvasMedia stream chunks with shared media item ids", () => {
|
||
const chunks: Array<{ type: string; data: string }> = [];
|
||
const writer = {
|
||
write(chunk: { type: string; data: string }) {
|
||
chunks.push(chunk);
|
||
},
|
||
};
|
||
|
||
writeCanvasMediaChunk(
|
||
writer,
|
||
{
|
||
messageId: "message-1",
|
||
imageUrls: [
|
||
" https://cdn.example.com/a.png ",
|
||
"https://cdn.example.com/a.png",
|
||
"not-a-url",
|
||
],
|
||
videoUrls: ["https://cdn.example.com/a.mp4", ""],
|
||
prompt: " prompt ",
|
||
sourceImageUrls: [" https://cdn.example.com/source.png ", "bad-url"],
|
||
mediaItemIds: [" media-1 ", "", "media-2"],
|
||
handledCanvasMediaItemIds: [" handled-1 ", ""],
|
||
},
|
||
"test-canvas-media"
|
||
);
|
||
|
||
expect(chunks).toHaveLength(1);
|
||
expect(chunks[0].type).toBe("data-canvasMedia");
|
||
expect(JSON.parse(chunks[0].data)).toMatchObject({
|
||
messageId: "message-1",
|
||
imageUrls: ["https://cdn.example.com/a.png"],
|
||
videoUrls: ["https://cdn.example.com/a.mp4"],
|
||
prompt: "prompt",
|
||
sourceImageUrls: ["https://cdn.example.com/source.png"],
|
||
mediaItemIds: ["media-1", "media-2"],
|
||
handledCanvasMediaItemIds: ["handled-1"],
|
||
});
|
||
});
|
||
|
||
test("keeps agent transient viewport out of persisted canvas state", () => {
|
||
const store = useCanvasStore.getState();
|
||
store.clearCanvas();
|
||
store.setViewport({ x: 10, y: 20, zoom: 2 });
|
||
store.setTransientViewport({ x: 100, y: 200, zoom: 3 });
|
||
|
||
expect(useCanvasStore.getState().viewport).toEqual({ x: 100, y: 200, zoom: 3 });
|
||
expect(useCanvasStore.getState().getCanvasState().viewport).toEqual({
|
||
x: 10,
|
||
y: 20,
|
||
zoom: 2,
|
||
});
|
||
|
||
store.clearCanvas();
|
||
});
|
||
|
||
test("normalizes and interpolates canvas agent view animations", () => {
|
||
expect(normalizeCanvasAgentViewAnimationDuration(undefined)).toBe(280);
|
||
expect(normalizeCanvasAgentViewAnimationDuration(360)).toBe(360);
|
||
expect(normalizeCanvasAgentViewAnimationDuration(-20)).toBe(0);
|
||
expect(normalizeCanvasAgentViewAnimationDuration(1200)).toBe(900);
|
||
|
||
expect(easeOutCanvasAgentViewProgress(-1)).toBe(0);
|
||
expect(easeOutCanvasAgentViewProgress(1.5)).toBe(1);
|
||
expect(easeOutCanvasAgentViewProgress(0.5)).toBeCloseTo(0.875);
|
||
|
||
expect(
|
||
interpolateCanvasAgentViewport(
|
||
{ x: 0, y: 20, zoom: 1 },
|
||
{ x: 100, y: 220, zoom: 2 },
|
||
0
|
||
)
|
||
).toEqual({ x: 0, y: 20, zoom: 1 });
|
||
expect(
|
||
interpolateCanvasAgentViewport(
|
||
{ x: 0, y: 20, zoom: 1 },
|
||
{ x: 100, y: 220, zoom: 2 },
|
||
1
|
||
)
|
||
).toEqual({ x: 100, y: 220, zoom: 2 });
|
||
expect(
|
||
interpolateCanvasAgentViewport(
|
||
{ x: 0, y: 20, zoom: 1 },
|
||
{ x: 100, y: 220, zoom: 2 },
|
||
0.5
|
||
)
|
||
).toEqual({ x: 87.5, y: 195, zoom: 1.875 });
|
||
});
|
||
|
||
test("defers canvas agent view operations during active canvas interactions", () => {
|
||
expect(shouldDeferCanvasAgentViewOperation({ pointerDown: true })).toBe(true);
|
||
expect(shouldDeferCanvasAgentViewOperation({ activeEditable: true })).toBe(true);
|
||
expect(
|
||
shouldDeferCanvasAgentViewOperation({
|
||
lastPointerInteractionAt: 1000,
|
||
now: 1000 + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS - 1,
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldDeferCanvasAgentViewOperation({
|
||
lastPointerInteractionAt: 1000,
|
||
now: 1000 + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS,
|
||
})
|
||
).toBe(false);
|
||
expect(shouldDeferCanvasAgentViewOperation({ now: 2000 })).toBe(false);
|
||
});
|
||
|
||
test("marks agent operation refresh windows for autosave guards", () => {
|
||
const store = useCanvasStore.getState();
|
||
store.clearAgentOperationRefresh();
|
||
expect(store.isAgentOperationRefreshActive()).toBe(false);
|
||
|
||
store.markAgentOperationRefresh(1000);
|
||
expect(useCanvasStore.getState().isAgentOperationRefreshActive()).toBe(true);
|
||
|
||
useCanvasStore.getState().clearAgentOperationRefresh();
|
||
expect(useCanvasStore.getState().isAgentOperationRefreshActive()).toBe(false);
|
||
});
|
||
|
||
test("queues target-based canvas view operations for post-refresh retry", () => {
|
||
expect(
|
||
shouldRetryCanvasViewOperationAfterRefresh({
|
||
version: 1,
|
||
viewOperationId: "view-result",
|
||
operation: "focus_latest_result",
|
||
nodeIds: ["new-image-1"],
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldRetryCanvasViewOperationAfterRefresh({
|
||
version: 1,
|
||
viewOperationId: "view-fit-all",
|
||
operation: "fit_all",
|
||
})
|
||
).toBe(true);
|
||
expect(
|
||
shouldRetryCanvasViewOperationAfterRefresh({
|
||
version: 1,
|
||
viewOperationId: "view-zoom",
|
||
operation: "zoom_in",
|
||
})
|
||
).toBe(false);
|
||
expect(
|
||
shouldRetryCanvasViewOperationAfterRefresh(
|
||
{
|
||
version: 1,
|
||
viewOperationId: "view-other-project",
|
||
operation: "focus_latest_result",
|
||
projectId: "project-other",
|
||
nodeIds: ["new-image-1"],
|
||
},
|
||
"project-current"
|
||
)
|
||
).toBe(false);
|
||
});
|
||
|
||
test("builds transient view operations for agent writeback results", () => {
|
||
expect(
|
||
buildCanvasAgentResultViewOperation({
|
||
projectId: plannerContext.projectId,
|
||
operationId: "operation-1",
|
||
mutationOperation: "insert_beside",
|
||
createdNodeIds: ["new-image-1"],
|
||
})
|
||
).toMatchObject({
|
||
version: 1,
|
||
viewOperationId: "operation-1:view",
|
||
operation: "focus_latest_result",
|
||
nodeIds: ["new-image-1"],
|
||
anchorNodeId: "new-image-1",
|
||
persist: false,
|
||
reason: "focus_canvas_agent_writeback_result",
|
||
});
|
||
|
||
expect(
|
||
buildCanvasAgentResultViewOperation({
|
||
projectId: plannerContext.projectId,
|
||
operationId: "operation-2",
|
||
mutationOperation: "append_revision",
|
||
createdNodeIds: ["new-image-1", "new-image-2", "new-image-1"],
|
||
updatedNodeIds: ["slot-1"],
|
||
})
|
||
).toMatchObject({
|
||
operation: "focus_nodes",
|
||
nodeIds: ["new-image-1", "new-image-2"],
|
||
anchorNodeId: "new-image-1",
|
||
});
|
||
|
||
expect(
|
||
buildCanvasAgentResultViewOperation({
|
||
projectId: plannerContext.projectId,
|
||
operationId: "operation-compare",
|
||
mutationOperation: "append_revision",
|
||
sourceNodeIds: ["image-1", " image-1 "],
|
||
createdNodeIds: ["new-image-1"],
|
||
anchorNodeId: "new-image-1",
|
||
})
|
||
).toMatchObject({
|
||
operation: "focus_nodes",
|
||
nodeIds: ["image-1", "new-image-1"],
|
||
anchorNodeId: "image-1",
|
||
reason: "focus_canvas_agent_writeback_comparison",
|
||
});
|
||
|
||
expect(
|
||
buildCanvasAgentResultViewOperation({
|
||
projectId: plannerContext.projectId,
|
||
operationId: "operation-3",
|
||
mutationOperation: "replace_node_media",
|
||
updatedNodeIds: ["image-1"],
|
||
})
|
||
).toMatchObject({
|
||
operation: "focus_node",
|
||
nodeIds: ["image-1"],
|
||
reason: "focus_canvas_agent_replaced_nodes",
|
||
});
|
||
|
||
expect(
|
||
buildCanvasAgentResultViewOperation({
|
||
projectId: plannerContext.projectId,
|
||
operationId: "operation-4",
|
||
})
|
||
).toBeNull();
|
||
});
|
||
|
||
test("resolves comparison view targets from selection and revision metadata", () => {
|
||
const comparisonContext: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: ["agent-result-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "agent-result-1",
|
||
type: "image",
|
||
title: "白底版本",
|
||
x: 460,
|
||
y: 20,
|
||
width: 300,
|
||
height: 240,
|
||
isSelected: true,
|
||
metadata: {
|
||
generatedByAgent: true,
|
||
revisionOf: "image-1",
|
||
sourceNodeIds: ["image-1"],
|
||
},
|
||
},
|
||
],
|
||
candidateNodes: [
|
||
{
|
||
nodeId: "image-1",
|
||
type: "image",
|
||
title: "原图",
|
||
x: 10,
|
||
y: 20,
|
||
width: 300,
|
||
height: 240,
|
||
},
|
||
],
|
||
};
|
||
|
||
expect(hasCanvasAgentComparisonViewIntent("对比原图和新图")).toBe(true);
|
||
expect(
|
||
resolveCanvasAgentComparisonNodeIds({
|
||
context: comparisonContext,
|
||
})
|
||
).toEqual(["image-1", "agent-result-1"]);
|
||
|
||
expect(
|
||
buildCanvasAgentComparisonViewPlan({
|
||
context: comparisonContext,
|
||
projectId: plannerContext.projectId || "",
|
||
operationId: "operation-compare",
|
||
})
|
||
).toMatchObject({
|
||
operation: "focus_nodes",
|
||
nodeIds: ["image-1", "agent-result-1"],
|
||
anchorNodeId: "image-1",
|
||
persist: false,
|
||
reason: "focus_canvas_comparison_targets",
|
||
});
|
||
|
||
expect(
|
||
resolveCanvasAgentComparisonNodeIds({
|
||
context: {
|
||
...comparisonContext,
|
||
selectedNodeIds: ["image-1", "agent-result-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "image-1",
|
||
type: "image",
|
||
x: 10,
|
||
y: 20,
|
||
isSelected: true,
|
||
},
|
||
{
|
||
nodeId: "agent-result-1",
|
||
type: "image",
|
||
x: 460,
|
||
y: 20,
|
||
isSelected: true,
|
||
metadata: { generatedByAgent: true, revisionOf: "image-1" },
|
||
},
|
||
],
|
||
candidateNodes: [],
|
||
},
|
||
})
|
||
).toEqual(["image-1", "agent-result-1"]);
|
||
});
|
||
|
||
test("builds latest-result view plans for follow-up focus requests", () => {
|
||
expect(hasCanvasAgentLatestResultViewIntent("聚焦刚生成结果")).toBe(true);
|
||
expect(hasCanvasAgentLatestResultViewIntent("放大看看最新结果")).toBe(true);
|
||
expect(hasCanvasAgentLatestResultViewIntent("生成一张新图")).toBe(false);
|
||
|
||
expect(
|
||
buildCanvasAgentLatestResultViewPlan({
|
||
userText: "帮我定位到刚生成的图",
|
||
projectId: plannerContext.projectId || "",
|
||
operationId: "operation-latest",
|
||
})
|
||
).toMatchObject({
|
||
viewOperationId: "operation-latest:view",
|
||
operation: "focus_latest_result",
|
||
projectId: plannerContext.projectId,
|
||
persist: false,
|
||
reason: "focus_latest_canvas_agent_result",
|
||
});
|
||
});
|
||
|
||
test("resolves latest-result and pan canvas view operations", () => {
|
||
expect(
|
||
resolveCanvasViewOperationTargetNodeIds({
|
||
plan: {
|
||
operation: "focus_latest_result",
|
||
},
|
||
selectedNodeIds: ["selected-1"],
|
||
nodes: [
|
||
{
|
||
id: "old-agent-result",
|
||
data: {
|
||
metadata: {
|
||
generatedByAgent: true,
|
||
},
|
||
},
|
||
createdAt: "2026-06-22T00:00:00.000Z",
|
||
},
|
||
{
|
||
id: "manual-node",
|
||
data: {
|
||
metadata: {},
|
||
},
|
||
createdAt: "2026-06-22T00:02:00.000Z",
|
||
},
|
||
{
|
||
id: "new-agent-result",
|
||
data: {
|
||
metadata: {
|
||
canvasAgentOperationId: "op-1",
|
||
},
|
||
},
|
||
updatedAt: "2026-06-22T00:03:00.000Z",
|
||
},
|
||
],
|
||
})
|
||
).toEqual(["new-agent-result", "old-agent-result"]);
|
||
|
||
expect(
|
||
resolveCanvasViewOperationTargetNodeIds({
|
||
plan: {
|
||
operation: "focus_selection",
|
||
},
|
||
selectedNodeIds: ["selected-1"],
|
||
nodes: [],
|
||
})
|
||
).toEqual(["selected-1"]);
|
||
|
||
expect(
|
||
computeCanvasAgentPanViewport({
|
||
currentViewport: { x: 10, y: 20, zoom: 1 },
|
||
center: { x: 100, y: 50 },
|
||
stage: { width: 1000, height: 800 },
|
||
zoom: 2,
|
||
})
|
||
).toEqual({
|
||
x: 320,
|
||
y: 268,
|
||
zoom: 2,
|
||
});
|
||
});
|
||
|
||
test("builds relative pan view plans from canvas viewport context", () => {
|
||
expect(hasCanvasAgentPanViewIntent("右边那张图")).toBe(false);
|
||
expect(hasCanvasAgentPanViewIntent("向右移动视角一点")).toBe(true);
|
||
|
||
const plan = buildCanvasAgentPanViewPlan({
|
||
userText: "向右移动视角一点",
|
||
context: {
|
||
...plannerContext,
|
||
viewport: { x: -200, y: -100, zoom: 2, width: 1000, height: 800 },
|
||
},
|
||
projectId: plannerContext.projectId || "",
|
||
operationId: "view-pan",
|
||
});
|
||
|
||
expect(plan).toMatchObject({
|
||
version: 1,
|
||
viewOperationId: "view-pan:view",
|
||
operation: "pan_to",
|
||
projectId: plannerContext.projectId,
|
||
center: {
|
||
x: 490,
|
||
y: 234,
|
||
},
|
||
zoom: 2,
|
||
persist: false,
|
||
reason: "pan_canvas_view",
|
||
});
|
||
|
||
const diagonalPlan = buildCanvasAgentPanViewPlan({
|
||
userText: "把画布往左上拖一点",
|
||
context: {
|
||
...plannerContext,
|
||
viewport: { x: -200, y: -100, zoom: 2, width: 1000, height: 800 },
|
||
},
|
||
projectId: plannerContext.projectId || "",
|
||
operationId: "view-pan-diagonal",
|
||
});
|
||
|
||
expect(diagonalPlan?.center?.x).toBeCloseTo(230);
|
||
expect(diagonalPlan?.center?.y).toBeCloseTo(130);
|
||
});
|
||
|
||
test("resolves canvas view operation plans into transient viewport targets", () => {
|
||
const zoomIn = resolveCanvasViewOperation({
|
||
plan: {
|
||
version: 1,
|
||
viewOperationId: "view-zoom-in",
|
||
operation: "zoom_in",
|
||
},
|
||
nodes: [],
|
||
currentViewport: { x: 10, y: 20, zoom: 2 },
|
||
stage: { width: 1000, height: 800 },
|
||
});
|
||
|
||
expect(zoomIn?.targetNodeIds).toEqual([]);
|
||
expect(zoomIn?.viewport.zoom).toBeCloseTo(2.44);
|
||
expect(zoomIn?.viewport.x).toBeCloseTo(-102.2);
|
||
expect(zoomIn?.viewport.y).toBeCloseTo(-56.56);
|
||
|
||
const focusSelection = resolveCanvasViewOperation({
|
||
plan: {
|
||
version: 1,
|
||
viewOperationId: "view-focus-selection",
|
||
operation: "focus_selection",
|
||
},
|
||
nodes: [
|
||
{
|
||
id: "image-1",
|
||
type: "image",
|
||
position: { x: 100, y: 120 },
|
||
size: { width: 320, height: 240 },
|
||
},
|
||
],
|
||
selectedNodeIds: ["image-1"],
|
||
currentViewport: { x: 0, y: 0, zoom: 1 },
|
||
stage: { width: 1000, height: 800 },
|
||
});
|
||
|
||
expect(focusSelection?.targetNodeIds).toEqual(["image-1"]);
|
||
expect(focusSelection?.viewport.x).toBeCloseTo(218.4);
|
||
expect(focusSelection?.viewport.y).toBeCloseTo(89.6);
|
||
expect(focusSelection?.viewport.zoom).toBeCloseTo(1.16);
|
||
|
||
const fitAll = resolveCanvasViewOperation({
|
||
plan: {
|
||
version: 1,
|
||
viewOperationId: "view-fit-all",
|
||
operation: "fit_all",
|
||
padding: 100,
|
||
},
|
||
nodes: [
|
||
{
|
||
id: "image-1",
|
||
type: "image",
|
||
position: { x: 0, y: 0 },
|
||
size: { width: 200, height: 100 },
|
||
},
|
||
{
|
||
id: "text-1",
|
||
type: "text",
|
||
position: { x: 500, y: 200 },
|
||
size: { width: 100, height: 100 },
|
||
},
|
||
{
|
||
id: "workflow-1",
|
||
type: "workflow",
|
||
position: { x: 2000, y: 2000 },
|
||
size: { width: 400, height: 400 },
|
||
},
|
||
],
|
||
currentViewport: { x: 0, y: 0, zoom: 1 },
|
||
stage: { width: 1000, height: 800 },
|
||
});
|
||
|
||
expect(fitAll?.targetNodeIds).toEqual([]);
|
||
expect(fitAll?.viewport.zoom).toBeCloseTo(0.1784615385);
|
||
expect(fitAll?.viewport.x).toBeCloseTo(305.8461538);
|
||
expect(fitAll?.viewport.y).toBeCloseTo(153.8461538);
|
||
|
||
const panTo = resolveCanvasViewOperation({
|
||
plan: {
|
||
version: 1,
|
||
viewOperationId: "view-pan-to",
|
||
operation: "pan_to",
|
||
center: { x: 490, y: 234 },
|
||
zoom: 2,
|
||
},
|
||
nodes: [],
|
||
currentViewport: { x: -200, y: -100, zoom: 2 },
|
||
stage: { width: 1000, height: 800 },
|
||
});
|
||
|
||
expect(panTo?.targetNodeIds).toEqual([]);
|
||
expect(panTo?.viewport).toEqual({
|
||
x: -460,
|
||
y: -100,
|
||
zoom: 2,
|
||
});
|
||
});
|
||
|
||
test("resolves grid media placements to the right of an anchor node", () => {
|
||
const placements = resolveCanvasAgentMediaPlacements({
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-grid",
|
||
operation: "insert_beside",
|
||
projectId: "project-1",
|
||
anchorNodeId: "anchor-1",
|
||
placement: "right",
|
||
placementStrategy: "grid",
|
||
},
|
||
mediaItems: [
|
||
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
|
||
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
|
||
{ mediaItemId: "media-3", type: "image", url: "https://cdn.example.com/3.png", width: 100, height: 80 },
|
||
],
|
||
nodes: [
|
||
{
|
||
id: "anchor-1",
|
||
position: { x: 10, y: 20 },
|
||
size: { width: 300, height: 200 },
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(placements.map((placement) => placement.position)).toEqual([
|
||
{ x: 350, y: 20 },
|
||
{ x: 474, y: 20 },
|
||
{ x: 350, y: 124 },
|
||
]);
|
||
expect(placements.map((placement) => placement.edgeSourceId)).toEqual([
|
||
"anchor-1",
|
||
"anchor-1",
|
||
"anchor-1",
|
||
]);
|
||
});
|
||
|
||
test("resolves batch media beside each item source node", () => {
|
||
const placements = resolveCanvasAgentMediaPlacements({
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-source-batch",
|
||
operation: "insert_beside",
|
||
projectId: "project-1",
|
||
placement: "right",
|
||
placementStrategy: "beside_each_source",
|
||
},
|
||
mediaItems: [
|
||
{
|
||
mediaItemId: "media-a-1",
|
||
type: "image",
|
||
url: "https://cdn.example.com/a1.png",
|
||
width: 20,
|
||
height: 20,
|
||
sourceNodeIds: ["source-a"],
|
||
},
|
||
{
|
||
mediaItemId: "media-b-1",
|
||
type: "image",
|
||
url: "https://cdn.example.com/b1.png",
|
||
width: 20,
|
||
height: 20,
|
||
sourceNodeIds: ["source-b"],
|
||
},
|
||
{
|
||
mediaItemId: "media-a-2",
|
||
type: "image",
|
||
url: "https://cdn.example.com/a2.png",
|
||
width: 20,
|
||
height: 20,
|
||
sourceNodeIds: ["source-a"],
|
||
},
|
||
],
|
||
nodes: [
|
||
{
|
||
id: "source-a",
|
||
position: { x: 0, y: 0 },
|
||
size: { width: 50, height: 50 },
|
||
},
|
||
{
|
||
id: "source-b",
|
||
position: { x: 1000, y: 10 },
|
||
size: { width: 50, height: 50 },
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(placements.map((placement) => placement.position)).toEqual([
|
||
{ x: 90, y: 0 },
|
||
{ x: 1090, y: 10 },
|
||
{ x: 134, y: 0 },
|
||
]);
|
||
expect(placements.map((placement) => placement.edgeSourceId)).toEqual([
|
||
"source-a",
|
||
"source-b",
|
||
"source-a",
|
||
]);
|
||
expect(placements[0]?.sourceNodeIds).toEqual(["source-a"]);
|
||
});
|
||
|
||
test("resolves overlay and viewport-centered media placements", () => {
|
||
const [overlay] = resolveCanvasAgentMediaPlacements({
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-overlay",
|
||
operation: "append_revision",
|
||
projectId: "project-1",
|
||
anchorNodeId: "source-1",
|
||
placementStrategy: "overlay",
|
||
},
|
||
mediaItems: [
|
||
{
|
||
mediaItemId: "media-overlay",
|
||
type: "image",
|
||
url: "https://cdn.example.com/overlay.png",
|
||
},
|
||
],
|
||
nodes: [
|
||
{
|
||
id: "source-1",
|
||
position: { x: 120, y: 80 },
|
||
size: { width: 160, height: 90 },
|
||
},
|
||
],
|
||
});
|
||
expect(overlay?.position).toEqual({ x: 120, y: 80 });
|
||
expect(overlay?.size).toEqual({ width: 160, height: 90 });
|
||
|
||
const centeredPlacements = resolveCanvasAgentMediaPlacements({
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-center",
|
||
operation: "insert_beside",
|
||
projectId: "project-1",
|
||
anchorNodeId: "source-1",
|
||
placement: "viewport-center",
|
||
placementStrategy: "grid",
|
||
},
|
||
mediaItems: [
|
||
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
|
||
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
|
||
],
|
||
nodes: [
|
||
{
|
||
id: "source-1",
|
||
position: { x: 1000, y: 1000 },
|
||
size: { width: 200, height: 200 },
|
||
},
|
||
],
|
||
viewport: { x: -200, y: -100, zoom: 1 },
|
||
});
|
||
|
||
expect(centeredPlacements.map((placement) => placement.position)).toEqual([
|
||
{ x: 88, y: 60 },
|
||
{ x: 212, y: 60 },
|
||
]);
|
||
|
||
const viewportAwarePlacements = resolveCanvasAgentMediaPlacements({
|
||
plan: {
|
||
version: 1,
|
||
operationId: "op-center-with-stage",
|
||
operation: "insert_beside",
|
||
projectId: "project-1",
|
||
placement: "viewport-center",
|
||
placementStrategy: "grid",
|
||
metadata: {
|
||
canvasAgentViewport: {
|
||
x: -200,
|
||
y: -100,
|
||
zoom: 1,
|
||
width: 1000,
|
||
height: 800,
|
||
},
|
||
},
|
||
},
|
||
mediaItems: [
|
||
{ mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 },
|
||
{ mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 },
|
||
],
|
||
nodes: [],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
});
|
||
|
||
expect(viewportAwarePlacements.map((placement) => placement.position)).toEqual([
|
||
{ x: 588, y: 460 },
|
||
{ x: 712, y: 460 },
|
||
]);
|
||
});
|
||
|
||
test("normalizes slot-only canvas agent context without image nodes", () => {
|
||
const context = normalizeCanvasAgentContext({
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["slot-1"],
|
||
canvasImageNodes: [],
|
||
canvasAgentContext: {
|
||
version: 1,
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["slot-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "slot-1",
|
||
type: "agent-slot",
|
||
x: 120,
|
||
y: 80,
|
||
width: 320,
|
||
height: 240,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
candidateNodes: [],
|
||
slots: [
|
||
{
|
||
nodeId: "slot-1",
|
||
slotType: "image",
|
||
x: 120,
|
||
y: 80,
|
||
width: 320,
|
||
height: 240,
|
||
aspectRatio: "4:3",
|
||
isSelected: true,
|
||
fillPolicy: "replace-empty",
|
||
},
|
||
],
|
||
annotations: [],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
focusPolicy: "slot",
|
||
},
|
||
});
|
||
|
||
expect(context?.selectedNodeIds).toEqual(["slot-1"]);
|
||
expect(context?.slots).toHaveLength(1);
|
||
expect(context?.candidateNodes).toEqual([]);
|
||
expect(context?.focusPolicy).toBe("slot");
|
||
});
|
||
|
||
test("bypasses missing-target clarification for selected agent slot generation", () => {
|
||
const slotOnlyContext = {
|
||
source: "canvas" as const,
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["slot-1"],
|
||
canvasImageNodes: [],
|
||
canvasAgentContext: {
|
||
version: 1 as const,
|
||
source: "canvas" as const,
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["slot-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "slot-1",
|
||
type: "agent-slot" as const,
|
||
x: 120,
|
||
y: 80,
|
||
width: 320,
|
||
height: 240,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
candidateNodes: [],
|
||
slots: [
|
||
{
|
||
nodeId: "slot-1",
|
||
slotType: "image" as const,
|
||
x: 120,
|
||
y: 80,
|
||
width: 320,
|
||
height: 240,
|
||
aspectRatio: "4:3",
|
||
isSelected: true,
|
||
fillPolicy: "replace-empty" as const,
|
||
},
|
||
],
|
||
annotations: [],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
focusPolicy: "slot" as const,
|
||
},
|
||
};
|
||
|
||
expect(
|
||
shouldBypassCanvasAgentMissingTargetClarification({
|
||
text: "在这里生成一张电商主图",
|
||
contextPayload: slotOnlyContext,
|
||
executionMode: "generate_image",
|
||
})
|
||
).toMatchObject({
|
||
shouldBypass: true,
|
||
reason: "selected_canvas_agent_slot_generation_intent",
|
||
});
|
||
|
||
expect(
|
||
shouldBypassCanvasAgentMissingTargetClarification({
|
||
text: "把这张图背景换白",
|
||
contextPayload: slotOnlyContext,
|
||
executionMode: "edit_image",
|
||
}).shouldBypass
|
||
).toBe(false);
|
||
|
||
expect(
|
||
shouldBypassCanvasAgentMissingTargetClarification({
|
||
text: "在这里生成一张电商主图",
|
||
contextPayload: {
|
||
...slotOnlyContext,
|
||
selectedNodeIds: [],
|
||
canvasAgentContext: {
|
||
...slotOnlyContext.canvasAgentContext,
|
||
selectedNodeIds: [],
|
||
slots: [{ ...slotOnlyContext.canvasAgentContext.slots[0], isSelected: false }],
|
||
},
|
||
},
|
||
executionMode: "generate_image",
|
||
}).shouldBypass
|
||
).toBe(false);
|
||
});
|
||
|
||
test("keeps view-only canvas agent context without nodes or media", () => {
|
||
const context = normalizeCanvasAgentContext({
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: [],
|
||
canvasImageNodes: [],
|
||
canvasAgentContext: {
|
||
version: 1,
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: [],
|
||
selectedNodes: [],
|
||
candidateNodes: [],
|
||
slots: [],
|
||
annotations: [],
|
||
viewport: { x: -240, y: -120, zoom: 0.8, width: 1440, height: 900 },
|
||
focusPolicy: "auto",
|
||
},
|
||
});
|
||
|
||
expect(context).toMatchObject({
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodes: [],
|
||
candidateNodes: [],
|
||
slots: [],
|
||
viewport: { x: -240, y: -120, zoom: 0.8, width: 1440, height: 900 },
|
||
focusPolicy: "auto",
|
||
});
|
||
});
|
||
|
||
test("clips oversized canvas agent context lists to protocol limits", () => {
|
||
const candidateNodes = Array.from({ length: 42 }, (_, index) => ({
|
||
nodeId: `image-${index + 1}`,
|
||
type: "image",
|
||
url: `https://cdn.example.com/${index + 1}.png`,
|
||
x: index * 10,
|
||
y: index * 5,
|
||
isSelected: index < 25,
|
||
}));
|
||
const slots = Array.from({ length: 30 }, (_, index) => ({
|
||
nodeId: `slot-${index + 1}`,
|
||
slotType: "image",
|
||
x: index,
|
||
y: index,
|
||
width: 320,
|
||
height: 320,
|
||
aspectRatio: "1:1",
|
||
fillPolicy: "replace-empty",
|
||
}));
|
||
const annotations = Array.from({ length: 30 }, (_, index) => ({
|
||
id: `annotation-${index + 1}`,
|
||
sourceNodeId: "image-1",
|
||
type: "text-note",
|
||
text: `批注 ${index + 1}`,
|
||
}));
|
||
|
||
const context = normalizeCanvasAgentContext({
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: candidateNodes.map((node) => node.nodeId),
|
||
canvasAgentContext: {
|
||
version: 1,
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: candidateNodes.map((node) => node.nodeId),
|
||
selectedNodes: candidateNodes,
|
||
candidateNodes,
|
||
slots,
|
||
annotations,
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
focusPolicy: "selected",
|
||
},
|
||
});
|
||
|
||
expect(context?.selectedNodeIds).toHaveLength(20);
|
||
expect(context?.selectedNodes).toHaveLength(20);
|
||
expect(context?.candidateNodes).toHaveLength(30);
|
||
expect(context?.slots).toHaveLength(20);
|
||
expect(context?.annotations).toHaveLength(20);
|
||
});
|
||
|
||
test("normalizes canvas agent annotations from node metadata", () => {
|
||
const context = normalizeCanvasAgentContext({
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["image-1"],
|
||
canvasAgentContext: {
|
||
version: 1,
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["image-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "image-1",
|
||
type: "image",
|
||
x: 0,
|
||
y: 0,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
candidateNodes: [],
|
||
slots: [],
|
||
annotations: [
|
||
{
|
||
id: "annotation-1",
|
||
sourceNodeId: "image-1",
|
||
type: "text-note",
|
||
text: "保留产品轮廓",
|
||
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
|
||
},
|
||
],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
focusPolicy: "selected",
|
||
},
|
||
});
|
||
|
||
expect(context?.annotations).toEqual([
|
||
{
|
||
id: "annotation-1",
|
||
sourceNodeId: "image-1",
|
||
type: "text-note",
|
||
text: "保留产品轮廓",
|
||
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
|
||
targetPoint: undefined,
|
||
color: undefined,
|
||
createdAt: undefined,
|
||
},
|
||
]);
|
||
});
|
||
|
||
test("keeps pending canvas agent UI requests until the matching panel event consumes them", () => {
|
||
const targetWindow = {} as CanvasAgentPendingRequestWindow;
|
||
const slotRequest = {
|
||
nodeId: "slot-1",
|
||
label: "主图槽",
|
||
};
|
||
const annotationRequest = {
|
||
nodeId: "image-annotated",
|
||
title: "白底主图",
|
||
annotationCount: 2,
|
||
};
|
||
|
||
storeCanvasAgentSlotFillRequest(targetWindow, slotRequest);
|
||
storeCanvasAgentAnnotationEditRequest(targetWindow, annotationRequest);
|
||
|
||
expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest);
|
||
expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toEqual(
|
||
annotationRequest
|
||
);
|
||
|
||
expect(
|
||
consumeCanvasAgentSlotFillRequest(targetWindow, { nodeId: "other-slot" })
|
||
).toEqual(slotRequest);
|
||
expect(
|
||
consumeCanvasAgentAnnotationEditRequest(targetWindow, {
|
||
nodeId: "other-image",
|
||
})
|
||
).toEqual(annotationRequest);
|
||
expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest);
|
||
expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toEqual(
|
||
annotationRequest
|
||
);
|
||
|
||
expect(
|
||
consumeCanvasAgentSlotFillRequest(targetWindow, { nodeId: "slot-1" })
|
||
).toEqual(slotRequest);
|
||
expect(
|
||
consumeCanvasAgentAnnotationEditRequest(targetWindow, {
|
||
nodeId: "image-annotated",
|
||
})
|
||
).toEqual(annotationRequest);
|
||
expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toBeNull();
|
||
expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toBeNull();
|
||
});
|
||
|
||
test("keeps pending annotation edit targets eligible for the next canvas context", () => {
|
||
const nodes = [
|
||
{
|
||
id: "image-annotated",
|
||
type: "image",
|
||
position: { x: 10, y: 20 },
|
||
size: { width: 300, height: 240 },
|
||
data: {
|
||
src: "https://cdn.example.com/annotated.png",
|
||
metadata: {
|
||
annotations: [
|
||
{
|
||
id: "annotation-1",
|
||
type: "text-note",
|
||
text: "背景换浅灰",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "text-1",
|
||
type: "text",
|
||
position: { x: 500, y: 20 },
|
||
size: { width: 220, height: 80 },
|
||
data: { content: "说明" },
|
||
},
|
||
] as any[];
|
||
|
||
const pendingNode = resolvePendingCanvasAgentTargetNode({
|
||
nodes,
|
||
pendingTarget: {
|
||
nodeId: "image-annotated",
|
||
requestedAt: 1000,
|
||
},
|
||
now: 1500,
|
||
isEligibleNode: (node) => node.type === "image",
|
||
});
|
||
|
||
expect(pendingNode?.id).toBe("image-annotated");
|
||
expect(
|
||
resolvePendingCanvasAgentTargetNode({
|
||
nodes,
|
||
pendingTarget: {
|
||
nodeId: "image-annotated",
|
||
requestedAt: 1000,
|
||
},
|
||
now: 1000 + 10 * 60 * 1000 + 1,
|
||
isEligibleNode: (node) => node.type === "image",
|
||
})
|
||
).toBeNull();
|
||
expect(
|
||
resolvePendingCanvasAgentTargetNode({
|
||
nodes,
|
||
pendingTarget: {
|
||
nodeId: "text-1",
|
||
requestedAt: 1000,
|
||
},
|
||
now: 1500,
|
||
isEligibleNode: (node) => node.type === "image",
|
||
})
|
||
).toBeNull();
|
||
});
|
||
|
||
test("merges pending annotation edit targets into selected node ids without duplicates", () => {
|
||
expect(
|
||
mergePendingCanvasAgentTargetNodeIds({
|
||
selectedNodeIds: ["image-1", "image-2"],
|
||
pendingTargetNodeId: "image-1",
|
||
})
|
||
).toEqual(["image-1", "image-2"]);
|
||
|
||
expect(
|
||
mergePendingCanvasAgentTargetNodeIds({
|
||
selectedNodeIds: ["image-1"],
|
||
pendingTargetNodeId: "image-annotated",
|
||
})
|
||
).toEqual(["image-1", "image-annotated"]);
|
||
|
||
expect(
|
||
mergePendingCanvasAgentTargetNodeIds({
|
||
selectedNodeIds: Array.from({ length: 20 }, (_, index) => `node-${index + 1}`),
|
||
pendingTargetNodeId: "node-21",
|
||
maxItems: 20,
|
||
})
|
||
).toHaveLength(20);
|
||
});
|
||
|
||
test("builds annotation-aware edit prompts only for annotation requests", () => {
|
||
const context: CanvasAgentContext = {
|
||
...plannerContext,
|
||
selectedNodeIds: ["image-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "image-1",
|
||
type: "image",
|
||
title: "主图",
|
||
x: 10,
|
||
y: 20,
|
||
width: 300,
|
||
height: 240,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
annotations: [
|
||
{
|
||
id: "annotation-1",
|
||
sourceNodeId: "image-1",
|
||
type: "box",
|
||
text: "把这里的水印去掉",
|
||
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
|
||
},
|
||
{
|
||
id: "annotation-2",
|
||
sourceNodeId: "image-2",
|
||
type: "text-note",
|
||
text: "不应进入 image-1 的提示",
|
||
},
|
||
],
|
||
};
|
||
|
||
expect(hasCanvasAgentAnnotationEditIntent("按批注修改这张图")).toBe(true);
|
||
expect(hasCanvasAgentAnnotationEditIntent("批注里的水印去掉")).toBe(true);
|
||
expect(hasCanvasAgentAnnotationEditIntent("把背景换成白色")).toBe(false);
|
||
expect(hasCanvasAgentAnnotationEditIntent("给选中图片加批注:这里保留")).toBe(
|
||
false
|
||
);
|
||
|
||
const prompt = buildCanvasAgentAnnotationEditPrompt({
|
||
context,
|
||
targetNodeIds: ["image-1"],
|
||
userText: "按批注修改这张图",
|
||
});
|
||
expect(prompt).toContain("【画布批注编辑要求】");
|
||
expect(prompt).toContain("把这里的水印去掉");
|
||
expect(prompt).toContain("框选区域:x=0.1, y=0.2, width=0.3, height=0.4");
|
||
expect(prompt).not.toContain("不应进入 image-1 的提示");
|
||
|
||
expect(
|
||
buildCanvasAgentAnnotationEditPrompt({
|
||
context,
|
||
targetNodeIds: ["image-1"],
|
||
userText: "把背景换成白色",
|
||
})
|
||
).toBe("");
|
||
expect(
|
||
buildCanvasAgentAnnotationEditPrompt({
|
||
context,
|
||
targetNodeIds: ["image-1"],
|
||
userText: "给选中图片加批注:这里保留",
|
||
})
|
||
).toBe("");
|
||
});
|
||
|
||
test("labels image note action as an agent annotation entry", () => {
|
||
expect(
|
||
getCanvasToolbarItem(defaultCanvasToolbarConfig, "imageNote")
|
||
).toMatchObject({
|
||
label: "添加批注",
|
||
enabled: true,
|
||
});
|
||
});
|
||
|
||
test("syncs image notes into structured canvas annotations", () => {
|
||
const first = syncCanvasNodeNoteAnnotationMetadata({
|
||
metadata: {
|
||
keep: true,
|
||
annotations: [
|
||
{
|
||
id: "agent-annotation-1",
|
||
sourceNodeId: "image-1",
|
||
type: "box",
|
||
text: "保留这个框选区域",
|
||
createdByAgent: true,
|
||
},
|
||
],
|
||
},
|
||
nodeId: "image-1",
|
||
note: " 让 Agent 按这个备注改主图 ",
|
||
now: "2026-06-22T00:00:00.000Z",
|
||
});
|
||
|
||
expect(first.noteAnnotationId).toBe("image-node-note:image-1");
|
||
expect(first.hasStructuredAnnotations).toBe(true);
|
||
expect(first.metadata.keep).toBe(true);
|
||
expect(first.annotations).toHaveLength(2);
|
||
expect(first.annotations.at(-1)).toMatchObject({
|
||
id: "image-node-note:image-1",
|
||
source: CANVAS_NODE_NOTE_ANNOTATION_SOURCE,
|
||
sourceNodeId: "image-1",
|
||
type: "text-note",
|
||
text: "让 Agent 按这个备注改主图",
|
||
createdAt: "2026-06-22T00:00:00.000Z",
|
||
updatedAt: "2026-06-22T00:00:00.000Z",
|
||
createdByUser: true,
|
||
});
|
||
|
||
const updated = syncCanvasNodeNoteAnnotationMetadata({
|
||
metadata: first.metadata,
|
||
nodeId: "image-1",
|
||
note: "换成蓝色背景",
|
||
now: "2026-06-22T00:01:00.000Z",
|
||
});
|
||
|
||
expect(updated.annotations).toHaveLength(2);
|
||
expect(updated.annotations.at(-1)).toMatchObject({
|
||
id: "image-node-note:image-1",
|
||
text: "换成蓝色背景",
|
||
createdAt: "2026-06-22T00:00:00.000Z",
|
||
updatedAt: "2026-06-22T00:01:00.000Z",
|
||
});
|
||
|
||
const cleared = syncCanvasNodeNoteAnnotationMetadata({
|
||
metadata: updated.metadata,
|
||
nodeId: "image-1",
|
||
note: "",
|
||
now: "2026-06-22T00:02:00.000Z",
|
||
});
|
||
|
||
expect(cleared.hasStructuredAnnotations).toBe(true);
|
||
expect(cleared.annotations).toEqual([
|
||
{
|
||
id: "agent-annotation-1",
|
||
sourceNodeId: "image-1",
|
||
type: "box",
|
||
text: "保留这个框选区域",
|
||
createdByAgent: true,
|
||
},
|
||
]);
|
||
expect(cleared.metadata.annotationsUpdatedAt).toBe("2026-06-22T00:02:00.000Z");
|
||
});
|
||
|
||
test("maps visual image annotations into structured canvas metadata", () => {
|
||
const result = appendCanvasVisualAnnotationsMetadata({
|
||
metadata: {
|
||
annotations: [
|
||
{
|
||
id: "note-1",
|
||
sourceNodeId: "image-1",
|
||
type: "text-note",
|
||
text: "已有备注",
|
||
},
|
||
],
|
||
},
|
||
nodeId: "image-1",
|
||
imageWidth: 200,
|
||
imageHeight: 100,
|
||
now: "2026-06-22T00:03:00.000Z",
|
||
actions: [
|
||
{
|
||
type: "circle",
|
||
color: "#ff0000",
|
||
startX: 20,
|
||
startY: 30,
|
||
radius: 10,
|
||
},
|
||
{
|
||
type: "arrow",
|
||
color: "#00ff00",
|
||
startX: 0,
|
||
startY: 0,
|
||
endX: 100,
|
||
endY: 50,
|
||
},
|
||
{
|
||
type: "text",
|
||
color: "#0000ff",
|
||
startX: 10,
|
||
startY: 20,
|
||
text: "去掉这里",
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(result.appendedAnnotations).toHaveLength(3);
|
||
expect(result.annotations).toHaveLength(4);
|
||
expect(result.appendedAnnotations[0]).toMatchObject({
|
||
source: CANVAS_VISUAL_ANNOTATION_SOURCE,
|
||
sourceNodeId: "image-1",
|
||
type: "box",
|
||
text: "用户圈选了需要关注或修改的区域",
|
||
bounds: { x: 0.05, y: 0.2, width: 0.1, height: 0.2 },
|
||
visualActionType: "circle",
|
||
createdByUser: true,
|
||
});
|
||
expect(result.appendedAnnotations[1]).toMatchObject({
|
||
type: "arrow-note",
|
||
targetPoint: { x: 0.5, y: 0.5 },
|
||
visualActionType: "arrow",
|
||
});
|
||
expect(result.appendedAnnotations[2]).toMatchObject({
|
||
type: "text-note",
|
||
text: "去掉这里",
|
||
targetPoint: { x: 0.05, y: 0.2 },
|
||
visualActionType: "text",
|
||
});
|
||
expect(result.metadata.annotationsUpdatedAt).toBe("2026-06-22T00:03:00.000Z");
|
||
});
|
||
|
||
test("syncs local edit regions into crop-box canvas annotations", () => {
|
||
const first = syncCanvasLocalEditRegionAnnotationsMetadata({
|
||
metadata: {
|
||
annotations: [
|
||
{
|
||
id: "note-1",
|
||
sourceNodeId: "image-1",
|
||
type: "text-note",
|
||
text: "保留的备注",
|
||
},
|
||
],
|
||
},
|
||
nodeId: "image-1",
|
||
now: "2026-06-22T00:04:00.000Z",
|
||
regions: [
|
||
{
|
||
id: "region-1",
|
||
index: 1,
|
||
color: "#ef4444",
|
||
prompt: "把框内 logo 去掉",
|
||
rect: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
|
||
},
|
||
{
|
||
id: "region-2",
|
||
index: 2,
|
||
color: "#06b6d4",
|
||
prompt: "比例调整",
|
||
rect: { x: 0.2, y: 0.3, width: 0.2, height: 0.2 },
|
||
targetRect: { x: 0.25, y: 0.35, width: 0.3, height: 0.25 },
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(first.regionAnnotations).toHaveLength(2);
|
||
expect(first.annotations).toHaveLength(3);
|
||
expect(first.regionAnnotations[0]).toMatchObject({
|
||
id: "image-local-edit-region:image-1:region-1",
|
||
source: CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE,
|
||
sourceNodeId: "image-1",
|
||
type: "crop-box",
|
||
text: "把框内 logo 去掉",
|
||
bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
|
||
color: "#ef4444",
|
||
createdByUser: true,
|
||
localEditRegionId: "region-1",
|
||
});
|
||
expect(first.regionAnnotations[1].text).toContain("目标区域:x=0.25");
|
||
|
||
const cleared = syncCanvasLocalEditRegionAnnotationsMetadata({
|
||
metadata: first.metadata,
|
||
nodeId: "image-1",
|
||
now: "2026-06-22T00:05:00.000Z",
|
||
regions: [],
|
||
});
|
||
|
||
expect(cleared.regionAnnotations).toHaveLength(0);
|
||
expect(cleared.annotations).toEqual([
|
||
{
|
||
id: "note-1",
|
||
sourceNodeId: "image-1",
|
||
type: "text-note",
|
||
text: "保留的备注",
|
||
},
|
||
]);
|
||
});
|
||
|
||
test("normalizes selected text-only canvas agent context", () => {
|
||
const context = normalizeCanvasAgentContext({
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["text-1"],
|
||
canvasImageNodes: [],
|
||
canvasAgentContext: {
|
||
version: 1,
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["text-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "text-1",
|
||
type: "text",
|
||
title: "旧标题",
|
||
x: 40,
|
||
y: 80,
|
||
width: 240,
|
||
height: 80,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
candidateNodes: [],
|
||
slots: [],
|
||
annotations: [],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
focusPolicy: "selected",
|
||
},
|
||
});
|
||
|
||
expect(context?.selectedNodeIds).toEqual(["text-1"]);
|
||
expect(context?.selectedNodes[0]).toMatchObject({
|
||
nodeId: "text-1",
|
||
type: "text",
|
||
title: "旧标题",
|
||
});
|
||
expect(context?.focusPolicy).toBe("selected");
|
||
});
|
||
|
||
test("builds canvas agent context from legacy canvas image nodes", () => {
|
||
const context = normalizeCanvasAgentContext({
|
||
source: "canvas",
|
||
selectedNodeIds: ["image-1"],
|
||
canvasImageNodes: [
|
||
{
|
||
nodeId: "image-1",
|
||
url: "https://cdn.example.com/image.png",
|
||
title: "主图",
|
||
x: 10,
|
||
y: 20,
|
||
width: 300,
|
||
height: 300,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(context?.selectedNodes).toHaveLength(1);
|
||
expect(context?.candidateNodes[0]).toMatchObject({
|
||
nodeId: "image-1",
|
||
type: "image",
|
||
url: "https://cdn.example.com/image.png",
|
||
isSelected: true,
|
||
});
|
||
});
|
||
|
||
test("normalizes media urls without query strings or trailing slashes", () => {
|
||
expect(normalizeMediaUrl(" HTTPS://EXAMPLE.COM/assets/image.png?sig=secret#preview ")).toBe(
|
||
"https://example.com/assets/image.png"
|
||
);
|
||
expect(normalizeMediaUrl("not-a-url?cache=1#hash/")).toBe("not-a-url");
|
||
});
|
||
|
||
test("collects message image urls from parts, attachments, and legacy content", () => {
|
||
const message = {
|
||
id: "message-1",
|
||
role: "user",
|
||
parts: [
|
||
{
|
||
type: "file",
|
||
url: "https://cdn.example.com/upload-a.png?x=1",
|
||
mediaType: "image/png",
|
||
},
|
||
{
|
||
type: "text",
|
||
text: "参考  和 https://cdn.example.com/plain-a.jpg?token=1",
|
||
},
|
||
],
|
||
attachments: [
|
||
{
|
||
name: "attachment",
|
||
url: "https://cdn.example.com/attachment-a.jpeg?download=1",
|
||
contentType: "image/jpeg",
|
||
},
|
||
],
|
||
content: "legacy https://cdn.example.com/legacy-a.png",
|
||
} as unknown as ChatMessage;
|
||
|
||
expect(collectMessageImageUrls(message)).toEqual([
|
||
"https://cdn.example.com/upload-a.png?x=1",
|
||
"https://cdn.example.com/markdown-a.webp",
|
||
"https://cdn.example.com/plain-a.jpg?token=1",
|
||
"https://cdn.example.com/attachment-a.jpeg?download=1",
|
||
"https://cdn.example.com/legacy-a.png",
|
||
]);
|
||
});
|
||
|
||
test("filters assistant result images that match nearest user references", () => {
|
||
const referenceKeys = new Set(["https://cdn.example.com/reference.png"]);
|
||
const urls = extractAssistantImageUrls(
|
||
[
|
||
"",
|
||
"",
|
||
].join("\n"),
|
||
referenceKeys
|
||
);
|
||
|
||
expect(urls).toEqual(["https://cdn.example.com/result.png?token=secret"]);
|
||
});
|
||
|
||
test("extracts and strips video markup without removing surrounding text", () => {
|
||
const text = [
|
||
"生成完成",
|
||
'<video src="https://cdn.example.com/video-a.mp4?sig=1"></video>',
|
||
"[下载视频](https://cdn.example.com/video-b.mp4)",
|
||
"继续优化",
|
||
].join("\n");
|
||
|
||
expect(extractVideoUrls(text)).toEqual([
|
||
"https://cdn.example.com/video-a.mp4?sig=1",
|
||
"https://cdn.example.com/video-b.mp4",
|
||
]);
|
||
expect(stripMediaMarkup(text)).toBe("生成完成\n\n\n继续优化");
|
||
});
|
||
|
||
test("builds detail review plans with clamped count and localized fallbacks", () => {
|
||
const plans = buildDetailReviewPlans({
|
||
language: "zh",
|
||
count: 9,
|
||
summary: " 已验证卖点 ",
|
||
brief: {
|
||
productSpu: "",
|
||
demandType: "",
|
||
productName: "无线吸尘器",
|
||
targetAudience: "养宠家庭",
|
||
mainUser: "",
|
||
usageScenario: "客厅清洁",
|
||
triggerMoment: "",
|
||
coreNeed: "快速清理毛发",
|
||
riskConcern: "吸力不足",
|
||
expectedResult: "地面干净",
|
||
coreSellingPoint: "强吸力,轻量,低噪音",
|
||
positioningStatement: "",
|
||
userVoice: "",
|
||
competitorReference: "",
|
||
priceBand: "",
|
||
brandTone: "",
|
||
bannedWords: "",
|
||
},
|
||
});
|
||
|
||
expect(plans).toHaveLength(5);
|
||
expect(plans[0]).toMatchObject({
|
||
index: 1,
|
||
title: "真实需求反馈1",
|
||
});
|
||
expect(plans[0]?.reviewText).toContain("养宠家庭");
|
||
expect(plans[0]?.reviewText).toContain("已验证卖点");
|
||
expect(plans[0]?.imagePrompt).toContain("无线吸尘器");
|
||
});
|
||
|
||
test("keeps detail slot indexes and generation mode options stable", () => {
|
||
expect(DETAIL_TARGET_WIDTH).toBe(790);
|
||
expect(DETAIL_TARGET_HEIGHT).toBe(1300);
|
||
expect(DETAIL_SLOT_CONFIGS.map((slot) => slot.role)).toEqual([
|
||
"product",
|
||
"product",
|
||
"product",
|
||
"product",
|
||
"product",
|
||
"model",
|
||
"bundle",
|
||
"bundle",
|
||
"bundle",
|
||
]);
|
||
expect(DETAIL_PRODUCT_SLOT_INDEXES).toEqual([0, 1, 2, 3, 4]);
|
||
expect(DETAIL_MODEL_SLOT_INDEX).toBe(5);
|
||
expect(DETAIL_BUNDLE_SLOT_INDEXES).toEqual([6, 7, 8]);
|
||
expect(DETAIL_GENERATION_MODE_OPTIONS.map((option) => option.value)).toEqual([
|
||
"classic",
|
||
"agent_layout",
|
||
]);
|
||
});
|
||
|
||
test("normalizes detail config parser helpers defensively", () => {
|
||
expect(normalizeDetailGenerationMode("agent_layout")).toBe("agent_layout");
|
||
expect(normalizeDetailGenerationMode("unknown")).toBe("classic");
|
||
expect(extractDetailFrameCount("需要 23 张详情页")).toBe(20);
|
||
expect(extractDetailFrameCount("需要 2 屏")).toBe(5);
|
||
expect(extractDetailFrameCount("没有明确数量")).toBe(15);
|
||
expect(extractFirstNumber("标题 64px / 700", 32)).toBe(64);
|
||
expect(extractFirstNumber("无数字", 32)).toBe(32);
|
||
expect(extractFontWeight("主标题 64px / 700 / 行高1.15", "600")).toBe("700");
|
||
expect(extractFontWeight("weight: 500", "600")).toBe("500");
|
||
expect(extractFontWeight("无权重", "600")).toBe("600");
|
||
expect(splitDetailKeywords("强吸力,轻量; 低噪音 | 易清洁", 3)).toEqual([
|
||
"强吸力",
|
||
"轻量",
|
||
"低噪音",
|
||
]);
|
||
});
|
||
|
||
test("sanitizes detail image model prompts away from pixel-locked layout instructions", () => {
|
||
const sanitized = sanitizeDetailImageModelPrompt(
|
||
[
|
||
"字体规范:主标题 64px / 700 / 行高1.15",
|
||
"输出为竖版电商详情图,尺寸固定 790x1300 像素。",
|
||
"安全区 y=64~360,左右边距56px,模块间距24px。",
|
||
"商业摄影质感,留出清晰文案排版区,不要水印。",
|
||
"突出产品真实材质与使用场景。",
|
||
].join("\n")
|
||
);
|
||
|
||
expect(sanitized).not.toContain("790x1300");
|
||
expect(sanitized).not.toContain("64px");
|
||
expect(sanitized).not.toContain("56px");
|
||
expect(sanitized).toContain("输出为竖版电商详情图。");
|
||
expect(sanitized).toContain("顶部标题安全区");
|
||
expect(sanitized).toContain("左右留白充足");
|
||
expect(sanitized).toContain("突出产品真实材质与使用场景。");
|
||
});
|
||
|
||
test("builds stable detail result and appended review markdown", () => {
|
||
const markdown = buildDetailResultMarkdown({
|
||
generationMode: "agent_layout",
|
||
summary: "详情页生成成功",
|
||
images: [
|
||
{
|
||
originalUrl: "https://cdn.example.com/detail-1.png",
|
||
thumbnailUrl: "https://cdn.example.com/detail-1-thumb.webp",
|
||
},
|
||
],
|
||
prompts: ["商业摄影质感"],
|
||
skillFlow: {
|
||
name: "详情页生成",
|
||
version: "1.0",
|
||
steps: ["拆解卖点", "生成分镜"],
|
||
},
|
||
qualityChecks: {
|
||
passed: true,
|
||
},
|
||
screens: [
|
||
{
|
||
screenType: "A",
|
||
title: "核心卖点",
|
||
useModel: true,
|
||
fabe: {
|
||
b: "收益",
|
||
},
|
||
},
|
||
],
|
||
reviewGroups: [
|
||
{
|
||
reviewText: "真实好用",
|
||
images: [
|
||
{
|
||
originalUrl: "https://cdn.example.com/review-1.png",
|
||
thumbnailUrl: "https://cdn.example.com/review-1-thumb.webp",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(markdown).toContain("生成路线:AGENT排版(画面+可编辑文字层)");
|
||
expect(markdown).toContain("质检结果:通过");
|
||
expect(markdown).toContain("![thumb|||https://cdn.example.com/detail-1.png|||2K]");
|
||
expect(markdown).toContain("评价 1:真实好用");
|
||
|
||
expect(
|
||
buildDetailReviewAppendMarkdown({
|
||
failedCount: 1,
|
||
groups: [
|
||
{
|
||
reviewText: "追加评价",
|
||
images: [
|
||
{
|
||
originalUrl: "https://cdn.example.com/review-2.png",
|
||
thumbnailUrl: "https://cdn.example.com/review-2-thumb.webp",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
})
|
||
).toContain("失败 1 条,已跳过。");
|
||
});
|
||
|
||
test("sanitizes and sorts detail archives from persisted payloads", () => {
|
||
const archives = sanitizeDetailArchiveList([
|
||
{
|
||
id: "older",
|
||
title: "",
|
||
prompt: " old prompt ",
|
||
generationMode: "invalid",
|
||
brief: {
|
||
productName: "旧产品",
|
||
targetAudience: "人群",
|
||
},
|
||
slots: [
|
||
{
|
||
url: "https://cdn.example.com/product.png",
|
||
name: "",
|
||
contentType: "",
|
||
},
|
||
],
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||
uiSurfaceLatestReview: {
|
||
screenIndex: 2,
|
||
action: "unsafe",
|
||
passed: true,
|
||
issues: [" issue-a ", "", "issue-b"],
|
||
},
|
||
},
|
||
{
|
||
id: "newer",
|
||
title: "新项目",
|
||
prompt: "new prompt",
|
||
generationMode: "agent_layout",
|
||
brief: {
|
||
coreNeed: "核心需求",
|
||
},
|
||
slots: [],
|
||
createdAt: "2026-01-03T00:00:00.000Z",
|
||
},
|
||
{
|
||
id: "",
|
||
createdAt: "2026-01-04T00:00:00.000Z",
|
||
},
|
||
]);
|
||
|
||
expect(archives.map((archive) => archive.id)).toEqual(["newer", "older"]);
|
||
expect(archives[0]?.generationMode).toBe("agent_layout");
|
||
expect(archives[1]).toMatchObject({
|
||
title: "未命名详情项目",
|
||
generationMode: "classic",
|
||
brief: {
|
||
productName: "旧产品",
|
||
coreNeed: "",
|
||
},
|
||
});
|
||
expect(archives[1]?.slots[0]).toMatchObject({
|
||
url: "https://cdn.example.com/product.png",
|
||
name: "产品图1",
|
||
contentType: "image/jpeg",
|
||
});
|
||
expect(archives[1]?.uiSurfaceLatestReview).toMatchObject({
|
||
screenIndex: 2,
|
||
action: undefined,
|
||
passed: true,
|
||
issues: ["issue-a", "issue-b"],
|
||
});
|
||
});
|
||
|
||
test("creates and updates detail archive lists with stable limits", () => {
|
||
const archive = createDetailArchiveSnapshot({
|
||
archiveId: "draft",
|
||
createdAt: 1000,
|
||
prompt: " 第一行\n第二行 ",
|
||
generationMode: "classic",
|
||
summary: "摘要",
|
||
imageCount: 2,
|
||
coverUrl: "https://cdn.example.com/cover.png",
|
||
brief: {
|
||
productSpu: " SPU-001 ",
|
||
demandType: "",
|
||
productName: "产品名",
|
||
targetAudience: "",
|
||
mainUser: "",
|
||
usageScenario: " 客厅清洁 ",
|
||
triggerMoment: "",
|
||
coreNeed: "",
|
||
riskConcern: "",
|
||
expectedResult: "",
|
||
coreSellingPoint: "",
|
||
positioningStatement: "",
|
||
userVoice: "",
|
||
competitorReference: "",
|
||
priceBand: "",
|
||
brandTone: "",
|
||
bannedWords: "",
|
||
},
|
||
slots: [
|
||
{
|
||
name: "产品",
|
||
url: "https://cdn.example.com/product.png",
|
||
contentType: "image/png",
|
||
},
|
||
null,
|
||
],
|
||
});
|
||
|
||
expect(archive).toMatchObject({
|
||
id: "draft",
|
||
title: "SPU-001+客厅清洁",
|
||
prompt: "第一行 第二行",
|
||
createdAt: 1000,
|
||
updatedAt: 1000,
|
||
summary: "摘要",
|
||
imageCount: 2,
|
||
coverUrl: "https://cdn.example.com/cover.png",
|
||
});
|
||
expect(archive.slots[0]).toMatchObject({
|
||
name: "产品",
|
||
url: "https://cdn.example.com/product.png",
|
||
contentType: "image/png",
|
||
});
|
||
|
||
const patched = patchDetailArchiveList([archive], "draft", { summary: "新摘要" }, 2000);
|
||
expect(patched[0]).toMatchObject({ summary: "新摘要", updatedAt: 2000 });
|
||
|
||
const saved = { ...archive, id: "saved", updatedAt: 3000 };
|
||
expect(replaceSavedDetailArchive([archive], "draft", saved).map((item) => item.id)).toEqual([
|
||
"saved",
|
||
]);
|
||
|
||
const manyArchives = Array.from({ length: 35 }, (_, index) => ({
|
||
...archive,
|
||
id: `archive-${index}`,
|
||
createdAt: index,
|
||
updatedAt: index,
|
||
}));
|
||
expect(prependDetailArchive(manyArchives, saved)).toHaveLength(30);
|
||
});
|
||
|
||
test("fills detail slots by current role group without crossing groups", () => {
|
||
expect(getDetailSlotTargetIndexes(0)).toEqual([0, 1, 2, 3, 4]);
|
||
expect(getDetailSlotTargetIndexes(3)).toEqual([3, 4]);
|
||
expect(getDetailSlotTargetIndexes(5)).toEqual([5]);
|
||
expect(getDetailSlotTargetIndexes(7)).toEqual([7, 8]);
|
||
expect(getDetailSlotTargetIndexes(-1)).toEqual([]);
|
||
expect(getDetailSlotTargetIndexes(99)).toEqual([]);
|
||
|
||
const current = Array.from({ length: DETAIL_SLOT_CONFIGS.length }, () => null);
|
||
const productA: PickedObjectAttachment = {
|
||
name: "product-a",
|
||
url: "https://cdn.example.com/product-a.png",
|
||
contentType: "image/png",
|
||
};
|
||
const productB: PickedObjectAttachment = {
|
||
name: "product-b",
|
||
url: "https://cdn.example.com/product-b.png",
|
||
contentType: "image/png",
|
||
};
|
||
|
||
const updatedSingle = applyDetailSlotAttachment(current, 5, productA);
|
||
expect(updatedSingle[5]).toBe(productA);
|
||
expect(current[5]).toBeNull();
|
||
|
||
const filled = fillDetailSlotAttachments(current, getDetailSlotTargetIndexes(3), [
|
||
productA,
|
||
productB,
|
||
]);
|
||
expect(filled.filledCount).toBe(2);
|
||
expect(filled.slots[3]).toBe(productA);
|
||
expect(filled.slots[4]).toBe(productB);
|
||
expect(filled.slots[5]).toBeNull();
|
||
});
|
||
|
||
test("validates and normalizes canvas image upload helper payloads", () => {
|
||
expect(validateCanvasImageUploadFile(null)).toEqual({
|
||
ok: false,
|
||
message: "请选择图片文件",
|
||
});
|
||
expect(
|
||
validateCanvasImageUploadFile({
|
||
name: "demo.txt",
|
||
type: "text/plain",
|
||
size: 10,
|
||
})
|
||
).toEqual({
|
||
ok: false,
|
||
message: "请上传图片文件",
|
||
});
|
||
expect(
|
||
validateCanvasImageUploadFile({
|
||
name: "huge.png",
|
||
type: "image/png",
|
||
size: CANVAS_IMAGE_UPLOAD_MAX_BYTES + 1,
|
||
})
|
||
).toEqual({
|
||
ok: false,
|
||
message: "图片大小不能超过 50MB",
|
||
});
|
||
expect(
|
||
validateCanvasImageUploadFile({
|
||
name: "valid.webp",
|
||
type: "image/webp",
|
||
size: CANVAS_IMAGE_UPLOAD_MAX_BYTES,
|
||
})
|
||
).toEqual({ ok: true });
|
||
|
||
expect(getCanvasUploadImageTitle(" product.hero.final.png ")).toBe("product.hero.final");
|
||
expect(getCanvasUploadImageTitle(".png")).toBe("图片");
|
||
expect(normalizeUploadedAssetId(" asset-123 ")).toBe("asset-123");
|
||
expect(normalizeUploadedAssetId(" ")).toBeUndefined();
|
||
|
||
expect(
|
||
buildCanvasUploadedImageAttachment({
|
||
file: {
|
||
name: "photo.png",
|
||
type: "",
|
||
size: 100,
|
||
},
|
||
imageUrl: "https://cdn.example.com/photo.png",
|
||
assetId: "asset-1",
|
||
})
|
||
).toEqual({
|
||
name: "photo.png",
|
||
url: "https://cdn.example.com/photo.png",
|
||
contentType: "image/jpeg",
|
||
assetId: "asset-1",
|
||
});
|
||
});
|
||
});
|