511 lines
16 KiB
TypeScript
511 lines
16 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import {
|
||
collectMessageImageUrls,
|
||
extractAssistantImageUrls,
|
||
extractVideoUrls,
|
||
normalizeMediaUrl,
|
||
stripMediaMarkup,
|
||
} from "@/components/canvas/chat-panel/canvas-chat-media";
|
||
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 { ChatMessage } from "@/lib/types";
|
||
|
||
test.describe("canvas chat pure helpers", () => {
|
||
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",
|
||
});
|
||
});
|
||
});
|