video 블록 및 리펙터링
CI / pr_and_merge (push) Has been skipped
CI / test (push) Failing after 46m46s
CI / test (pull_request) Failing after 43m8s
CI / pr_and_merge (pull_request) Has been skipped

This commit is contained in:
2025-11-27 10:07:59 +09:00
parent 672cca5271
commit 48e13d2a75
223 changed files with 8979 additions and 2125 deletions
+730 -2
View File
@@ -73,6 +73,144 @@ describe("/api/export", () => {
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[] = [];
@@ -259,6 +397,55 @@ describe("/api/export", () => {
expect(html).not.toMatch(/name=\"plan\"[^>]*required/);
});
it("FormBlock 에 fieldIds/fields 가 모두 없으면 기본 폼/필드를 내보내지 않아야 한다", 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("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
const imageId = `test-image-${Date.now()}`;
const uploadDir = path.join(process.cwd(), "uploads");
@@ -312,6 +499,460 @@ describe("/api/export", () => {
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:24px");
expect(html).toContain("border-radius:16px");
});
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[] = [
{
@@ -976,7 +1617,16 @@ describe("/api/export", () => {
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
// v1 fields 설정이 있는 FormBlock 은 여전히 정적 HTML 폼으로 렌더되어야 한다.
fields: [
{
id: "f1",
name: "email",
label: "이메일",
type: "email",
required: true,
},
],
fieldIds: [],
formWidthMode: "auto",
backgroundColorCustom: "#111111",
@@ -1072,6 +1722,77 @@ describe("/api/export", () => {
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("formInput 블록의 textColorCustom 은 정적 HTML에서 input 텍스트 색상 스타일로 반영되어야 한다", async () => {
const blocks: Block[] = [
{
@@ -1113,7 +1834,7 @@ describe("/api/export", () => {
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
expect(html).toContain("name=\"email\"");
expect(html).toContain('name="email"');
expect(html).toContain("color:#123456");
});
@@ -1695,6 +2416,13 @@ describe("/api/export", () => {
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(".pb-section-columns");
expect(css).toContain("gap: 2rem");
expect(css).toContain(".pb-section-column");
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import "dotenv/config";
import { describe, it, expect, vi } from "vitest";
import { promises as fs } from "fs";
import path from "path";
const BASE_URL = "http://localhost";
// /api/video 업로드 TDD
// - POST /api/video 로 FormData(file) 를 전송하면 업로드 파일이 uploads 디렉터리에 저장되고
// id / servedUrl(`/api/video/:id`) 을 반환해야 한다.
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 목 처리한다.
vi.mock("@/generated/prisma/client", () => {
class PrismaClient {
project = {
upsert: async () => ({ id: "project-mock" }),
};
asset = {
create: async () => ({ id: "asset-mock" }),
};
}
return { PrismaClient };
});
describe("/api/video 업로드", () => {
it("POST /api/video 는 업로드된 비디오 파일을 저장하고 id/servedUrl 을 반환해야 한다", async () => {
const { POST: handleUpload } = await import("@/app/api/video/route");
const formData = new FormData();
const blob = new Blob(["dummy-video"], { type: "video/mp4" });
// FormData 에서는 File/Blob 모두 허용되므로, Blob 을 그대로 사용한다.
formData.append("file", blob as any);
const res = await handleUpload(
new Request(`${BASE_URL}/api/video`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(201);
const json = (await res.json()) as { id: string; servedUrl?: string | null };
expect(json.id).toBeTruthy();
expect(json.servedUrl).toBe(`/api/video/${json.id}`);
const filePath = path.join(process.cwd(), "uploads", json.id);
const stat = await fs.stat(filePath);
expect(stat.isFile()).toBe(true);
});
});
+10
View File
@@ -22,6 +22,7 @@ const otherActions = {
addTextBlock: vi.fn(),
addButtonBlock: vi.fn(),
addImageBlock: vi.fn(),
addVideoBlock: vi.fn(),
addDividerBlock: vi.fn(),
addListBlock: vi.fn(),
addSectionBlock: vi.fn(),
@@ -110,6 +111,15 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
expect(screen.getByText("페이지 푸터 섹션"));
});
it("비디오 블록 추가 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "비디오 블록 추가" });
fireEvent.click(button);
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
});
it("템플릿들은 카테고리별로 그룹 헤더를 가져야 한다", () => {
render(<BlocksSidebar />);
@@ -0,0 +1,206 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import EditorPage from "@/app/editor/page";
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "섹션 배경 이미지 테스트",
slug: "section-bg-image-test",
canvasPreset: "full",
canvasWidthPx: 1024,
canvasBgColorHex: "#0f172a",
bodyBgColorHex: "#020617",
} as ProjectConfig;
mockState = {
blocks: [] as Block[],
projectConfig: baseProjectConfig,
selectedBlockId: null as string | null,
selectedListItemId: null as string | null,
undo: vi.fn(),
redo: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
selectBlock: vi.fn(),
selectListItem: vi.fn(),
addTextBlock: vi.fn(),
addButtonBlock: vi.fn(),
addImageBlock: vi.fn(),
addDividerBlock: vi.fn(),
addListBlock: vi.fn(),
addSectionBlock: vi.fn(),
addFormBlock: vi.fn(),
addFormInputBlock: vi.fn(),
addFormSelectBlock: vi.fn(),
addFormCheckboxBlock: vi.fn(),
addFormRadioBlock: vi.fn(),
addHeroTemplateSection: vi.fn(),
addFeaturesTemplateSection: vi.fn(),
addCtaTemplateSection: vi.fn(),
addFaqTemplateSection: vi.fn(),
addPricingTemplateSection: vi.fn(),
addTestimonialsTemplateSection: vi.fn(),
addBlogTemplateSection: vi.fn(),
addTeamTemplateSection: vi.fn(),
addFooterTemplateSection: vi.fn(),
updateBlock: vi.fn(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
};
});
afterEach(() => {
cleanup();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
(useEditorStore as any).getState = () => mockState;
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
// 섹션 배경 이미지 TDD
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
describe("EditorPage - 섹션 배경 이미지", () => {
it("섹션에 backgroundImageSrc/size/position 이 설정되면 editor-section 스타일에 backgroundImage/Size/Position 이 반영되어야 한다", () => {
const section: Block = {
id: "sec_bg_image",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_image_col_1", span: 12 }],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
backgroundImageSrc: "/api/image/section-bg-1",
backgroundImageSize: "cover",
backgroundImagePosition: "center",
backgroundImageRepeat: "repeat",
} as any,
} as any;
const child: Block = {
id: "txt_1",
type: "text",
props: {
text: "섹션 안 텍스트",
align: "left",
size: "base",
},
sectionId: "sec_bg_image",
columnId: "sec_bg_image_col_1",
} as any;
mockState.blocks = [section, child];
mockState.selectedBlockId = "sec_bg_image";
render(<EditorPage />);
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
expect(sectionEl.style.backgroundImage).toContain("/api/image/section-bg-1");
expect(sectionEl.style.backgroundSize).toBe("cover");
expect(sectionEl.style.backgroundPosition).toBe("center center");
expect(sectionEl.style.backgroundRepeat).toBe("repeat");
});
it("backgroundImagePositionMode 가 custom 이고 X/Y 퍼센트가 설정되면 background-position 은 'X% Y%' 로 설정되어야 한다", () => {
const section: Block = {
id: "sec_bg_image_xy",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_image_xy_col_1", span: 12 }],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
backgroundImageSrc: "/api/image/section-bg-xy",
backgroundImageSize: "cover",
backgroundImagePositionMode: "custom",
backgroundImagePositionXPercent: 30,
backgroundImagePositionYPercent: 70,
} as any,
} as any;
const child: Block = {
id: "txt_xy",
type: "text",
props: {
text: "XY 커스텀 위치 텍스트",
align: "left",
size: "base",
},
sectionId: "sec_bg_image_xy",
columnId: "sec_bg_image_xy_col_1",
} as any;
mockState.blocks = [section, child];
mockState.selectedBlockId = "sec_bg_image_xy";
render(<EditorPage />);
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
expect(sectionEl.style.backgroundImage).toContain("/api/image/section-bg-xy");
expect(sectionEl.style.backgroundPosition).toBe("30% 70%");
});
it("backgroundVideoSrc 가 설정된 섹션은 배경 비디오 video 태그가 렌더되어야 한다", () => {
const section: Block = {
id: "sec_bg_video",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_video_col_1", span: 12 }],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
backgroundVideoSrc: "/videos/section-bg.mp4",
} as any,
} as any;
const child: Block = {
id: "txt_video",
type: "text",
props: {
text: "섹션 배경 비디오 텍스트",
align: "left",
size: "base",
},
sectionId: "sec_bg_video",
columnId: "sec_bg_video_col_1",
} as any;
mockState.blocks = [section, child];
mockState.selectedBlockId = "sec_bg_video";
render(<EditorPage />);
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
expect(sectionEl).toBeTruthy();
const videoEl = screen.getByTestId("editor-section-bg-video") as HTMLVideoElement;
expect(videoEl).toBeTruthy();
expect(videoEl.src).toContain("/videos/section-bg.mp4");
expect(videoEl.autoplay).toBe(true);
expect(videoEl.loop).toBe(true);
expect(videoEl.muted).toBe(true);
});
});
+276
View File
@@ -0,0 +1,276 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import EditorPage from "@/app/editor/page";
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "비디오 블록 스타일 테스트",
slug: "editor-video-style-test",
canvasPreset: "full",
canvasWidthPx: 1024,
canvasBgColorHex: "#0f172a",
bodyBgColorHex: "#020617",
} as ProjectConfig;
mockState = {
blocks: [] as Block[],
projectConfig: baseProjectConfig,
selectedBlockId: null as string | null,
selectedListItemId: null as string | null,
undo: vi.fn(),
redo: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
selectBlock: vi.fn(),
selectListItem: vi.fn(),
addTextBlock: vi.fn(),
addButtonBlock: vi.fn(),
addImageBlock: vi.fn(),
addDividerBlock: vi.fn(),
addListBlock: vi.fn(),
addSectionBlock: vi.fn(),
addFormBlock: vi.fn(),
addFormInputBlock: vi.fn(),
addFormSelectBlock: vi.fn(),
addFormCheckboxBlock: vi.fn(),
addFormRadioBlock: vi.fn(),
addHeroTemplateSection: vi.fn(),
addFeaturesTemplateSection: vi.fn(),
addCtaTemplateSection: vi.fn(),
addFaqTemplateSection: vi.fn(),
addPricingTemplateSection: vi.fn(),
addTestimonialsTemplateSection: vi.fn(),
addBlogTemplateSection: vi.fn(),
addTeamTemplateSection: vi.fn(),
addFooterTemplateSection: vi.fn(),
updateBlock: vi.fn(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
};
});
afterEach(() => {
cleanup();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
(useEditorStore as any).getState = () => mockState;
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
describe("EditorPage - 비디오 블록 스타일", () => {
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "video_youtube_1",
type: "video" as any,
props: {
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "video_youtube_1";
render(<EditorPage />);
const iframe = screen.getByTitle("비디오") as HTMLIFrameElement;
const wrapper = iframe.parentElement as HTMLElement;
expect(iframe).toBeTruthy();
expect(iframe.src).toContain("youtube.com/embed");
expect(wrapper.className).toContain("pb-video-wrapper");
});
it("업로드 비디오 URL 은 video 태그로 렌더되고 controls 속성을 포함해야 한다", () => {
const blocks: Block[] = [
{
id: "video_upload_1",
type: "video" as any,
props: {
sourceUrl: "/api/video/abc123",
align: "left",
widthMode: "fixed",
widthPx: 640,
aspectRatio: "16:9",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "video_upload_1";
render(<EditorPage />);
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
expect(video).toBeTruthy();
expect(video.getAttribute("controls")).not.toBeNull();
expect(video.style.width).toBe("640px");
});
it("posterImageSrc 가 설정된 HTML5 비디오는 video 태그 poster 속성으로 렌더되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_poster_1",
type: "video" as any,
props: {
sourceUrl: "/videos/sample.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
posterImageSrc: "/images/sample-poster.png",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "video_poster_1";
render(<EditorPage />);
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
expect(video).toBeTruthy();
expect(video.getAttribute("poster")).toBe("/images/sample-poster.png");
});
it("startTimeSec/endTimeSec 가 설정된 HTML5 비디오는 loadedmetadata/timeupdate 이벤트에서 시작/종료 범위가 적용되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_range_1",
type: "video" as any,
props: {
sourceUrl: "/videos/range.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
startTimeSec: 5,
endTimeSec: 10,
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "video_range_1";
render(<EditorPage />);
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
fireEvent(video, new Event("loadedmetadata"));
expect(video.currentTime).toBe(5);
(video as any).pause = vi.fn();
video.currentTime = 10.1;
fireEvent(video, new Event("timeupdate"));
expect((video as any).pause).toHaveBeenCalled();
});
it("HTML5 비디오의 ariaLabel/titleText/captionText 는 에디터에서 aria-label 및 캡션 요소로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_a11y_html5",
type: "video" as any,
props: {
sourceUrl: "/videos/a11y.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
titleText: "접근성 비디오 타이틀",
ariaLabel: "접근성 비디오 설명",
captionText: "비디오 캡션입니다.",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "video_a11y_html5";
render(<EditorPage />);
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
expect(video).toBeTruthy();
expect(video.getAttribute("aria-label")).toBe("접근성 비디오 설명");
const caption = screen.getByTestId("editor-video-caption");
expect(caption).toBeTruthy();
expect(caption.textContent).toBe("비디오 캡션입니다.");
});
it("sourceUrl 이 비어 있는 HTML5 비디오는 src 속성을 렌더링하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "video_empty_src",
type: "video" as any,
props: {
sourceUrl: "",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "video_empty_src";
render(<EditorPage />);
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
expect(video).toBeTruthy();
expect(video.getAttribute("src")).toBeNull();
});
it("비디오 카드 스타일 props(backgroundColorCustom/cardPaddingPx/borderRadiusPx)는 wrapper 의 스타일에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_card_1",
type: "video" as any,
props: {
sourceUrl: "/api/video/card-test",
align: "center",
widthMode: "fixed",
widthPx: 480,
aspectRatio: "16:9",
backgroundColorCustom: "#123456",
cardPaddingPx: 24,
borderRadiusPx: 16,
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "video_card_1";
render(<EditorPage />);
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
const wrapper = video.parentElement as HTMLElement;
expect(wrapper).toBeTruthy();
// #123456 -> rgb(18, 52, 86)
expect(wrapper.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(wrapper.style.padding).toBe("24px");
expect(wrapper.style.borderRadius).toBe("16px");
});
});
@@ -0,0 +1,57 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 이미지 블록 TDD
// - src 가 비어 있을 때는 <img> 를 렌더하지 않아 Next.js 경고를 피한다.
// - src 가 비어 있지 않을 때는 정상적으로 <img> 를 렌더한다.
describe("PublicPageRenderer - image block", () => {
afterEach(() => {
cleanup();
});
it("src 가 비어 있으면 img 요소를 렌더하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "img_empty",
type: "image",
props: {
src: "",
alt: "빈 이미지",
align: "center",
widthMode: "auto",
borderRadius: "md",
},
} as any,
];
render(<PublicPageRenderer blocks={blocks} />);
const img = screen.queryByRole("img", { name: "빈 이미지" });
expect(img).toBeNull();
});
it("src 가 비어 있지 않으면 img 요소를 렌더해야 한다", () => {
const blocks: Block[] = [
{
id: "img_non_empty",
type: "image",
props: {
src: "https://example.com/test.png",
alt: "정상 이미지",
align: "center",
widthMode: "auto",
borderRadius: "md",
},
} as any,
];
render(<PublicPageRenderer blocks={blocks} />);
const img = screen.getByRole("img", { name: "정상 이미지" }) as HTMLImageElement;
expect(img).toBeTruthy();
expect(img.src).toContain("https://example.com/test.png");
});
});
@@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 섹션 배경 이미지 TDD
// - SectionBlockProps 의 backgroundImage* 속성이 섹션 wrapper 스타일에 반영되는지 검증한다.
describe("PublicPageRenderer - 섹션 배경 이미지", () => {
it("backgroundImageSrc/size/position 이 설정된 섹션은 backgroundImage/backgroundSize/backgroundPosition 스타일을 가져야 한다", () => {
const blocks: Block[] = [
{
id: "sec_bg_image",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_image_col_1", span: 12 }],
backgroundImageSrc: "/images/section-bg.png",
backgroundImageSize: "cover",
backgroundImagePosition: "top",
backgroundImageRepeat: "repeat-y",
},
} as any,
{
id: "sec_bg_text",
type: "text",
sectionId: "sec_bg_image",
columnId: "sec_bg_image_col_1",
props: {
text: "섹션 배경 이미지 테스트",
align: "left",
size: "base",
},
} as any,
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const sectionEl = getByTestId("preview-section") as HTMLElement;
expect(sectionEl.style.backgroundImage).toContain("/images/section-bg.png");
expect(sectionEl.style.backgroundSize).toBe("cover");
expect(sectionEl.style.backgroundPosition).toBe("center top");
expect(sectionEl.style.backgroundRepeat).toBe("repeat-y");
});
it("backgroundImagePositionMode 가 custom 인 섹션은 background-position 에 'X% Y%' 값이 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "sec_bg_image_xy",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_bg_image_xy_col_1", span: 12 }],
backgroundImageSrc: "/images/section-bg-xy.png",
backgroundImageSize: "contain",
backgroundImagePositionMode: "custom",
backgroundImagePositionXPercent: 25,
backgroundImagePositionYPercent: 80,
},
} as any,
{
id: "sec_bg_text_xy",
type: "text",
sectionId: "sec_bg_image_xy",
columnId: "sec_bg_image_xy_col_1",
props: {
text: "XY 커스텀 위치 텍스트",
align: "left",
size: "base",
},
} as any,
];
const { getAllByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const sectionEl = getAllByTestId("preview-section").find(
(el) => (el as HTMLElement).dataset.sectionId === "sec_bg_image_xy",
) as HTMLElement;
expect(sectionEl.style.backgroundImage).toContain("/images/section-bg-xy.png");
expect(sectionEl.style.backgroundSize).toBe("contain");
expect(sectionEl.style.backgroundPosition).toBe("25% 80%");
});
it("backgroundVideoSrc 가 설정된 섹션은 프리뷰에서도 배경 비디오 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: "/videos/section-bg-preview.mp4",
},
} as any,
{
id: "sec_bg_text_video",
type: "text",
sectionId: "sec_bg_video",
columnId: "sec_bg_video_col_1",
props: {
text: "섹션 배경 비디오 테스트",
align: "left",
size: "base",
},
} as any,
];
const { getAllByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const sectionEl = getAllByTestId("preview-section").find(
(el) => (el as HTMLElement).dataset.sectionId === "sec_bg_video",
) as HTMLElement;
expect(sectionEl).toBeTruthy();
const videoEl = getAllByTestId("preview-section-bg-video")[0] as HTMLVideoElement;
expect(videoEl).toBeTruthy();
expect(videoEl.src).toContain("/videos/section-bg-preview.mp4");
expect(videoEl.autoplay).toBe(true);
expect(videoEl.loop).toBe(true);
expect(videoEl.muted).toBe(true);
});
});
@@ -0,0 +1,120 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
// PublicPageRenderer 섹션/루트 레이아웃 TDD
// - Export 레이어의 pb-root / pb-section-* 구조와 개념적으로 대응되는 프리뷰 레이아웃을 고정한다.
// - 정확한 px 값 비교 대신, 주요 Tailwind 레이아웃 클래스 존재 여부를 검증한다.
describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
afterEach(() => {
cleanup();
});
const makeSectionWithText = (sectionOverride: Partial<SectionBlockProps> = {}, textOverride: Partial<TextBlockProps> = {}): Block[] => {
const sectionProps: SectionBlockProps = {
background: "default",
paddingY: "md",
columns: [{ id: "sec_layout_col_1", span: 12 }],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
...sectionOverride,
} as SectionBlockProps;
const textProps: TextBlockProps = {
text: "섹션 레이아웃 텍스트",
align: "left",
size: "base",
...textOverride,
} as TextBlockProps;
const section: Block = {
id: "sec_layout_1",
type: "section",
props: sectionProps,
} as any;
const text: Block = {
id: "sec_layout_1_text",
type: "text",
sectionId: "sec_layout_1",
columnId: "sec_layout_col_1",
props: textProps,
} as any;
return [section, text];
};
it("섹션 레이아웃은 mx-auto / max-w / px-* 및 flex 컬럼/갭 구조로 렌더되어야 한다", () => {
const blocks: Block[] = makeSectionWithText();
render(<PublicPageRenderer blocks={blocks} />);
const inner = screen.getByTestId("preview-section-inner") as HTMLDivElement;
const columns = screen.getByTestId("preview-section-columns") as HTMLDivElement;
// Export 의 pb-section-inner / pb-section-columns 에 대응되는 레이아웃 구조를 최소한으로 보장한다.
expect(inner.className).toContain("mx-auto");
expect(inner.className).toContain("px-4");
expect(inner.className).toMatch(/max-w-/);
expect(columns.className).toContain("flex");
// gapX 기본값(md)에 대해 gap-8 이 사용되는 현재 구현을 고정한다.
expect(columns.className).toContain("gap-8");
// 컬럼 래퍼는 flex-col 과 일정한 세로 간격을 가진다.
const columnWrapper = columns.querySelector("div.flex.flex-col") as HTMLDivElement | null;
expect(columnWrapper).not.toBeNull();
if (columnWrapper) {
expect(columnWrapper.className).toContain("gap-4");
}
// 섹션 텍스트가 실제로 섹션 안에 렌더되는지 확인한다.
const textEl = screen.getByText("섹션 레이아웃 텍스트");
const section = textEl.closest("section");
expect(section).not.toBeNull();
});
it("루트 블록은 상단 섹션 내 max-width 컨테이너 안에서 렌더되어야 한다", () => {
const rootTextProps: TextBlockProps = {
text: "루트 레이아웃 텍스트",
align: "left",
size: "base",
} as TextBlockProps;
const rootTextBlock: Block = {
id: "root_layout_text",
type: "text",
props: rootTextProps,
} as any;
const sectionBlocks = makeSectionWithText();
const blocks: Block[] = [rootTextBlock, ...sectionBlocks];
render(<PublicPageRenderer blocks={blocks} />);
const rootTextEl = screen.getByText("루트 레이아웃 텍스트") as HTMLParagraphElement;
// 루트 텍스트는 상단 섹션(py-12) 안에 존재해야 한다.
const rootSection = rootTextEl.closest("section");
expect(rootSection).not.toBeNull();
if (!rootSection) return;
expect(rootSection.className).toContain("py-12");
// 섹션 바로 아래의 max-width 컨테이너 구조를 확인한다.
const innerContainer = rootSection.querySelector("div");
expect(innerContainer).not.toBeNull();
if (!innerContainer) return;
const className = innerContainer.className;
expect(className).toContain("mx-auto");
expect(className).toContain("max-w-3xl");
expect(className).toContain("px-4");
expect(className).toContain("flex");
expect(className).toContain("gap-4");
});
});
@@ -58,7 +58,9 @@ describe("PublicPageRenderer - 섹션 템플릿", () => {
render(<PublicPageRenderer blocks={blocks as Block[]} />);
// CTA 본문 텍스트와 버튼 라벨이 존재해야 한다.
screen.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
screen.getByText((content) =>
content.includes("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요."),
);
screen.getByText("CTA 버튼");
});
});
@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block, TextBlockProps } from "@/features/editor/state/editorStore";
// PublicPageRenderer 텍스트 블록 스타일 TDD
// - computeTextPublicTokens 결과가 실제 프리뷰 렌더링에 올바르게 반영되는지 검증한다.
describe("PublicPageRenderer - 텍스트 블록 스타일", () => {
afterEach(() => {
cleanup();
});
const makeTextBlock = (override: Partial<TextBlockProps> = {}): Block => {
const base: TextBlockProps = {
text: "퍼블릭 텍스트 스타일 테스트",
align: "left",
size: "base",
} as TextBlockProps;
return {
id: "text_1",
type: "text",
props: { ...base, ...override },
} as any;
};
it("기본 텍스트 블록은 text-left / text-base 및 기본 색상으로 렌더되어야 한다", () => {
const blocks: Block[] = [makeTextBlock()];
render(<PublicPageRenderer blocks={blocks} />);
const el = screen.getByText("퍼블릭 텍스트 스타일 테스트") as HTMLParagraphElement;
expect(el.className).toContain("text-left");
expect(el.className).toContain("text-base");
// 기본 색상은 Tailwind text-slate-50 클래스로 고정되어 있다.
expect(el.className).toContain("text-slate-50");
});
it("fontSizeMode=custom, fontSizeCustom, colorCustom, backgroundColorCustom 이 설정되면 스타일이 인라인으로 반영되어야 한다", () => {
const blocks: Block[] = [
makeTextBlock({
align: "center",
fontSizeMode: "custom",
fontSizeCustom: "24px",
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
}),
];
render(<PublicPageRenderer blocks={blocks} />);
const el = screen.getByText("퍼블릭 텍스트 스타일 테스트") as HTMLParagraphElement;
// 정렬 클래스
expect(el.className).toContain("text-center");
// 커스텀 폰트 크기: sizeClass 는 비워지고, 인라인 스타일로 설정된다.
expect(el.style.fontSize).toBe("24px");
// 커스텀 색상/배경색
expect(el.style.color).toBe("rgb(255, 0, 0)");
expect(el.style.backgroundColor).toBe("rgb(18, 52, 86)");
});
});
@@ -0,0 +1,195 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 비디오 블록 스타일 TDD
// - YouTube URL 은 iframe + pb-video-wrapper 로 렌더링되어야 한다.
// - 업로드(/api/video/:id) URL 은 <video> 태그로 렌더되고 widthPx 가 em 단위로 반영되어야 한다.
describe("PublicPageRenderer - 비디오 블록 스타일", () => {
afterEach(() => {
cleanup();
});
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 wrapper 를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "video_youtube_preview_1",
type: "video" as any,
props: {
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const iframe = container.querySelector('iframe[title="비디오"]') as HTMLIFrameElement | null;
expect(iframe).not.toBeNull();
expect(iframe!.src).toContain("youtube.com/embed");
const wrapper = iframe!.parentElement as HTMLElement | null;
expect(wrapper).not.toBeNull();
expect(wrapper!.className).toContain("pb-video-wrapper");
});
it("업로드 비디오 URL 은 video 태그로 렌더되고 widthPx 가 em 단위 너비로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_upload_preview_1",
type: "video" as any,
props: {
sourceUrl: "/api/video/abc123",
align: "left",
widthMode: "fixed",
widthPx: 640,
aspectRatio: "16:9",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const video = container.querySelector("video") as HTMLVideoElement | null;
expect(video).not.toBeNull();
expect(video!.getAttribute("controls")).not.toBeNull();
// 640px / 16 = 40em
expect(video!.style.width).toBe("40em");
});
it("posterImageSrc 가 설정된 HTML5 비디오는 video 태그 poster 속성으로 렌더되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_poster_preview_1",
type: "video" as any,
props: {
sourceUrl: "/videos/sample-preview.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
posterImageSrc: "/images/sample-poster-preview.png",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const video = container.querySelector("video") as HTMLVideoElement | null;
expect(video).not.toBeNull();
expect(video!.getAttribute("poster")).toBe("/images/sample-poster-preview.png");
});
it("startTimeSec/endTimeSec 가 설정된 HTML5 비디오는 프리뷰에서도 loadedmetadata/timeupdate 로 시작/종료 범위가 적용되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_range_preview_1",
type: "video" as any,
props: {
sourceUrl: "/videos/range-preview.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
startTimeSec: 3,
endTimeSec: 8,
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const video = container.querySelector("video") as HTMLVideoElement | null;
expect(video).not.toBeNull();
fireEvent(video as HTMLVideoElement, new Event("loadedmetadata"));
expect((video as HTMLVideoElement).currentTime).toBe(3);
(video as any).pause = vi.fn();
(video as HTMLVideoElement).currentTime = 8.2;
fireEvent(video as HTMLVideoElement, new Event("timeupdate"));
expect((video as any).pause).toHaveBeenCalled();
});
it("HTML5 비디오의 ariaLabel/titleText/captionText 는 프리뷰에서 aria-label 및 캡션 요소로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_a11y_preview",
type: "video" as any,
props: {
sourceUrl: "/videos/a11y-preview.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
titleText: "프리뷰 접근성 타이틀",
ariaLabel: "프리뷰 접근성 설명",
captionText: "프리뷰 비디오 캡션입니다.",
} as any,
},
];
const { container, getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const video = container.querySelector("video") as HTMLVideoElement | null;
expect(video).not.toBeNull();
expect(video!.getAttribute("aria-label")).toBe("프리뷰 접근성 설명");
const caption = getByTestId("preview-video-caption");
expect(caption.textContent).toBe("프리뷰 비디오 캡션입니다.");
});
it("titleText 가 설정된 YouTube 비디오는 iframe title 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_youtube_a11y_1",
type: "video" as any,
props: {
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
titleText: "사용자 정의 비디오 제목",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const iframe = container.querySelector('iframe[title="사용자 정의 비디오 제목"]') as HTMLIFrameElement | null;
expect(iframe).not.toBeNull();
});
it("비디오 카드 스타일 props(backgroundColorCustom/cardPaddingPx/borderRadiusPx)는 wrapper 의 스타일에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "video_card_preview_1",
type: "video" as any,
props: {
sourceUrl: "/api/video/preview-card",
align: "center",
widthMode: "fixed",
widthPx: 320,
aspectRatio: "16:9",
backgroundColorCustom: "#123456",
cardPaddingPx: 32,
borderRadiusPx: 16,
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const video = container.querySelector("video") as HTMLVideoElement | null;
expect(video).not.toBeNull();
const wrapper = video!.parentElement as HTMLElement | null;
expect(wrapper).not.toBeNull();
// #123456 -> rgb(18, 52, 86)
expect(wrapper!.style.backgroundColor).toBe("rgb(18, 52, 86)");
// 32px / 16 = 2em, 16px / 16 = 1em
expect(wrapper!.style.padding).toBe("2em");
expect(wrapper!.style.borderRadius).toBe("1em");
});
});
+314 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
import { SectionPropertiesPanel } from "@/app/editor/panels/SectionPropertiesPanel";
@@ -13,6 +13,103 @@ describe("SectionPropertiesPanel", () => {
gapX: "md",
};
afterEach(() => {
cleanup();
});
it("가로 위치 프리셋 드롭다운 변경 시 backgroundImagePositionXPercent 가 0/50/100 등으로 설정된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImagePositionMode: "custom",
backgroundImagePositionXPercent: 50,
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
}}
selectedBlockId="section-xy-x"
updateBlock={updateBlock}
/>,
);
const presetSelect = screen.getByLabelText("배경 이미지 가로 위치 프리셋");
fireEvent.change(presetSelect, { target: { value: "left" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-xy-x",
expect.objectContaining({ backgroundImagePositionXPercent: 0 }),
);
});
it("세로 위치 프리셋 드롭다운 변경 시 backgroundImagePositionYPercent 가 0/50/100 등으로 설정된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImagePositionMode: "custom",
backgroundImagePositionYPercent: 50,
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
}}
selectedBlockId="section-xy-y"
updateBlock={updateBlock}
/>,
);
const presetSelect = screen.getByLabelText("배경 이미지 세로 위치 프리셋");
fireEvent.change(presetSelect, { target: { value: "top" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-xy-y",
expect.objectContaining({ backgroundImagePositionYPercent: 0 }),
);
});
it("위치 모드를 custom 으로 두면 X/Y 퍼센트 슬라이더 변경 시 backgroundImagePositionXPercent/YPercent 가 업데이트된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImagePositionMode: "custom",
backgroundImagePositionXPercent: 50,
backgroundImagePositionYPercent: 50,
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
}}
selectedBlockId="section-xy"
updateBlock={updateBlock}
/>,
);
const xSlider = screen.getByLabelText("배경 이미지 가로 위치 슬라이더");
fireEvent.change(xSlider, { target: { value: "30" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-xy",
expect.objectContaining({
backgroundImagePositionMode: "custom",
backgroundImagePositionXPercent: 30,
}),
);
const ySlider = screen.getByLabelText("배경 이미지 세로 위치 슬라이더");
fireEvent.change(ySlider, { target: { value: "70" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-xy",
expect.objectContaining({
backgroundImagePositionMode: "custom",
backgroundImagePositionYPercent: 70,
}),
);
});
it("세로 패딩 프리셋/슬라이더 변경 시 updateBlock 이 paddingYPx 로 호출된다", () => {
const updateBlock = vi.fn();
render(
@@ -28,4 +125,219 @@ describe("SectionPropertiesPanel", () => {
expect(updateBlock).toHaveBeenCalledWith("section-1", expect.objectContaining({ paddingYPx: 40 }));
});
it("배경 이미지 URL 인풋 변경 시 updateBlock 이 backgroundImageSrc/SourceType/AssetId 로 호출된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{ ...baseProps, backgroundImageSrc: "", backgroundImageSourceType: "externalUrl" }}
selectedBlockId="section-2"
updateBlock={updateBlock}
/>,
);
const urlInput = screen.getByLabelText("배경 이미지 URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/bg.png" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-2",
expect.objectContaining({
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
backgroundImageAssetId: null,
}),
);
});
it("배경 이미지 크기 셀렉트 변경 시 updateBlock 이 backgroundImageSize 로 호출된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImageSize: "cover",
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
}}
selectedBlockId="section-3"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("배경 이미지 크기");
fireEvent.change(select, { target: { value: "contain" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-3",
expect.objectContaining({ backgroundImageSize: "contain" }),
);
});
it("배경 이미지 위치 셀렉트 변경 시 updateBlock 이 backgroundImagePosition 으로 호출된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImagePosition: "center",
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
}}
selectedBlockId="section-4"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("배경 이미지 위치");
fireEvent.change(select, { target: { value: "top" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-4",
expect.objectContaining({ backgroundImagePosition: "top" }),
);
});
it("기본 상태에서는 배경 이미지 소스가 '없음' 이고, '없음' 선택 시 관련 필드가 초기화된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{ ...baseProps }}
selectedBlockId="section-0"
updateBlock={updateBlock}
/>,
);
const sourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
expect(sourceSelect.value).toBe("none");
fireEvent.change(sourceSelect, { target: { value: "none" } });
expect(updateBlock).toHaveBeenCalledWith("section-0", {
backgroundImageSrc: undefined,
backgroundImageSourceType: undefined,
backgroundImageAssetId: null,
} as any);
// "없음" 상태에서는 크기/위치/반복 컨트롤이 렌더되지 않아야 한다.
expect(screen.queryByLabelText("배경 이미지 크기")).toBeNull();
expect(screen.queryByLabelText("배경 이미지 위치")).toBeNull();
expect(screen.queryByLabelText("배경 이미지 반복")).toBeNull();
});
it("배경 이미지 반복 셀렉트 변경 시 updateBlock 이 backgroundImageRepeat 로 호출된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImageRepeat: "no-repeat",
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
}}
selectedBlockId="section-6"
updateBlock={updateBlock}
/>,
);
const repeatSelect = screen.getByLabelText("배경 이미지 반복");
fireEvent.change(repeatSelect, { target: { value: "repeat-x" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-6",
expect.objectContaining({ backgroundImageRepeat: "repeat-x" }),
);
});
it("배경 이미지 소스 셀렉트 변경 시 updateBlock 이 backgroundImageSourceType/backgroundImageAssetId 로 호출된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImageSrc: "/api/image/asset-1",
backgroundImageSourceType: "asset",
backgroundImageAssetId: "asset-1",
}}
selectedBlockId="section-5"
updateBlock={updateBlock}
/>,
);
const sourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
expect(sourceSelect.value).toBe("upload");
fireEvent.change(sourceSelect, { target: { value: "url" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-5",
expect.objectContaining({
backgroundImageSourceType: "externalUrl",
backgroundImageAssetId: null,
}),
);
});
it("배경 비디오 URL 인풋 변경 시 updateBlock 이 backgroundVideoSrc/backgroundVideoSourceType/backgroundVideoAssetId 로 호출된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundVideoSrc: "",
backgroundVideoSourceType: "externalUrl",
}}
selectedBlockId="section-video-1"
updateBlock={updateBlock}
/>,
);
const urlInput = screen.getByLabelText("배경 비디오 URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/bg-video.mp4" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-video-1",
expect.objectContaining({
backgroundVideoSrc: "https://example.com/bg-video.mp4",
backgroundVideoSourceType: "externalUrl",
backgroundVideoAssetId: null,
}),
);
});
it("배경 비디오 소스 셀렉트 변경 시 updateBlock 이 backgroundVideoSourceType/backgroundVideoAssetId 로 호출된다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundVideoSrc: "/api/video/asset-video-1",
backgroundVideoSourceType: "asset",
backgroundVideoAssetId: "asset-video-1",
}}
selectedBlockId="section-video-2"
updateBlock={updateBlock}
/>,
);
const sourceSelect = screen.getByLabelText("배경 비디오 소스") as HTMLSelectElement;
expect(sourceSelect.value).toBe("upload");
fireEvent.change(sourceSelect, { target: { value: "url" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-video-2",
expect.objectContaining({
backgroundVideoSourceType: "externalUrl",
backgroundVideoAssetId: null,
}),
);
});
});
+296
View File
@@ -0,0 +1,296 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { VideoPropertiesPanel } from "@/app/editor/panels/VideoPropertiesPanel";
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
// VideoPropertiesPanel 컨트롤 TDD
// - 비디오 URL 인풋/정렬/너비 모드/화면 비율 컨트롤이 updateBlock 을 올바르게 호출하는지 검증한다.
describe("VideoPropertiesPanel", () => {
const baseProps: VideoBlockProps = {
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
};
afterEach(() => {
cleanup();
});
it("비디오 URL 인풋 변경 시 updateBlock 이 sourceUrl / sourceType / assetId 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, sourceType: "externalUrl" }}
selectedBlockId="video-1"
updateBlock={updateBlock}
/>,
);
const urlInput = screen.getByLabelText("비디오 URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/video.mp4" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-1",
expect.objectContaining({
sourceUrl: "https://example.com/video.mp4",
sourceType: "externalUrl",
assetId: null,
}),
);
});
it("정렬 셀렉트 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, align: "center" }}
selectedBlockId="video-2"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("비디오 정렬");
fireEvent.change(select, { target: { value: "left" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-2",
expect.objectContaining({ align: "left" }),
);
});
it("너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, widthMode: "auto" }}
selectedBlockId="video-3"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("비디오 너비 모드");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-3",
expect.objectContaining({ widthMode: "fixed" }),
);
});
it("화면 비율 셀렉트 변경 시 updateBlock 이 aspectRatio 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, aspectRatio: "16:9" }}
selectedBlockId="video-4"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("화면 비율");
fireEvent.change(select, { target: { value: "4:3" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-4",
expect.objectContaining({ aspectRatio: "4:3" }),
);
});
it("카드 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, sourceType: "externalUrl" }}
selectedBlockId="video-card-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("비디오 카드 배경색 HEX");
fireEvent.change(hexInput, { target: { value: "#123456" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-card-1",
expect.objectContaining({ backgroundColorCustom: "#123456" }),
);
});
it("카드 패딩 슬라이더 변경 시 updateBlock 이 cardPaddingPx 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, cardPaddingPx: 16 }}
selectedBlockId="video-card-2"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("카드 패딩 슬라이더");
fireEvent.change(slider, { target: { value: "24" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-card-2",
expect.objectContaining({ cardPaddingPx: 24 }),
);
});
it("카드 모서리 둥글기 슬라이더 변경 시 updateBlock 이 borderRadiusPx 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, borderRadiusPx: 8 }}
selectedBlockId="video-card-3"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("카드 모서리 둥글기 슬라이더");
fireEvent.change(slider, { target: { value: "20" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-card-3",
expect.objectContaining({ borderRadiusPx: 20 }),
);
});
it("포스터 이미지 URL 인풋 변경 시 updateBlock 이 posterImageSrc / posterSourceType / posterAssetId 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const posterProps: VideoBlockProps = {
...baseProps,
sourceType: "externalUrl",
posterImageSrc: "https://example.com/poster-before.png",
posterSourceType: "externalUrl",
posterAssetId: null,
};
render(
<VideoPropertiesPanel
videoProps={posterProps}
selectedBlockId="video-poster-1"
updateBlock={updateBlock}
/>,
);
const input = screen.getByLabelText("포스터 이미지 URL");
fireEvent.change(input, { target: { value: "https://example.com/poster-after.png" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-poster-1",
expect.objectContaining({
posterImageSrc: "https://example.com/poster-after.png",
posterSourceType: "externalUrl",
posterAssetId: null,
}),
);
});
it("시작 시점 슬라이더 변경 시 updateBlock 이 startTimeSec 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, startTimeSec: 0 }}
selectedBlockId="video-range-1"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("시작 시점 (초) 슬라이더");
fireEvent.change(slider, { target: { value: "5" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-range-1",
expect.objectContaining({ startTimeSec: 5 }),
);
});
it("종료 시점 슬라이더 변경 시 updateBlock 이 endTimeSec 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, endTimeSec: 10 }}
selectedBlockId="video-range-2"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("종료 시점 (초) 슬라이더");
fireEvent.change(slider, { target: { value: "15" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-range-2",
expect.objectContaining({ endTimeSec: 15 }),
);
});
it("비디오 제목 인풋 변경 시 updateBlock 이 titleText 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, titleText: "기존 제목" } as any}
selectedBlockId="video-a11y-1"
updateBlock={updateBlock}
/>,
);
const input = screen.getByLabelText("비디오 제목");
fireEvent.change(input, { target: { value: "새 비디오 제목" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-a11y-1",
expect.objectContaining({ titleText: "새 비디오 제목" }),
);
});
it("비디오 aria-label 인풋 변경 시 updateBlock 이 ariaLabel 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, ariaLabel: "기존 aria" } as any}
selectedBlockId="video-a11y-2"
updateBlock={updateBlock}
/>,
);
const input = screen.getByLabelText("비디오 aria-label");
fireEvent.change(input, { target: { value: "새 aria-label" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-a11y-2",
expect.objectContaining({ ariaLabel: "새 aria-label" }),
);
});
it("비디오 캡션 텍스트 인풋 변경 시 updateBlock 이 captionText 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, captionText: "기존 캡션" } as any}
selectedBlockId="video-a11y-3"
updateBlock={updateBlock}
/>,
);
const input = screen.getByLabelText("비디오 캡션 텍스트");
fireEvent.change(input, { target: { value: "새 캡션 텍스트" } });
expect(updateBlock).toHaveBeenCalledWith(
"video-a11y-3",
expect.objectContaining({ captionText: "새 캡션 텍스트" }),
);
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect } from "vitest";
import {
computeButtonPbTokens,
computeButtonPublicTokens,
computeButtonEditorTokens,
} from "@/features/editor/utils/buttonHelpers";
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
const baseProps = {
align: "left" as const,
size: "md" as const,
variant: "solid" as const,
colorPalette: "primary" as const,
};
describe("buttonHelpers.computeButtonPbTokens", () => {
it("기본 버튼은 pb-text-left / pb-btn-size-md / pb-btn-variant-solid-primary / pb-btn-radius-md 를 포함해야 한다", () => {
const tokens = computeButtonPbTokens(baseProps);
expect(tokens.alignClass).toBe("pb-text-left");
expect(tokens.sizeClass).toBe("pb-btn-size-md");
expect(tokens.variantClass).toBe("pb-btn-variant-solid-primary");
expect(tokens.radiusClass).toBe("pb-btn-radius-md");
expect(tokens.inlineStyles.length).toBe(0);
});
it("정렬/사이즈/팔레트/둥글기 스케일은 pb 토큰으로 올바르게 매핑되어야 한다", () => {
const tokens = computeButtonPbTokens({
align: "center",
size: "lg",
variant: "outline",
colorPalette: "danger",
borderRadius: "full",
});
expect(tokens.alignClass).toBe("pb-text-center");
expect(tokens.sizeClass).toBe("pb-btn-size-lg");
expect(tokens.variantClass).toBe("pb-btn-variant-outline-danger");
expect(tokens.radiusClass).toBe("pb-btn-radius-full");
});
it("widthMode=fixed, widthPx, paddingX/paddingY, fill/stroke/text 색상은 inlineStyles 에 포함되어야 한다", () => {
const tokens = computeButtonPbTokens({
align: "right",
size: "sm",
widthMode: "fixed",
widthPx: 200,
paddingX: 16,
paddingY: 8,
fillColorCustom: "#111111",
strokeColorCustom: "#222222",
textColorCustom: "#ffffff",
});
expect(tokens.alignClass).toBe("pb-text-right");
expect(tokens.inlineStyles).toContain("width:200px");
expect(tokens.inlineStyles).toContain("padding-left:16px");
expect(tokens.inlineStyles).toContain("padding-right:16px");
expect(tokens.inlineStyles).toContain("padding-top:8px");
expect(tokens.inlineStyles).toContain("padding-bottom:8px");
expect(tokens.inlineStyles).toContain("background-color:#111111");
expect(tokens.inlineStyles).toContain("border-color:#222222");
expect(tokens.inlineStyles).toContain("color:#ffffff");
});
});
describe("buttonHelpers.computeButtonPublicTokens", () => {
it("색상 커스텀 값은 backgroundColor/borderColor/borderWidth/borderStyle/color 에 반영되어야 한다", () => {
const tokens = computeButtonPublicTokens({
label: "컬러 버튼",
href: "#",
align: "center",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
textColorCustom: "#00ff00",
} as ButtonBlockProps);
expect(tokens.alignClass).toBe("text-center");
expect(tokens.styleOverrides.backgroundColor).toBe("#123456");
expect(tokens.styleOverrides.borderColor).toBe("#ff0000");
expect(tokens.styleOverrides.borderWidth).toBe("1px");
expect(tokens.styleOverrides.borderStyle).toBe("solid");
expect(tokens.styleOverrides.color).toBe("#00ff00");
});
it("widthMode=fixed 인 경우 widthPx/paddingX/paddingY 는 em 단위로 변환되어야 한다", () => {
const tokens = computeButtonPublicTokens({
label: "레이아웃 버튼",
href: "#",
align: "left",
widthMode: "fixed",
widthPx: 320,
paddingX: 16,
paddingY: 8,
} as ButtonBlockProps);
expect(tokens.alignClass).toBe("text-left");
expect(tokens.styleOverrides.width).toBe("20em");
expect(tokens.styleOverrides.paddingInline).toBe("1em");
expect(tokens.styleOverrides.paddingBlock).toBe("0.5em");
});
it("borderRadius 토큰은 em 단위 둥글기 또는 9999px 로 변환되어야 한다", () => {
const lgTokens = computeButtonPublicTokens({
label: "둥근 버튼",
href: "#",
borderRadius: "lg",
} as ButtonBlockProps);
expect(lgTokens.styleOverrides.borderRadius).toBe("0.75em");
const fullTokens = computeButtonPublicTokens({
label: "풀 둥근 버튼",
href: "#",
borderRadius: "full",
} as ButtonBlockProps);
expect(fullTokens.styleOverrides.borderRadius).toBe("9999px");
});
});
describe("buttonHelpers.computeButtonEditorTokens", () => {
it("fullWidth 나 widthMode 에 따라 wrapperWidthClass 와 버튼 스타일이 계산되어야 한다", () => {
const tokens = computeButtonEditorTokens({
label: "테스트 버튼",
href: "#",
align: "center",
size: "lg",
variant: "outline",
colorPalette: "success",
borderRadius: "full",
fullWidth: true,
fontSizeCustom: "18px",
lineHeightCustom: "1.8",
letterSpacingCustom: "0.1em",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
textColorCustom: "#ff0000",
paddingX: 16,
paddingY: 10,
} as ButtonBlockProps);
// fullWidth=true 이므로 wrapperWidthClass 는 w-full 이어야 한다.
expect(tokens.wrapperWidthClass).toBe("w-full");
// 버튼 스타일에 색상/패딩/타이포 값이 그대로 반영되어야 한다.
expect(tokens.buttonStyle.backgroundColor).toBe("#123456");
expect(tokens.buttonStyle.borderColor).toBe("#654321");
expect(tokens.buttonStyle.color).toBe("#ff0000");
expect(tokens.buttonStyle.paddingInline).toBe("16px");
expect(tokens.buttonStyle.paddingBlock).toBe("10px");
expect(tokens.buttonStyle.fontSize).toBe("18px");
expect(tokens.buttonStyle.lineHeight).toBe("1.8");
expect(tokens.buttonStyle.letterSpacing).toBe("0.1em");
});
it("widthMode=fixed 인 경우 widthPx 는 버튼 width 스타일에 반영되고 wrapperWidthClass 는 inline-block 이어야 한다", () => {
const tokens = computeButtonEditorTokens({
label: "고정 너비 버튼",
href: "#",
align: "left",
size: "md",
variant: "solid",
colorPalette: "primary",
borderRadius: "md",
fullWidth: false,
widthMode: "fixed",
widthPx: 200,
} as ButtonBlockProps);
expect(tokens.wrapperWidthClass).toBe("inline-block");
expect(tokens.buttonStyle.width).toBe("200px");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect } from "vitest";
import {
computeDividerExportTokens,
computeDividerEditorTokens,
computeDividerPublicTokens,
} from "@/features/editor/utils/dividerHelpers";
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
const baseProps: DividerBlockProps = {
align: "left",
thickness: "thin",
colorHex: undefined,
marginYPx: undefined,
};
describe("dividerHelpers.computeDividerExportTokens", () => {
const escapers = { escapeAttr: (v: string) => v };
it("기본 값은 1px 두께, 기본 색상(#475569), marginY=16px 이어야 한다", () => {
const tokens = computeDividerExportTokens(baseProps, escapers);
expect(tokens.style).toBe("border:0;border-bottom:1px solid #475569;margin:16px 0;");
});
it("thickness=\"medium\" 인 경우 2px 두께로 렌더링되어야 한다", () => {
const tokens = computeDividerExportTokens({ ...baseProps, thickness: "medium" }, escapers);
expect(tokens.style).toContain("border-bottom:2px solid #475569;");
});
it("colorHex 가 설정되면 공백을 제거한 뒤 해당 색상으로 border-color 를 사용해야 한다", () => {
const tokens = computeDividerExportTokens({ ...baseProps, colorHex: " #ff0000 " }, escapers);
expect(tokens.style).toContain("border-bottom:1px solid #ff0000;");
});
it("marginYPx > 0 이면 해당 값을 margin Y 로 사용해야 한다", () => {
const tokens = computeDividerExportTokens({ ...baseProps, marginYPx: 24 }, escapers);
expect(tokens.style).toContain("margin:24px 0;");
});
it("marginYPx 가 0 이하이거나 숫자가 아니면 기본 16px 을 사용해야 한다", () => {
const zeroTokens = computeDividerExportTokens({ ...baseProps, marginYPx: 0 }, escapers);
expect(zeroTokens.style).toContain("margin:16px 0;");
});
});
describe("dividerHelpers.computeDividerEditorTokens", () => {
it("기본 값은 얇은 두께, 기본 색상, 전체 폭, margin 16px 이어야 한다", () => {
const tokens = computeDividerEditorTokens(baseProps);
expect(tokens.thicknessClass).toBe("border-t");
expect(tokens.borderColor).toBe("#475569");
expect(tokens.innerWidthClass).toBe("w-full");
expect(tokens.widthPx).toBeUndefined();
expect(tokens.marginPx).toBe(16);
});
it("thickness=\"medium\", colorHex, widthMode=\"auto\", marginYPx 가 있으면 그대로 반영되어야 한다", () => {
const tokens = computeDividerEditorTokens({
...baseProps,
thickness: "medium",
colorHex: " #ff0000 ",
widthMode: "auto",
marginYPx: 20,
});
expect(tokens.thicknessClass).toBe("border-t-2");
expect(tokens.borderColor).toBe("#ff0000");
expect(tokens.innerWidthClass).toBe("w-1/2");
expect(tokens.widthPx).toBeUndefined();
expect(tokens.marginPx).toBe(20);
});
it("widthMode=\"fixed\" 이고 widthPx 가 양수이면 해당 px 로 width 를 사용해야 한다", () => {
const tokens = computeDividerEditorTokens({
...baseProps,
widthMode: "fixed",
widthPx: 480,
marginY: "lg",
});
expect(tokens.innerWidthClass).toBe("");
expect(tokens.widthPx).toBe(480);
expect(tokens.marginPx).toBe(24);
});
it("widthMode=\"fixed\" 이지만 widthPx 가 없거나 0 이하이면 기본 320px 을 사용해야 한다", () => {
const tokens = computeDividerEditorTokens({
...baseProps,
widthMode: "fixed",
widthPx: undefined,
});
expect(tokens.innerWidthClass).toBe("");
expect(tokens.widthPx).toBe(320);
});
});
describe("dividerHelpers.computeDividerPublicTokens", () => {
it("colorHex 가 있으면 해당 색상을, 없으면 기본 색상(#475569)을 사용해야 한다", () => {
const withColor = computeDividerPublicTokens({
thickness: "thin",
colorHex: "#123456",
} as DividerBlockProps);
expect(withColor.lineStyle.backgroundColor).toBe("#123456");
const withoutColor = computeDividerPublicTokens({
thickness: "thin",
} as DividerBlockProps);
expect(withoutColor.lineStyle.backgroundColor).toBe("#475569");
});
it("marginYPx 와 marginY 토큰은 em 스케일 wrapperMarginEm 으로 변환되어야 한다", () => {
const withMarginPx = computeDividerPublicTokens({
thickness: "thin",
marginYPx: 32,
} as DividerBlockProps);
expect(withMarginPx.wrapperMarginEm).toBe("2em");
const withMarginToken = computeDividerPublicTokens({
thickness: "thin",
marginY: "lg",
} as DividerBlockProps);
expect(withMarginToken.wrapperMarginEm).toBe("1.5em");
});
it("widthMode=fixed 인 경우 widthPx 는 라인 width 에 em 으로 반영되어야 한다", () => {
const tokens = computeDividerPublicTokens({
thickness: "thin",
widthMode: "fixed",
widthPx: 480,
} as DividerBlockProps);
expect(tokens.innerWidthClass).toBe("");
expect(tokens.innerStyle.width).toBe("auto");
expect(tokens.lineStyle.width).toBe("30em");
});
});
+3 -285
View File
@@ -686,288 +686,10 @@ describe("editorStore", () => {
expect(buttonBlocks.length).toBeGreaterThanOrEqual(1);
const section = sectionBlocks[0] as any;
// 템플릿에서 생성된 텍스트/버튼 블록은 모두 동일 섹션의 첫 컬럼에 배치된다고 가정한다.
const columns = (section.props as any).columns;
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBeGreaterThanOrEqual(1);
const firstColumnId = columns[0].id;
textBlocks.forEach((tb: any) => {
expect(tb.sectionId).toBe(section.id);
expect(tb.columnId).toBe(firstColumnId);
});
buttonBlocks.forEach((bb: any) => {
expect(bb.sectionId).toBe(section.id);
expect(bb.columnId).toBe(firstColumnId);
});
// 마지막으로 생성된 블록(예: CTA 버튼)이 선택되어 있다고 가정한다.
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
});
it("선택된 섹션이 있을 때 Team 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Team 템플릿으로 교체되어야 한다", () => {
const store = createEditorStore();
store.getState().addTeamTemplateSection();
let { blocks, selectedBlockId } = store.getState();
const sectionBlocks = blocks.filter((b) => b.type === "section");
expect(sectionBlocks).toHaveLength(1);
const section = sectionBlocks[0] as any;
const sectionId = section.id;
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
const originalLastSelectedId = selectedBlockId;
store.getState().selectBlock(sectionId);
store.getState().addTeamTemplateSection();
({ blocks, selectedBlockId } = store.getState());
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
expect(sectionBlocksAfter).toHaveLength(1);
const replacedSection = sectionBlocksAfter[0] as any;
expect(replacedSection.id).toBe(sectionId);
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
textBlocksAfter.forEach((tb) => {
expect(tb.sectionId).toBe(sectionId);
});
const allIdsAfter = blocks.map((b) => b.id);
oldTextIds.forEach((id) => {
expect(allIdsAfter).not.toContain(id);
});
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
expect(selectedBlockId).not.toBe(originalLastSelectedId);
});
it("선택된 섹션이 있을 때 Blog 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Blog 템플릿으로 교체되어야 한다", () => {
const store = createEditorStore();
store.getState().addBlogTemplateSection();
let { blocks, selectedBlockId } = store.getState();
const sectionBlocks = blocks.filter((b) => b.type === "section");
expect(sectionBlocks).toHaveLength(1);
const section = sectionBlocks[0] as any;
const sectionId = section.id;
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
const originalLastSelectedId = selectedBlockId;
store.getState().selectBlock(sectionId);
store.getState().addBlogTemplateSection();
({ blocks, selectedBlockId } = store.getState());
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
expect(sectionBlocksAfter).toHaveLength(1);
const replacedSection = sectionBlocksAfter[0] as any;
expect(replacedSection.id).toBe(sectionId);
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
textBlocksAfter.forEach((tb) => {
expect(tb.sectionId).toBe(sectionId);
});
const allIdsAfter = blocks.map((b) => b.id);
oldTextIds.forEach((id) => {
expect(allIdsAfter).not.toContain(id);
});
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
expect(selectedBlockId).not.toBe(originalLastSelectedId);
});
it("선택된 섹션이 있을 때 Hero 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Hero 템플릿으로 교체되어야 한다", () => {
const store = createEditorStore();
// 1) 먼저 Hero 템플릿 섹션을 하나 추가한다.
store.getState().addHeroTemplateSection();
let { blocks, selectedBlockId } = store.getState();
const sectionBlocks = blocks.filter((b) => b.type === "section");
expect(sectionBlocks).toHaveLength(1);
const section = sectionBlocks[0] as any;
const sectionId = section.id;
// 기존 텍스트/버튼 블록들의 id 를 기록해 둔다.
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
const oldButtonIds = blocks.filter((b) => b.type === "button").map((b) => b.id);
const originalLastSelectedId = selectedBlockId;
// 2) 해당 섹션을 선택한 상태에서 다시 Hero 템플릿 섹션 액션을 호출한다.
store.getState().selectBlock(sectionId);
store.getState().addHeroTemplateSection();
({ blocks, selectedBlockId } = store.getState());
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
expect(sectionBlocksAfter).toHaveLength(1);
const replacedSection = sectionBlocksAfter[0] as any;
// 교체 후에도 섹션 id 는 동일하게 유지되어야 한다 (섹션 컨테이너 재사용).
expect(replacedSection.id).toBe(sectionId);
const columns = (replacedSection.props as any).columns;
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBeGreaterThanOrEqual(1);
const firstColumnId = columns[0].id;
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
const buttonBlocksAfter = blocks.filter((b) => b.type === "button") as any[];
// 새 텍스트/버튼 블록이 최소 1개 이상 존재해야 한다.
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
expect(buttonBlocksAfter.length).toBeGreaterThanOrEqual(1);
// 새 텍스트/버튼 블록은 모두 동일 섹션의 첫 컬럼에 배치되어야 한다.
textBlocksAfter.forEach((tb) => {
expect(tb.sectionId).toBe(sectionId);
expect(tb.columnId).toBe(firstColumnId);
});
buttonBlocksAfter.forEach((bb) => {
expect(bb.sectionId).toBe(sectionId);
expect(bb.columnId).toBe(firstColumnId);
});
// 기존 텍스트/버튼 블록 id 들은 모두 제거되어야 한다.
const allIdsAfter = blocks.map((b) => b.id);
[...oldTextIds, ...oldButtonIds].forEach((id) => {
expect(allIdsAfter).not.toContain(id);
});
// 선택 상태는 새로 생성된 템플릿의 마지막 블록을 가리켜야 한다.
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
expect(selectedBlockId).not.toBe(originalLastSelectedId);
});
it("Blog 템플릿 섹션을 추가하면 3개의 포스트 카드(제목/요약)가 생성되어야 한다", () => {
const store = createEditorStore();
store.getState().addBlogTemplateSection();
const { blocks, selectedBlockId } = store.getState();
const sectionBlocks = blocks.filter((b) => b.type === "section");
expect(sectionBlocks).toHaveLength(1);
const section = sectionBlocks[0] as any;
const columns = (section.props as any).columns;
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBe(3);
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
// 3개의 포스트 카드에 대해 각 컬럼에 최소 2개 텍스트(제목 + 요약)를 가정한다.
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
textBlocks.forEach((tb) => {
expect(tb.sectionId).toBe(section.id);
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
});
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
});
it("Team 템플릿 섹션을 추가하면 3명의 팀 카드(이름/역할/소개)가 생성되어야 한다", () => {
const store = createEditorStore();
store.getState().addTeamTemplateSection();
const { blocks, selectedBlockId } = store.getState();
const sectionBlocks = blocks.filter((b) => b.type === "section");
expect(sectionBlocks).toHaveLength(1);
const section = sectionBlocks[0] as any;
const columns = (section.props as any).columns;
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBe(3);
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
// 3명의 팀원 카드에 대해 각 컬럼에 최소 3개 텍스트(이름/역할/소개)를 가정한다.
expect(textBlocks.length).toBeGreaterThanOrEqual(9);
textBlocks.forEach((tb) => {
expect(tb.sectionId).toBe(section.id);
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
});
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
});
it("Footer 템플릿 섹션을 추가하면 링크/카피라이트 텍스트가 포함된 섹션이 생성되어야 한다", () => {
const store = createEditorStore();
store.getState().addFooterTemplateSection();
const { blocks, selectedBlockId } = store.getState();
const sectionBlocks = blocks.filter((b) => b.type === "section");
expect(sectionBlocks).toHaveLength(1);
const section = sectionBlocks[0] as any;
const columns = (section.props as any).columns;
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBeGreaterThanOrEqual(1);
const firstColumnId = columns[0].id;
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
expect(textBlocks.length).toBeGreaterThanOrEqual(2);
textBlocks.forEach((tb) => {
expect(tb.sectionId).toBe(section.id);
expect(tb.columnId).toBe(firstColumnId);
});
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
});
it("Features 템플릿 섹션을 추가하면 3컬럼 섹션과 각 컬럼의 제목/설명 텍스트가 생성되어야 한다", () => {
const store = createEditorStore();
// Features 템플릿 액션을 호출한다고 가정한다.
store.getState().addFeaturesTemplateSection();
const { blocks, selectedBlockId } = store.getState();
const sectionBlocks = blocks.filter((b) => b.type === "section");
expect(sectionBlocks).toHaveLength(1);
const section = sectionBlocks[0] as any;
const columns = (section.props as any).columns;
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBe(3);
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
// 최소 3개의 feature 항목(제목/설명)을 가정하고, 각 컬럼에는 2개의 텍스트(제목+설명)가 있다고 본다.
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
// 모든 텍스트 블록이 동일 섹션에 속하는지 확인한다.
textBlocks.forEach((tb) => {
expect(tb.sectionId).toBe(section.id);
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
@@ -992,8 +714,6 @@ describe("editorStore", () => {
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBeGreaterThanOrEqual(1);
const firstColumnId = columns[0].id;
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
const buttonBlocks = blocks.filter((b) => b.type === "button") as any[];
@@ -1002,12 +722,12 @@ describe("editorStore", () => {
textBlocks.forEach((tb) => {
expect(tb.sectionId).toBe(section.id);
expect(tb.columnId).toBe(firstColumnId);
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
});
buttonBlocks.forEach((bb) => {
expect(bb.sectionId).toBe(section.id);
expect(bb.columnId).toBe(firstColumnId);
expect(columns.some((col: any) => col.id === bb.columnId)).toBe(true);
});
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
@@ -1028,8 +748,6 @@ describe("editorStore", () => {
expect(Array.isArray(columns)).toBe(true);
expect(columns.length).toBeGreaterThanOrEqual(1);
const firstColumnId = columns[0].id;
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
// 최소 3개의 FAQ 항목(질문+답변 쌍)을 가정한다.
@@ -1037,7 +755,7 @@ describe("editorStore", () => {
textBlocks.forEach((tb) => {
expect(tb.sectionId).toBe(section.id);
expect(tb.columnId).toBe(firstColumnId);
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
});
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
+561
View File
@@ -0,0 +1,561 @@
import { describe, it, expect } from "vitest";
import {
computeFormInputExportTokens,
computeFormSelectExportTokens,
computeFormCheckboxExportTokens,
computeFormRadioExportTokens,
computeFormInputEditorTokens,
computeFormSelectEditorTokens,
computeFormOptionGroupEditorTokens,
computeFormInputPublicTokens,
computeFormSelectPublicTokens,
computeFormCheckboxPublicTokens,
computeFormRadioPublicTokens,
computeFormControllerPublicTokens,
computeFormBlockExportTokens,
} from "@/features/editor/utils/formHelpers";
import type {
Block,
FormBlockProps,
FormInputBlockProps,
FormSelectBlockProps,
FormCheckboxBlockProps,
FormRadioBlockProps,
} from "@/features/editor/state/editorStore";
const baseInput: FormInputBlockProps = {
label: "이름",
formFieldName: "name",
};
const baseSelect: FormSelectBlockProps = {
label: "플랜",
formFieldName: "plan",
options: [],
};
const baseCheckbox: FormCheckboxBlockProps = {
groupLabel: "옵션",
formFieldName: "options",
options: [],
};
const baseRadio: FormRadioBlockProps = {
groupLabel: "선택",
formFieldName: "choice",
options: [],
};
describe("formHelpers.computeFormInputExportTokens", () => {
it("텍스트 색상/배경/테두리/패딩/너비/둥글기 스타일을 계산해야 한다", () => {
const tokens = computeFormInputExportTokens({
...baseInput,
textColorCustom: " #ff0000 ",
fillColorCustom: " #111827 ",
strokeColorCustom: " #4b5563 ",
paddingX: 8,
paddingY: 4,
widthMode: "fixed",
widthPx: 300,
borderRadius: "lg",
});
expect(tokens.labelStyleAttr).toBe(' style="color:#ff0000"');
expect(tokens.inputStyleAttr).toContain("color:#ff0000");
expect(tokens.inputStyleAttr).toContain("background-color:#111827");
expect(tokens.inputStyleAttr).toContain("border-color:#4b5563");
expect(tokens.inputStyleAttr).toContain("padding-left:8px");
expect(tokens.inputStyleAttr).toContain("padding-right:8px");
expect(tokens.inputStyleAttr).toContain("padding-top:4px");
expect(tokens.inputStyleAttr).toContain("padding-bottom:4px");
expect(tokens.inputStyleAttr).toContain("width:300px");
expect(tokens.inputStyleAttr).toContain("border-radius:6px");
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
const tokens = computeFormInputExportTokens(baseInput);
expect(tokens.labelStyleAttr).toBe("");
expect(tokens.inputStyleAttr).toBe(' style="border-radius:4px"');
});
});
describe("formHelpers.computeFormSelectExportTokens", () => {
it("텍스트 색상/배경/테두리/패딩/너비/둥글기 스타일을 계산해야 한다", () => {
const tokens = computeFormSelectExportTokens({
...baseSelect,
textColorCustom: " #00ff00 ",
fillColorCustom: " #020617 ",
strokeColorCustom: " #64748b ",
paddingX: 12,
paddingY: 6,
widthMode: "fixed",
widthPx: 240,
borderRadius: "full",
});
expect(tokens.labelStyleAttr).toBe(' style="color:#00ff00"');
expect(tokens.selectStyleAttr).toContain("color:#00ff00");
expect(tokens.selectStyleAttr).toContain("background-color:#020617");
expect(tokens.selectStyleAttr).toContain("border-color:#64748b");
expect(tokens.selectStyleAttr).toContain("padding-left:12px");
expect(tokens.selectStyleAttr).toContain("padding-right:12px");
expect(tokens.selectStyleAttr).toContain("padding-top:6px");
expect(tokens.selectStyleAttr).toContain("padding-bottom:6px");
expect(tokens.selectStyleAttr).toContain("width:240px");
expect(tokens.selectStyleAttr).toContain("border-radius:9999px");
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
const tokens = computeFormSelectExportTokens(baseSelect);
expect(tokens.labelStyleAttr).toBe("");
expect(tokens.selectStyleAttr).toBe(' style="border-radius:4px"');
});
});
describe("formHelpers.computeFormCheckboxExportTokens", () => {
it("텍스트 색상/배경/테두리/패딩/둥글기 스타일을 계산해야 한다", () => {
const tokens = computeFormCheckboxExportTokens({
...baseCheckbox,
textColorCustom: " #38bdf8 ",
fillColorCustom: " #0f172a ",
strokeColorCustom: " #e5e7eb ",
paddingX: 10,
paddingY: 5,
borderRadius: "sm",
});
expect(tokens.labelStyleAttr).toBe(' style="color:#38bdf8"');
expect(tokens.optionStyleAttr).toContain("color:#38bdf8");
expect(tokens.optionStyleAttr).toContain("background-color:#0f172a");
expect(tokens.optionStyleAttr).toContain("border-color:#e5e7eb");
expect(tokens.optionStyleAttr).toContain("border-width:1px");
expect(tokens.optionStyleAttr).toContain("border-style:solid");
expect(tokens.optionStyleAttr).toContain("padding-left:10px");
expect(tokens.optionStyleAttr).toContain("padding-right:10px");
expect(tokens.optionStyleAttr).toContain("padding-top:5px");
expect(tokens.optionStyleAttr).toContain("padding-bottom:5px");
expect(tokens.optionStyleAttr).toContain("border-radius:2px");
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
const tokens = computeFormCheckboxExportTokens(baseCheckbox);
expect(tokens.labelStyleAttr).toBe("");
expect(tokens.optionStyleAttr).toBe(' style="border-radius:4px"');
});
});
describe("formHelpers.computeFormRadioExportTokens", () => {
it("체크박스와 동일한 규칙으로 옵션 스타일을 계산해야 한다", () => {
const tokens = computeFormRadioExportTokens({
...baseRadio,
textColorCustom: " #fbbf24 ",
fillColorCustom: " #111827 ",
strokeColorCustom: " #facc15 ",
paddingX: 4,
paddingY: 2,
borderRadius: "md",
});
expect(tokens.labelStyleAttr).toBe(' style="color:#fbbf24"');
expect(tokens.optionStyleAttr).toContain("color:#fbbf24");
expect(tokens.optionStyleAttr).toContain("background-color:#111827");
expect(tokens.optionStyleAttr).toContain("border-color:#facc15");
expect(tokens.optionStyleAttr).toContain("border-width:1px");
expect(tokens.optionStyleAttr).toContain("border-style:solid");
expect(tokens.optionStyleAttr).toContain("padding-left:4px");
expect(tokens.optionStyleAttr).toContain("padding-right:4px");
expect(tokens.optionStyleAttr).toContain("padding-top:2px");
expect(tokens.optionStyleAttr).toContain("padding-bottom:2px");
expect(tokens.optionStyleAttr).toContain("border-radius:4px");
});
});
describe("formHelpers - editor tokens", () => {
it("computeFormInputEditorTokens: 에디터 인풋 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormInputEditorTokens({
...baseInput,
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 240,
borderRadius: "full",
align: "center",
labelLayout: "stacked",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("240px");
expect(tokens.fieldStyle.borderRadius).toBe(9999);
});
it("computeFormSelectEditorTokens: 에디터 셀렉트 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormSelectEditorTokens({
...baseSelect,
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 260,
borderRadius: "lg",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("260px");
expect(tokens.fieldStyle.borderRadius).toBe(9999);
});
it("computeFormOptionGroupEditorTokens: 에디터 라디오/체크박스 그룹 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormOptionGroupEditorTokens({
...baseRadio,
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 280,
borderRadius: "lg",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("280px");
expect(tokens.fieldStyle.borderRadius).toBe(9999);
});
});
describe("formHelpers - public tokens", () => {
it("computeFormInputPublicTokens: 퍼블릭 인풋 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormInputPublicTokens({
...baseInput,
align: "center",
widthMode: "fixed",
widthPx: 320,
paddingX: 16,
paddingY: 8,
textColorCustom: "#112233",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any);
expect(tokens.wrapperLayoutClass).toBe("flex flex-col gap-1");
expect(tokens.widthClass).toBe("");
expect(tokens.inputStyle.textAlign).toBe("center");
expect(tokens.inputStyle.width).toBe("20em");
expect(tokens.inputStyle.paddingInline).toBe("1em");
expect(tokens.inputStyle.paddingBlock).toBe("0.5em");
expect(tokens.inputStyle.color).toBe("#112233");
expect(tokens.inputStyle.backgroundColor).toBe("#123456");
expect(tokens.inputStyle.borderColor).toBe("#ff0000");
expect(tokens.inputStyle.borderRadius).toBe("6px");
});
it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormSelectPublicTokens({
...baseSelect,
widthMode: "fixed",
widthPx: 240,
paddingX: 16,
paddingY: 8,
textColorCustom: "#00ff00",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any);
expect(tokens.widthClass).toBe("");
expect(tokens.wrapperStyle.width).toBe("15em");
expect(tokens.selectStyle.paddingInline).toBe("1em");
expect(tokens.selectStyle.paddingBlock).toBe("0.5em");
expect(tokens.selectStyle.color).toBe("#00ff00");
expect(tokens.selectStyle.backgroundColor).toBe("#123456");
expect(tokens.selectStyle.borderColor).toBe("#ff0000");
expect(tokens.selectStyle.borderRadius).toBe("6px");
});
it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => {
const tokens = computeFormCheckboxPublicTokens({
...baseCheckbox,
widthMode: "fixed",
widthPx: 320,
paddingX: 8,
paddingY: 4,
optionGapPx: 16,
textColorCustom: "#ffffff",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any);
expect(tokens.widthClass).toBe("");
expect(tokens.groupStyle.width).toBe("20em");
expect(tokens.optionsStyle.rowGap).toBe("1em");
expect(tokens.optionContainerStyle.paddingInline).toBe("0.5em");
expect(tokens.optionContainerStyle.paddingBlock).toBe("0.25em");
expect(tokens.optionContainerStyle.backgroundColor).toBe("#123456");
expect(tokens.optionContainerStyle.borderColor).toBe("#ff0000");
expect(tokens.optionContainerStyle.borderWidth).toBe("1px");
expect(tokens.optionContainerStyle.borderStyle).toBe("solid");
expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
expect(tokens.groupTextStyle.color).toBe("#ffffff");
expect(tokens.optionTextStyle.color).toBe("#ffffff");
});
it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => {
const tokens = computeFormRadioPublicTokens({
...baseRadio,
widthMode: "fixed",
widthPx: 320,
paddingX: 8,
paddingY: 4,
optionGapPx: 16,
textColorCustom: "#ffffff",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any);
expect(tokens.widthClass).toBe("");
expect(tokens.groupStyle.width).toBe("20em");
expect(tokens.optionsStyle.rowGap).toBe("1em");
expect(tokens.optionContainerStyle.paddingInline).toBe("0.5em");
expect(tokens.optionContainerStyle.paddingBlock).toBe("0.25em");
expect(tokens.optionContainerStyle.backgroundColor).toBe("#123456");
expect(tokens.optionContainerStyle.borderColor).toBe("#ff0000");
expect(tokens.optionContainerStyle.borderWidth).toBe("1px");
expect(tokens.optionContainerStyle.borderStyle).toBe("solid");
expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
expect(tokens.groupTextStyle.color).toBe("#ffffff");
expect(tokens.optionTextStyle.color).toBe("#ffffff");
});
});
describe("formHelpers.computeFormControllerPublicTokens", () => {
it("fieldIds 를 기반으로 컨트롤러 필드를 매핑하고 레이아웃/submitLabel 을 계산해야 한다", () => {
const formBlock: Block = {
id: "form-1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fieldIds: ["input-1", "checkbox-1"],
submitButtonId: "btn-1",
formWidthMode: "fixed",
formWidthPx: 320,
marginYPx: 32,
backgroundColorCustom: " #123456 ",
} as FormBlockProps,
} as Block;
const inputBlock: Block = {
id: "input-1",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
inputType: "email",
required: true,
} as any,
} as Block;
const checkboxBlock: Block = {
id: "checkbox-1",
type: "formCheckbox",
props: {
groupLabel: "옵션",
formFieldName: "options",
required: true,
groupLabelMode: "image",
groupLabelImageUrl: "https://example.com/group.png",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} as any,
} as Block;
const buttonBlock: Block = {
id: "btn-1",
type: "button",
props: {
label: "컨트롤러 버튼",
} as any,
} as Block;
const blocks: Block[] = [formBlock, inputBlock, checkboxBlock, buttonBlock];
const tokens = computeFormControllerPublicTokens(formBlock, blocks);
expect(tokens.fields).toHaveLength(2);
const inputField = tokens.fields[0];
const checkboxField = tokens.fields[1];
expect(inputField.id).toBe("input-1");
expect(inputField.name).toBe("name");
expect(inputField.label).toBe("이름");
expect(inputField.type).toBe("text");
expect(inputField.required).toBe(true);
expect(checkboxField.id).toBe("checkbox-1");
expect(checkboxField.name).toBe("options");
expect(checkboxField.label).toBe("옵션");
expect(checkboxField.type).toBe("checkbox");
expect(checkboxField.required).toBe(true);
expect(checkboxField.options).toHaveLength(2);
expect(checkboxField.groupLabelMode).toBe("image");
expect(checkboxField.groupLabelImageUrl).toBe("https://example.com/group.png");
expect(tokens.formClassName).toBe("space-y-3");
expect(tokens.formStyle.width).toBe("20em");
expect(tokens.formStyle.marginTop).toBe("2em");
expect(tokens.formStyle.marginBottom).toBe("2em");
expect(tokens.formStyle.backgroundColor).toBe("#123456");
expect(tokens.submitLabel).toBe("컨트롤러 버튼");
});
it("fields 가 있을 때는 fields 를 사용하고, 없으면 빈 배열을 반환해야 한다", () => {
const formWithFields: Block = {
id: "form-2",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [
{
id: "f1",
name: "email",
label: "이메일",
type: "email",
required: true,
},
],
} as FormBlockProps,
} as Block;
const tokensWithFields = computeFormControllerPublicTokens(formWithFields, [formWithFields]);
expect(tokensWithFields.fields).toHaveLength(1);
expect(tokensWithFields.fields[0].id).toBe("f1");
expect(tokensWithFields.fields[0].name).toBe("email");
expect(tokensWithFields.fields[0].label).toBe("이메일");
expect(tokensWithFields.fields[0].type).toBe("email");
expect(tokensWithFields.fields[0].required).toBe(true);
const formFallback: Block = {
id: "form-3",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
} as FormBlockProps,
} as Block;
const tokensFallback = computeFormControllerPublicTokens(formFallback, [formFallback]);
expect(tokensFallback.fields).toHaveLength(0);
});
});
describe("formHelpers.computeFormBlockExportTokens", () => {
it("fieldIds 가 있을 때 컨트롤러 필드와 폼 배경 스타일을 계산해야 한다", () => {
const formBlock: Block = {
id: "form-export-1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fieldIds: ["input-1", "select-1"],
fields: [],
backgroundColorCustom: " #123456 ",
} as FormBlockProps,
} as Block;
const inputBlock: Block = {
id: "input-1",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
inputType: "email",
required: true,
} as any,
} as Block;
const selectBlock: Block = {
id: "select-1",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
} as any,
} as Block;
const blocks: Block[] = [formBlock, inputBlock, selectBlock];
const tokens = computeFormBlockExportTokens(formBlock, blocks);
expect(tokens.hasControllerFields).toBe(true);
expect(tokens.controllerFields).toHaveLength(2);
expect(tokens.controllerFields[0].id).toBe("input-1");
expect(tokens.controllerFields[1].id).toBe("select-1");
expect(tokens.fallbackFields).toHaveLength(0);
expect(tokens.formStyleParts).toEqual(["background-color:#123456"]);
});
it("fieldIds 가 없고 fields 가 있을 때 fallbackFields 를 사용해야 한다", () => {
const formBlock: Block = {
id: "form-export-2",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fieldIds: [],
fields: [
{
id: "f1",
name: "email",
label: "이메일",
type: "email",
required: true,
},
],
backgroundColorCustom: undefined,
} as FormBlockProps,
} as Block;
const tokens = computeFormBlockExportTokens(formBlock, [formBlock]);
expect(tokens.hasControllerFields).toBe(false);
expect(tokens.controllerFields).toHaveLength(0);
expect(tokens.fallbackFields).toHaveLength(1);
expect(tokens.fallbackFields[0].id).toBe("f1");
expect(tokens.formStyleParts).toEqual([]);
});
it("fieldIds 와 fields 가 모두 없으면 fallbackFields 도 빈 배열이어야 한다", () => {
const formBlock: Block = {
id: "form-export-3",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
} as FormBlockProps,
} as Block;
const tokens = computeFormBlockExportTokens(formBlock, [formBlock]);
expect(tokens.hasControllerFields).toBe(false);
expect(tokens.controllerFields).toHaveLength(0);
expect(tokens.fallbackFields).toHaveLength(0);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect } from "vitest";
import {
computeImageExportStyles,
computeImageEditorTokens,
computeImagePublicTokens,
} from "@/features/editor/utils/imageHelpers";
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
describe("imageHelpers.computeImageExportStyles", () => {
it("기본 값에서는 배경/너비 없이 border-radius 8px 를 사용해야 한다(md 토큰)", () => {
const { wrapperStyleParts, imgStyleParts } = computeImageExportStyles({});
expect(wrapperStyleParts.length).toBe(0);
expect(imgStyleParts).toContain("border-radius:8px");
});
it("backgroundColorCustom 이 있으면 wrapperStyle 에 background-color 스타일을 추가해야 한다", () => {
const { wrapperStyleParts } = computeImageExportStyles({
backgroundColorCustom: "#123456",
});
expect(wrapperStyleParts).toContain("background-color:#123456");
});
it("widthMode=fixed 이고 widthPx 가 양수이면 이미지 width 스타일을 추가해야 한다", () => {
const { imgStyleParts } = computeImageExportStyles({
widthMode: "fixed",
widthPx: 320,
});
expect(imgStyleParts).toContain("width:320px");
});
it("borderRadius 토큰이 full 이면 border-radius 9999px 를 사용해야 한다", () => {
const { imgStyleParts } = computeImageExportStyles({
borderRadius: "full",
});
expect(imgStyleParts).toContain("border-radius:9999px");
});
it("borderRadiusPx 가 지정되면 토큰 대신 해당 값을 px 로 사용해야 한다", () => {
const { imgStyleParts } = computeImageExportStyles({
borderRadius: "lg",
borderRadiusPx: 20,
});
expect(imgStyleParts).toContain("border-radius:20px");
});
});
describe("computeImageEditorTokens", () => {
it("기본 값에서는 widthPx 없이 borderRadius 8px 를 사용해야 한다(md 토큰)", () => {
const tokens = computeImageEditorTokens({});
expect(tokens.widthPx).toBeUndefined();
expect(tokens.radiusPx).toBe(8);
});
it("widthMode=fixed 이고 widthPx 가 양수이면 widthPx 토큰을 그대로 사용해야 한다", () => {
const tokens = computeImageEditorTokens({
widthMode: "fixed",
widthPx: 320,
});
expect(tokens.widthPx).toBe(320);
});
it("borderRadius 토큰이 full 이면 radiusPx 9999 를 사용해야 한다", () => {
const tokens = computeImageEditorTokens({
borderRadius: "full",
});
expect(tokens.radiusPx).toBe(9999);
});
it("borderRadiusPx 가 지정되면 토큰 대신 해당 radiusPx 를 사용해야 한다", () => {
const tokens = computeImageEditorTokens({
borderRadius: "lg",
borderRadiusPx: 24,
});
expect(tokens.radiusPx).toBe(24);
});
});
describe("imageHelpers.computeImagePublicTokens", () => {
const baseProps: ImageBlockProps = {
src: "/images/example.png",
alt: "이미지",
align: "center",
widthMode: "auto",
};
it("backgroundColorCustom 이 설정된 경우에만 wrapperStyle.backgroundColor 를 설정해야 한다", () => {
const tokensWithBg = computeImagePublicTokens({
...baseProps,
backgroundColorCustom: "#123456",
});
expect(tokensWithBg.wrapperStyle.backgroundColor).toBe("#123456");
const tokensWithoutBg = computeImagePublicTokens({
...baseProps,
backgroundColorCustom: undefined,
});
expect(tokensWithoutBg.wrapperStyle.backgroundColor).toBeUndefined();
});
it("widthMode 가 fixed 이고 widthPx 가 설정된 경우 imageStyle.width 는 em 단위로 설정되어야 한다", () => {
const tokens = computeImagePublicTokens({
...baseProps,
widthMode: "fixed",
widthPx: 480,
});
expect(tokens.imageStyle.width).toBe("30em");
});
it("borderRadiusPx 가 설정된 경우 imageStyle.borderRadius 는 em 단위로 설정되어야 한다", () => {
const tokens = computeImagePublicTokens({
...baseProps,
borderRadiusPx: 32,
});
expect(tokens.imageStyle.borderRadius).toBe("2em");
});
});
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import {
computeListExportTokens,
computeListEditorTokens,
computeListPublicTokens,
} from "@/features/editor/utils/listHelpers";
import type { ListBlockProps } from "@/features/editor/state/editorStore";
const baseProps: ListBlockProps = {
items: [],
ordered: false,
align: "left",
};
describe("listHelpers.computeListExportTokens", () => {
it("기본 unordered 리스트는 Tag=ul, left 정렬, items 플랫닝을 수행해야 한다", () => {
const props: ListBlockProps = {
...baseProps,
items: [" first ", "", " second"],
};
const tokens = computeListExportTokens(props);
expect(tokens.Tag).toBe("ul");
expect(tokens.items).toEqual([" first ", " second"]);
expect(tokens.listStyleParts).toContain("text-align:left");
});
it("ordered=true 인 경우 Tag=ol 이어야 한다", () => {
const props: ListBlockProps = {
...baseProps,
ordered: true,
items: ["one", "two"],
};
const tokens = computeListExportTokens(props);
expect(tokens.Tag).toBe("ol");
expect(tokens.items).toEqual(["one", "two"]);
});
it("itemsTree 가 있으면 중첩 트리를 DFS 순서로 평탄화해야 한다", () => {
const props: ListBlockProps = {
...baseProps,
itemsTree: [
{
id: "n1",
text: "parent",
children: [
{ id: "n1-1", text: "child-1", children: [] },
{ id: "n1-2", text: "child-2", children: [] },
],
},
],
};
const tokens = computeListExportTokens(props);
expect(tokens.items).toEqual(["parent", "child-1", "child-2"]);
});
it("align 값에 따라 text-align 스타일이 left/center/right 로 설정되어야 한다", () => {
const centerTokens = computeListExportTokens({ ...baseProps, align: "center", items: ["a"] });
const rightTokens = computeListExportTokens({ ...baseProps, align: "right", items: ["a"] });
expect(centerTokens.listStyleParts).toContain("text-align:center");
expect(rightTokens.listStyleParts).toContain("text-align:right");
});
it("backgroundColorCustom 이 있으면 background-color 스타일을 추가해야 한다", () => {
const tokens = computeListExportTokens({
...baseProps,
items: ["a"],
backgroundColorCustom: " #ff0000 ",
});
expect(tokens.listStyleParts).toContain("background-color:#ff0000");
});
it("items 와 itemsTree 가 모두 비어 있으면 items 배열은 빈 배열이어야 한다", () => {
const tokens = computeListExportTokens({ ...baseProps });
expect(tokens.items).toEqual([]);
});
});
describe("listHelpers.computeListEditorTokens", () => {
it("gapY 토큰에 따라 gapPx 가 sm=4, md=8, lg=16 으로 계산되어야 한다", () => {
const sm = computeListEditorTokens({ ...baseProps, gapY: "sm" });
const md = computeListEditorTokens({ ...baseProps, gapY: "md" });
const lg = computeListEditorTokens({ ...baseProps, gapY: "lg" });
expect(sm.gapPx).toBe(4);
expect(md.gapPx).toBe(8);
expect(lg.gapPx).toBe(16);
});
it("gapYPx 가 있으면 gapY 토큰보다 우선해서 gapPx 를 설정해야 한다", () => {
const tokens = computeListEditorTokens({ ...baseProps, gapY: "sm", gapYPx: 24 });
expect(tokens.gapPx).toBe(24);
});
it("fontSizeCustom / lineHeightCustom / textColorCustom 이 listStyle 에 반영되어야 한다", () => {
const tokens = computeListEditorTokens({
...baseProps,
fontSizeCustom: "18px",
lineHeightCustom: "1.8",
textColorCustom: "#ff0000",
});
expect(tokens.listStyle.fontSize).toBe("18px");
expect(tokens.listStyle.lineHeight).toBe("1.8");
expect(tokens.listStyle.color).toBe("#ff0000");
});
it("bulletStyle 은 bulletStyle 지정값 또는 ordered 여부에 따라 결정되어야 한다", () => {
const defaultDisc = computeListEditorTokens({
...baseProps,
ordered: false,
bulletStyle: undefined,
});
const defaultDecimal = computeListEditorTokens({
...baseProps,
ordered: true,
bulletStyle: undefined,
});
const none = computeListEditorTokens({
...baseProps,
bulletStyle: "none",
});
expect(defaultDisc.bulletStyle).toBe("disc");
expect(defaultDecimal.bulletStyle).toBe("decimal");
expect(none.bulletStyle).toBe("none");
});
});
describe("listHelpers.computeListPublicTokens", () => {
it("align 값에 따라 alignClass 가 text-left/center/right 로 설정되어야 한다", () => {
const leftTokens = computeListPublicTokens({ ...baseProps, align: "left" });
const centerTokens = computeListPublicTokens({ ...baseProps, align: "center" });
const rightTokens = computeListPublicTokens({ ...baseProps, align: "right" });
expect(leftTokens.alignClass).toBe("text-left");
expect(centerTokens.alignClass).toBe("text-center");
expect(rightTokens.alignClass).toBe("text-right");
});
it("gapYPx 와 gapY 토큰에 따라 gapEm 이 px/16 값으로 계산되어야 한다", () => {
const byPx = computeListPublicTokens({ ...baseProps, gapYPx: 24 });
const byTokenSm = computeListPublicTokens({ ...baseProps, gapY: "sm" });
const byTokenLg = computeListPublicTokens({ ...baseProps, gapY: "lg" });
expect(byPx.gapEm).toBe(24 / 16);
expect(byTokenSm.gapEm).toBe(4 / 16);
expect(byTokenLg.gapEm).toBe(16 / 16);
});
it("fontSizeCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => {
const tokens = computeListPublicTokens({
...baseProps,
fontSizeCustom: "32px",
backgroundColorCustom: " #123456 ",
});
expect(tokens.listStyle.fontSize).toBe("2em");
expect(tokens.listStyle.backgroundColor).toBe("#123456");
});
it("bulletStyle 은 bulletStyle 지정값 또는 ordered 여부에 따라 결정되어야 한다", () => {
const defaultDisc = computeListPublicTokens({ ...baseProps, ordered: false, bulletStyle: undefined });
const defaultDecimal = computeListPublicTokens({ ...baseProps, ordered: true, bulletStyle: undefined });
const none = computeListPublicTokens({ ...baseProps, bulletStyle: "none" });
expect(defaultDisc.bulletStyle).toBe("disc");
expect(defaultDecimal.bulletStyle).toBe("decimal");
expect(none.bulletStyle).toBe("none");
});
});
+190
View File
@@ -0,0 +1,190 @@
import { describe, it, expect } from "vitest";
import {
computeSectionExportTokens,
computeSectionPublicTokens,
computeSectionEditorTokens,
} from "@/features/editor/utils/sectionHelpers";
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
const baseProps: SectionBlockProps = {
background: "default",
paddingY: "md",
columns: [{ id: "sec_col_1", span: 12 }],
maxWidthMode: "normal",
gapX: "md",
};
describe("sectionHelpers.computeSectionExportTokens", () => {
it("기본 섹션은 pb-section / pb-section-bg-default / pb-section-py-md 를 포함해야 한다", () => {
const tokens = computeSectionExportTokens(baseProps);
expect(tokens.sectionClasses).toContain("pb-section");
expect(tokens.sectionClasses).toContain("pb-section-bg-default");
expect(tokens.sectionClasses).toContain("pb-section-py-md");
expect(tokens.sectionStyleParts.length).toBe(0);
expect(tokens.backgroundVideoHtml).toBe("");
});
it("background=muted/primary 와 paddingY=sm/lg 는 각각 대응하는 pb-section-bg-*/pb-section-py-* 클래스로 매핑되어야 한다", () => {
const muted = computeSectionExportTokens({ ...baseProps, background: "muted" });
const primary = computeSectionExportTokens({ ...baseProps, background: "primary" });
const sm = computeSectionExportTokens({ ...baseProps, paddingY: "sm" });
const lg = computeSectionExportTokens({ ...baseProps, paddingY: "lg" });
expect(muted.sectionClasses).toContain("pb-section-bg-muted");
expect(primary.sectionClasses).toContain("pb-section-bg-primary");
expect(sm.sectionClasses).toContain("pb-section-py-sm");
expect(lg.sectionClasses).toContain("pb-section-py-lg");
});
it("backgroundColorCustom 이 있으면 background-color 스타일을 추가해야 한다", () => {
const tokens = computeSectionExportTokens({
...baseProps,
backgroundColorCustom: "#123456",
});
expect(tokens.sectionStyleParts).toContain("background-color:#123456");
});
it("backgroundImage 관련 옵션이 있으면 background-image/size/position/repeat 스타일을 추가해야 한다", () => {
const tokens = computeSectionExportTokens({
...baseProps,
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSize: "cover",
backgroundImagePositionMode: "preset",
backgroundImagePosition: "top",
backgroundImageRepeat: "repeat-x",
});
expect(tokens.sectionStyleParts).toContain("background-image:url(https://example.com/bg.png)");
expect(tokens.sectionStyleParts).toContain("background-size:cover");
expect(tokens.sectionStyleParts).toContain("background-position:center top");
expect(tokens.sectionStyleParts).toContain("background-repeat:repeat-x");
});
it("배경 비디오가 있으면 backgroundVideoHtml 과 position/overflow 스타일을 추가해야 한다", () => {
const tokens = computeSectionExportTokens({
...baseProps,
backgroundVideoSrc: "https://example.com/bg.mp4",
});
expect(tokens.backgroundVideoHtml).toContain("pb-section-bg-video");
expect(tokens.backgroundVideoHtml).toContain("https://example.com/bg.mp4");
expect(tokens.sectionStyleParts).toContain("position:relative");
expect(tokens.sectionStyleParts).toContain("overflow:hidden");
});
});
describe("sectionHelpers.computeSectionPublicTokens", () => {
it("배경색 / paddingYPx / maxWidthPx / gapXPx 를 em 단위 스타일로 계산해야 한다", () => {
const tokens = computeSectionPublicTokens({
...baseProps,
backgroundColorCustom: " #123456 ",
paddingYPx: 32,
maxWidthPx: 800,
gapXPx: 24,
});
expect(tokens.sectionStyle.backgroundColor).toBe("#123456");
expect(tokens.sectionStyle.paddingTop).toBe("2em");
expect(tokens.sectionStyle.paddingBottom).toBe("2em");
expect(tokens.innerWrapperStyle.maxWidth).toBe("50em");
expect(tokens.columnsContainerStyle.columnGap).toBe("1.5em");
});
it("backgroundImage* 와 backgroundVideoSrc 를 올바르게 반영해야 한다", () => {
const tokens = computeSectionPublicTokens({
...baseProps,
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSize: "cover",
backgroundImagePositionMode: "preset",
backgroundImagePosition: "top",
backgroundImageRepeat: "repeat-x",
backgroundVideoSrc: " https://example.com/bg.mp4 ",
} as any);
expect(tokens.sectionStyle.backgroundImage).toBe("url(https://example.com/bg.png)");
expect(tokens.sectionStyle.backgroundSize).toBe("cover");
expect(tokens.sectionStyle.backgroundPosition).toBe("center top");
expect(tokens.sectionStyle.backgroundRepeat).toBe("repeat-x");
expect(tokens.hasBackgroundVideo).toBe(true);
expect(tokens.backgroundVideoSrc).toBe("https://example.com/bg.mp4");
expect(tokens.sectionStyle.position).toBe("relative");
expect(tokens.sectionStyle.overflow).toBe("hidden");
});
});
describe("sectionHelpers.computeSectionEditorTokens", () => {
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 px 단위 스타일로 계산되어야 한다", () => {
const tokens = computeSectionEditorTokens({
...baseProps,
backgroundColorCustom: "#123456",
paddingYPx: 80,
maxWidthPx: 960,
gapXPx: 32,
alignItems: "center",
} as any);
expect(tokens.wrapperStyle.backgroundColor).toBe("#123456");
expect(tokens.wrapperStyle.paddingTop).toBe("80px");
expect(tokens.wrapperStyle.paddingBottom).toBe("80px");
expect(tokens.innerWrapperStyle.maxWidth).toBe("960px");
expect(tokens.columnsContainerStyle.columnGap).toBe("32px");
expect(tokens.alignItemsClass).toBe("items-center");
});
it("background 및 paddingY 값에 따라 bgClass/pyClass 를 계산해야 한다", () => {
const muted = computeSectionEditorTokens({ ...baseProps, background: "muted" } as any);
const primary = computeSectionEditorTokens({ ...baseProps, background: "primary" } as any);
const sm = computeSectionEditorTokens({ ...baseProps, paddingY: "sm" } as any);
const lg = computeSectionEditorTokens({ ...baseProps, paddingY: "lg" } as any);
expect(muted.bgClass).toContain("bg-slate-950/40");
expect(primary.bgClass).toContain("bg-sky-950/40");
expect(primary.bgClass).toContain("border-sky-900/60");
expect(sm.pyClass).toBe("py-4");
expect(lg.pyClass).toBe("py-10");
});
it("backgroundImage* 옵션이 있으면 wrapperStyle 에 backgroundImage/Size/Position/Repeat 이 반영되어야 한다", () => {
const tokens = computeSectionEditorTokens({
...baseProps,
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSize: "cover",
backgroundImagePositionMode: "preset",
backgroundImagePosition: "top",
backgroundImageRepeat: "repeat-x",
} as any);
expect(tokens.wrapperStyle.backgroundImage).toBe("url(https://example.com/bg.png)");
expect(tokens.wrapperStyle.backgroundSize).toBe("cover");
expect(tokens.wrapperStyle.backgroundPosition).toBe("center top");
expect(tokens.wrapperStyle.backgroundRepeat).toBe("repeat-x");
});
it("backgroundImagePositionMode 가 custom 이고 X/Y 퍼센트가 설정되면 background-position 은 'X% Y%' 로 설정되어야 한다", () => {
const tokens = computeSectionEditorTokens({
...baseProps,
backgroundImageSrc: "https://example.com/bg-xy.png",
backgroundImageSize: "cover",
backgroundImagePositionMode: "custom",
backgroundImagePositionXPercent: 30,
backgroundImagePositionYPercent: 70,
} as any);
expect(tokens.wrapperStyle.backgroundImage).toBe("url(https://example.com/bg-xy.png)");
expect(tokens.wrapperStyle.backgroundPosition).toBe("30% 70%");
});
it("backgroundVideoSrc 가 있으면 hasBackgroundVideo=true, backgroundVideoSrc 와 position/overflow 스타일이 설정되어야 한다", () => {
const tokens = computeSectionEditorTokens({
...baseProps,
backgroundVideoSrc: " https://example.com/bg.mp4 ",
} as any);
expect(tokens.hasBackgroundVideo).toBe(true);
expect(tokens.backgroundVideoSrc).toBe("https://example.com/bg.mp4");
expect(tokens.wrapperStyle.position).toBe("relative");
expect(tokens.wrapperStyle.overflow).toBe("hidden");
});
});
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect } from "vitest";
import {
computeTextPbTokens,
computeTextEditorTokens,
computeTextPublicTokens,
} from "@/features/editor/utils/textHelpers";
import type { TextBlockProps } from "@/features/editor/state/editorStore";
const baseProps = {
text: "텍스트",
align: "left" as const,
size: "base" as const,
};
describe("textHelpers.computeTextPbTokens", () => {
it("기본 텍스트는 pb-text-left / pb-text-base / pb-leading-normal / pb-font-normal / pb-text-color-strong / pb-whitespace-pre-wrap 를 포함해야 한다", () => {
const tokens = computeTextPbTokens(baseProps);
expect(tokens.alignClass).toBe("pb-text-left");
expect(tokens.sizeClass).toBe("pb-text-base");
expect(tokens.leadingClass).toBe("pb-leading-normal");
expect(tokens.weightClass).toBe("pb-font-normal");
expect(tokens.colorClass).toBe("pb-text-color-strong");
expect(tokens.extraClasses).toContain("pb-whitespace-pre-wrap");
});
it("스케일 모드 텍스트는 폰트/라인하이트/굵기 스케일 토큰을 올바르게 매핑해야 한다", () => {
const tokens = computeTextPbTokens({
...baseProps,
align: "center",
fontSizeMode: "scale",
fontSizeScale: "lg",
lineHeightMode: "scale",
lineHeightScale: "relaxed",
fontWeightMode: "scale",
fontWeightScale: "semibold",
colorMode: "palette",
colorPalette: "strong",
underline: true,
italic: true,
});
expect(tokens.alignClass).toBe("pb-text-center");
expect(tokens.sizeClass).toBe("pb-text-lg");
expect(tokens.leadingClass).toBe("pb-leading-relaxed");
expect(tokens.weightClass).toBe("pb-font-semibold");
expect(tokens.colorClass).toBe("pb-text-color-strong");
expect(tokens.extraClasses).toContain("pb-underline");
expect(tokens.extraClasses).toContain("pb-italic");
});
it("커스텀 색상과 배경색은 inlineStyles 에 color/background-color 로 포함되어야 한다", () => {
const tokens = computeTextPbTokens({
...baseProps,
colorMode: "custom",
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
});
expect(tokens.inlineStyles).toContain("color:#ff0000");
expect(tokens.inlineStyles).toContain("background-color:#123456");
});
it("maxWidthScale 가 prose/narrow 인 경우 pb-text-maxw-* 클래스를 포함해야 한다", () => {
const prose = computeTextPbTokens({ ...baseProps, maxWidthMode: "scale", maxWidthScale: "prose" });
const narrow = computeTextPbTokens({ ...baseProps, maxWidthMode: "scale", maxWidthScale: "narrow" });
expect(prose.maxWidthClass).toBe("pb-text-maxw-prose");
expect(narrow.maxWidthClass).toBe("pb-text-maxw-narrow");
});
});
describe("textHelpers.computeTextEditorTokens", () => {
const baseBlockProps: TextBlockProps = {
text: "텍스트",
align: "left",
size: "base",
} as TextBlockProps;
it("기본 텍스트는 pb-text-left / pb-text-base / pb-leading-normal / pb-font-normal / pb-text-color-default 를 사용해야 한다", () => {
const tokens = computeTextEditorTokens(baseBlockProps);
expect(tokens.alignClass).toBe("pb-text-left");
expect(tokens.sizeClass).toBe("pb-text-base");
expect(tokens.leadingClass).toBe("pb-leading-normal");
expect(tokens.weightClass).toBe("pb-font-normal");
expect(tokens.colorClass).toBe("pb-text-color-default");
expect(tokens.styleOverrides.color).toBeUndefined();
});
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 styleOverrides 에 반영되어야 한다", () => {
const tokens = computeTextEditorTokens({
...baseBlockProps,
align: "center",
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
maxWidthCustom: "320px",
} as TextBlockProps);
expect(tokens.alignClass).toBe("pb-text-center");
expect(tokens.styleOverrides.color).toBe("#ff0000");
expect(tokens.styleOverrides.backgroundColor).toBe("#123456");
expect(tokens.styleOverrides.maxWidth).toBe("320px");
});
});
describe("textHelpers.computeTextPublicTokens", () => {
const baseBlockProps: TextBlockProps = {
text: "텍스트",
align: "left",
size: "base",
} as TextBlockProps;
it("기본 텍스트는 text-left / text-base 를 사용하고 색상/배경 styleOverrides 는 없어야 한다", () => {
const tokens = computeTextPublicTokens(baseBlockProps);
expect(tokens.alignClass).toBe("text-left");
expect(tokens.sizeClass).toBe("text-base");
expect(tokens.styleOverrides.color).toBeUndefined();
expect(tokens.styleOverrides.backgroundColor).toBeUndefined();
});
it("fontSizeMode=custom 인 경우 sizeClass 는 비워지고 styleOverrides.fontSize 에 값이 설정되어야 한다", () => {
const tokens = computeTextPublicTokens({
...baseBlockProps,
fontSizeMode: "custom",
fontSizeCustom: "24px",
} as TextBlockProps);
expect(tokens.sizeClass).toBe("");
expect(tokens.styleOverrides.fontSize).toBe("24px");
});
it("colorCustom / backgroundColorCustom 이 styleOverrides 에 반영되어야 한다", () => {
const tokens = computeTextPublicTokens({
...baseBlockProps,
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
} as TextBlockProps);
expect(tokens.styleOverrides.color).toBe("#ff0000");
expect(tokens.styleOverrides.backgroundColor).toBe("#123456");
});
});
+262
View File
@@ -0,0 +1,262 @@
import { describe, it, expect } from "vitest";
import {
normalizeVideoSourceUrl,
resolveVideoPlatform,
buildVideoEmbedUrl,
computeVideoPublicTokens,
computeVideoEditorTokens,
computeVideoExportTokens,
} from "@/features/editor/utils/videoHelpers";
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
describe("videoHelpers", () => {
it("normalizeVideoSourceUrl 은 null/undefined 를 빈 문자열로 만들고 공백을 제거해야 한다", () => {
expect(normalizeVideoSourceUrl(undefined)).toBe("");
expect(normalizeVideoSourceUrl(null)).toBe("");
expect(normalizeVideoSourceUrl("")).toBe("");
expect(normalizeVideoSourceUrl(" https://example.com/video.mp4 ")).toBe("https://example.com/video.mp4");
});
it("resolveVideoPlatform 은 platform 이 auto 일 때 URL 로부터 youtube/vimeo/html5 를 판별해야 한다", () => {
expect(resolveVideoPlatform("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "auto")).toBe("youtube");
expect(resolveVideoPlatform("https://youtu.be/dQw4w9WgXcQ", "auto")).toBe("youtube");
expect(resolveVideoPlatform("https://vimeo.com/123456", "auto")).toBe("vimeo");
expect(resolveVideoPlatform("/videos/local.mp4", "auto")).toBe("html5");
});
it("resolveVideoPlatform 은 platform 이 명시된 경우 해당 값을 우선 사용해야 한다", () => {
expect(resolveVideoPlatform("https://example.com/video", "youtube")).toBe("youtube");
expect(resolveVideoPlatform("https://example.com/video", "vimeo")).toBe("vimeo");
expect(resolveVideoPlatform("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "html5")).toBe("html5");
});
it("buildVideoEmbedUrl 은 YouTube URL 을 embed URL 로 변환해야 한다", () => {
expect(buildVideoEmbedUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "youtube")).toBe(
"https://www.youtube.com/embed/dQw4w9WgXcQ",
);
expect(buildVideoEmbedUrl("https://youtu.be/dQw4w9WgXcQ", "youtube")).toBe(
"https://www.youtube.com/embed/dQw4w9WgXcQ",
);
expect(buildVideoEmbedUrl("https://www.youtube.com/shorts/dQw4w9WgXcQ", "youtube")).toBe(
"https://www.youtube.com/embed/dQw4w9WgXcQ",
);
});
it("buildVideoEmbedUrl 은 Vimeo URL 에 대해 옵션에 따라 embed URL 또는 원본 URL 을 반환해야 한다", () => {
const url = "https://vimeo.com/123456";
expect(buildVideoEmbedUrl(url, "vimeo", { enableVimeoEmbed: true })).toBe(
"https://player.vimeo.com/video/123456",
);
expect(buildVideoEmbedUrl(url, "vimeo", { enableVimeoEmbed: false })).toBe("https://vimeo.com/123456");
expect(buildVideoEmbedUrl(url, "vimeo")).toBe("https://vimeo.com/123456");
});
it("buildVideoEmbedUrl 은 html5 플랫폼에서는 정규화된 원본 URL 을 그대로 반환해야 한다", () => {
expect(buildVideoEmbedUrl(" /videos/local.mp4 ", "html5")).toBe("/videos/local.mp4");
});
});
describe("videoHelpers.computeVideoPublicTokens", () => {
const baseProps: VideoBlockProps = {
sourceUrl: "/videos/sample.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
};
it("fixed width / 카드 스타일을 em 단위로 계산해야 한다", () => {
const tokens = computeVideoPublicTokens({
...baseProps,
align: "left",
widthMode: "fixed",
widthPx: 640,
backgroundColorCustom: "#123456",
cardPaddingPx: 32,
borderRadiusPx: 16,
} as any);
expect(tokens.justifyClass).toBe("justify-start");
expect(tokens.wrapperStyle.width).toBe("40em");
expect(tokens.videoStyle.width).toBe("40em");
expect(tokens.wrapperStyle.backgroundColor).toBe("#123456");
expect(tokens.wrapperStyle.padding).toBe("2em");
expect(tokens.wrapperStyle.borderRadius).toBe("1em");
});
it("widthMode 가 full 이면 wrapper/video width 를 100% 로 설정해야 한다", () => {
const tokens = computeVideoPublicTokens({
...baseProps,
widthMode: "full",
widthPx: 640,
} as any);
expect(tokens.wrapperStyle.width).toBe("100%");
expect(tokens.videoStyle.width).toBe("100%");
});
it("aspectRatio 에 따라 aspectClass 를 설정해야 한다", () => {
const defaultTokens = computeVideoPublicTokens({
...baseProps,
aspectRatio: "16:9",
} as any);
const fourThree = computeVideoPublicTokens({
...baseProps,
aspectRatio: "4:3",
} as any);
const oneOne = computeVideoPublicTokens({
...baseProps,
aspectRatio: "1:1",
} as any);
expect(defaultTokens.aspectClass).toBe("");
expect(fourThree.aspectClass).toBe(" pb-video-wrapper--4by3");
expect(oneOne.aspectClass).toBe(" pb-video-wrapper--1by1");
});
});
describe("videoHelpers.computeVideoExportTokens", () => {
const baseProps: VideoBlockProps = {
sourceUrl: "/videos/sample-export.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
};
const escapers = { escapeAttr: (value: string) => value };
it("fixed width / 카드 스타일을 px 단위 스타일 파트로 계산해야 한다", () => {
const tokens = computeVideoExportTokens(
{
...baseProps,
align: "left",
widthMode: "fixed",
widthPx: 640,
backgroundColorCustom: "#123456",
cardPaddingPx: 24,
borderRadiusPx: 16,
} as any,
escapers,
);
expect(tokens.aspectClass).toBe("");
expect(tokens.wrapperStyleParts).toContain("width:640px");
expect(tokens.videoStyleParts).toContain("width:640px");
expect(tokens.wrapperStyleParts).toContain("background-color:#123456");
expect(tokens.wrapperStyleParts).toContain("padding:24px");
expect(tokens.wrapperStyleParts).toContain("border-radius:16px");
expect(tokens.outerStyleParts).toContain("display:flex");
expect(tokens.outerStyleParts).toContain("justify-content:flex-start");
});
it("widthMode 가 full 이면 width:100% 로 설정하고 align 에 따라 justify-content 를 계산해야 한다", () => {
const right = computeVideoExportTokens(
{
...baseProps,
align: "right",
widthMode: "full",
} as any,
escapers,
);
expect(right.wrapperStyleParts).toContain("width:100%");
expect(right.videoStyleParts).toContain("width:100%");
expect(right.outerStyleParts).toContain("justify-content:flex-end");
const center = computeVideoExportTokens(
{
...baseProps,
align: "center",
widthMode: "auto",
} as any,
escapers,
);
expect(center.outerStyleParts).toContain("justify-content:center");
});
it("aspectRatio 에 따라 aspectClass 를 설정해야 한다", () => {
const def = computeVideoExportTokens(
{
...baseProps,
aspectRatio: "16:9",
} as any,
escapers,
);
const fourThree = computeVideoExportTokens(
{
...baseProps,
aspectRatio: "4:3",
} as any,
escapers,
);
const oneOne = computeVideoExportTokens(
{
...baseProps,
aspectRatio: "1:1",
} as any,
escapers,
);
expect(def.aspectClass).toBe("");
expect(fourThree.aspectClass).toBe(" pb-video-wrapper--4by3");
expect(oneOne.aspectClass).toBe(" pb-video-wrapper--1by1");
});
});
describe("videoHelpers.computeVideoEditorTokens", () => {
const baseProps: VideoBlockProps = {
sourceUrl: "/videos/sample-editor.mp4",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
};
it("fixed width / 카드 스타일을 px 단위로 계산해야 한다", () => {
const tokens = computeVideoEditorTokens({
...baseProps,
align: "left",
widthMode: "fixed",
widthPx: 640,
backgroundColorCustom: "#123456",
cardPaddingPx: 24,
borderRadiusPx: 16,
} as any);
expect(tokens.justifyClass).toBe("justify-start");
expect(tokens.wrapperStyle.width).toBe("640px");
expect(tokens.videoStyle.width).toBe("640px");
expect(tokens.wrapperStyle.backgroundColor).toBe("#123456");
expect(tokens.wrapperStyle.padding).toBe("24px");
expect(tokens.wrapperStyle.borderRadius).toBe("16px");
});
it("widthMode 가 full 이면 wrapper/video width 를 100% 로 설정해야 한다", () => {
const tokens = computeVideoEditorTokens({
...baseProps,
widthMode: "full",
widthPx: 640,
} as any);
expect(tokens.wrapperStyle.width).toBe("100%");
expect(tokens.videoStyle.width).toBe("100%");
});
it("aspectRatio 에 따라 aspectClass 를 설정해야 한다", () => {
const defaultTokens = computeVideoEditorTokens({
...baseProps,
aspectRatio: "16:9",
} as any);
const fourThree = computeVideoEditorTokens({
...baseProps,
aspectRatio: "4:3",
} as any);
const oneOne = computeVideoEditorTokens({
...baseProps,
aspectRatio: "1:1",
} as any);
expect(defaultTokens.aspectClass).toBe("");
expect(fourThree.aspectClass).toBe(" pb-video-wrapper--4by3");
expect(oneOne.aspectClass).toBe(" pb-video-wrapper--1by1");
});
});