이미지 파일 업로드 기능
This commit is contained in:
@@ -0,0 +1,725 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import JSZip from "jszip";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// /api/export 라우트 TDD:
|
||||
// - blocks + projectConfig 를 받아 HTML/CSS/JS 가 포함된 ZIP 을 반환해야 한다.
|
||||
// - index.html 의 <title> 은 projectConfig.title 을 사용해야 한다.
|
||||
// - ZIP 안에 builder.css 와 main.js 파일이 포함되어야 한다.
|
||||
|
||||
describe("/api/export", () => {
|
||||
it("POST /api/export 로 블록과 프로젝트 설정을 전송하면 기본 정적 ZIP 파일을 반환해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_test",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "ZIP 내보내기 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "내보내기 테스트 페이지",
|
||||
slug: "export-test",
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#ffffff",
|
||||
bodyBgColorHex: "#111111",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/zip");
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
const cssEntry = zip.file("builder.css");
|
||||
const jsEntry = zip.file("main.js");
|
||||
|
||||
expect(indexEntry).toBeTruthy();
|
||||
expect(cssEntry).toBeTruthy();
|
||||
expect(jsEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<title>내보내기 테스트 페이지</title>");
|
||||
expect(html).toContain("ZIP 내보내기 테스트");
|
||||
// 캔버스 래퍼 배경색
|
||||
expect(html).toContain("background-color:#ffffff");
|
||||
// body 스타일: 배경색 + margin/padding 0
|
||||
expect(html).toContain('style="background-color:#111111;margin:0;padding:0;"');
|
||||
});
|
||||
|
||||
it("projectConfig.headHtml 과 trackingScript 는 index.html 의 head/body 에 그대로 포함되어야 한다", async () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "헤드/트래킹 테스트",
|
||||
slug: "head-tracking-test",
|
||||
canvasPreset: "full",
|
||||
headHtml: '<meta name="robots" content="noindex" />',
|
||||
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\/script>',
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
// head 커스텀 HTML
|
||||
expect(html).toContain('<meta name="robots" content="noindex" />');
|
||||
// body 하단 추적 스크립트
|
||||
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\/script>');
|
||||
});
|
||||
|
||||
it("폼 블록과 컨트롤러 필드 블록들은 정적 HTML에서도 폼 요소로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fields: [],
|
||||
fieldIds: ["input_name", "select_plan"],
|
||||
submitButtonId: null,
|
||||
formWidthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "input_name",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
required: true,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "select_plan",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [
|
||||
{ label: "Basic", value: "basic" },
|
||||
{ label: "Pro", value: "pro" },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "폼 내보내기 테스트",
|
||||
slug: "form-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
|
||||
// form 요소와 기본 input/select 가 포함되어야 한다.
|
||||
expect(html).toContain("<form");
|
||||
expect(html).toContain("name=\"name\"");
|
||||
expect(html).toContain("name=\"plan\"");
|
||||
expect(html).toContain("<select");
|
||||
});
|
||||
|
||||
it("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
|
||||
const imageId = `test-image-${Date.now()}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
await fs.writeFile(path.join(uploadDir, imageId), Buffer.from("dummy-image"));
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_image",
|
||||
type: "image",
|
||||
props: {
|
||||
src: `/api/image/${imageId}`,
|
||||
alt: "테스트 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "이미지 내보내기 테스트",
|
||||
slug: "image-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const imageEntry = zip.file(`images/${imageId}`);
|
||||
expect(imageEntry).toBeTruthy();
|
||||
|
||||
const imageBytes = await imageEntry!.async("nodebuffer");
|
||||
expect(imageBytes.length).toBeGreaterThan(0);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain(`src="./images/${imageId}"`);
|
||||
expect(html).not.toContain(`/api/image/${imageId}`);
|
||||
});
|
||||
|
||||
it("divider 블록은 정적 HTML에서 구분선 요소로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "divider_1",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "medium",
|
||||
widthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "divider 테스트",
|
||||
slug: "divider-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<hr");
|
||||
expect(html).toContain("pb-divider");
|
||||
});
|
||||
|
||||
it("list 블록은 정적 HTML에서 리스트 요소로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "list_1",
|
||||
type: "list",
|
||||
props: {
|
||||
items: ["Item 1", "Item 2"],
|
||||
ordered: false,
|
||||
align: "left",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "list 테스트",
|
||||
slug: "list-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<ul");
|
||||
expect(html).toContain("<li>Item 1</li>");
|
||||
expect(html).toContain("<li>Item 2</li>");
|
||||
});
|
||||
|
||||
it("formInput 단독 블록은 입력 필드로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "input_standalone",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
required: true,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "formInput 단독 테스트",
|
||||
slug: "form-input-standalone",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<input");
|
||||
expect(html).toContain("name=\"name\"");
|
||||
expect(html).toContain("이름");
|
||||
});
|
||||
|
||||
it("formSelect 단독 블록은 셀렉트 박스로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "select_standalone",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [
|
||||
{ label: "Basic", value: "basic" },
|
||||
{ label: "Pro", value: "pro" },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "formSelect 단독 테스트",
|
||||
slug: "form-select-standalone",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<select");
|
||||
expect(html).toContain("<option value=\"basic\">Basic</option>");
|
||||
expect(html).toContain("<option value=\"pro\">Pro</option>");
|
||||
});
|
||||
|
||||
it("formCheckbox 단독 블록은 체크박스 그룹으로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "checkbox_standalone",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel: "관심사",
|
||||
formFieldName: "interests",
|
||||
options: [
|
||||
{ label: "뉴스", value: "news" },
|
||||
{ label: "업데이트", value: "updates" },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "formCheckbox 단독 테스트",
|
||||
slug: "form-checkbox-standalone",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("type=\"checkbox\"");
|
||||
expect(html).toContain("뉴스");
|
||||
expect(html).toContain("업데이트");
|
||||
});
|
||||
|
||||
it("formRadio 단독 블록은 라디오 그룹으로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "radio_standalone",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [
|
||||
{ label: "Basic", value: "basic" },
|
||||
{ label: "Pro", value: "pro" },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "formRadio 단독 테스트",
|
||||
slug: "form-radio-standalone",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("type=\"radio\"");
|
||||
expect(html).toContain("Basic");
|
||||
expect(html).toContain("Pro");
|
||||
});
|
||||
|
||||
it("text 블록은 pb 텍스트 스타일 클래스를 포함해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "text_styled",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "스타일 테스트",
|
||||
align: "center",
|
||||
size: "base",
|
||||
fontSizeMode: "scale",
|
||||
fontSizeScale: "lg",
|
||||
lineHeightMode: "scale",
|
||||
lineHeightScale: "relaxed",
|
||||
fontWeightMode: "scale",
|
||||
fontWeightScale: "semibold",
|
||||
colorMode: "palette",
|
||||
colorPalette: "strong",
|
||||
underline: true,
|
||||
italic: true,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "텍스트 스타일 테스트",
|
||||
slug: "text-style-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("스타일 테스트");
|
||||
expect(html).toContain("pb-text-center");
|
||||
expect(html).toContain("pb-text-lg");
|
||||
expect(html).toContain("pb-leading-relaxed");
|
||||
expect(html).toContain("pb-font-semibold");
|
||||
expect(html).toContain("pb-text-color-strong");
|
||||
expect(html).toContain("pb-underline");
|
||||
expect(html).toContain("pb-italic");
|
||||
});
|
||||
|
||||
it("버튼 블록은 버튼 스타일 클래스를 포함해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "button_styled",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "클릭",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "md",
|
||||
variant: "solid",
|
||||
colorPalette: "primary",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "버튼 스타일 테스트",
|
||||
slug: "button-style-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("클릭");
|
||||
expect(html).toContain("pb-text-center");
|
||||
expect(html).toContain("pb-btn-base");
|
||||
expect(html).toContain("pb-btn-size-md");
|
||||
expect(html).toContain("pb-btn-variant-solid-primary");
|
||||
});
|
||||
|
||||
it("section 블록은 배경/패딩 토큰을 pb-section 클래스 조합으로 반영해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_1",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "primary",
|
||||
paddingY: "lg",
|
||||
columns: [
|
||||
{
|
||||
id: "sec_1_col_1",
|
||||
span: 12,
|
||||
},
|
||||
],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_1_text",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_1",
|
||||
columnId: "sec_1_col_1",
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "섹션 스타일 테스트",
|
||||
slug: "section-style-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("pb-section");
|
||||
expect(html).toContain("pb-section-bg-primary");
|
||||
expect(html).toContain("pb-section-py-lg");
|
||||
});
|
||||
|
||||
it("builder.css 는 리스트/섹션 유틸리티 클래스를 포함해야 한다", async () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "CSS 유틸리티 테스트",
|
||||
slug: "css-utility-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const cssEntry = zip.file("builder.css");
|
||||
expect(cssEntry).toBeTruthy();
|
||||
|
||||
const css = await cssEntry!.async("string");
|
||||
expect(css).toContain(".pb-section-bg-default");
|
||||
expect(css).toContain(".pb-section-py-md");
|
||||
expect(css).toContain(".pb-list");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,59 @@ test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트'
|
||||
await expect(canvas.getByText("새 텍스트")).toBeVisible();
|
||||
});
|
||||
|
||||
test("선택된 블록이 없을 때 우측 패널에서 프로젝트 설정 패널이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const sidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
await expect(sidebar.getByText("프로젝트 설정")).toBeVisible();
|
||||
await expect(sidebar.getByLabel("프로젝트 제목")).toBeVisible();
|
||||
await expect(sidebar.getByLabel("프로젝트 주소 (slug)")).toBeVisible();
|
||||
await expect(sidebar.getByRole("combobox", { name: "캔버스 너비 프리셋" })).toBeVisible();
|
||||
await expect(sidebar.getByLabel("페이지 배경색 HEX")).toBeVisible();
|
||||
});
|
||||
|
||||
test("프로젝트 설정 패널에서 캔버스 배경색을 변경하면 에디터 캔버스 배경에 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const sidebar = page.getByTestId("properties-sidebar");
|
||||
const inner = page.getByTestId("editor-canvas-inner");
|
||||
|
||||
const beforeBg = await inner.evaluate((el) => {
|
||||
const s = window.getComputedStyle(el as HTMLElement);
|
||||
return s.backgroundColor;
|
||||
});
|
||||
|
||||
const bgHexInput = sidebar.getByLabel("캔버스 배경색 HEX");
|
||||
await bgHexInput.fill("#ff0000");
|
||||
|
||||
const afterBg = await inner.evaluate((el) => {
|
||||
const s = window.getComputedStyle(el as HTMLElement);
|
||||
return s.backgroundColor;
|
||||
});
|
||||
|
||||
expect(afterBg).not.toBe(beforeBg);
|
||||
});
|
||||
|
||||
test("프로젝트 설정 패널에서 캔버스 프리셋을 변경하면 에디터 캔버스 너비가 달라져야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const inner = page.getByTestId("editor-canvas-inner");
|
||||
|
||||
const fullWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
|
||||
|
||||
const presetSelect = page.getByRole("combobox", { name: "캔버스 너비 프리셋" });
|
||||
|
||||
await presetSelect.selectOption("mobile");
|
||||
const mobileWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
|
||||
|
||||
await presetSelect.selectOption("desktop");
|
||||
const desktopWidth = await inner.evaluate((el) => (el as HTMLElement).getBoundingClientRect().width);
|
||||
|
||||
expect(mobileWidth).toBeLessThan(desktopWidth);
|
||||
expect(desktopWidth).toBeLessThanOrEqual(fullWidth);
|
||||
});
|
||||
|
||||
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
|
||||
test.skip(process.env.CI === "true");
|
||||
await page.goto("/editor");
|
||||
@@ -414,6 +467,60 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
|
||||
expect(parseFloat(updatedPadding.paddingBlock)).toBeGreaterThan(parseFloat(initialPadding.paddingBlock));
|
||||
});
|
||||
|
||||
test("이미지 블록 고정 너비와 모서리 둥글기 스타일이 에디터에서 적용되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
await propertiesSidebar.getByLabel("이미지 URL").fill("https://picsum.photos/400");
|
||||
await propertiesSidebar.getByLabel("너비 모드").selectOption("fixed");
|
||||
await propertiesSidebar.getByLabel("고정 너비 (px) 커스텀 (px)").fill("300");
|
||||
|
||||
// 모서리 둥글기를 full 에 해당하는 값(8px)으로 설정한다.
|
||||
await propertiesSidebar.getByLabel("모서리 둥글기 커스텀").fill("8");
|
||||
|
||||
const image = canvas.getByRole("img").first();
|
||||
|
||||
const styles = await image.evaluate((el) => {
|
||||
const s = window.getComputedStyle(el as HTMLImageElement);
|
||||
return {
|
||||
width: s.width,
|
||||
borderRadius: s.borderRadius,
|
||||
};
|
||||
});
|
||||
|
||||
const widthPx = parseFloat(styles.width);
|
||||
expect(widthPx).toBeGreaterThan(200);
|
||||
expect(widthPx).toBeLessThan(360);
|
||||
|
||||
// borderRadius 가 0보다 커야 한다.
|
||||
expect(parseFloat(styles.borderRadius)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("에디터 메뉴에서 페이지 파일로 내보내기 (ZIP)를 클릭하면 ZIP 다운로드가 시작되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 최소 한 개의 블록을 추가해 내보낼 데이터가 있도록 한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
|
||||
// 헤더 메뉴를 연다.
|
||||
await page.getByRole("button", { name: "메뉴 ▼" }).click();
|
||||
|
||||
const exportButton = page.getByRole("button", { name: "페이지 파일로 내보내기 (ZIP)" });
|
||||
await expect(exportButton).toBeVisible();
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
exportButton.click(),
|
||||
]);
|
||||
|
||||
const suggested = download.suggestedFilename();
|
||||
expect(suggested).toMatch(/\.zip$/);
|
||||
});
|
||||
|
||||
test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면 컬럼이 변경되어야 한다", async ({ page }) => {
|
||||
test.skip(true, "섹션 컬럼 드래그 E2E는 불안정하여 임시 스킵");
|
||||
await page.goto("/editor");
|
||||
|
||||
@@ -40,6 +40,21 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
|
||||
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => {
|
||||
// 에디터에서 간단히 진입한 뒤 프리뷰로 이동한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
|
||||
// 프리뷰 헤더와 함께 "에디터로 돌아가기" 링크가 보여야 한다.
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "에디터로 돌아가기" })).toBeVisible();
|
||||
|
||||
// 링크를 클릭하면 다시 에디터로 돌아가야 한다.
|
||||
await page.getByRole("link", { name: "에디터로 돌아가기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
@@ -275,6 +290,137 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
|
||||
await expect(page.getByText("선택 필드")).toBeVisible();
|
||||
});
|
||||
|
||||
test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
await propertiesSidebar.getByLabel("그룹 타이틀 타입").selectOption("image");
|
||||
await propertiesSidebar.getByLabel("그룹 타이틀 이미지 소스").selectOption("upload");
|
||||
await propertiesSidebar.getByLabel("그룹 타이틀", { exact: true }).fill("체크박스 라벨 이미지");
|
||||
|
||||
const fileInput = propertiesSidebar.getByLabel("그룹 타이틀 이미지 파일 업로드");
|
||||
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
|
||||
|
||||
const editorImage = canvas.getByRole("img", { name: "체크박스 라벨 이미지" }).first();
|
||||
await expect(editorImage).toBeVisible();
|
||||
|
||||
const editorSrc = await editorImage.getAttribute("src");
|
||||
expect(editorSrc).not.toBeNull();
|
||||
expect(editorSrc).toMatch(/^\/api\/image\//);
|
||||
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const previewImage = page.getByRole("img", { name: "체크박스 라벨 이미지" }).first();
|
||||
await expect(previewImage).toBeVisible();
|
||||
|
||||
const previewSrc = await previewImage.getAttribute("src");
|
||||
expect(previewSrc).toBe(editorSrc);
|
||||
});
|
||||
|
||||
test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
const firstOptionLabel = propertiesSidebar.getByPlaceholder("라벨").first();
|
||||
await firstOptionLabel.fill("라디오 옵션 이미지");
|
||||
|
||||
await propertiesSidebar.getByLabel("옵션 이미지 소스").selectOption("upload");
|
||||
|
||||
const fileInput = propertiesSidebar.getByLabel("옵션 이미지 파일 업로드").first();
|
||||
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
|
||||
|
||||
const editorImage = canvas.getByRole("img", { name: "라디오 옵션 이미지" }).first();
|
||||
await expect(editorImage).toBeVisible();
|
||||
|
||||
const editorSrc = await editorImage.getAttribute("src");
|
||||
expect(editorSrc).not.toBeNull();
|
||||
expect(editorSrc).toMatch(/^\/api\/image\//);
|
||||
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const previewImage = page.getByRole("img", { name: "라디오 옵션 이미지" }).first();
|
||||
await expect(previewImage).toBeVisible();
|
||||
|
||||
const previewSrc = await previewImage.getAttribute("src");
|
||||
expect(previewSrc).toBe(editorSrc);
|
||||
});
|
||||
|
||||
test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
// 첫 번째 옵션 라벨을 이미지 alt 텍스트로 사용할 값으로 설정한다.
|
||||
const firstOptionLabel = propertiesSidebar.getByPlaceholder("라벨").first();
|
||||
await firstOptionLabel.fill("체크박스 옵션 이미지");
|
||||
|
||||
await propertiesSidebar.getByLabel("옵션 이미지 소스").selectOption("upload");
|
||||
|
||||
const fileInput = propertiesSidebar.getByLabel("옵션 이미지 파일 업로드").first();
|
||||
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
|
||||
|
||||
const editorImage = canvas.getByRole("img", { name: "체크박스 옵션 이미지" }).first();
|
||||
await expect(editorImage).toBeVisible();
|
||||
|
||||
const editorSrc = await editorImage.getAttribute("src");
|
||||
expect(editorSrc).not.toBeNull();
|
||||
expect(editorSrc).toMatch(/^\/api\/image\//);
|
||||
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const previewImage = page.getByRole("img", { name: "체크박스 옵션 이미지" }).first();
|
||||
await expect(previewImage).toBeVisible();
|
||||
|
||||
const previewSrc = await previewImage.getAttribute("src");
|
||||
expect(previewSrc).toBe(editorSrc);
|
||||
});
|
||||
|
||||
test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
await propertiesSidebar.getByLabel("그룹 타이틀 타입").selectOption("image");
|
||||
await propertiesSidebar.getByLabel("그룹 타이틀 이미지 소스").selectOption("upload");
|
||||
await propertiesSidebar.getByLabel("그룹 타이틀", { exact: true }).fill("라디오 라벨 이미지");
|
||||
|
||||
const fileInput = propertiesSidebar.getByLabel("그룹 타이틀 이미지 파일 업로드");
|
||||
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
|
||||
|
||||
const editorImage = canvas.getByRole("img", { name: "라디오 라벨 이미지" }).first();
|
||||
await expect(editorImage).toBeVisible();
|
||||
|
||||
const editorSrc = await editorImage.getAttribute("src");
|
||||
expect(editorSrc).not.toBeNull();
|
||||
expect(editorSrc).toMatch(/^\/api\/image\//);
|
||||
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const previewImage = page.getByRole("img", { name: "라디오 라벨 이미지" }).first();
|
||||
await expect(previewImage).toBeVisible();
|
||||
|
||||
const previewSrc = await previewImage.getAttribute("src");
|
||||
expect(previewSrc).toBe(editorSrc);
|
||||
});
|
||||
|
||||
test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
@@ -487,6 +633,43 @@ test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어
|
||||
expect(computed.inlineWidth.endsWith("em")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
const propertiesSidebar = page.getByTestId("properties-sidebar");
|
||||
|
||||
// alt 텍스트를 먼저 설정해 에디터/프리뷰에서 이미지를 안정적으로 찾을 수 있게 한다.
|
||||
await propertiesSidebar.getByLabel("대체 텍스트").fill("업로드 이미지");
|
||||
|
||||
// 이미지 소스를 파일 업로드 모드로 전환한다.
|
||||
await propertiesSidebar.getByLabel("이미지 소스").selectOption("upload");
|
||||
|
||||
// 아직 구현되지 않은 이미지 업로드 인풋(aria-label: "이미지 파일 업로드")을 통해 로컬 파일을 업로드한다고 가정한다.
|
||||
const fileInput = propertiesSidebar.getByLabel("이미지 파일 업로드");
|
||||
await fileInput.setInputFiles("tests/fixtures/sample-image.png");
|
||||
|
||||
// 에디터 캔버스에서 alt 텍스트 기준으로 이미지를 찾는다.
|
||||
const editorImage = canvas.getByRole("img", { name: "업로드 이미지" }).first();
|
||||
await expect(editorImage).toBeVisible();
|
||||
|
||||
const editorSrc = await editorImage.getAttribute("src");
|
||||
expect(editorSrc).not.toBeNull();
|
||||
expect(editorSrc).toMatch(/^\/api\/image\//);
|
||||
|
||||
// 프리뷰로 이동하면 동일한 /api/image/:id URL을 사용하는 이미지가 보여야 한다.
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const previewImage = page.getByRole("img", { name: "업로드 이미지" }).first();
|
||||
await expect(previewImage).toBeVisible();
|
||||
|
||||
const previewSrc = await previewImage.getAttribute("src");
|
||||
expect(previewSrc).toBe(editorSrc);
|
||||
});
|
||||
|
||||
test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
PNG TEST FIXTURE
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
DividerBlockProps,
|
||||
ImageBlockProps,
|
||||
SectionBlockProps,
|
||||
ListLine,
|
||||
ButtonBlockProps,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
|
||||
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
|
||||
@@ -1188,4 +1190,40 @@ describe("editorStore", () => {
|
||||
expect(updatedFormProps.fieldIds).toEqual(fieldIds);
|
||||
expect(updatedFormProps.submitButtonId).toBe(submitButtonId);
|
||||
});
|
||||
|
||||
it("projectConfig 기본값과 updateProjectConfig 액션으로 프로젝트 설정을 관리할 수 있어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
const stateAny = store.getState() as any;
|
||||
|
||||
expect(stateAny.projectConfig).toBeTruthy();
|
||||
expect(stateAny.projectConfig.title).toBe("새 페이지");
|
||||
expect(stateAny.projectConfig.slug).toBe("my-landing");
|
||||
expect(stateAny.projectConfig.canvasPreset).toBe("full");
|
||||
expect(stateAny.projectConfig.canvasBgColorHex).toBe("#020617");
|
||||
expect(stateAny.projectConfig.bodyBgColorHex).toBe("#020617");
|
||||
expect(stateAny.projectConfig.headHtml).toBe("");
|
||||
expect(stateAny.projectConfig.trackingScript).toBe("");
|
||||
|
||||
stateAny.updateProjectConfig({
|
||||
title: "랜딩 페이지",
|
||||
slug: "landing-page",
|
||||
canvasPreset: "desktop",
|
||||
bodyBgColorHex: "#111111",
|
||||
});
|
||||
|
||||
const next = store.getState() as any;
|
||||
expect(next.projectConfig.title).toBe("랜딩 페이지");
|
||||
expect(next.projectConfig.slug).toBe("landing-page");
|
||||
expect(next.projectConfig.canvasPreset).toBe("desktop");
|
||||
expect(next.projectConfig.bodyBgColorHex).toBe("#111111");
|
||||
|
||||
stateAny.updateProjectConfig({
|
||||
canvasPreset: "custom",
|
||||
canvasWidthPx: 1024,
|
||||
});
|
||||
|
||||
const custom = store.getState() as any;
|
||||
expect(custom.projectConfig.canvasPreset).toBe("custom");
|
||||
expect(custom.projectConfig.canvasWidthPx).toBe(1024);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
describe("프리뷰와 정적 내보내기 일치 (고수준)", () => {
|
||||
it("여러 블록이 섞인 페이지에서 핵심 텍스트는 프리뷰와 정적 HTML 모두에 존재해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "text_root",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "텍스트 블록",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "btn_root",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "버튼 A",
|
||||
href: "#",
|
||||
align: "center",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "img_root",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "/api/image/sample-id",
|
||||
alt: "이미지 설명",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_1",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "primary",
|
||||
paddingY: "lg",
|
||||
columns: [
|
||||
{
|
||||
id: "sec_1_col_1",
|
||||
span: 12,
|
||||
},
|
||||
],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_1_text",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 안 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_1",
|
||||
columnId: "sec_1_col_1",
|
||||
} as any,
|
||||
{
|
||||
id: "sec_1_btn",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "섹션 버튼",
|
||||
href: "#",
|
||||
align: "left",
|
||||
},
|
||||
sectionId: "sec_1",
|
||||
columnId: "sec_1_col_1",
|
||||
} as any,
|
||||
{
|
||||
id: "divider_1",
|
||||
type: "divider",
|
||||
props: {
|
||||
align: "center",
|
||||
thickness: "medium",
|
||||
widthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "list_1",
|
||||
type: "list",
|
||||
props: {
|
||||
items: ["리스트 아이템 1", "리스트 아이템 2"],
|
||||
ordered: false,
|
||||
align: "left",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "form_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
// v1 fallback 필드를 사용해 양쪽 렌더러가 동일한 폼 필드를 가지도록 한다.
|
||||
fields: [
|
||||
{ id: "f_name", name: "name", label: "폼 이름", type: "text", required: true },
|
||||
{ id: "f_email", name: "email", label: "폼 이메일", type: "email", required: true },
|
||||
{ id: "f_msg", name: "message", label: "폼 메시지", type: "textarea", required: true },
|
||||
],
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
formWidthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "input_standalone",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "입력(단독)",
|
||||
formFieldName: "single_input",
|
||||
required: true,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "select_standalone",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "선택(단독)",
|
||||
formFieldName: "single_select",
|
||||
options: [
|
||||
{ label: "옵션 A", value: "a" },
|
||||
{ label: "옵션 B", value: "b" },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "checkbox_standalone",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel: "체크 그룹",
|
||||
formFieldName: "checks",
|
||||
options: [
|
||||
{ label: "체크 A", value: "a" },
|
||||
{ label: "체크 B", value: "b" },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "radio_standalone",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel: "라디오 그룹",
|
||||
formFieldName: "radios",
|
||||
options: [
|
||||
{ label: "라디오 A", value: "a" },
|
||||
{ label: "라디오 B", value: "b" },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "프리뷰-내보내기 고수준 테스트",
|
||||
slug: "preview-export-parity",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const previewHtml = ReactDOMServer.renderToString(
|
||||
React.createElement(PublicPageRenderer, { blocks }),
|
||||
);
|
||||
|
||||
const staticHtml = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
const importantStrings = [
|
||||
"텍스트 블록",
|
||||
"버튼 A",
|
||||
"이미지 설명",
|
||||
"섹션 안 텍스트",
|
||||
"섹션 버튼",
|
||||
"리스트 아이템 1",
|
||||
"폼 이름",
|
||||
"폼 이메일",
|
||||
"폼 메시지",
|
||||
"입력(단독)",
|
||||
"선택(단독)",
|
||||
"체크 A",
|
||||
"라디오 A",
|
||||
];
|
||||
|
||||
for (const text of importantStrings) {
|
||||
expect(previewHtml).toContain(text);
|
||||
expect(staticHtml).toContain(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user