ai-chatbot/tests/routes/canvas-chat-helpers.test.ts

5348 lines
162 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import {
collectMessageImageUrls,
extractAssistantImageUrls,
extractVideoUrls,
normalizeMediaUrl,
sortCanvasMessagesByCreatedAt,
stripMediaMarkup,
} from "@/components/canvas/chat-panel/canvas-chat-media";
import {
filterCanvasMediaPayloadForUnhandledItems,
buildAgentProgressNodeSteps,
getCanvasMediaPayloadHandledItemIds,
getCanvasOperationHandledMediaItemIds,
getAgentProgressStepLabel,
isCanvasAgentViewOperationClientEnabled,
normalizeCanvasAgentClientBoolean,
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 {
normalizeCanvasMediaStreamPayload,
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 {
resolveCanvasAgentRequestPolicy,
resolveCanvasAgentTargetPreference,
} from "@/lib/ai/runtime/canvas-agent-kernel";
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 {
buildCanvasSaveRequestBody,
mergeCanvasSaveRevisionOptions,
} from "@/lib/canvas/canvas-save-request";
import { canvasService } from "@/services/canvas";
import {
hasCanvasAgentSlotFillContextIntent,
hasCanvasAgentNonGenerativeControlIntent,
shouldPreserveCanvasAgentSlotOnlyContext,
shouldBypassCanvasImagePointsPrecheckForAgentControl,
} from "@/lib/canvas/agent-client-intent";
import {
getDirectAgentToolLabel,
getDirectAgentToolSuccessMessage,
hasExplicitDirectAgentBatchImageEditIntent,
isDirectAgentBatchImageEditCapability,
resolveDirectAgentToolContext,
resolveDirectAgentToolExecution,
shouldRunDirectAgentBatchImageEdit,
} from "@/lib/ai/runtime/direct-agent-execution";
import {
buildDirectAgentImageEditAssistantText,
buildDirectAgentImageGenerationAssistantText,
} from "@/lib/ai/runtime/direct-agent-response-builders";
import { inferAngleEditOptionsFromText } from "@/lib/ai/runtime/angle-edit-options";
import {
hasDirectAgentLocalEditIntent,
normalizeDirectAgentLocalEditRect,
resolveDirectAgentLocalEditRegions,
} from "@/lib/ai/runtime/local-edit-options";
import {
applyCanvasOperationPlanToCanvasData,
CanvasAgentMutationError,
} from "@/lib/canvas/agent-mutations";
import {
appendCanvasAgentOperationAuditIfMissing,
buildIdempotentCanvasAgentAuditResult,
findCanvasAgentOperationAudit,
} from "@/lib/canvas/agent-operation-audit";
import {
CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY,
CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY,
CANVAS_AGENT_SLOT_ENABLED_KEY,
CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY,
CANVAS_AGENT_WRITEBACK_MODE_KEY,
canvasAgentFeatureFlagsToSettings,
canvasAgentFeatureSettingsToSystemSettings,
canApplyCanvasAgentMutations,
getCanvasAgentFeatureFlags,
normalizeCanvasAgentFeatureSettings,
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 {
buildOpenClawDisplayMessage,
buildOpenClawMessageWithSpreadsheetMarkdown,
isOpenClawSpreadsheetFileName,
rowsToOpenClawMarkdownTable,
} from "@/lib/openclaw-chat/spreadsheet-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("keeps optimistic messages without timestamps after persisted history", () => {
const messages = [
{
id: "optimistic-latest-user",
role: "user",
parts: [{ type: "text", text: "你能为我做什么?列出表给我" }],
},
{
id: "persisted-first-user",
role: "user",
createdAt: "2026-07-07T01:00:00.000Z",
parts: [{ type: "text", text: "你好" }],
},
{
id: "persisted-first-assistant",
role: "assistant",
createdAt: "2026-07-07T01:00:01.000Z",
parts: [{ type: "text", text: "你好,我在。" }],
},
] as ChatMessage[];
expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([
"persisted-first-user",
"persisted-first-assistant",
"optimistic-latest-user",
]);
});
test("preserves relative order among messages without timestamps", () => {
const messages = [
{
id: "pending-user",
role: "user",
parts: [{ type: "text", text: "生成一张图" }],
},
{
id: "pending-assistant",
role: "assistant",
parts: [{ type: "text", text: "分析中" }],
},
] as ChatMessage[];
expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([
"pending-user",
"pending-assistant",
]);
});
test("formats uploaded spreadsheet rows as safe markdown tables for OpenClaw", () => {
const markdown = rowsToOpenClawMarkdownTable([
["名称", "备注"],
["A|B", "第一行\n第二行"],
["空值列", ""],
]);
expect(markdown).toContain("| 名称 | 备注 |");
expect(markdown).toContain("A\\|B");
expect(markdown).toContain("第一行<br>第二行");
expect(isOpenClawSpreadsheetFileName("sample.xlsx")).toBe(true);
expect(isOpenClawSpreadsheetFileName("sample.xls")).toBe(false);
});
test("keeps OpenClaw spreadsheet display text compact while upstream message includes markdown", () => {
const files = [
{
fileName: "sales.csv",
rowCount: 12,
sheetCount: 1,
truncated: false,
},
];
const display = buildOpenClawDisplayMessage({ message: "", files });
expect(display).toContain("请分析上传的表格内容");
expect(display).toContain("sales.csv");
expect(display).not.toContain("| SKU |");
const upstream = buildOpenClawMessageWithSpreadsheetMarkdown({
message: "分析销量",
spreadsheetMarkdown: "| SKU | 销量 |\n| --- | --- |\n| A01 | 20 |",
});
expect(upstream).toContain("分析销量");
expect(upstream).toContain("以下是用户上传表格转换后的 Markdown 内容");
expect(upstream).toContain("| SKU | 销量 |");
});
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("normalizes client-side canvas agent feature flags", () => {
expect(normalizeCanvasAgentClientBoolean(undefined, true)).toBe(true);
expect(normalizeCanvasAgentClientBoolean("", false)).toBe(false);
expect(normalizeCanvasAgentClientBoolean("enabled", false)).toBe(true);
expect(normalizeCanvasAgentClientBoolean("off", true)).toBe(false);
expect(normalizeCanvasAgentClientBoolean("disabled", true)).toBe(false);
expect(normalizeCanvasAgentClientBoolean("unexpected", true)).toBe(true);
expect(isCanvasAgentViewOperationClientEnabled(undefined)).toBe(true);
expect(isCanvasAgentViewOperationClientEnabled("false")).toBe(false);
expect(isCanvasAgentViewOperationClientEnabled("1")).toBe(true);
});
test("bypasses image point precheck only for non-generative canvas agent controls", () => {
expect(hasCanvasAgentNonGenerativeControlIntent("把画布放大一点")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("缩小看全局")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("聚焦刚生成结果")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("对比原图和新图")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("新建一个图片槽位")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("创建一个图片槽位")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("删除选中的节点")).toBe(true);
expect(hasCanvasAgentNonGenerativeControlIntent("把选中图片背景换白")).toBe(false);
expect(hasCanvasAgentNonGenerativeControlIntent("生成一张产品主图")).toBe(false);
expect(hasCanvasAgentNonGenerativeControlIntent("抠图去背景")).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "把画布放大一点",
activeComposerMode: "image",
agentEnabled: true,
})
).toBe(true);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "聚焦刚生成结果",
activeComposerMode: "image",
agentEnabled: true,
})
).toBe(true);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "对比原图和新图",
activeComposerMode: "image",
agentEnabled: true,
})
).toBe(true);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "把画布放大一点",
activeComposerMode: "image",
attachmentCount: 1,
agentEnabled: true,
})
).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "把画布放大一点",
activeComposerMode: "image",
hasWorkflowContext: true,
agentEnabled: true,
})
).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "把画布放大一点",
activeComposerMode: "image",
agentEnabled: false,
})
).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "生成一张产品主图",
activeComposerMode: "image",
agentEnabled: true,
})
).toBe(false);
expect(
shouldBypassCanvasImagePointsPrecheckForAgentControl({
text: "任意 agent 模式文本",
activeComposerMode: "agent",
agentEnabled: true,
})
).toBe(true);
});
test("preserves selected slot-only canvas context for fill and generation intents", () => {
expect(hasCanvasAgentSlotFillContextIntent("在这里生成一张主图")).toBe(true);
expect(hasCanvasAgentSlotFillContextIntent("填充这个槽位")).toBe(true);
expect(hasCanvasAgentSlotFillContextIntent("普通聊天")).toBe(false);
expect(
shouldPreserveCanvasAgentSlotOnlyContext({
text: "在这里生成一张主图",
activeComposerMode: "image",
selectedAgentSlotCount: 1,
agentEnabled: true,
})
).toBe(true);
expect(
shouldPreserveCanvasAgentSlotOnlyContext({
text: "生成一张主图",
activeComposerMode: "image",
selectedAgentSlotCount: 0,
agentEnabled: true,
})
).toBe(false);
expect(
shouldPreserveCanvasAgentSlotOnlyContext({
text: "在这里生成一张主图",
activeComposerMode: "image",
selectedAgentSlotCount: 1,
attachmentCount: 1,
agentEnabled: true,
})
).toBe(false);
expect(
shouldPreserveCanvasAgentSlotOnlyContext({
text: "任意 agent 模式文本",
activeComposerMode: "agent",
selectedAgentSlotCount: 1,
agentEnabled: false,
})
).toBe(true);
});
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",
});
});
test("builds canvas save requests with fallback revision guards", () => {
const canvasData = {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select" as const,
};
expect(
mergeCanvasSaveRevisionOptions({
fallbackOptions: {
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
},
})
).toEqual({
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
});
expect(
mergeCanvasSaveRevisionOptions({
options: {
baseCanvasRevision: "2026-06-22T00:00:02.000Z",
},
fallbackOptions: {
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
},
})
).toEqual({
baseCanvasRevision: "2026-06-22T00:00:02.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
});
expect(
buildCanvasSaveRequestBody({
canvasData,
workspaceType: "video",
fallbackOptions: {
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
},
})
).toMatchObject({
canvasData,
workspaceType: "video",
baseCanvasRevision: "2026-06-22T00:00:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z",
});
});
test("canvas service save uses store revision fallback when options are omitted", async () => {
const canvasData = {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select" as const,
};
const originalFetch = globalThis.fetch;
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const originalWindow = (globalThis as typeof globalThis & { window?: unknown })
.window;
const bodies: Array<Record<string, unknown>> = [];
useCanvasStore
.getState()
.setCanvasRevision(
"2026-06-22T00:00:10.000Z",
"2026-06-22T00:00:10.000Z"
);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {},
writable: true,
});
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body || "{}")));
return {
ok: true,
status: 200,
json: async () => ({
success: true,
data: { updatedAt: "2026-06-22T00:00:11.000Z" },
}),
} as Response;
}) as typeof fetch;
try {
await canvasService.saveCanvasState(
"11111111-1111-4111-8111-111111111111",
canvasData
);
await canvasService.saveCanvasState(
"11111111-1111-4111-8111-111111111111",
canvasData,
"canvas",
{
baseCanvasRevision: "2026-06-22T00:00:12.000Z",
}
);
} finally {
globalThis.fetch = originalFetch;
if (hadWindow) {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
writable: true,
});
} else {
Reflect.deleteProperty(globalThis, "window");
}
useCanvasStore.getState().setCanvasRevision(null, null);
}
expect(bodies).toHaveLength(2);
expect(bodies[0]).toMatchObject({
canvasData,
baseCanvasRevision: "2026-06-22T00:00:10.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:10.000Z",
});
expect(bodies[1]).toMatchObject({
canvasData,
workspaceType: "canvas",
baseCanvasRevision: "2026-06-22T00:00:12.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:00:11.000Z",
});
});
test("canvas service keeps store revision aligned after project load and save", async () => {
const canvasData = {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select" as const,
};
const originalFetch = globalThis.fetch;
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const originalWindow = (globalThis as typeof globalThis & { window?: unknown })
.window;
const responses = [
{
success: true,
data: {
id: "11111111-1111-4111-8111-111111111111",
title: "测试画布",
canvasData,
updatedAt: "2026-06-22T00:01:00.000Z",
},
},
{
success: true,
data: {
updatedAt: "2026-06-22T00:01:05.000Z",
},
},
];
useCanvasStore.getState().setCanvasRevision(null, null);
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {},
writable: true,
});
globalThis.fetch = (async () =>
({
ok: true,
status: 200,
json: async () => responses.shift()!,
}) as Response) as typeof fetch;
try {
await canvasService.getProject("11111111-1111-4111-8111-111111111111");
expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:01:00.000Z"
);
await canvasService.saveCanvasState(
"11111111-1111-4111-8111-111111111111",
canvasData
);
expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:01:05.000Z"
);
expect(useCanvasStore.getState().canvasUpdatedAt).toBe(
"2026-06-22T00:01:05.000Z"
);
} finally {
globalThis.fetch = originalFetch;
if (hadWindow) {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
writable: true,
});
} else {
Reflect.deleteProperty(globalThis, "window");
}
useCanvasStore.getState().setCanvasRevision(null, null);
}
});
test("canvas service automatically retries stale save conflicts", async () => {
const originalFetch = globalThis.fetch;
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const originalWindow = (globalThis as typeof globalThis & { window?: unknown })
.window;
const bodies: Array<Record<string, unknown>> = [];
const responses = [
{
ok: false,
status: 409,
json: async () => ({
success: false,
error: "画布已更新,请刷新后再保存",
code: "STALE_CANVAS_REVISION",
canvasUpdatedAt: "2026-06-22T00:02:00.000Z",
}),
},
{
ok: true,
status: 200,
json: async () => ({
success: true,
data: { updatedAt: "2026-06-22T00:02:01.000Z" },
}),
},
];
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {},
writable: true,
});
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body || "{}")));
return responses.shift() as Response;
}) as typeof fetch;
try {
const result = await canvasService.saveCanvasState(
"11111111-1111-4111-8111-111111111111",
{
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select",
}
);
expect(result).toMatchObject({
success: true,
data: { updatedAt: "2026-06-22T00:02:01.000Z" },
});
expect(bodies).toHaveLength(2);
expect(bodies[1]).toMatchObject({
baseCanvasRevision: "2026-06-22T00:02:00.000Z",
baseCanvasUpdatedAt: "2026-06-22T00:02:00.000Z",
});
expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:02:01.000Z"
);
} finally {
globalThis.fetch = originalFetch;
if (hadWindow) {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow,
writable: true,
});
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
});
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(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("does not fill a selected agent slot when the result type mismatches", () => {
const context: CanvasAgentContext = {
...plannerContext,
selectedNodeIds: ["slot-video-1"],
selectedNodes: [
{
nodeId: "slot-video-1",
type: "agent-slot",
title: "视频槽位",
x: 100,
y: 120,
width: 320,
height: 180,
isSelected: true,
},
],
slots: [
{
nodeId: "slot-video-1",
slotType: "video",
label: "视频槽位",
x: 100,
y: 120,
width: 320,
height: 180,
aspectRatio: "16:9",
isSelected: true,
fillPolicy: "replace-empty",
},
],
};
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-mismatch",
operationLabel: "agent-generate-image",
resultType: "image",
resultUrls: ["https://cdn.example.com/not-video-slot.png"],
prompt: "生成主图",
mode: "generate",
context,
model: "image-model",
});
expect(result).toMatchObject({
operation: "insert_beside",
targetSlotId: "",
shouldFillSlot: false,
plan: {
operation: "insert_beside",
targetSlotId: undefined,
anchorNodeId: "slot-video-1",
placement: "right",
},
});
expect(result?.mediaItems[0]).toMatchObject({
type: "image",
sourceNodeIds: ["slot-video-1"],
});
});
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("routes watermark removal through the dedicated direct image tool", () => {
const result = resolveDirectAgentToolExecution({
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_watermark",
targetSource: "explicit_selection",
preferredCapability: "remove_watermark",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [
{
name: "resolveTargetImage",
args: { source: "selected_canvas" },
},
{
name: "editTargetImage",
reason: "planner only provided the generic edit tool",
},
],
availableTools: ["resolveTargetImage", "editTargetImage", "removeWatermark"],
});
expect(result).toMatchObject({
toolName: "removeWatermark",
capability: "remove_watermark",
sourcePreferences: ["selected_canvas"],
});
expect(getDirectAgentToolLabel("remove_watermark")).toBe("Agent工具:去水印");
expect(getDirectAgentToolSuccessMessage("remove_watermark")).toBe(
"已完成去水印处理,结果如下:"
);
});
test("formats direct agent image results as structured markdown cards", () => {
const generatedText = buildDirectAgentImageGenerationAssistantText({
imageMarkdown: "![生成结果](https://cdn.example.com/generated.png)",
modelDisplayName: "Test Image Model",
});
expect(generatedText).toContain("### 执行结果");
expect(generatedText).toContain("- 已完成图片生成,结果如下。");
expect(generatedText).toContain("![生成结果](https://cdn.example.com/generated.png)");
expect(generatedText).toContain("### 后续操作");
const editText = buildDirectAgentImageEditAssistantText({
capability: "remove_background",
originalUrl: "https://cdn.example.com/cutout.png",
});
expect(editText).toContain("### 执行结果");
expect(editText).toContain("- 已完成抠图处理,结果如下。");
expect(editText).toContain("https://cdn.example.com/cutout.png");
expect(editText).toContain("替换背景");
});
test("builds capability-aware agent progress node labels", () => {
const selectingSteps = buildAgentProgressNodeSteps({
stage: "planning",
currentStep: "select_tool",
capability: "remove_background",
});
expect(selectingSteps.map((step) => step.label)).toEqual([
"理解用户需求",
"分析使用工具",
"确认抠图工具",
"抠图中",
"整理结果",
]);
expect(selectingSteps.map((step) => step.state)).toEqual([
"completed",
"completed",
"current",
"upcoming",
"upcoming",
]);
const executingSteps = buildAgentProgressNodeSteps({
stage: "executing",
currentStep: "execute",
capability: "remove_background",
});
expect(executingSteps[2]?.state).toBe("completed");
expect(executingSteps[3]).toMatchObject({
label: "抠图中",
state: "current",
});
expect(getAgentProgressStepLabel("execute", { capability: "generate_video" })).toBe(
"视频生成中"
);
});
test("resolves deictic canvas edit requests to latest results and direct tools", () => {
const latestResultUrl = "https://cdn.example.com/latest-cat-result.png";
const requestPolicy = resolveCanvasAgentRequestPolicy({
composerMode: "agent",
selectedChatModel: "conversation-model-id",
});
expect(requestPolicy.effectiveSelectedChatModel).toBe("auto");
expect(requestPolicy.shouldForceToolOrchestration).toBe(true);
const targetPreference = resolveCanvasAgentTargetPreference({
text: "将这只猫从图中扣图扣出来",
selectedImageCount: 0,
latestResultImageCount: 1,
workingMemoryTargetImageCount: 1,
});
expect(targetPreference.shouldPreferLatestResult).toBe(true);
const mediaInput = {
imageRefs: [],
currentMessageImageRefs: [],
selectedImageRefs: [],
canvasImageRefs: [],
historyUserImageRefs: [],
historyAssistantImageRefs: [
{
url: latestResultUrl,
normalizedUrl: latestResultUrl,
source: "history_assistant_result",
role: "history_result",
},
],
resolvedImageRefs: [],
currentMessageImageUrls: [],
selectedImageUrls: [],
historyUserImageUrls: [],
historyAssistantImageUrls: [latestResultUrl],
resolvedImageUrls: [latestResultUrl],
selectedAssetIds: [],
candidateAssetIds: [],
uploadedAssetIds: [],
hasImage: true,
source: "history",
fromHistory: true,
} as any;
const workingState = {
lastGeneratedImageUrls: [latestResultUrl],
lastResolvedTargetImageUrls: [latestResultUrl],
};
const targetResolution = {
targetRefs: [],
candidateRefs: [],
targetImageUrls: [latestResultUrl],
targetAssetIds: [],
candidateImageUrls: [],
candidateAssetIds: [],
resolutionMode: "working_memory",
confidence: targetPreference.confidence,
needsClarification: false,
reason: targetPreference.reason,
fromHistory: true,
} as any;
const directContext = resolveDirectAgentToolContext({
execution: {
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "remove_background",
targetSource: targetResolution.resolutionMode,
preferredCapability: "remove_background",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [],
availableTools: ["removeBackground"],
} as any,
requestResolvedMediaInput: mediaInput,
requestTargetResolution: targetResolution,
requestWorkingState: workingState,
});
expect(directContext?.execution.toolName).toBe("removeBackground");
expect(directContext?.targetImageUrl).toBe(latestResultUrl);
});
test("only runs direct image edit batches for explicit safe batch requests", () => {
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "remove_watermark",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "把这几张都去水印",
})
).toBe(true);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "remove_background",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "每张分别抠图",
})
).toBe(true);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "remove_watermark",
sourceImageUrls: ["https://cdn.example.com/a.png"],
userText: "全部去水印",
})
).toBe(false);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "generic_edit",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "把这几张都改成黄昏",
})
).toBe(false);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "angle_variant",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "这两张都生成右侧面视角",
})
).toBe(true);
expect(hasExplicitDirectAgentBatchImageEditIntent("只处理第一张")).toBe(false);
expect(isDirectAgentBatchImageEditCapability("box_cutout")).toBe(false);
});
test("routes angle variant requests through the dedicated direct image tool", () => {
const result = resolveDirectAgentToolExecution({
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "angle_variant",
targetSource: "explicit_selection",
preferredCapability: "angle_variant",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [
{
name: "resolveTargetImage",
args: { source: "selected_canvas" },
},
{
name: "editTargetImage",
reason: "planner only provided the generic edit tool",
},
],
availableTools: ["resolveTargetImage", "editTargetImage", "angleEditImage"],
});
expect(result).toMatchObject({
toolName: "angleEditImage",
capability: "angle_variant",
sourcePreferences: ["selected_canvas"],
});
expect(getDirectAgentToolLabel("angle_variant")).toBe("Agent工具:多视角");
expect(getDirectAgentToolSuccessMessage("angle_variant")).toBe(
"已完成多视角生成,结果如下:"
);
});
test("routes local edit requests through the dedicated direct image tool", () => {
const result = resolveDirectAgentToolExecution({
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "local_edit",
targetSource: "explicit_selection",
preferredCapability: "local_edit",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [
{
name: "resolveTargetImage",
args: { source: "selected_canvas" },
},
{
name: "editTargetImage",
reason: "planner only provided the generic edit tool",
},
],
availableTools: ["resolveTargetImage", "editTargetImage", "localEditImage"],
});
expect(result).toMatchObject({
toolName: "localEditImage",
capability: "local_edit",
sourcePreferences: ["selected_canvas"],
});
expect(getDirectAgentToolLabel("local_edit")).toBe("Agent工具:局部编辑");
expect(getDirectAgentToolSuccessMessage("local_edit")).toBe(
"已完成局部编辑,结果如下:"
);
expect(isDirectAgentBatchImageEditCapability("local_edit")).toBe(false);
});
test("resolves direct local edit regions from selected canvas annotations", () => {
const context: CanvasAgentContext = {
version: 1,
source: "canvas",
projectId: "project-1",
selectedNodeIds: ["image-1"],
selectedNodes: [
{
nodeId: "image-1",
type: "image",
url: "https://cdn.example.com/source.png",
x: 0,
y: 0,
isSelected: true,
},
],
candidateNodes: [],
slots: [],
annotations: [
{
id: "annotation-1",
sourceNodeId: "image-1",
type: "crop-box",
text: "把杯子换成蓝色陶瓷杯",
bounds: { x: 0.2, y: 0.3, width: 0.25, height: 0.2 },
color: "#1677ff",
},
{
id: "annotation-other",
sourceNodeId: "image-2",
type: "crop-box",
text: "不应带入",
bounds: { x: 0.1, y: 0.1, width: 0.1, height: 0.1 },
},
],
focusPolicy: "selected",
};
const result = resolveDirectAgentLocalEditRegions({
canvasAgentContext: context,
requestTextContent: "按批注修改",
sourceImageUrl: "https://cdn.example.com/source.png",
targetNodeIds: ["image-1"],
});
expect(result.sourceNodeId).toBe("image-1");
expect(result.regions).toEqual([
{
id: "annotation-1",
color: "#1677ff",
prompt: "把杯子换成蓝色陶瓷杯",
rect: { x: 0.2, y: 0.3, width: 0.25, height: 0.2 },
},
]);
expect(hasDirectAgentLocalEditIntent("按照批注修改这张图")).toBe(true);
expect(hasDirectAgentLocalEditIntent("给这张图添加批注")).toBe(false);
expect(
normalizeDirectAgentLocalEditRect({
x: 0.8,
y: 0.8,
width: 0.4,
height: 0.3,
})
).toEqual({ x: 0.8, y: 0.8, width: 0.2, height: 0.2 });
});
test("infers deterministic angle edit options from canvas agent text", () => {
expect(inferAngleEditOptionsFromText("生成右侧面近景视角")).toMatchObject({
horizontalAngle: 90,
verticalAngle: 0,
zoom: 7,
});
expect(inferAngleEditOptionsFromText("改成背面俯视远景")).toMatchObject({
horizontalAngle: 180,
verticalAngle: 45,
zoom: 3,
});
expect(inferAngleEditOptionsFromText("水平角120度,垂直角-10度,zoom8")).toMatchObject({
horizontalAngle: 120,
verticalAngle: -10,
zoom: 8,
});
});
test("plans watermark removal results as non-destructive canvas revisions", () => {
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",
chatId: "chat-1",
assistantMessageId: "assistant-watermark",
operationLabel: "agent-remove_watermark",
resultType: "image",
resultUrls: ["https://cdn.example.com/watermark-removed.png"],
prompt: "把选中图片上的水印去掉",
requestTextContent: "把选中图片上的水印去掉,结果放右边",
mode: "edit",
context: plannerContext,
targetRefs: [{ nodeId: "image-1" }],
sourceImageUrls: ["https://cdn.example.com/source-watermarked.png"],
});
expect(result).toMatchObject({
operation: "append_revision",
shouldFillSlot: false,
mediaItemIds: ["assistant-watermark:agent-remove_watermark:1"],
plan: {
operation: "append_revision",
anchorNodeId: "image-1",
placement: "right",
preserveOriginal: true,
},
});
expect(result?.mediaItems[0]).toMatchObject({
type: "image",
url: "https://cdn.example.com/watermark-removed.png",
sourceNodeIds: ["image-1"],
metadata: {
sourceImageUrls: ["https://cdn.example.com/source-watermarked.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 image edits 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-batch-edit",
operationLabel: "agent-remove_watermark",
resultType: "image",
resultUrls: [
"https://cdn.example.com/a-clean.png",
"https://cdn.example.com/b-clean.png",
],
prompt: "这两张都去水印",
requestTextContent: "这两张都去水印",
mode: "edit",
context,
targetRefs: [{ nodeId: "image-a" }, { nodeId: "image-b" }],
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
});
expect(result).toMatchObject({
operation: "append_revision",
shouldFillSlot: false,
plan: {
operation: "append_revision",
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 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 text update plans for selected text nodes", () => {
const outcome = buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中文案改成:轻盈强吸力,家务更省心",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "update_text",
targetNodeIds: ["text-1"],
textContent: "轻盈强吸力,家务更省心",
preserveOriginal: true,
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
expect(outcome?.reply).toContain("文本");
});
test("builds deterministic link plans for selected canvas nodes", () => {
const outcome = buildCanvasAgentExplicitOperationPlan({
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "把选中的两个节点连成参考关系",
});
expect(outcome?.operationPlan).toMatchObject({
operation: "link_nodes",
sourceNodeIds: ["image-1", "text-1"],
edgeType: "reference",
preserveOriginal: true,
metadata: {
messageId: "message-1",
chatId: "chat-1",
planner: "deterministic",
},
});
expect(outcome?.reply).toContain("参考连线");
});
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("视频槽位");
const imageOutcome = buildCanvasAgentExplicitOperationPlan({
context,
projectId: plannerContext.projectId || "",
messageId: "message-2",
chatId: "chat-1",
userText: "创建一个图片槽位",
});
expect(imageOutcome?.operationPlan).toMatchObject({
operation: "create_agent_slot",
targetPosition: { x: 340, y: 240 },
slot: {
slotType: "image",
label: "图片槽位",
aspectRatio: "1:1",
fillPolicy: "replace-empty",
},
metadata: {
messageId: "message-2",
chatId: "chat-1",
planner: "deterministic",
},
});
expect(imageOutcome?.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();
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"],
});
const removeBackgroundOutcome = normalizeCanvasAgentModelPlannerDecision({
decision: {
action: "none",
},
context: plannerContext,
projectId: plannerContext.projectId || "",
messageId: "message-1",
chatId: "chat-1",
userText: "删除背景,结果放右边",
});
expect(removeBackgroundOutcome).toEqual({
kind: "none",
reason: undefined,
});
});
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);
const settingsOverride = getCanvasAgentFeatureFlags(
{
CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED: "disabled",
CANVAS_AGENT_WRITEBACK_MODE: "legacy_canvas_media",
CANVAS_AGENT_SLOT_ENABLED: "0",
},
{
[CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: true,
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: "operation_authoritative",
[CANVAS_AGENT_SLOT_ENABLED_KEY]: true,
[CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: false,
[CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: "off",
}
);
expect(settingsOverride).toMatchObject({
operationProtocolEnabled: true,
writebackMode: "operation_authoritative",
slotEnabled: true,
viewOperationEnabled: false,
annotationEditEnabled: false,
});
expect(canApplyCanvasAgentMutations(settingsOverride)).toBe(true);
expect(canvasAgentFeatureFlagsToSettings(settingsOverride)).toEqual({
operationProtocolEnabled: true,
writebackMode: "operation_authoritative",
slotEnabled: true,
viewOperationEnabled: false,
annotationEditEnabled: false,
});
expect(
canvasAgentFeatureSettingsToSystemSettings({
operationProtocolEnabled: false,
writebackMode: "operation_shadow",
slotEnabled: true,
viewOperationEnabled: true,
annotationEditEnabled: false,
})
).toEqual({
[CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: false,
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: "operation_shadow",
[CANVAS_AGENT_SLOT_ENABLED_KEY]: true,
[CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: true,
[CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: false,
});
expect(
normalizeCanvasAgentFeatureSettings({
operationProtocolEnabled: "off",
writebackMode: "invalid-mode",
slotEnabled: 0,
viewOperationEnabled: "on",
annotationEditEnabled: false,
})
).toEqual({
operationProtocolEnabled: false,
writebackMode: "operation_authoritative",
slotEnabled: false,
viewOperationEnabled: true,
annotationEditEnabled: false,
});
const invalidSettingsFallback = getCanvasAgentFeatureFlags(
{ CANVAS_AGENT_WRITEBACK_MODE: "operation_shadow" },
{
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: "not-a-mode",
}
);
expect(invalidSettingsFallback.writebackMode).toBe("operation_shadow");
});
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);
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-2", "media-3"],
imageUrls: ["https://cdn.example.com/b.png", "https://cdn.example.com/c.png"],
},
handledCanvasMediaItemIds: handled,
})
).toMatchObject({
mediaItemIds: ["media-3"],
imageUrls: ["https://cdn.example.com/c.png"],
videoUrls: [],
});
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("filters handled items out of partially applied canvasMedia batches", () => {
const handled = new Set(["media-2"]);
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-1", "media-2", "media-3"],
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
"https://cdn.example.com/c.png",
],
prompt: "keep prompt",
},
handledCanvasMediaItemIds: handled,
})
).toMatchObject({
mediaItemIds: ["media-1", "media-3"],
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/c.png",
],
videoUrls: [],
prompt: "keep prompt",
});
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-2"],
imageUrls: ["https://cdn.example.com/b.png"],
},
handledCanvasMediaItemIds: handled,
})
).toMatchObject({
mediaItemIds: [],
imageUrls: [],
videoUrls: [],
});
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-image", "media-video"],
imageUrls: ["https://cdn.example.com/image.png"],
videoUrls: ["https://cdn.example.com/video.mp4"],
},
handledCanvasMediaItemIds: new Set(["media-image"]),
})
).toMatchObject({
mediaItemIds: ["media-video"],
imageUrls: [],
videoUrls: ["https://cdn.example.com/video.mp4"],
});
expect(
filterCanvasMediaPayloadForUnhandledItems({
payload: {
mediaItemIds: ["media-1", "media-2"],
imageUrls: ["https://cdn.example.com/a.png"],
},
handledCanvasMediaItemIds: handled,
})
).toMatchObject({
mediaItemIds: ["media-1", "media-2"],
imageUrls: ["https://cdn.example.com/a.png"],
});
});
test("keeps canvasMedia ids aligned with normalized batch media urls", () => {
expect(
normalizeCanvasMediaStreamPayload({
imageUrls: [
"not-a-url",
"https://cdn.example.com/a.png",
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
videoUrls: ["https://cdn.example.com/c.mp4"],
mediaItemIds: [
"media-invalid",
"media-a",
"media-a-duplicate",
"media-b",
"media-c",
],
handledCanvasMediaItemIds: [" handled-1 ", ""],
})
).toEqual({
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
videoUrls: ["https://cdn.example.com/c.mp4"],
mediaItemIds: ["media-a", "media-b", "media-c"],
handledCanvasMediaItemIds: ["handled-1"],
});
expect(
normalizeCanvasMediaStreamPayload({
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
mediaItemIds: ["only-one-id"],
})
).toEqual({
imageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
videoUrls: [],
mediaItemIds: [],
handledCanvasMediaItemIds: [],
});
});
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("consumes pending canvas agent UI requests when no event detail is provided", () => {
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(consumeCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest);
expect(consumeCanvasAgentAnnotationEditRequest(targetWindow)).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: "参考 ![a](https://cdn.example.com/markdown-a.webp) 和 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(
[
"![ref](https://cdn.example.com/reference.png?token=secret)",
"![result](https://cdn.example.com/result.png?token=secret)",
].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",
});
});
});