997 lines
30 KiB
TypeScript
997 lines
30 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import {
|
||
collectMessageImageUrls,
|
||
extractAssistantImageUrls,
|
||
extractVideoUrls,
|
||
normalizeMediaUrl,
|
||
stripMediaMarkup,
|
||
} from "@/components/canvas/chat-panel/canvas-chat-media";
|
||
import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context";
|
||
import {
|
||
buildPendingCanvasOperation,
|
||
extractPendingCanvasOperationFromWorkingState,
|
||
isCanvasOperationCancelText,
|
||
isCanvasOperationConfirmText,
|
||
withConfirmedCanvasOperationPlan,
|
||
} from "@/lib/canvas/agent-confirmation";
|
||
import {
|
||
normalizeCanvasAgentModelPlannerDecision,
|
||
shouldConsiderCanvasAgentModelPlanner,
|
||
} from "@/lib/canvas/agent-model-planner";
|
||
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
|
||
import { useCanvasStore } from "@/store/canvas";
|
||
import {
|
||
buildDetailResultMarkdown,
|
||
buildDetailReviewAppendMarkdown,
|
||
buildDetailReviewPlans,
|
||
} from "@/components/canvas/chat-panel/canvas-detail-markdown";
|
||
import {
|
||
DETAIL_BUNDLE_SLOT_INDEXES,
|
||
DETAIL_GENERATION_MODE_OPTIONS,
|
||
DETAIL_MODEL_SLOT_INDEX,
|
||
DETAIL_PRODUCT_SLOT_INDEXES,
|
||
DETAIL_SLOT_CONFIGS,
|
||
DETAIL_TARGET_HEIGHT,
|
||
DETAIL_TARGET_WIDTH,
|
||
extractDetailFrameCount,
|
||
extractFirstNumber,
|
||
extractFontWeight,
|
||
normalizeDetailGenerationMode,
|
||
sanitizeDetailImageModelPrompt,
|
||
splitDetailKeywords,
|
||
} from "@/components/canvas/chat-panel/canvas-detail-config";
|
||
import {
|
||
createDetailArchiveSnapshot,
|
||
patchDetailArchiveList,
|
||
prependDetailArchive,
|
||
replaceSavedDetailArchive,
|
||
sanitizeDetailArchiveList,
|
||
} from "@/components/canvas/chat-panel/canvas-detail-archive";
|
||
import {
|
||
applyDetailSlotAttachment,
|
||
fillDetailSlotAttachments,
|
||
getDetailSlotTargetIndexes,
|
||
} from "@/components/canvas/chat-panel/canvas-detail-slots";
|
||
import {
|
||
buildCanvasUploadedImageAttachment,
|
||
CANVAS_IMAGE_UPLOAD_MAX_BYTES,
|
||
getCanvasUploadImageTitle,
|
||
normalizeUploadedAssetId,
|
||
validateCanvasImageUploadFile,
|
||
} from "@/components/canvas/chat-panel/canvas-upload-helpers";
|
||
import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session";
|
||
import type { CanvasAgentContext } from "@/types/canvas/agent-protocol";
|
||
import type { ChatMessage } from "@/lib/types";
|
||
|
||
test.describe("canvas chat pure helpers", () => {
|
||
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);
|
||
});
|
||
|
||
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("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"],
|
||
});
|
||
});
|
||
|
||
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("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("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("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 },
|
||
]);
|
||
});
|
||
|
||
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("normalizes selected text-only canvas agent context", () => {
|
||
const context = normalizeCanvasAgentContext({
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["text-1"],
|
||
canvasImageNodes: [],
|
||
canvasAgentContext: {
|
||
version: 1,
|
||
source: "canvas",
|
||
projectId: "11111111-1111-4111-8111-111111111111",
|
||
selectedNodeIds: ["text-1"],
|
||
selectedNodes: [
|
||
{
|
||
nodeId: "text-1",
|
||
type: "text",
|
||
title: "旧标题",
|
||
x: 40,
|
||
y: 80,
|
||
width: 240,
|
||
height: 80,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
candidateNodes: [],
|
||
slots: [],
|
||
annotations: [],
|
||
viewport: { x: 0, y: 0, zoom: 1 },
|
||
focusPolicy: "selected",
|
||
},
|
||
});
|
||
|
||
expect(context?.selectedNodeIds).toEqual(["text-1"]);
|
||
expect(context?.selectedNodes[0]).toMatchObject({
|
||
nodeId: "text-1",
|
||
type: "text",
|
||
title: "旧标题",
|
||
});
|
||
expect(context?.focusPolicy).toBe("selected");
|
||
});
|
||
|
||
test("builds canvas agent context from legacy canvas image nodes", () => {
|
||
const context = normalizeCanvasAgentContext({
|
||
source: "canvas",
|
||
selectedNodeIds: ["image-1"],
|
||
canvasImageNodes: [
|
||
{
|
||
nodeId: "image-1",
|
||
url: "https://cdn.example.com/image.png",
|
||
title: "主图",
|
||
x: 10,
|
||
y: 20,
|
||
width: 300,
|
||
height: 300,
|
||
isSelected: true,
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(context?.selectedNodes).toHaveLength(1);
|
||
expect(context?.candidateNodes[0]).toMatchObject({
|
||
nodeId: "image-1",
|
||
type: "image",
|
||
url: "https://cdn.example.com/image.png",
|
||
isSelected: true,
|
||
});
|
||
});
|
||
|
||
test("normalizes media urls without query strings or trailing slashes", () => {
|
||
expect(normalizeMediaUrl(" HTTPS://EXAMPLE.COM/assets/image.png?sig=secret#preview ")).toBe(
|
||
"https://example.com/assets/image.png"
|
||
);
|
||
expect(normalizeMediaUrl("not-a-url?cache=1#hash/")).toBe("not-a-url");
|
||
});
|
||
|
||
test("collects message image urls from parts, attachments, and legacy content", () => {
|
||
const message = {
|
||
id: "message-1",
|
||
role: "user",
|
||
parts: [
|
||
{
|
||
type: "file",
|
||
url: "https://cdn.example.com/upload-a.png?x=1",
|
||
mediaType: "image/png",
|
||
},
|
||
{
|
||
type: "text",
|
||
text: "参考  和 https://cdn.example.com/plain-a.jpg?token=1",
|
||
},
|
||
],
|
||
attachments: [
|
||
{
|
||
name: "attachment",
|
||
url: "https://cdn.example.com/attachment-a.jpeg?download=1",
|
||
contentType: "image/jpeg",
|
||
},
|
||
],
|
||
content: "legacy https://cdn.example.com/legacy-a.png",
|
||
} as unknown as ChatMessage;
|
||
|
||
expect(collectMessageImageUrls(message)).toEqual([
|
||
"https://cdn.example.com/upload-a.png?x=1",
|
||
"https://cdn.example.com/markdown-a.webp",
|
||
"https://cdn.example.com/plain-a.jpg?token=1",
|
||
"https://cdn.example.com/attachment-a.jpeg?download=1",
|
||
"https://cdn.example.com/legacy-a.png",
|
||
]);
|
||
});
|
||
|
||
test("filters assistant result images that match nearest user references", () => {
|
||
const referenceKeys = new Set(["https://cdn.example.com/reference.png"]);
|
||
const urls = extractAssistantImageUrls(
|
||
[
|
||
"",
|
||
"",
|
||
].join("\n"),
|
||
referenceKeys
|
||
);
|
||
|
||
expect(urls).toEqual(["https://cdn.example.com/result.png?token=secret"]);
|
||
});
|
||
|
||
test("extracts and strips video markup without removing surrounding text", () => {
|
||
const text = [
|
||
"生成完成",
|
||
'<video src="https://cdn.example.com/video-a.mp4?sig=1"></video>',
|
||
"[下载视频](https://cdn.example.com/video-b.mp4)",
|
||
"继续优化",
|
||
].join("\n");
|
||
|
||
expect(extractVideoUrls(text)).toEqual([
|
||
"https://cdn.example.com/video-a.mp4?sig=1",
|
||
"https://cdn.example.com/video-b.mp4",
|
||
]);
|
||
expect(stripMediaMarkup(text)).toBe("生成完成\n\n\n继续优化");
|
||
});
|
||
|
||
test("builds detail review plans with clamped count and localized fallbacks", () => {
|
||
const plans = buildDetailReviewPlans({
|
||
language: "zh",
|
||
count: 9,
|
||
summary: " 已验证卖点 ",
|
||
brief: {
|
||
productSpu: "",
|
||
demandType: "",
|
||
productName: "无线吸尘器",
|
||
targetAudience: "养宠家庭",
|
||
mainUser: "",
|
||
usageScenario: "客厅清洁",
|
||
triggerMoment: "",
|
||
coreNeed: "快速清理毛发",
|
||
riskConcern: "吸力不足",
|
||
expectedResult: "地面干净",
|
||
coreSellingPoint: "强吸力,轻量,低噪音",
|
||
positioningStatement: "",
|
||
userVoice: "",
|
||
competitorReference: "",
|
||
priceBand: "",
|
||
brandTone: "",
|
||
bannedWords: "",
|
||
},
|
||
});
|
||
|
||
expect(plans).toHaveLength(5);
|
||
expect(plans[0]).toMatchObject({
|
||
index: 1,
|
||
title: "真实需求反馈1",
|
||
});
|
||
expect(plans[0]?.reviewText).toContain("养宠家庭");
|
||
expect(plans[0]?.reviewText).toContain("已验证卖点");
|
||
expect(plans[0]?.imagePrompt).toContain("无线吸尘器");
|
||
});
|
||
|
||
test("keeps detail slot indexes and generation mode options stable", () => {
|
||
expect(DETAIL_TARGET_WIDTH).toBe(790);
|
||
expect(DETAIL_TARGET_HEIGHT).toBe(1300);
|
||
expect(DETAIL_SLOT_CONFIGS.map((slot) => slot.role)).toEqual([
|
||
"product",
|
||
"product",
|
||
"product",
|
||
"product",
|
||
"product",
|
||
"model",
|
||
"bundle",
|
||
"bundle",
|
||
"bundle",
|
||
]);
|
||
expect(DETAIL_PRODUCT_SLOT_INDEXES).toEqual([0, 1, 2, 3, 4]);
|
||
expect(DETAIL_MODEL_SLOT_INDEX).toBe(5);
|
||
expect(DETAIL_BUNDLE_SLOT_INDEXES).toEqual([6, 7, 8]);
|
||
expect(DETAIL_GENERATION_MODE_OPTIONS.map((option) => option.value)).toEqual([
|
||
"classic",
|
||
"agent_layout",
|
||
]);
|
||
});
|
||
|
||
test("normalizes detail config parser helpers defensively", () => {
|
||
expect(normalizeDetailGenerationMode("agent_layout")).toBe("agent_layout");
|
||
expect(normalizeDetailGenerationMode("unknown")).toBe("classic");
|
||
expect(extractDetailFrameCount("需要 23 张详情页")).toBe(20);
|
||
expect(extractDetailFrameCount("需要 2 屏")).toBe(5);
|
||
expect(extractDetailFrameCount("没有明确数量")).toBe(15);
|
||
expect(extractFirstNumber("标题 64px / 700", 32)).toBe(64);
|
||
expect(extractFirstNumber("无数字", 32)).toBe(32);
|
||
expect(extractFontWeight("主标题 64px / 700 / 行高1.15", "600")).toBe("700");
|
||
expect(extractFontWeight("weight: 500", "600")).toBe("500");
|
||
expect(extractFontWeight("无权重", "600")).toBe("600");
|
||
expect(splitDetailKeywords("强吸力,轻量; 低噪音 | 易清洁", 3)).toEqual([
|
||
"强吸力",
|
||
"轻量",
|
||
"低噪音",
|
||
]);
|
||
});
|
||
|
||
test("sanitizes detail image model prompts away from pixel-locked layout instructions", () => {
|
||
const sanitized = sanitizeDetailImageModelPrompt(
|
||
[
|
||
"字体规范:主标题 64px / 700 / 行高1.15",
|
||
"输出为竖版电商详情图,尺寸固定 790x1300 像素。",
|
||
"安全区 y=64~360,左右边距56px,模块间距24px。",
|
||
"商业摄影质感,留出清晰文案排版区,不要水印。",
|
||
"突出产品真实材质与使用场景。",
|
||
].join("\n")
|
||
);
|
||
|
||
expect(sanitized).not.toContain("790x1300");
|
||
expect(sanitized).not.toContain("64px");
|
||
expect(sanitized).not.toContain("56px");
|
||
expect(sanitized).toContain("输出为竖版电商详情图。");
|
||
expect(sanitized).toContain("顶部标题安全区");
|
||
expect(sanitized).toContain("左右留白充足");
|
||
expect(sanitized).toContain("突出产品真实材质与使用场景。");
|
||
});
|
||
|
||
test("builds stable detail result and appended review markdown", () => {
|
||
const markdown = buildDetailResultMarkdown({
|
||
generationMode: "agent_layout",
|
||
summary: "详情页生成成功",
|
||
images: [
|
||
{
|
||
originalUrl: "https://cdn.example.com/detail-1.png",
|
||
thumbnailUrl: "https://cdn.example.com/detail-1-thumb.webp",
|
||
},
|
||
],
|
||
prompts: ["商业摄影质感"],
|
||
skillFlow: {
|
||
name: "详情页生成",
|
||
version: "1.0",
|
||
steps: ["拆解卖点", "生成分镜"],
|
||
},
|
||
qualityChecks: {
|
||
passed: true,
|
||
},
|
||
screens: [
|
||
{
|
||
screenType: "A",
|
||
title: "核心卖点",
|
||
useModel: true,
|
||
fabe: {
|
||
b: "收益",
|
||
},
|
||
},
|
||
],
|
||
reviewGroups: [
|
||
{
|
||
reviewText: "真实好用",
|
||
images: [
|
||
{
|
||
originalUrl: "https://cdn.example.com/review-1.png",
|
||
thumbnailUrl: "https://cdn.example.com/review-1-thumb.webp",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(markdown).toContain("生成路线:AGENT排版(画面+可编辑文字层)");
|
||
expect(markdown).toContain("质检结果:通过");
|
||
expect(markdown).toContain("![thumb|||https://cdn.example.com/detail-1.png|||2K]");
|
||
expect(markdown).toContain("评价 1:真实好用");
|
||
|
||
expect(
|
||
buildDetailReviewAppendMarkdown({
|
||
failedCount: 1,
|
||
groups: [
|
||
{
|
||
reviewText: "追加评价",
|
||
images: [
|
||
{
|
||
originalUrl: "https://cdn.example.com/review-2.png",
|
||
thumbnailUrl: "https://cdn.example.com/review-2-thumb.webp",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
})
|
||
).toContain("失败 1 条,已跳过。");
|
||
});
|
||
|
||
test("sanitizes and sorts detail archives from persisted payloads", () => {
|
||
const archives = sanitizeDetailArchiveList([
|
||
{
|
||
id: "older",
|
||
title: "",
|
||
prompt: " old prompt ",
|
||
generationMode: "invalid",
|
||
brief: {
|
||
productName: "旧产品",
|
||
targetAudience: "人群",
|
||
},
|
||
slots: [
|
||
{
|
||
url: "https://cdn.example.com/product.png",
|
||
name: "",
|
||
contentType: "",
|
||
},
|
||
],
|
||
createdAt: "2026-01-01T00:00:00.000Z",
|
||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||
uiSurfaceLatestReview: {
|
||
screenIndex: 2,
|
||
action: "unsafe",
|
||
passed: true,
|
||
issues: [" issue-a ", "", "issue-b"],
|
||
},
|
||
},
|
||
{
|
||
id: "newer",
|
||
title: "新项目",
|
||
prompt: "new prompt",
|
||
generationMode: "agent_layout",
|
||
brief: {
|
||
coreNeed: "核心需求",
|
||
},
|
||
slots: [],
|
||
createdAt: "2026-01-03T00:00:00.000Z",
|
||
},
|
||
{
|
||
id: "",
|
||
createdAt: "2026-01-04T00:00:00.000Z",
|
||
},
|
||
]);
|
||
|
||
expect(archives.map((archive) => archive.id)).toEqual(["newer", "older"]);
|
||
expect(archives[0]?.generationMode).toBe("agent_layout");
|
||
expect(archives[1]).toMatchObject({
|
||
title: "未命名详情项目",
|
||
generationMode: "classic",
|
||
brief: {
|
||
productName: "旧产品",
|
||
coreNeed: "",
|
||
},
|
||
});
|
||
expect(archives[1]?.slots[0]).toMatchObject({
|
||
url: "https://cdn.example.com/product.png",
|
||
name: "产品图1",
|
||
contentType: "image/jpeg",
|
||
});
|
||
expect(archives[1]?.uiSurfaceLatestReview).toMatchObject({
|
||
screenIndex: 2,
|
||
action: undefined,
|
||
passed: true,
|
||
issues: ["issue-a", "issue-b"],
|
||
});
|
||
});
|
||
|
||
test("creates and updates detail archive lists with stable limits", () => {
|
||
const archive = createDetailArchiveSnapshot({
|
||
archiveId: "draft",
|
||
createdAt: 1000,
|
||
prompt: " 第一行\n第二行 ",
|
||
generationMode: "classic",
|
||
summary: "摘要",
|
||
imageCount: 2,
|
||
coverUrl: "https://cdn.example.com/cover.png",
|
||
brief: {
|
||
productSpu: " SPU-001 ",
|
||
demandType: "",
|
||
productName: "产品名",
|
||
targetAudience: "",
|
||
mainUser: "",
|
||
usageScenario: " 客厅清洁 ",
|
||
triggerMoment: "",
|
||
coreNeed: "",
|
||
riskConcern: "",
|
||
expectedResult: "",
|
||
coreSellingPoint: "",
|
||
positioningStatement: "",
|
||
userVoice: "",
|
||
competitorReference: "",
|
||
priceBand: "",
|
||
brandTone: "",
|
||
bannedWords: "",
|
||
},
|
||
slots: [
|
||
{
|
||
name: "产品",
|
||
url: "https://cdn.example.com/product.png",
|
||
contentType: "image/png",
|
||
},
|
||
null,
|
||
],
|
||
});
|
||
|
||
expect(archive).toMatchObject({
|
||
id: "draft",
|
||
title: "SPU-001+客厅清洁",
|
||
prompt: "第一行 第二行",
|
||
createdAt: 1000,
|
||
updatedAt: 1000,
|
||
summary: "摘要",
|
||
imageCount: 2,
|
||
coverUrl: "https://cdn.example.com/cover.png",
|
||
});
|
||
expect(archive.slots[0]).toMatchObject({
|
||
name: "产品",
|
||
url: "https://cdn.example.com/product.png",
|
||
contentType: "image/png",
|
||
});
|
||
|
||
const patched = patchDetailArchiveList([archive], "draft", { summary: "新摘要" }, 2000);
|
||
expect(patched[0]).toMatchObject({ summary: "新摘要", updatedAt: 2000 });
|
||
|
||
const saved = { ...archive, id: "saved", updatedAt: 3000 };
|
||
expect(replaceSavedDetailArchive([archive], "draft", saved).map((item) => item.id)).toEqual([
|
||
"saved",
|
||
]);
|
||
|
||
const manyArchives = Array.from({ length: 35 }, (_, index) => ({
|
||
...archive,
|
||
id: `archive-${index}`,
|
||
createdAt: index,
|
||
updatedAt: index,
|
||
}));
|
||
expect(prependDetailArchive(manyArchives, saved)).toHaveLength(30);
|
||
});
|
||
|
||
test("fills detail slots by current role group without crossing groups", () => {
|
||
expect(getDetailSlotTargetIndexes(0)).toEqual([0, 1, 2, 3, 4]);
|
||
expect(getDetailSlotTargetIndexes(3)).toEqual([3, 4]);
|
||
expect(getDetailSlotTargetIndexes(5)).toEqual([5]);
|
||
expect(getDetailSlotTargetIndexes(7)).toEqual([7, 8]);
|
||
expect(getDetailSlotTargetIndexes(-1)).toEqual([]);
|
||
expect(getDetailSlotTargetIndexes(99)).toEqual([]);
|
||
|
||
const current = Array.from({ length: DETAIL_SLOT_CONFIGS.length }, () => null);
|
||
const productA: PickedObjectAttachment = {
|
||
name: "product-a",
|
||
url: "https://cdn.example.com/product-a.png",
|
||
contentType: "image/png",
|
||
};
|
||
const productB: PickedObjectAttachment = {
|
||
name: "product-b",
|
||
url: "https://cdn.example.com/product-b.png",
|
||
contentType: "image/png",
|
||
};
|
||
|
||
const updatedSingle = applyDetailSlotAttachment(current, 5, productA);
|
||
expect(updatedSingle[5]).toBe(productA);
|
||
expect(current[5]).toBeNull();
|
||
|
||
const filled = fillDetailSlotAttachments(current, getDetailSlotTargetIndexes(3), [
|
||
productA,
|
||
productB,
|
||
]);
|
||
expect(filled.filledCount).toBe(2);
|
||
expect(filled.slots[3]).toBe(productA);
|
||
expect(filled.slots[4]).toBe(productB);
|
||
expect(filled.slots[5]).toBeNull();
|
||
});
|
||
|
||
test("validates and normalizes canvas image upload helper payloads", () => {
|
||
expect(validateCanvasImageUploadFile(null)).toEqual({
|
||
ok: false,
|
||
message: "请选择图片文件",
|
||
});
|
||
expect(
|
||
validateCanvasImageUploadFile({
|
||
name: "demo.txt",
|
||
type: "text/plain",
|
||
size: 10,
|
||
})
|
||
).toEqual({
|
||
ok: false,
|
||
message: "请上传图片文件",
|
||
});
|
||
expect(
|
||
validateCanvasImageUploadFile({
|
||
name: "huge.png",
|
||
type: "image/png",
|
||
size: CANVAS_IMAGE_UPLOAD_MAX_BYTES + 1,
|
||
})
|
||
).toEqual({
|
||
ok: false,
|
||
message: "图片大小不能超过 50MB",
|
||
});
|
||
expect(
|
||
validateCanvasImageUploadFile({
|
||
name: "valid.webp",
|
||
type: "image/webp",
|
||
size: CANVAS_IMAGE_UPLOAD_MAX_BYTES,
|
||
})
|
||
).toEqual({ ok: true });
|
||
|
||
expect(getCanvasUploadImageTitle(" product.hero.final.png ")).toBe("product.hero.final");
|
||
expect(getCanvasUploadImageTitle(".png")).toBe("图片");
|
||
expect(normalizeUploadedAssetId(" asset-123 ")).toBe("asset-123");
|
||
expect(normalizeUploadedAssetId(" ")).toBeUndefined();
|
||
|
||
expect(
|
||
buildCanvasUploadedImageAttachment({
|
||
file: {
|
||
name: "photo.png",
|
||
type: "",
|
||
size: 100,
|
||
},
|
||
imageUrl: "https://cdn.example.com/photo.png",
|
||
assetId: "asset-1",
|
||
})
|
||
).toEqual({
|
||
name: "photo.png",
|
||
url: "https://cdn.example.com/photo.png",
|
||
contentType: "image/jpeg",
|
||
assetId: "asset-1",
|
||
});
|
||
});
|
||
});
|