3180 lines
102 KiB
TypeScript
3180 lines
102 KiB
TypeScript
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";
|
|
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
|
|
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
|
|
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
|
|
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
|
|
import { getEditorTemplatesMessages } from "@/features/i18n/messages/editorTemplates";
|
|
import { DEFAULT_LOCALE } from "@/features/i18n/locale";
|
|
import { buildStaticHtml } from "@/app/api/export/route";
|
|
|
|
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 mainJs = await jsEntry!.async("string");
|
|
expect(mainJs).toContain("pbInitFormControllers");
|
|
expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")');
|
|
expect(mainJs).toContain("fetch(");
|
|
|
|
const builderCss = await cssEntry!.async("string");
|
|
// 버튼 베이스 클래스는 정적 Export 에서도 밑줄이 보이지 않도록 text-decoration:none 을 포함해야 한다.
|
|
expect(builderCss).toContain(".pb-btn-base");
|
|
expect(builderCss).toContain("text-decoration: none");
|
|
|
|
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("업로드 디렉터리에 이미지 파일이 없어도 ZIP 생성은 실패하지 않아야 한다", async () => {
|
|
const missingImageId = `missing-image-${Date.now()}`;
|
|
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "blk_image_missing",
|
|
type: "image",
|
|
props: {
|
|
src: `/api/image/${missingImageId}`,
|
|
alt: "누락된 이미지",
|
|
align: "center",
|
|
widthMode: "auto",
|
|
borderRadius: "md",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "이미지 파일 누락 내보내기 테스트",
|
|
slug: "image-missing-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);
|
|
|
|
// 업로드 파일이 없으므로 images/ 디렉터리에 엔트리는 없어야 한다.
|
|
const imageEntry = zip.file(`images/${missingImageId}`);
|
|
expect(imageEntry).toBeNull();
|
|
|
|
const html = await zip.file("index.html")!.async("string");
|
|
// HTML 은 원본 /api/image 경로를 그대로 유지하거나, 최소한 오류 없이 렌더되어야 한다.
|
|
expect(html).toContain(`/api/image/${missingImageId}`);
|
|
});
|
|
|
|
it("업로드 디렉터리에 비디오 파일이 없어도 ZIP 생성은 실패하지 않아야 한다", async () => {
|
|
const missingVideoId = `missing-video-${Date.now()}`;
|
|
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "blk_video_missing",
|
|
type: "video",
|
|
props: {
|
|
sourceUrl: `/api/video/${missingVideoId}`,
|
|
align: "center",
|
|
widthMode: "auto",
|
|
aspectRatio: "16:9",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "비디오 파일 누락 내보내기 테스트",
|
|
slug: "video-missing-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 videoEntry = zip.file(`videos/${missingVideoId}`);
|
|
expect(videoEntry).toBeNull();
|
|
|
|
const html = await zip.file("index.html")!.async("string");
|
|
// HTML 은 원본 /api/video 경로를 그대로 유지하거나, 최소한 오류 없이 렌더되어야 한다.
|
|
expect(html).toContain(`/api/video/${missingVideoId}`);
|
|
});
|
|
|
|
it("video 블록의 sourceUrl 이 비어 있어도 ZIP 생성은 실패하지 않아야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "blk_video_empty_source",
|
|
type: "video",
|
|
props: {
|
|
sourceUrl: "",
|
|
align: "center",
|
|
widthMode: "auto",
|
|
aspectRatio: "16:9",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "비디오 sourceUrl 빈 값 내보내기 테스트",
|
|
slug: "video-empty-source-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 html = await zip.file("index.html")!.async("string");
|
|
// sourceUrl 이 비어 있어도 index.html 은 정상 생성되어야 한다.
|
|
expect(html).toContain("<video");
|
|
});
|
|
|
|
it("canvasPreset / canvasWidthPx / 배경색은 정적 HTML 캔버스 wrapper 및 body 스타일에 반영되어야 한다", () => {
|
|
const blocks: Block[] = [];
|
|
|
|
const createConfig = (partial: Partial<ProjectConfig>): ProjectConfig => ({
|
|
title: "캔버스 프리셋 테스트",
|
|
slug: "canvas-preset-test",
|
|
canvasPreset: "full",
|
|
canvasBgColorHex: "#0f172a",
|
|
bodyBgColorHex: "#020617",
|
|
...partial,
|
|
});
|
|
|
|
const htmlMobile = buildStaticHtml(blocks, createConfig({ canvasPreset: "mobile" }));
|
|
expect(htmlMobile).toContain("max-width:390px;");
|
|
|
|
const htmlTablet = buildStaticHtml(blocks, createConfig({ canvasPreset: "tablet" }));
|
|
expect(htmlTablet).toContain("max-width:768px;");
|
|
|
|
const htmlDesktop = buildStaticHtml(blocks, createConfig({ canvasPreset: "desktop" }));
|
|
expect(htmlDesktop).toContain("max-width:1200px;");
|
|
|
|
const htmlCustom = buildStaticHtml(blocks, createConfig({ canvasPreset: "custom", canvasWidthPx: 1024 }));
|
|
expect(htmlCustom).toContain("max-width:1024px;");
|
|
|
|
const htmlFull = buildStaticHtml(blocks, createConfig({ canvasPreset: "full" }));
|
|
// full 프리셋에서 canvasWidthPx 가 없으면 max-width 스타일이 없어야 한다.
|
|
expect(htmlFull).not.toContain("max-width:");
|
|
|
|
// bodyBgColorHex / canvasBgColorHex 도 각각 body 및 캔버스 wrapper 스타일에 반영된다.
|
|
const htmlColors = buildStaticHtml(
|
|
blocks,
|
|
createConfig({ canvasBgColorHex: "#111111", bodyBgColorHex: "#222222" }),
|
|
);
|
|
expect(htmlColors).toContain("background-color:#111111;");
|
|
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
|
|
});
|
|
|
|
it("bodyBgColorHex 가 빈 문자열이면 buildStaticHtml 의 body style 에 background-color 가 포함되지 않아야 한다", () => {
|
|
const blocks: Block[] = [];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "배경색 비어 있음 테스트",
|
|
slug: "body-bg-empty-test",
|
|
canvasPreset: "full",
|
|
canvasBgColorHex: "#0f172a",
|
|
bodyBgColorHex: "",
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
// body style 에 background-color 가 없어야 한다.
|
|
expect(html).toContain('<body style="margin:0;padding:0;"');
|
|
});
|
|
|
|
it("projectConfig.headHtml / trackingScript 가 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>',
|
|
} as ProjectConfig;
|
|
|
|
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("버튼 블록에 imageSrc 가 있으면 내보내기 HTML 의 버튼 안에 img 요소가 포함되어야 한다", () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "btn_img_export",
|
|
type: "button",
|
|
props: {
|
|
label: "이미지 버튼",
|
|
href: "#",
|
|
imageSrc: "https://example.com/button.png",
|
|
imageAlt: "버튼 이미지",
|
|
imagePlacement: "left",
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "버튼 이미지 내보내기 테스트",
|
|
slug: "btn-img-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
expect(html).toContain('<a href="#"');
|
|
expect(html).toContain('<img src="https://example.com/button.png"');
|
|
expect(html).toContain('alt="버튼 이미지"');
|
|
});
|
|
|
|
it("index.html head 에는 기본 viewport 메타 태그가 포함되어야 한다", async () => {
|
|
const blocks: Block[] = [];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "뷰포트 메타 테스트",
|
|
slug: "viewport-meta-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(
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
|
|
);
|
|
});
|
|
|
|
it("seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl/seoNoIndex 가 설정된 경우 head 에 SEO 메타 태그들이 생성되어야 한다", () => {
|
|
const blocks: Block[] = [];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "기본 타이틀",
|
|
slug: "seo-meta-test",
|
|
canvasPreset: "full",
|
|
seoTitle: "SEO 타이틀",
|
|
seoDescription: "SEO 설명",
|
|
seoOgImageUrl: "https://example.com/og.png",
|
|
seoCanonicalUrl: "https://example.com/landing",
|
|
seoNoIndex: true,
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
expect(html).toContain("<title>SEO 타이틀</title>");
|
|
expect(html).toContain('meta name="description" content="SEO 설명"');
|
|
expect(html).toContain('meta property="og:title" content="SEO 타이틀"');
|
|
expect(html).toContain('meta property="og:description" content="SEO 설명"');
|
|
expect(html).toContain('meta property="og:type" content="website"');
|
|
expect(html).toContain('meta property="og:image" content="https://example.com/og.png"');
|
|
expect(html).toContain('meta property="og:url" content="https://example.com/landing"');
|
|
|
|
expect(html).toContain('meta name="twitter:card" content="summary_large_image"');
|
|
expect(html).toContain('meta name="twitter:title" content="SEO 타이틀"');
|
|
expect(html).toContain('meta name="twitter:description" content="SEO 설명"');
|
|
expect(html).toContain('meta name="twitter:image" content="https://example.com/og.png"');
|
|
|
|
expect(html).toContain('<link rel="canonical" href="https://example.com/landing" />');
|
|
expect(html).toContain('<meta name="robots" content="noindex, nofollow" />');
|
|
});
|
|
|
|
it("기본 SEO 메타 태그들 이후에 projectConfig.headHtml 이 head 에 추가되어야 한다", () => {
|
|
const blocks: Block[] = [];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "헤드 SEO 순서 테스트",
|
|
slug: "head-seo-order-test",
|
|
canvasPreset: "full",
|
|
seoTitle: "SEO 타이틀",
|
|
seoDescription: "SEO 설명",
|
|
headHtml: '<meta name="custom" content="custom-meta" />',
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
const headStart = html.indexOf("<head>");
|
|
const headEnd = html.indexOf("</head>");
|
|
const head = html.slice(headStart, headEnd);
|
|
|
|
const descriptionIndex = head.indexOf('meta name="description"');
|
|
const customIndex = head.indexOf('meta name="custom" content="custom-meta"');
|
|
|
|
expect(descriptionIndex).toBeGreaterThan(-1);
|
|
expect(customIndex).toBeGreaterThan(-1);
|
|
expect(customIndex).toBeGreaterThan(descriptionIndex);
|
|
});
|
|
|
|
it("SEO 필드 값에 특수 문자가 포함되어도 HTML 이 깨지지 않고 이스케이프되어야 한다", () => {
|
|
const blocks: Block[] = [];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: '기본 & "타이틀" <테스트>',
|
|
slug: "seo-escape-test",
|
|
canvasPreset: "full",
|
|
seoTitle: 'SEO & "타이틀" <테스트>',
|
|
seoDescription: '설명 & "디스크립션" <테스트>',
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
expect(html).toContain(
|
|
"<title>SEO & "타이틀" <테스트></title>",
|
|
);
|
|
expect(html).toContain(
|
|
'meta name="description" content="설명 & "디스크립션" <테스트>"',
|
|
);
|
|
expect(html).toContain(
|
|
'meta property="og:title" content="SEO & "타이틀" <테스트>"',
|
|
);
|
|
});
|
|
|
|
it("FormBlock 은 Export 레이어에서 컨트롤러 역할만 하고, 필드 블록들은 form 속성으로 해당 폼과 연결되어야 한다", 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");
|
|
|
|
// FormBlock 은 컨트롤러 전용이므로, Export 에서는 id 를 가진 단일 <form> 컨테이너만 생성하고
|
|
// 실제 필드 레이아웃은 개별 formInput/formSelect 블록이 담당한다.
|
|
// - <form id="form_form_1" ...>
|
|
const formId = "form_form_1";
|
|
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"`));
|
|
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"[^>]*action=\\"/api/forms/submit\\"`));
|
|
|
|
// 컨트롤러 필드 블록(formInput/formSelect) 이 렌더한 input/select 는 각각 name 과 form 속성을 가져야 한다.
|
|
// name="name" input 은 required + form="form_form_1" 이어야 한다.
|
|
expect(html).toMatch(/name=\"name\"[^>]*required[^>]*form=\"form_form_1\"/);
|
|
// name="plan" select 는 required 는 아니지만 form="form_form_1" 이어야 한다.
|
|
expect(html).toMatch(/name=\"plan\"[^>]*form=\"form_form_1\"/);
|
|
|
|
// FormBlock 이 자체적으로 필드 레이아웃을 중복 생성하지 않도록,
|
|
// id 가 form_form_1 인 <form> 내부에는 name="name"/"plan" 필드가 없어야 한다.
|
|
const formStart = html.indexOf("<form");
|
|
const formEnd = html.indexOf("</form>", formStart);
|
|
const formHtml = html.slice(formStart, formEnd);
|
|
expect(formHtml).not.toContain('name="name"');
|
|
expect(formHtml).not.toContain('name="plan"');
|
|
expect(formHtml).toContain('name="__config"');
|
|
});
|
|
|
|
it("formInput 플로팅 라벨은 Export 에서 placeholder 텍스트와 라벨이 겹치지 않아야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_floating_1",
|
|
type: "form",
|
|
props: {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
fields: [],
|
|
fieldIds: ["field_name"],
|
|
submitButtonId: null,
|
|
formWidthMode: "full",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "field_name",
|
|
type: "formInput",
|
|
props: {
|
|
label: "이름",
|
|
formFieldName: "name",
|
|
labelDisplay: "floating",
|
|
// placeholder 가 라벨과 같을 때도 Export 에서는 placeholder 텍스트가 인풋 안에 보이지 않아야 한다.
|
|
placeholder: "이름",
|
|
required: true,
|
|
} as any,
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "플로팅 라벨 Export 테스트",
|
|
slug: "form-floating-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
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");
|
|
|
|
// 플로팅 라벨의 컨테이너는 pb-form-field pb-form-field--floating 클래스를 포함해야 한다.
|
|
expect(html).toContain("pb-form-field pb-form-field--floating");
|
|
|
|
// name="name" 인 input 태그를 찾아 placeholder 를 검사한다.
|
|
const inputMatch = html.match(/<input[^>]*name=\"name\"[^>]*>/);
|
|
expect(inputMatch).not.toBeNull();
|
|
|
|
const inputTag = inputMatch![0];
|
|
// placeholder 안에 "이름" 텍스트가 그대로 노출되면 안 된다.
|
|
expect(inputTag).not.toContain('placeholder="이름"');
|
|
// 대신 플로팅 라벨 전용으로 공백 placeholder 를 사용한다.
|
|
expect(inputTag).toContain('placeholder=" "');
|
|
|
|
// 플로팅 라벨 컨테이너 내부에서 input 이 label 보다 먼저 나와야 CSS sibling 기반 플로팅이 동작한다.
|
|
const fieldMatch = html.match(
|
|
/<div class=\"pb-form-field pb-form-field--floating\">([\s\S]*?)<\/div>/,
|
|
);
|
|
expect(fieldMatch).not.toBeNull();
|
|
const fieldInner = fieldMatch![1];
|
|
|
|
const inputIndex = fieldInner.indexOf("<input");
|
|
const labelIndex = fieldInner.indexOf("<label");
|
|
expect(inputIndex).toBeGreaterThanOrEqual(0);
|
|
expect(labelIndex).toBeGreaterThanOrEqual(0);
|
|
expect(inputIndex).toBeLessThan(labelIndex);
|
|
});
|
|
|
|
it("formSelect 라벨 레이아웃(inline)이 Export HTML 에도 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_select_export_1",
|
|
type: "form",
|
|
props: {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
fields: [],
|
|
fieldIds: ["select_plan"],
|
|
submitButtonId: null,
|
|
formWidthMode: "full",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "select_plan",
|
|
type: "formSelect",
|
|
props: {
|
|
label: "플랜",
|
|
formFieldName: "plan",
|
|
options: [
|
|
{ label: "Basic", value: "basic" },
|
|
{ label: "Pro", value: "pro" },
|
|
],
|
|
labelDisplay: "visible",
|
|
labelLayout: "inline",
|
|
labelGapPx: 16,
|
|
} as any,
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "셀렉트 라벨/레이아웃 Export 테스트",
|
|
slug: "form-select-layout-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
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");
|
|
|
|
// 라벨 표시 방식 visible + inline 레이아웃: pb-form-label 이 정상적으로 노출되어야 한다.
|
|
expect(html).toMatch(/<label class=\"pb-form-label\"[^>]*>플랜<\/label>/);
|
|
|
|
// inline 레이아웃: pb-form-field 컨테이너에 flex-direction:row / align-items:center / column-gap 이 style 로 반영되어야 한다.
|
|
// labelGapPx: 16px -> 1em
|
|
const divMatch = html.match(/<div class=\"pb-form-field\"[^>]*style=\"([^\"]*)\"[^>]*>/);
|
|
expect(divMatch).not.toBeNull();
|
|
const styleAttr = divMatch![1];
|
|
expect(styleAttr).toContain("flex-direction:row");
|
|
expect(styleAttr).toContain("align-items:center");
|
|
expect(styleAttr).toContain("column-gap:1em");
|
|
});
|
|
|
|
it("formInput 라벨 표시 방식 hidden/floating 이 Export HTML 에도 반영되어야 한다", () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_input_hidden_export",
|
|
type: "formInput",
|
|
props: {
|
|
label: "숨김 라벨",
|
|
formFieldName: "hidden_label",
|
|
labelDisplay: "hidden",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "form_input_floating_export",
|
|
type: "formInput",
|
|
props: {
|
|
label: "플로팅 라벨",
|
|
formFieldName: "floating_label",
|
|
labelDisplay: "floating",
|
|
placeholder: "플로팅 플레이스홀더",
|
|
paddingY: 16,
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "formInput 라벨 표시 방식 Export 테스트",
|
|
slug: "form-input-label-display-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
// hidden: pb-form-label sr-only 로 렌더되어야 한다.
|
|
expect(html).toMatch(
|
|
/<div class="pb-form-field">\s*<input class="pb-input"[^>]*name="hidden_label"[^>]*>\s*<label class="pb-form-label sr-only"[^>]*>숨김 라벨<\/label>\s*<\/div>/,
|
|
);
|
|
|
|
// floating: pb-form-field pb-form-field--floating + placeholder=" " + pb-form-label 구조를 사용해야 한다.
|
|
expect(html).toMatch(
|
|
/<div class="pb-form-field pb-form-field--floating"[^>]*>\s*<input class="pb-input"[^>]*name="floating_label"[^>]*placeholder=" "[^>]*>\s*<label class="pb-form-label"[^>]*>플로팅 라벨<\/label>\s*<\/div>/,
|
|
);
|
|
|
|
const floatingDivMatch = html.match(
|
|
/<div class="pb-form-field pb-form-field--floating"([^>]*)>/,
|
|
);
|
|
expect(floatingDivMatch).not.toBeNull();
|
|
expect(floatingDivMatch![1]).toContain('style="--pb-input-padding-y:1rem"');
|
|
});
|
|
|
|
it("formInput Export HTML 은 label 과 input 을 for/id 로 연결해야 한다", () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_input_hidden_export_for_id",
|
|
type: "formInput",
|
|
props: {
|
|
label: "숨김 라벨",
|
|
formFieldName: "hidden_label_for_id",
|
|
labelDisplay: "hidden",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "form_input_floating_export_for_id",
|
|
type: "formInput",
|
|
props: {
|
|
label: "플로팅 라벨",
|
|
formFieldName: "floating_label_for_id",
|
|
labelDisplay: "floating",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "formInput for/id Export 테스트",
|
|
slug: "form-input-for-id-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
expect(html).toMatch(
|
|
/<input[^>]*name="hidden_label_for_id"[^>]*id="hidden_label_for_id"[^>]*>/,
|
|
);
|
|
expect(html).toMatch(
|
|
/<label[^>]*for="hidden_label_for_id"[^>]*>숨김 라벨<\/label>/,
|
|
);
|
|
|
|
expect(html).toMatch(
|
|
/<input[^>]*name="floating_label_for_id"[^>]*id="floating_label_for_id"[^>]*>/,
|
|
);
|
|
expect(html).toMatch(
|
|
/<label[^>]*for="floating_label_for_id"[^>]*>플로팅 라벨<\/label>/,
|
|
);
|
|
});
|
|
|
|
it("formSelect Export HTML 은 name 과 option value 를 정확히 반영해야 한다", () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "select_export_attrs",
|
|
type: "formSelect",
|
|
props: {
|
|
label: "플랜",
|
|
formFieldName: "plan",
|
|
options: [
|
|
{ label: "Basic", value: "basic" },
|
|
{ label: "Pro", value: "pro" },
|
|
],
|
|
required: true,
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "formSelect Export 속성 테스트",
|
|
slug: "form-select-attrs-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
expect(html).toMatch(/<select[^>]*class="pb-select"[^>]*name="plan"[^>]*required[^>]*>/);
|
|
expect(html).toContain('<option value="basic">Basic<\/option>'.replace("\\/", "/"));
|
|
expect(html).toContain('<option value="pro">Pro<\/option>'.replace("\\/", "/"));
|
|
});
|
|
|
|
it("formCheckbox/formRadio Export HTML 의 input 은 type/name/value 를 정확히 반영해야 한다", () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "features_export_attrs",
|
|
type: "formCheckbox",
|
|
props: {
|
|
groupLabel: "기능",
|
|
formFieldName: "features",
|
|
options: [
|
|
{ label: "옵션 1", value: "opt1" },
|
|
{ label: "옵션 2", value: "opt2" },
|
|
],
|
|
} as any,
|
|
},
|
|
{
|
|
id: "choices_export_attrs",
|
|
type: "formRadio",
|
|
props: {
|
|
groupLabel: "선택",
|
|
formFieldName: "choice",
|
|
options: [
|
|
{ label: "A", value: "a" },
|
|
{ label: "B", value: "b" },
|
|
],
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "formCheckbox/formRadio Export 속성 테스트",
|
|
slug: "form-option-attrs-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
// 체크박스 옵션 input: type="checkbox" name="features" value="opt1"/"opt2"
|
|
expect(html).toMatch(/<input type="checkbox"[^>]*name="features"[^>]*value="opt1"[^>]*>/);
|
|
expect(html).toMatch(/<input type="checkbox"[^>]*name="features"[^>]*value="opt2"[^>]*>/);
|
|
|
|
// 라디오 옵션 input: type="radio" name="choice" value="a"/"b"
|
|
expect(html).toMatch(/<input type="radio"[^>]*name="choice"[^>]*value="a"[^>]*>/);
|
|
expect(html).toMatch(/<input type="radio"[^>]*name="choice"[^>]*value="b"[^>]*>/);
|
|
});
|
|
|
|
it("FormBlock.requiredFieldIds 는 Export HTML input/select 의 required 속성에도 반영되어야 한다", () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_required_export",
|
|
type: "form",
|
|
props: {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
fieldIds: ["field_name", "field_plan"],
|
|
requiredFieldIds: ["field_name", "field_plan"],
|
|
} as any,
|
|
},
|
|
{
|
|
id: "field_name",
|
|
type: "formInput",
|
|
props: {
|
|
label: "이름",
|
|
formFieldName: "name",
|
|
required: false,
|
|
} as any,
|
|
},
|
|
{
|
|
id: "field_plan",
|
|
type: "formRadio",
|
|
props: {
|
|
groupLabel: "플랜",
|
|
formFieldName: "plan",
|
|
options: [
|
|
{ label: "Basic", value: "basic" },
|
|
{ label: "Pro", value: "pro" },
|
|
],
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "FormBlock required Export 테스트",
|
|
slug: "form-required-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
// field_name 은 FormBlock.requiredFieldIds 로 인해 required 이어야 한다.
|
|
expect(html).toMatch(/<input[^>]*name="name"[^>]*required[^>]*>/);
|
|
|
|
// 라디오 그룹에서는 첫 번째 옵션만 required 여야 한다.
|
|
const firstRadioMatch = html.match(/<input type="radio"[^>]*name="plan"[^>]*value="basic"[^>]*>/);
|
|
const secondRadioMatch = html.match(/<input type="radio"[^>]*name="plan"[^>]*value="pro"[^>]*>/);
|
|
|
|
expect(firstRadioMatch).not.toBeNull();
|
|
expect(firstRadioMatch![0]).toContain("required");
|
|
expect(secondRadioMatch).not.toBeNull();
|
|
expect(secondRadioMatch![0]).not.toContain("required");
|
|
});
|
|
|
|
it("formCheckbox/formRadio 의 optionLayout 값이 Export HTML 의 pb-form-options 컨테이너 클래스로 반영되어야 한다", () => {
|
|
const checkboxBlocks: Block[] = [
|
|
{
|
|
id: "features_export_options",
|
|
type: "formCheckbox",
|
|
props: {
|
|
groupLabel: "기능",
|
|
formFieldName: "features",
|
|
options: [
|
|
{ label: "옵션 1", value: "opt1" },
|
|
{ label: "옵션 2", value: "opt2" },
|
|
],
|
|
optionLayout: "stacked",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const radioBlocks: Block[] = [
|
|
{
|
|
id: "choices_export_options",
|
|
type: "formRadio",
|
|
props: {
|
|
groupLabel: "선택",
|
|
formFieldName: "choice",
|
|
options: [
|
|
{ label: "A", value: "a" },
|
|
{ label: "B", value: "b" },
|
|
],
|
|
optionLayout: "inline",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "옵션 레이아웃 Export 테스트",
|
|
slug: "form-option-layout-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
const checkboxHtml = buildStaticHtml(checkboxBlocks, projectConfig);
|
|
expect(checkboxHtml).toContain('class="pb-form-options pb-form-options--stacked"');
|
|
|
|
const radioHtml = buildStaticHtml(radioBlocks, projectConfig);
|
|
expect(radioHtml).toContain('class="pb-form-options pb-form-options--inline"');
|
|
// 각 옵션 라벨은 pb-form-option 클래스를 사용해야 한다.
|
|
expect(radioHtml).toContain('class="pb-form-option"');
|
|
});
|
|
|
|
it("formCheckbox/formRadio 그룹 타이틀 표시 방식과 레이아웃이 Export HTML 에도 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_group_export_1",
|
|
type: "form",
|
|
props: {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
fields: [],
|
|
fieldIds: ["features", "choices"],
|
|
submitButtonId: null,
|
|
formWidthMode: "full",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "features",
|
|
type: "formCheckbox",
|
|
props: {
|
|
groupLabel: "기능",
|
|
formFieldName: "features",
|
|
options: [
|
|
{ label: "옵션 1", value: "opt1" },
|
|
{ label: "옵션 2", value: "opt2" },
|
|
],
|
|
groupLabelDisplay: "hidden",
|
|
labelLayout: "inline",
|
|
labelGapPx: 8,
|
|
} as any,
|
|
} as any,
|
|
{
|
|
id: "choices",
|
|
type: "formRadio",
|
|
props: {
|
|
groupLabel: "선택",
|
|
formFieldName: "choice",
|
|
options: [
|
|
{ label: "A", value: "a" },
|
|
{ label: "B", value: "b" },
|
|
],
|
|
groupLabelDisplay: "visible",
|
|
labelLayout: "inline",
|
|
labelGapPx: 24,
|
|
} as any,
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "체크박스/라디오 그룹 라벨 Export 테스트",
|
|
slug: "form-group-layout-export-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
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");
|
|
|
|
// 체크박스 그룹: groupLabelDisplay hidden -> pb-form-label sr-only 여야 한다.
|
|
expect(html).toContain('<label class="pb-form-label sr-only"');
|
|
|
|
// 라디오 그룹: inline 레이아웃 + labelGapPx 24px -> column-gap:1.5em, 세로 중앙 정렬이 pb-form-field style 에 반영되어야 한다.
|
|
const radioDivMatch = html.match(/<div class=\"pb-form-field\"[^>]*style=\"([^\"]*)\"[^>]*>[^]*선택[^]*<label class=\"pb-form-option/);
|
|
expect(radioDivMatch).not.toBeNull();
|
|
const radioStyle = radioDivMatch![1];
|
|
expect(radioStyle).toContain("flex-direction:row");
|
|
expect(radioStyle).toContain("align-items:center");
|
|
expect(radioStyle).toContain("column-gap:1.5em");
|
|
});
|
|
|
|
|
|
it("FormBlock 에 fieldIds/fields 가 모두 없으면 Export 에서 아무 <form>/필드도 생성하지 않아야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_fallback_1",
|
|
type: "form",
|
|
props: {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
// fieldIds, fields 를 모두 생략해 fallback 경로를 타게 한다.
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 fallback 내보내기 테스트",
|
|
slug: "form-fallback-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");
|
|
|
|
// 빈 FormBlock 은 컨트롤러 역할만 하고, 정적 HTML 에서는 기본 폼/필드를 생성하지 않아야 한다.
|
|
expect(html).not.toContain("<form");
|
|
expect(html).not.toMatch(/name=\"name\"/);
|
|
expect(html).not.toMatch(/name=\"email\"/);
|
|
expect(html).not.toMatch(/name=\"message\"/);
|
|
});
|
|
|
|
it("FormBlock 이 fields[] 만 가지고 있고 fieldIds 가 없더라도, Export 레이어에서 자체 pb-form 레이아웃/폼 UI 를 생성하지 않아야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_legacy_1",
|
|
type: "form",
|
|
props: {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
fieldIds: [],
|
|
fields: [
|
|
{
|
|
id: "f1",
|
|
name: "email",
|
|
label: "이메일",
|
|
type: "email",
|
|
required: true,
|
|
},
|
|
],
|
|
} as any,
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 fallback(fields[]) 내보내기 테스트",
|
|
slug: "form-fallback-fields-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");
|
|
|
|
// 새로운 규칙: FormBlock 은 어떤 경우에도 자체 레이아웃/폼 UI 를 생성하지 않는다.
|
|
// - fallback fields 기반 pb-form 도 더 이상 허용하지 않는다.
|
|
expect(html).not.toContain("class=\"pb-form\"");
|
|
expect(html).not.toContain("<form");
|
|
expect(html).not.toMatch(/name=\"email\"/);
|
|
});
|
|
|
|
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("비디오 포스터 이미지 /api/image/:id 는 ZIP 안 images/ 로 복사되고 poster 경로가 상대 경로로 치환되어야 한다", async () => {
|
|
const posterId = `test-video-poster-${Date.now()}`;
|
|
const uploadDir = path.join(process.cwd(), "uploads");
|
|
await fs.mkdir(uploadDir, { recursive: true });
|
|
await fs.writeFile(path.join(uploadDir, posterId), Buffer.from("dummy-poster"));
|
|
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "blk_video_poster",
|
|
type: "video" as any,
|
|
props: {
|
|
sourceUrl: "https://example.com/video.mp4",
|
|
align: "center",
|
|
widthMode: "auto",
|
|
aspectRatio: "16:9",
|
|
posterImageSrc: `/api/image/${posterId}`,
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "비디오 포스터 내보내기 테스트",
|
|
slug: "video-poster-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 posterEntry = zip.file(`images/${posterId}`);
|
|
expect(posterEntry).toBeTruthy();
|
|
|
|
const posterBytes = await posterEntry!.async("nodebuffer");
|
|
expect(posterBytes.length).toBeGreaterThan(0);
|
|
|
|
const html = await zip.file("index.html")!.async("string");
|
|
expect(html).toContain(`poster=\"./images/${posterId}\"`);
|
|
expect(html).not.toContain(`/api/image/${posterId}`);
|
|
});
|
|
|
|
it("HTML5 비디오 startTimeSec/endTimeSec 는 정적 HTML video 태그 data-start-seconds/data-end-seconds 속성으로 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "blk_video_range",
|
|
type: "video" as any,
|
|
props: {
|
|
sourceUrl: "https://example.com/video-range.mp4",
|
|
align: "center",
|
|
widthMode: "auto",
|
|
aspectRatio: "16:9",
|
|
startTimeSec: 5,
|
|
endTimeSec: 12,
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "비디오 시작/종료 시점 내보내기 테스트",
|
|
slug: "video-range-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 html = await zip.file("index.html")!.async("string");
|
|
expect(html).toContain("data-start-seconds=\"5\"");
|
|
expect(html).toContain("data-end-seconds=\"12\"");
|
|
});
|
|
|
|
it("섹션 backgroundImageSrc 에 /api/image/:id 가 포함된 경우에도 ZIP images/ 및 상대 경로로 치환되어야 한다", async () => {
|
|
const imageId = `test-section-bg-${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: "sec_bg",
|
|
type: "section",
|
|
props: {
|
|
background: "default",
|
|
paddingY: "md",
|
|
columns: [{ id: "sec_bg_col_1", span: 12 }],
|
|
backgroundImageSrc: `/api/image/${imageId}`,
|
|
backgroundImageSize: "cover",
|
|
backgroundImagePosition: "center",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "sec_bg_text",
|
|
type: "text",
|
|
props: {
|
|
text: "섹션 배경 이미지 내보내기 테스트",
|
|
align: "left",
|
|
size: "base",
|
|
},
|
|
sectionId: "sec_bg",
|
|
columnId: "sec_bg_col_1",
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "섹션 배경 이미지 내보내기 테스트",
|
|
slug: "section-bg-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(`background-image:url(./images/${imageId})`);
|
|
expect(html).not.toContain(`/api/image/${imageId}`);
|
|
});
|
|
|
|
it("섹션 backgroundImageRepeat 가 설정되면 정적 HTML style 에 background-repeat 이 포함되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "sec_bg_repeat",
|
|
type: "section",
|
|
props: {
|
|
background: "default",
|
|
paddingY: "md",
|
|
columns: [{ id: "sec_bg_repeat_col_1", span: 12 }],
|
|
backgroundImageSrc: "/images/pattern.png",
|
|
backgroundImageSize: "auto",
|
|
backgroundImageRepeat: "repeat-x",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "섹션 배경 반복 테스트",
|
|
slug: "section-bg-repeat-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 html = await zip.file("index.html")!.async("string");
|
|
expect(html).toContain("background-repeat:repeat-x");
|
|
});
|
|
|
|
it("섹션 backgroundImagePositionMode 가 custom 이고 X/Y 퍼센트가 설정되면 정적 HTML background-position 에 'X% Y%' 가 포함되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "sec_bg_xy",
|
|
type: "section",
|
|
props: {
|
|
background: "default",
|
|
paddingY: "md",
|
|
columns: [{ id: "sec_bg_xy_col_1", span: 12 }],
|
|
backgroundImageSrc: "/images/section-bg-xy.png",
|
|
backgroundImageSize: "cover",
|
|
backgroundImagePositionMode: "custom",
|
|
backgroundImagePositionXPercent: 10,
|
|
backgroundImagePositionYPercent: 90,
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "섹션 배경 XY 위치 테스트",
|
|
slug: "section-bg-xy-position-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 html = await zip.file("index.html")!.async("string");
|
|
expect(html).toContain("background-position:10% 90%");
|
|
});
|
|
|
|
it("HTML5 비디오 ariaLabel/captionText 는 정적 HTML video 태그 aria-label 및 pb-video-caption 으로 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "blk_video_a11y_html5",
|
|
type: "video" as any,
|
|
props: {
|
|
sourceUrl: "https://example.com/video-a11y.mp4",
|
|
align: "center",
|
|
widthMode: "auto",
|
|
aspectRatio: "16:9",
|
|
ariaLabel: "정적 내보내기 접근성 비디오 설명",
|
|
captionText: "정적 내보내기 비디오 캡션입니다.",
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "비디오 접근성 HTML5 내보내기 테스트",
|
|
slug: "video-a11y-html5-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 html = await zip.file("index.html")!.async("string");
|
|
expect(html).toContain('aria-label="정적 내보내기 접근성 비디오 설명"');
|
|
expect(html).toContain('<p class="pb-video-caption">정적 내보내기 비디오 캡션입니다.</p>');
|
|
});
|
|
|
|
it("titleText 가 설정된 YouTube 비디오는 정적 HTML iframe title 로 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "blk_video_a11y_youtube",
|
|
type: "video" as any,
|
|
props: {
|
|
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
align: "center",
|
|
widthMode: "auto",
|
|
aspectRatio: "16:9",
|
|
titleText: "정적 내보내기 YouTube 비디오 제목",
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "비디오 접근성 YouTube 내보내기 테스트",
|
|
slug: "video-a11y-youtube-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 html = await zip.file("index.html")!.async("string");
|
|
expect(html).toContain('iframe title="정적 내보내기 YouTube 비디오 제목"');
|
|
});
|
|
|
|
it("/api/video/:id 기반 비디오 URL은 ZIP 안 videos/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
|
|
const videoId = `test-video-${Date.now()}`;
|
|
const uploadDir = path.join(process.cwd(), "uploads");
|
|
await fs.mkdir(uploadDir, { recursive: true });
|
|
await fs.writeFile(path.join(uploadDir, videoId), Buffer.from("dummy-video"));
|
|
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "blk_video",
|
|
type: "video" as any,
|
|
props: {
|
|
sourceUrl: `/api/video/${videoId}`,
|
|
align: "center",
|
|
widthMode: "auto",
|
|
aspectRatio: "16:9",
|
|
backgroundColorCustom: "#123456",
|
|
cardPaddingPx: 24,
|
|
borderRadiusPx: 16,
|
|
} as any,
|
|
},
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "비디오 내보내기 테스트",
|
|
slug: "video-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 videoEntry = zip.file(`videos/${videoId}`);
|
|
expect(videoEntry).toBeTruthy();
|
|
|
|
const videoBytes = await videoEntry!.async("nodebuffer");
|
|
expect(videoBytes.length).toBeGreaterThan(0);
|
|
|
|
const html = await zip.file("index.html")!.async("string");
|
|
expect(html).toContain(`src="./videos/${videoId}`);
|
|
expect(html).not.toContain(`/api/video/${videoId}`);
|
|
// 비디오 카드 스타일이 wrapper style 에 반영되어야 한다.
|
|
expect(html).toContain("background-color:#123456");
|
|
expect(html).toContain("padding:1.5em");
|
|
expect(html).toContain("border-radius:1em");
|
|
});
|
|
|
|
it("섹션 backgroundVideoSrc 가 /api/video/:id 인 경우 ZIP videos/ 및 상대 경로로 치환되고 섹션 안에 배경 비디오 video 태그로 렌더되어야 한다", async () => {
|
|
const videoId = `test-section-bg-video-${Date.now()}`;
|
|
const uploadDir = path.join(process.cwd(), "uploads");
|
|
await fs.mkdir(uploadDir, { recursive: true });
|
|
await fs.writeFile(path.join(uploadDir, videoId), Buffer.from("dummy-section-video"));
|
|
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "sec_bg_video",
|
|
type: "section",
|
|
props: {
|
|
background: "default",
|
|
paddingY: "md",
|
|
columns: [{ id: "sec_bg_video_col_1", span: 12 }],
|
|
backgroundVideoSrc: `/api/video/${videoId}`,
|
|
},
|
|
} as any,
|
|
{
|
|
id: "sec_bg_video_text",
|
|
type: "text",
|
|
sectionId: "sec_bg_video",
|
|
columnId: "sec_bg_video_col_1",
|
|
props: {
|
|
text: "섹션 배경 비디오 내보내기 테스트",
|
|
align: "left",
|
|
size: "base",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "섹션 배경 비디오 내보내기 테스트",
|
|
slug: "section-bg-video-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 videoEntry = zip.file(`videos/${videoId}`);
|
|
expect(videoEntry).toBeTruthy();
|
|
|
|
const videoBytes = await videoEntry!.async("nodebuffer");
|
|
expect(videoBytes.length).toBeGreaterThan(0);
|
|
|
|
const html = await zip.file("index.html")!.async("string");
|
|
expect(html).toContain(`src="./videos/${videoId}`);
|
|
expect(html).not.toContain(`/api/video/${videoId}`);
|
|
expect(html).toContain("class=\"pb-section-bg-video\"");
|
|
});
|
|
|
|
it("divider 블록은 정적 HTML에서 구분선 요소로 렌더되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "divider_1",
|
|
type: "divider",
|
|
props: {
|
|
align: "center",
|
|
thickness: "medium",
|
|
widthMode: "full",
|
|
colorHex: "#123456",
|
|
marginYPx: 32,
|
|
},
|
|
} 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");
|
|
// 스타일 검증: medium 두께, 지정 색상, marginYPx 기반 여백이 style 속성에 포함되어야 한다.
|
|
expect(html).toContain("border-bottom:2px solid #123456");
|
|
expect(html).toContain("margin:2em 0;");
|
|
});
|
|
|
|
it("image 블록은 정적 HTML에서 카드 스타일(배경/너비/둥글기)이 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "image_1",
|
|
type: "image",
|
|
props: {
|
|
src: "/images/example.png",
|
|
alt: "카드 이미지",
|
|
align: "center",
|
|
widthMode: "fixed",
|
|
widthPx: 480,
|
|
borderRadiusPx: 32,
|
|
backgroundColorCustom: "#123456",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "image 스타일 테스트",
|
|
slug: "image-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('<img src="/images/example.png"');
|
|
// 카드 배경색이 wrapper div style 에 반영되어야 한다.
|
|
expect(html).toContain("background-color:#123456");
|
|
// 고정 너비/둥글기 스타일이 img style 에 반영되어야 한다.
|
|
expect(html).toContain("width:30em");
|
|
expect(html).toContain("border-radius:32px");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
function createIdFactory() {
|
|
let i = 0;
|
|
return () => `tpl_${++i}`;
|
|
}
|
|
|
|
async function exportTemplateHtml(blocks: Block[]): Promise<string> {
|
|
const projectConfig: ProjectConfig = {
|
|
title: "템플릿 export 테스트",
|
|
slug: "template-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");
|
|
return html;
|
|
}
|
|
|
|
it("Hero 템플릿 섹션은 export HTML 에 헤드라인/서브텍스트/버튼 텍스트를 포함해야 한다", async () => {
|
|
const sectionId = "hero_section_export";
|
|
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
|
|
const { blocks } = createHeroTemplateBlocks({
|
|
sectionId,
|
|
createId: createIdFactory(),
|
|
messages: tpl.hero,
|
|
});
|
|
|
|
const html = await exportTemplateHtml(blocks as Block[]);
|
|
|
|
expect(html).toContain("Your hero headline goes here");
|
|
expect(html).toContain(
|
|
"Describe your product or service in a single, clear sentence.",
|
|
);
|
|
expect(html).toContain("Get started now");
|
|
});
|
|
|
|
it("Features 템플릿 섹션은 export HTML 에 3개의 Feature 제목과 설명 텍스트를 포함해야 한다", async () => {
|
|
const sectionId = "features_section_export";
|
|
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
|
|
const { blocks } = createFeaturesTemplateBlocks({
|
|
sectionId,
|
|
createId: createIdFactory(),
|
|
messages: tpl.features,
|
|
});
|
|
|
|
const html = await exportTemplateHtml(blocks as Block[]);
|
|
|
|
expect(html).toContain("Feature 1");
|
|
expect(html).toContain("Feature 2");
|
|
expect(html).toContain("Feature 3");
|
|
|
|
const descText = "A short description of this feature.";
|
|
const count = html.split(descText).length - 1;
|
|
expect(count).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it("CTA 템플릿 섹션은 export HTML 에 CTA 텍스트와 버튼 라벨을 포함해야 한다", async () => {
|
|
const sectionId = "cta_section_export";
|
|
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
|
|
const { blocks } = createCtaTemplateBlocks({
|
|
sectionId,
|
|
createId: createIdFactory(),
|
|
messages: tpl.cta,
|
|
});
|
|
|
|
const html = await exportTemplateHtml(blocks as Block[]);
|
|
|
|
expect(html).toContain("Write a compelling CTA message here.");
|
|
// 작은따옴표는 HTML 이스케이프되어 Don't 형태로 렌더된다.
|
|
expect(html).toContain("Don't hesitate to get started!");
|
|
expect(html).toContain("Call to action");
|
|
});
|
|
|
|
it("Footer 템플릿 섹션은 export HTML 에 푸터 설명과 링크 텍스트를 포함해야 한다", async () => {
|
|
const sectionId = "footer_section_export";
|
|
const tpl = getEditorTemplatesMessages(DEFAULT_LOCALE);
|
|
const { blocks } = createFooterTemplateBlocks({
|
|
sectionId,
|
|
createId: createIdFactory(),
|
|
messages: tpl.footer,
|
|
});
|
|
|
|
const html = await exportTemplateHtml(blocks as Block[]);
|
|
|
|
expect(html).toContain("The best choice for building better websites.");
|
|
expect(html).toContain("Product");
|
|
expect(html).toContain("Pricing");
|
|
expect(html).toContain("Support");
|
|
expect(html).toContain("Contact");
|
|
});
|
|
|
|
it("기본 텍스트 블록은 프리뷰처럼 흰 글자와 줄바꿈 유지 스타일을 사용해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "text_default",
|
|
type: "text",
|
|
props: {
|
|
text: "첫 줄\n둘째 줄",
|
|
align: "left",
|
|
size: "base",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "텍스트 기본 스타일 테스트",
|
|
slug: "text-default-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("둘째 줄");
|
|
expect(html).toContain("pb-text-color-strong");
|
|
expect(html).toContain("pb-whitespace-pre-wrap");
|
|
});
|
|
|
|
it("custom 색상을 가진 텍스트 블록은 정적 HTML에서도 같은 색상을 사용해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "text_custom_color",
|
|
type: "text",
|
|
props: {
|
|
text: "커스텀 색상 텍스트",
|
|
align: "left",
|
|
size: "base",
|
|
colorMode: "custom",
|
|
colorCustom: "#ff0000",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "텍스트 색상 커스텀 테스트",
|
|
slug: "text-custom-color-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("color:#ff0000");
|
|
});
|
|
|
|
it("backgroundColorCustom 이 지정된 텍스트 블록은 정적 HTML에서 같은 배경색을 사용해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "text_bg_custom",
|
|
type: "text",
|
|
props: {
|
|
text: "배경색 있는 텍스트",
|
|
align: "left",
|
|
size: "base",
|
|
backgroundColorCustom: "#123456",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "텍스트 배경색 테스트",
|
|
slug: "text-bg-color-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("background-color:#123456");
|
|
});
|
|
|
|
it("backgroundColorCustom 이 지정된 리스트 블록은 정적 HTML에서 리스트 컨테이너 배경색을 반영해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "list_bg_custom",
|
|
type: "list",
|
|
props: {
|
|
items: ["아이템 1"],
|
|
ordered: false,
|
|
align: "left",
|
|
backgroundColorCustom: "#00ff88",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "리스트 배경색 테스트",
|
|
slug: "list-bg-color-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("아이템 1");
|
|
expect(html).toContain("background-color:#00ff88");
|
|
});
|
|
|
|
it("backgroundColorCustom 이 지정된 폼 블록은 정적 HTML에서 form 요소 배경색을 반영하지 않아야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_bg_custom",
|
|
type: "form",
|
|
props: {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
// v1 fields 설정이 있는 FormBlock 은 여전히 정적 HTML 폼으로 렌더되어야 한다.
|
|
fields: [
|
|
{
|
|
id: "f1",
|
|
name: "email",
|
|
label: "이메일",
|
|
type: "email",
|
|
required: true,
|
|
},
|
|
],
|
|
fieldIds: [],
|
|
formWidthMode: "auto",
|
|
backgroundColorCustom: "#111111",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 배경색 테스트",
|
|
slug: "form-bg-color-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");
|
|
|
|
// 새로운 규칙: FormBlock 은 Export 에서 자체 폼 레이아웃을 생성하지 않는다.
|
|
// - backgroundColorCustom 이 있더라도 form 요소/컨테이너에 배경색이 적용되지 않아야 한다.
|
|
// - pb-form 기반 레이아웃도 생성되지 않아야 한다.
|
|
expect(html).not.toContain("class=\"pb-form\"");
|
|
expect(html).not.toContain("background-color:#111111");
|
|
});
|
|
|
|
it("backgroundColorCustom 이 지정된 섹션 블록은 정적 HTML에서 섹션 요소 배경색을 인라인 스타일로 반영해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "sec_1",
|
|
type: "section",
|
|
props: {
|
|
background: "default",
|
|
paddingY: "md",
|
|
columns: [{ id: "sec_1_col_1", span: 12 }],
|
|
backgroundColorCustom: "#123456",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "sec_1_text",
|
|
type: "text",
|
|
sectionId: "sec_1",
|
|
columnId: "sec_1_col_1",
|
|
props: {
|
|
text: "섹션 안 텍스트",
|
|
align: "left",
|
|
size: "base",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "섹션 배경색 테스트",
|
|
slug: "section-bg-color-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("class=\"pb-section");
|
|
expect(html).toContain("background-color:#123456");
|
|
});
|
|
|
|
it("루트/섹션 레이아웃은 pb-root / pb-section-inner / pb-section-columns 구조를 사용해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "root_layout_text",
|
|
type: "text",
|
|
props: {
|
|
text: "루트 레이아웃 텍스트",
|
|
align: "left",
|
|
size: "base",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "sec_layout_1",
|
|
type: "section",
|
|
props: {
|
|
background: "default",
|
|
paddingY: "md",
|
|
columns: [{ id: "sec_layout_1_col_1", span: 12 }],
|
|
},
|
|
} as any,
|
|
{
|
|
id: "sec_layout_1_text",
|
|
type: "text",
|
|
sectionId: "sec_layout_1",
|
|
columnId: "sec_layout_1_col_1",
|
|
props: {
|
|
text: "섹션 레이아웃 텍스트",
|
|
align: "left",
|
|
size: "base",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "레이아웃 구조 테스트",
|
|
slug: "layout-structure-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");
|
|
// 루트 텍스트는 pb-root / pb-root-inner 안에 포함되어야 한다.
|
|
expect(html).toContain('<section class="pb-root"><div class="pb-root-inner">');
|
|
expect(html).toContain("루트 레이아웃 텍스트");
|
|
// 섹션 레이아웃 구조: pb-section + pb-section-inner + pb-section-columns + pb-section-column
|
|
expect(html).toContain('class="pb-section');
|
|
expect(html).toContain('class="pb-section-inner"');
|
|
expect(html).toContain('class="pb-section-columns"');
|
|
expect(html).toContain('class="pb-section-column"');
|
|
expect(html).toContain("섹션 레이아웃 텍스트");
|
|
});
|
|
|
|
it("FormBlock.submitButtonId 로 매핑된 버튼은 해당 form 을 submit 하는 button 요소로 Export 되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_1",
|
|
type: "form",
|
|
props: {
|
|
fieldIds: ["field_email"],
|
|
requiredFieldIds: [],
|
|
submitButtonId: "submit_btn",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "field_email",
|
|
type: "formInput",
|
|
props: {
|
|
label: "이메일",
|
|
formFieldName: "email",
|
|
},
|
|
} as any,
|
|
{
|
|
id: "submit_btn",
|
|
type: "button",
|
|
props: {
|
|
label: "폼 제출",
|
|
href: "#",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 제출 버튼 매핑 테스트",
|
|
slug: "form-submit-button-mapping-test",
|
|
canvasPreset: "full",
|
|
} as ProjectConfig;
|
|
|
|
const { buildStaticHtml } = await import("@/app/api/export/route");
|
|
|
|
const html = buildStaticHtml(blocks, projectConfig);
|
|
|
|
expect(html).toContain('<form id="form_form_1" class="pb-form-controller" method="post" action="/api/forms/submit"');
|
|
expect(html).toContain('type="submit" form="form_form_1"');
|
|
expect(html).toContain("폼 제출");
|
|
});
|
|
|
|
it("formInput 블록의 textColorCustom 은 정적 HTML에서 input 텍스트 색상 스타일로 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_input_color",
|
|
type: "formInput",
|
|
props: {
|
|
label: "이메일",
|
|
formFieldName: "email",
|
|
required: true,
|
|
textColorCustom: "#123456",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 입력 텍스트 색상 테스트",
|
|
slug: "form-input-text-color-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('name="email"');
|
|
expect(html).toContain("color:#123456");
|
|
});
|
|
|
|
it("formSelect 블록의 textColorCustom 은 정적 HTML에서 select 텍스트 색상 스타일로 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_select_color_export",
|
|
type: "formSelect",
|
|
props: {
|
|
label: "플랜",
|
|
formFieldName: "plan",
|
|
options: [
|
|
{ label: "Basic", value: "basic" },
|
|
{ label: "Pro", value: "pro" },
|
|
],
|
|
textColorCustom: "#00ff88",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 셀렉트 텍스트 색상 테스트",
|
|
slug: "form-select-text-color-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("name=\"plan\"");
|
|
expect(html).toContain("color:#00ff88");
|
|
});
|
|
|
|
it("formCheckbox 블록의 textColorCustom 은 정적 HTML에서 그룹/옵션 텍스트 색상 스타일로 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_checkbox_color_export",
|
|
type: "formCheckbox",
|
|
props: {
|
|
groupLabel: "기능",
|
|
formFieldName: "features",
|
|
options: [{ label: "옵션 1", value: "opt1" }],
|
|
textColorCustom: "#8800ff",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 체크박스 텍스트 색상 테스트",
|
|
slug: "form-checkbox-text-color-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("name=\"features\"");
|
|
expect(html).toContain("color:#8800ff");
|
|
});
|
|
|
|
it("formRadio 블록의 textColorCustom 은 정적 HTML에서 그룹/옵션 텍스트 색상 스타일로 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_radio_color_export",
|
|
type: "formRadio",
|
|
props: {
|
|
groupLabel: "플랜",
|
|
formFieldName: "plan",
|
|
options: [{ label: "A 플랜", value: "a" }],
|
|
textColorCustom: "#ff9900",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 라디오 텍스트 색상 테스트",
|
|
slug: "form-radio-text-color-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("name=\"plan\"");
|
|
expect(html).toContain("color:#ff9900");
|
|
});
|
|
|
|
it("formInput 블록은 스타일 속성으로 필드 배경/보더/패딩/너비/둥글기를 반영해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_input_styles_export",
|
|
type: "formInput",
|
|
props: {
|
|
label: "이메일",
|
|
formFieldName: "email",
|
|
required: true,
|
|
widthMode: "fixed",
|
|
widthPx: 320,
|
|
paddingX: 16,
|
|
paddingY: 8,
|
|
textColorCustom: "#112233",
|
|
fillColorCustom: "#123456",
|
|
strokeColorCustom: "#ff0000",
|
|
borderRadius: "lg",
|
|
fontSizeCustom: "24px",
|
|
lineHeightCustom: "30px",
|
|
letterSpacingCustom: "2px",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 입력 스타일 테스트",
|
|
slug: "form-input-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("name=\"email\"");
|
|
expect(html).toContain("width:20em");
|
|
expect(html).toContain("padding-left:1em");
|
|
expect(html).toContain("padding-right:1em");
|
|
expect(html).toContain("padding-top:0.5em");
|
|
expect(html).toContain("padding-bottom:0.5em");
|
|
expect(html).toContain("background-color:#123456");
|
|
expect(html).toContain("border-color:#ff0000");
|
|
expect(html).toContain("border-radius:6px");
|
|
expect(html).toContain("font-size:1.5em");
|
|
expect(html).toContain("line-height:1.875em");
|
|
expect(html).toContain("letter-spacing:0.125em");
|
|
});
|
|
|
|
it("formSelect 블록은 스타일 속성으로 셀렉트 배경/보더/패딩/너비/둥글기를 반영해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_select_styles_export",
|
|
type: "formSelect",
|
|
props: {
|
|
label: "카테고리",
|
|
formFieldName: "category",
|
|
options: [
|
|
{ label: "옵션 A", value: "a" },
|
|
{ label: "옵션 B", value: "b" },
|
|
],
|
|
widthMode: "fixed",
|
|
widthPx: 240,
|
|
paddingX: 16,
|
|
paddingY: 8,
|
|
textColorCustom: "#00ff00",
|
|
fillColorCustom: "#123456",
|
|
strokeColorCustom: "#ff0000",
|
|
borderRadius: "lg",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 셀렉트 스타일 테스트",
|
|
slug: "form-select-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("name=\"category\"");
|
|
expect(html).toContain("width:15em");
|
|
expect(html).toContain("padding-left:1em");
|
|
expect(html).toContain("padding-right:1em");
|
|
expect(html).toContain("padding-top:0.5em");
|
|
expect(html).toContain("padding-bottom:0.5em");
|
|
expect(html).toContain("background-color:#123456");
|
|
expect(html).toContain("border-color:#ff0000");
|
|
expect(html).toContain("border-radius:6px");
|
|
});
|
|
|
|
it("formCheckbox 블록은 스타일 속성으로 옵션 컨테이너 배경/보더/패딩/둥글기를 반영해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_checkbox_styles_export",
|
|
type: "formCheckbox",
|
|
props: {
|
|
groupLabel: "기능",
|
|
formFieldName: "features",
|
|
options: [{ label: "옵션 1", value: "opt1" }],
|
|
paddingX: 8,
|
|
paddingY: 4,
|
|
textColorCustom: "#ffffff",
|
|
fillColorCustom: "#123456",
|
|
strokeColorCustom: "#ff0000",
|
|
borderRadius: "lg",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 체크박스 스타일 테스트",
|
|
slug: "form-checkbox-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("name=\"features\"");
|
|
expect(html).toContain("padding-left:0.5em");
|
|
expect(html).toContain("padding-right:0.5em");
|
|
expect(html).toContain("padding-top:0.25em");
|
|
expect(html).toContain("padding-bottom:0.25em");
|
|
expect(html).toContain("background-color:#123456");
|
|
expect(html).toContain("border-color:#ff0000");
|
|
expect(html).toContain("border-radius:6px");
|
|
});
|
|
|
|
it("formRadio 블록은 스타일 속성으로 옵션 컨테이너 배경/보더/패딩/둥글기를 반영해야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "form_radio_styles_export",
|
|
type: "formRadio",
|
|
props: {
|
|
groupLabel: "플랜",
|
|
formFieldName: "plan",
|
|
options: [{ label: "플랜 A", value: "a" }],
|
|
paddingX: 8,
|
|
paddingY: 4,
|
|
textColorCustom: "#ffffff",
|
|
fillColorCustom: "#123456",
|
|
strokeColorCustom: "#ff0000",
|
|
borderRadius: "lg",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "폼 라디오 스타일 테스트",
|
|
slug: "form-radio-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("name=\"plan\"");
|
|
expect(html).toContain("padding-left:0.5em");
|
|
expect(html).toContain("padding-right:0.5em");
|
|
expect(html).toContain("padding-top:0.25em");
|
|
expect(html).toContain("padding-bottom:0.25em");
|
|
expect(html).toContain("background-color:#123456");
|
|
expect(html).toContain("border-color:#ff0000");
|
|
expect(html).toContain("border-radius:6px");
|
|
});
|
|
|
|
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("버튼 블록의 커스텀 스타일은 style 속성으로 반영되어야 한다", async () => {
|
|
const blocks: Block[] = [
|
|
{
|
|
id: "button_custom_styles",
|
|
type: "button",
|
|
props: {
|
|
label: "스타일 버튼",
|
|
href: "#",
|
|
align: "left",
|
|
widthMode: "fixed",
|
|
widthPx: 240,
|
|
paddingX: 16,
|
|
paddingY: 8,
|
|
fillColorCustom: "#123456",
|
|
strokeColorCustom: "#ff0000",
|
|
textColorCustom: "#00ff00",
|
|
},
|
|
} as any,
|
|
];
|
|
|
|
const projectConfig: ProjectConfig = {
|
|
title: "버튼 커스텀 스타일 테스트",
|
|
slug: "button-custom-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");
|
|
// widthMode=fixed, widthPx, paddingX/paddingY, fillColorCustom, strokeColorCustom, textColorCustom 이 style 속성에 포함되어야 한다.
|
|
expect(html).toContain("width:15em");
|
|
expect(html).toContain("padding-left:1em");
|
|
expect(html).toContain("padding-right:1em");
|
|
expect(html).toContain("padding-top:0.5em");
|
|
expect(html).toContain("padding-bottom:0.5em");
|
|
expect(html).toContain("background-color:#123456");
|
|
expect(html).toContain("border-color:#ff0000");
|
|
expect(html).toContain("color:#00ff00");
|
|
});
|
|
|
|
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");
|
|
expect(css).toContain(".pb-whitespace-pre-wrap");
|
|
expect(css).toContain("color: var(--pb-color-text-strong)");
|
|
// 레이아웃 관련 유틸리티 클래스도 고정해 둔다.
|
|
expect(css).toContain(".pb-root-inner");
|
|
expect(css).toContain("max-width: 72rem");
|
|
expect(css).toContain(".pb-section-inner");
|
|
expect(css).toContain("margin-inline: auto");
|
|
expect(css).toContain(".pb-section-columns");
|
|
expect(css).toContain("gap: 2rem");
|
|
expect(css).toContain(".pb-section-column");
|
|
});
|
|
});
|