1차 싱크 완료
CI / test (push) Failing after 41m13s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-24 21:32:37 +09:00
parent 7a8ad7c057
commit 672cca5271
83 changed files with 6681 additions and 325 deletions
+1146 -171
View File
File diff suppressed because it is too large Load Diff
+153
View File
@@ -128,6 +128,7 @@ describe("/api/forms/submit", () => {
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.ok).toBe(true);
expect(json.message).toBe(config.successMessage);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
@@ -144,4 +145,156 @@ describe("/api/forms/submit", () => {
expect(parsed.source).toBe("builder");
expect(parsed.formId).toBe("contact-json");
});
it("webhook 모드 성공 시에도 successMessage 가 응답 message 필드에 포함되어야 한다", async () => {
const destinationUrl = `${BASE_URL}/webhook-success-message`;
const config: FormBlockProps = {
kind: "contact",
submitTarget: "webhook",
destinationUrl,
method: "POST",
headers: { Authorization: "Bearer success-token" },
extraParams: { source: "builder" },
successMessage: "웹훅으로 전송되었습니다.",
errorMessage: "웹훅 전송 실패",
fieldIds: [],
submitButtonId: null,
};
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
// @ts-expect-error - 글로벌 fetch 재정의
global.fetch = fetchMock;
const formData = new FormData();
formData.append("email_address", "success-webhook@example.com");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.ok).toBe(true);
expect(json.message).toBe(config.successMessage);
});
it("webhook 모드에서 destinationUrl 이 없으면 400 과 destinationUrl_missing 및 errorMessage 가 포함되어야 한다", async () => {
const config: FormBlockProps = {
kind: "contact",
submitTarget: "webhook",
// destinationUrl 누락
successMessage: "성공 메시지",
errorMessage: "Webhook URL 이 설정되지 않았습니다.",
fieldIds: [],
submitButtonId: null,
} as any;
const formData = new FormData();
formData.append("email_address", "missing-destination@example.com");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(400);
const json = (await res.json()) as any;
expect(json.ok).toBe(false);
expect(json.error).toBe("destinationUrl_missing");
expect(json.message).toBe(config.errorMessage);
});
it("webhook 모드에서 원격 응답이 실패하면 502 와 webhook_failed, errorMessage 가 포함되어야 한다", async () => {
const destinationUrl = `${BASE_URL}/webhook-fail`;
const config: FormBlockProps = {
kind: "contact",
submitTarget: "webhook",
destinationUrl,
method: "POST",
headers: {},
extraParams: {},
successMessage: "성공 메시지",
errorMessage: "웹훅 호출이 실패했습니다.",
fieldIds: [],
submitButtonId: null,
};
const fetchMock = vi.fn(async () => new Response("remote error", { status: 503 }));
// @ts-expect-error - 글로벌 fetch 재정의
global.fetch = fetchMock;
const formData = new FormData();
formData.append("email_address", "fail-remote@example.com");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(502);
const json = (await res.json()) as any;
expect(json.ok).toBe(false);
expect(json.error).toBe("webhook_failed");
expect(json.message).toBe(config.errorMessage);
});
it("webhook 모드에서 fetch 예외가 발생하면 500 과 webhook_exception, errorMessage 가 포함되어야 한다", async () => {
const destinationUrl = `${BASE_URL}/webhook-exception`;
const config: FormBlockProps = {
kind: "contact",
submitTarget: "webhook",
destinationUrl,
method: "POST",
headers: {},
extraParams: {},
successMessage: "성공 메시지",
errorMessage: "웹훅 호출 중 예외가 발생했습니다.",
fieldIds: [],
submitButtonId: null,
};
const fetchMock = vi.fn(async () => {
throw new Error("network error");
});
// @ts-expect-error - 글로벌 fetch 재정의
global.fetch = fetchMock;
const formData = new FormData();
formData.append("email_address", "exception@example.com");
formData.append("__config", JSON.stringify(config));
const { POST: handleSubmit } = await import("@/app/api/forms/submit/route");
const res = await handleSubmit(
new Request(`${BASE_URL}/api/forms/submit`, {
method: "POST",
body: formData,
}),
);
expect(res.status).toBe(500);
const json = (await res.json()) as any;
expect(json.ok).toBe(false);
expect(json.error).toBe("webhook_exception");
expect(json.message).toBe(config.errorMessage);
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { BlocksSidebar } from "@/app/editor/panels/BlocksSidebar";
// BlocksSidebar 템플릿 버튼 TDD
// - 각 템플릿 추가 버튼이 대응하는 editorStore 액션(add*TemplateSection)을 호출하는지 검증한다.
const templateActions = {
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(),
};
// BlocksSidebar 가 사용하는 나머지 액션들도 더미 함수로 채워둔다.
const otherActions = {
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(),
};
const mockState = {
...templateActions,
...otherActions,
};
vi.mock("@/features/editor/state/editorStore", () => {
return {
__esModule: true,
// useEditorStore(selector) 형태로 사용되므로, selector 에 mockState 를 넘겨준다.
useEditorStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
};
});
describe("BlocksSidebar - 템플릿 버튼", () => {
afterEach(() => {
cleanup();
Object.values(templateActions).forEach((fn) => fn.mockReset());
});
it("Hero 템플릿 추가 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "Hero 템플릿 추가" });
fireEvent.click(button);
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
});
it("Features 템플릿 추가 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "Features 템플릿 추가" });
fireEvent.click(button);
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
});
it("CTA 템플릿 추가 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "CTA 템플릿 추가" });
fireEvent.click(button);
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
});
it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => {
render(<BlocksSidebar />);
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Pricing 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Testimonials 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Blog 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿 추가" }));
expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addTestimonialsTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addBlogTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addTeamTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addFooterTemplateSection).toHaveBeenCalledTimes(1);
});
it("템플릿 섹션에는 각 템플릿의 용도를 설명하는 텍스트가 함께 표시되어야 한다", () => {
render(<BlocksSidebar />);
expect(screen.getByText("페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)"));
expect(screen.getByText("3컬럼 기능 소개 섹션"));
expect(screen.getByText("콜투액션(CTA) 섹션"));
expect(screen.getByText("자주 묻는 질문(FAQ) 섹션"));
expect(screen.getByText("요금제/플랜 소개 섹션"));
expect(screen.getByText("고객 후기(Testimonials) 섹션"));
expect(screen.getByText("블로그 포스트 목록 섹션"));
expect(screen.getByText("팀 소개 섹션"));
expect(screen.getByText("페이지 푸터 섹션"));
});
it("템플릿들은 카테고리별로 그룹 헤더를 가져야 한다", () => {
render(<BlocksSidebar />);
expect(screen.getByText("히어로 · CTA")).toBeDefined();
expect(screen.getByText("콘텐츠 섹션")).toBeDefined();
expect(screen.getByText("신뢰/소개")).toBeDefined();
expect(screen.getByText("푸터/기타")).toBeDefined();
});
it("각 템플릿 카드에는 미니 썸네일(레이아웃 힌트)가 렌더링되어야 한다", () => {
render(<BlocksSidebar />);
expect(screen.getByTestId("template-preview-hero")).toBeDefined();
expect(screen.getByTestId("template-preview-features")).toBeDefined();
expect(screen.getByTestId("template-preview-cta")).toBeDefined();
expect(screen.getByTestId("template-preview-faq")).toBeDefined();
expect(screen.getByTestId("template-preview-pricing")).toBeDefined();
expect(screen.getByTestId("template-preview-testimonials")).toBeDefined();
expect(screen.getByTestId("template-preview-blog")).toBeDefined();
expect(screen.getByTestId("template-preview-team")).toBeDefined();
expect(screen.getByTestId("template-preview-footer")).toBeDefined();
});
it("미니 썸네일은 각 템플릿 레이아웃을 암시하는 구조를 가져야 한다", () => {
render(<BlocksSidebar />);
const heroThumb = screen.getByTestId("template-preview-hero");
const ctaThumb = screen.getByTestId("template-preview-cta");
const featuresThumb = screen.getByTestId("template-preview-features");
const pricingThumb = screen.getByTestId("template-preview-pricing");
const blogThumb = screen.getByTestId("template-preview-blog");
const teamThumb = screen.getByTestId("template-preview-team");
// Hero/CTA: 하나의 메인 영역을 가진 1열 구조
expect(heroThumb.children.length).toBe(1);
expect(ctaThumb.children.length).toBe(1);
// Features/Blog: 3컬럼 레이아웃을 암시하는 3개의 세그먼트
expect(featuresThumb.children.length).toBe(3);
expect(blogThumb.children.length).toBe(3);
// Pricing/Team: 높이가 다른 3개의 바를 가진 3컬럼 카드 레이아웃
expect(pricingThumb.children.length).toBe(3);
expect(teamThumb.children.length).toBe(3);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ButtonPropertiesPanel } from "@/app/editor/panels/ButtonPropertiesPanel";
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
// ButtonPropertiesPanel 컨트롤 TDD
// - 색상 HEX 인풋 및 너비 모드 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다.
describe("ButtonPropertiesPanel", () => {
const baseProps: ButtonBlockProps = {
label: "버튼",
href: "#",
align: "left",
size: "md",
variant: "solid",
colorPalette: "primary",
};
afterEach(() => {
cleanup();
});
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, textColorCustom: "#ffffff" }}
selectedBlockId="btn-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("버튼 텍스트 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-1",
expect.objectContaining({ textColorCustom: "#112233" }),
);
});
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, fillColorCustom: "#000000" }}
selectedBlockId="btn-2"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("버튼 채움 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#445566" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-2",
expect.objectContaining({ fillColorCustom: "#445566" }),
);
});
it("외곽선 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, strokeColorCustom: "#000000" }}
selectedBlockId="btn-3"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("버튼 외곽선 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#778899" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-3",
expect.objectContaining({ strokeColorCustom: "#778899" }),
);
});
it("버튼 너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, widthMode: "auto", fullWidth: false }}
selectedBlockId="btn-4"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("버튼 너비 모드");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-4",
expect.objectContaining({ widthMode: "fixed" }),
);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { DividerPropertiesPanel } from "@/app/editor/panels/DividerPropertiesPanel";
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
// DividerPropertiesPanel 컨트롤 TDD
// - 정렬/두께 select 변경 시 updateBlock 이 올바른 필드로 호출되는지
// - 색상 HEX 인풋 변경 시 colorHex 로 호출되는지
// - 위/아래 여백 커스텀(px) 입력 시 marginYPx 로 호출되는지
describe("DividerPropertiesPanel", () => {
const baseProps: DividerBlockProps = {
align: "left",
thickness: "thin",
widthMode: "full",
colorHex: "#475569",
marginY: "md",
};
afterEach(() => {
cleanup();
});
it("정렬 select 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<DividerPropertiesPanel
dividerProps={baseProps}
selectedBlockId="divider-1"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("구분선 정렬");
fireEvent.change(select, { target: { value: "center" } });
expect(updateBlock).toHaveBeenCalledWith(
"divider-1",
expect.objectContaining({ align: "center" }),
);
});
it("두께 select 변경 시 updateBlock 이 thickness 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<DividerPropertiesPanel
dividerProps={baseProps}
selectedBlockId="divider-2"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("구분선 두께");
fireEvent.change(select, { target: { value: "medium" } });
expect(updateBlock).toHaveBeenCalledWith(
"divider-2",
expect.objectContaining({ thickness: "medium" }),
);
});
it("색상 HEX 인풋 변경 시 updateBlock 이 colorHex 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<DividerPropertiesPanel
dividerProps={{ ...baseProps, colorHex: "#111111" }}
selectedBlockId="divider-3"
updateBlock={updateBlock}
/>,
);
const hexInputs = screen.getAllByLabelText("구분선 색상 HEX");
const hexInput = hexInputs[0] as HTMLInputElement;
fireEvent.change(hexInput, { target: { value: "#123456" } });
expect(updateBlock).toHaveBeenCalledWith(
"divider-3",
expect.objectContaining({ colorHex: "#123456" }),
);
});
it("위/아래 여백 커스텀 (px) 인풋 변경 시 updateBlock 이 marginYPx 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<DividerPropertiesPanel
dividerProps={baseProps}
selectedBlockId="divider-4"
updateBlock={updateBlock}
/>,
);
const marginInputs = screen.getAllByLabelText("위/아래 여백 커스텀 (px)");
const marginInput = marginInputs[0] as HTMLInputElement;
fireEvent.change(marginInput, { target: { value: "24" } });
expect(updateBlock).toHaveBeenCalledWith(
"divider-4",
expect.objectContaining({ marginYPx: 24 }),
);
});
});
+166
View File
@@ -0,0 +1,166 @@
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: "editor-button-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>,
};
});
describe("EditorPage - 버튼 블록 스타일", () => {
it("크기/변형/색상/패딩/텍스트 정렬이 에디터 버튼 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "btn_style_1",
type: "button",
props: {
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 any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "btn_style_1";
render(<EditorPage />);
const editorBlock = screen.getByTestId("editor-block") as HTMLElement;
const button = screen.getByRole("button", { name: "테스트 버튼" }) as HTMLButtonElement;
// 정렬: align="center" → editor-block 에 pb-text-center 클래스
expect(editorBlock.className).toContain("pb-text-center");
// 버튼 클래스: 크기/변형/둥글기 토큰이 pb-btn-* 클래스로 반영되어야 한다.
expect(button.className).toContain("pb-btn-base");
expect(button.className).toContain("pb-btn-size-lg");
expect(button.className).toContain("pb-btn-radius-full");
expect(button.className).toContain("pb-btn-variant-outline-success");
// fullWidth=true 인 경우, 래퍼 div 가 w-full 클래스를 가져야 한다.
const wrapper = button.parentElement as HTMLElement;
expect(wrapper.className).toContain("w-full");
// 스타일: 색상/패딩/타이포
expect(button.style.backgroundColor).toBe("rgb(18, 52, 86)"); // #123456
expect(button.style.borderColor).toBe("rgb(101, 67, 33)"); // #654321
expect(button.style.color).toBe("rgb(255, 0, 0)"); // #ff0000
expect(button.style.paddingInline).toBe("16px");
expect(button.style.paddingBlock).toBe("10px");
expect(button.style.fontSize).toBe("18px");
expect(button.style.lineHeight).toBe("1.8");
expect(button.style.letterSpacing).toBe("0.1em");
});
it("widthMode=fixed 인 경우 widthPx 가 에디터 버튼 width 스타일에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "btn_width_fixed",
type: "button",
props: {
label: "고정 너비 버튼",
href: "#",
align: "left",
size: "md",
variant: "solid",
colorPalette: "primary",
borderRadius: "md",
fullWidth: false,
widthMode: "fixed",
widthPx: 200,
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "btn_width_fixed";
render(<EditorPage />);
const button = screen.getByRole("button", { name: "고정 너비 버튼" }) as HTMLButtonElement;
// widthMode="fixed" + widthPx=200 이면 버튼 style.width 가 200px 이어야 한다.
expect(button.style.width).toBe("200px");
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { EditorCanvas } from "@/app/editor/EditorCanvas";
// 에디터 캔버스/페이지 배경색 적용 규칙에 대한 최소 TDD
// - canvasBgColorHex: 실제 캔버스 영역(editor-canvas-inner)의 배경색
// - bodyBgColorHex: 에디터 내 페이지 배경(캔버스 바깥 래퍼)의 배경색
afterEach(() => {
cleanup();
});
describe("EditorCanvas - 캔버스/페이지 배경색", () => {
const baseProjectConfig: ProjectConfig = {
title: "배경 테스트",
slug: "bg-test",
canvasPreset: "full",
canvasBgColorHex: "#111111",
bodyBgColorHex: "#222222",
};
const baseProps = {
blocks: [] as Block[],
rootBlocks: [] as Block[],
selectedBlockId: null as string | null,
editingBlockId: null as string | null,
editingText: "",
activeDragId: null as string | null,
sensors: null as any,
onCanvasEmptyClick: () => {},
renderBlocks: () => null as any,
handleDragStart: () => {},
handleDragEnd: () => {},
handleDragCancel: () => {},
projectConfig: baseProjectConfig,
};
it("canvasBgColorHex 는 editor-canvas-inner 배경색에 적용되어야 한다", () => {
render(<EditorCanvas {...baseProps} />);
const inner = screen.getByTestId("editor-canvas-inner") as HTMLElement;
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
});
it("bodyBgColorHex 는 editor-canvas 바깥 래퍼(editor-canvas)의 배경색에 적용되어야 한다", () => {
const { container } = render(<EditorCanvas {...baseProps} />);
const outer = container.querySelector('[data-testid="editor-canvas"]') as HTMLElement | null;
expect(outer).not.toBeNull();
expect(outer!.style.backgroundColor).toBe("rgb(34, 34, 34)");
});
it("canvasPreset / canvasWidthPx 에 따라 editor-canvas-inner 의 maxWidth 가 설정되어야 한다", () => {
const renderWithConfig = (partial: Partial<ProjectConfig>) => {
const projectConfig = { ...baseProjectConfig, ...partial } as ProjectConfig;
const { getByTestId, unmount } = render(<EditorCanvas {...baseProps} projectConfig={projectConfig} />);
const inner = getByTestId("editor-canvas-inner") as HTMLElement;
const maxWidth = inner.style.maxWidth;
unmount();
return maxWidth;
};
expect(renderWithConfig({ canvasPreset: "mobile", canvasWidthPx: 390 })).toBe("390px");
expect(renderWithConfig({ canvasPreset: "tablet", canvasWidthPx: 768 })).toBe("768px");
expect(renderWithConfig({ canvasPreset: "desktop", canvasWidthPx: 1200 })).toBe("1200px");
// full 프리셋에서 canvasWidthPx 가 없으면 maxWidth 는 비워져 있어야 한다.
expect(renderWithConfig({ canvasPreset: "full", canvasWidthPx: undefined })).toBe("");
// custom 프리셋에서는 canvasWidthPx 값이 그대로 사용된다.
expect(renderWithConfig({ canvasPreset: "custom", canvasWidthPx: 1024 })).toBe("1024px");
});
});
+121
View File
@@ -0,0 +1,121 @@
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: "editor-divider-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>,
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
return `rgb(${r}, ${g}, ${b})`;
}
describe("EditorPage - 디바이더 블록 스타일", () => {
it("colorHex / widthMode+widthPx / marginYPx 가 에디터 디바이더 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "divider_style_1",
type: "divider",
props: {
align: "center",
thickness: "medium",
widthMode: "fixed",
widthPx: 400,
colorHex: "#123456",
marginYPx: 32,
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "divider_style_1";
render(<EditorPage />);
// EditorPage 의 디바이더는 wrapper div 안에 border-t 클래스를 가진 div 로 렌더된다.
const editorBlock = screen.getByTestId("editor-block") as HTMLElement;
const inner = editorBlock.querySelector("div.border-t") as HTMLElement;
expect(inner).toBeTruthy();
expect(inner.style.borderColor).toBe(hexToRgb("#123456"));
expect(inner.style.width).toBe("400px");
// marginTop/marginBottom 은 상위 wrapper div 에 적용된다.
const wrapper = inner.parentElement as HTMLElement;
expect(wrapper.style.marginTop).toBe("32px");
expect(wrapper.style.marginBottom).toBe("32px");
});
});
+231
View File
@@ -0,0 +1,231 @@
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: "editor-form-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>,
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
return `rgb(${r}, ${g}, ${b})`;
}
describe("EditorPage - 폼 블록 스타일", () => {
it("formInput: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_1",
type: "formInput",
props: {
label: "폼 입력",
inputType: "text",
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 240,
borderRadius: "full",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_input_1";
render(<EditorPage />);
const field = screen.getByTestId("form-input-field") as HTMLElement;
expect(field.style.color).toBe(hexToRgb("#ff0000"));
expect(field.style.backgroundColor).toBe(hexToRgb("#123456"));
expect(field.style.borderColor).toBe(hexToRgb("#654321"));
expect(field.style.width).toBe("240px");
// borderRadius=full → 9999px 로 적용
expect(field.style.borderRadius).toBe("9999px");
});
it("formSelect: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_select_1",
type: "formSelect",
props: {
label: "폼 셀렉트",
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 260,
borderRadius: "lg",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_select_1";
render(<EditorPage />);
const select = screen.getByLabelText("폼 셀렉트") as HTMLSelectElement;
const wrapper = select.parentElement as HTMLElement; // style 이 적용된 div
expect(wrapper.style.color).toBe(hexToRgb("#ff0000"));
expect(wrapper.style.backgroundColor).toBe(hexToRgb("#123456"));
expect(wrapper.style.borderColor).toBe(hexToRgb("#654321"));
expect(wrapper.style.width).toBe("260px");
expect(wrapper.style.borderRadius).toBe("9999px"); // lg → 9999 (에디터 구현 기준)
});
it("formRadio: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_1",
type: "formRadio",
props: {
formFieldName: "radio-group",
groupLabel: "라디오 그룹",
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 280,
borderRadius: "lg",
options: [
{ label: "옵션 A", value: "a" },
{ label: "옵션 B", value: "b" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_1";
render(<EditorPage />);
const label = screen.getByText("라디오 그룹");
const container = label.nextElementSibling as HTMLElement; // style 이 적용된 div
expect(container).toBeTruthy();
expect(container.style.color).toBe(hexToRgb("#ff0000"));
expect(container.style.backgroundColor).toBe(hexToRgb("#123456"));
expect(container.style.borderColor).toBe(hexToRgb("#654321"));
expect(container.style.width).toBe("280px");
expect(container.style.borderRadius).toBe("9999px");
});
it("formCheckbox: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_1",
type: "formCheckbox",
props: {
formFieldName: "check-group",
groupLabel: "체크 그룹",
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 300,
borderRadius: "lg",
options: [
{ label: "체크 1", value: "c1" },
{ label: "체크 2", value: "c2" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_checkbox_1";
render(<EditorPage />);
// "체크 그룹" 텍스트를 포함하는 가장 가까운 컨테이너를 찾아 스타일을 검사한다.
const label = screen.getByText("체크 그룹");
const groupContainer = label.closest("div") as HTMLElement;
expect(groupContainer.style.color).toBe(hexToRgb("#ff0000"));
expect(groupContainer.style.backgroundColor).toBe(hexToRgb("#123456"));
expect(groupContainer.style.borderColor).toBe(hexToRgb("#654321"));
expect(groupContainer.style.width).toBe("300px");
expect(groupContainer.style.borderRadius).toBe("9999px");
});
});
+105
View File
@@ -0,0 +1,105 @@
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: "editor-image-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>,
};
});
describe("EditorPage - 이미지 블록 스타일", () => {
it("widthMode=fixed 일 때 widthPx / borderRadiusPx 가 에디터 이미지 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "image_style_1",
type: "image",
props: {
src: "/images/example.png",
alt: "예제 이미지",
align: "center",
widthMode: "fixed",
widthPx: 320,
borderRadius: "lg",
borderRadiusPx: 24,
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "image_style_1";
render(<EditorPage />);
const img = screen.getByAltText("예제 이미지") as HTMLImageElement;
expect(img.style.width).toBe("320px");
expect(img.style.borderRadius).toBe("24px");
});
});
+91
View File
@@ -0,0 +1,91 @@
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: "layout-scroll-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>,
};
});
describe("EditorPage - 레이아웃 스크롤 컨테이너", () => {
it("좌측 사이드바, 캔버스, 우측 속성 패널에 pb-scroll 클래스가 적용되어야 한다", () => {
render(<EditorPage />);
const blocksHeading = screen.getByText("블록");
const blocksSidebar = blocksHeading.closest("aside") as HTMLElement;
const canvas = screen.getByTestId("editor-canvas");
const propertiesSidebar = screen.getByTestId("properties-sidebar");
expect(blocksSidebar).not.toBeNull();
expect(blocksSidebar.className).toContain("pb-scroll");
expect(canvas.className).toContain("pb-scroll");
expect(propertiesSidebar.className).toContain("pb-scroll");
});
});
+114
View File
@@ -0,0 +1,114 @@
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: "editor-list-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>,
};
});
function hexToRgb(hex: string) {
const clean = hex.replace("#", "");
const int = parseInt(clean, 16);
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
return `rgb(${r}, ${g}, ${b})`;
}
describe("EditorPage - 리스트 블록 스타일", () => {
it("fontSizeCustom / lineHeightCustom / textColorCustom 가 에디터 리스트 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "list_style_1",
type: "list",
props: {
items: ["Item 1", "Item 2"],
ordered: false,
align: "left",
fontSizeCustom: "18px",
lineHeightCustom: "1.8",
textColorCustom: "#ff0000",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "list_style_1";
render(<EditorPage />);
const ul = screen.getByRole("list") as HTMLElement;
expect(ul.style.fontSize).toBe("18px");
expect(ul.style.lineHeight).toBe("1.8");
expect(ul.style.color).toBe(hexToRgb("#ff0000"));
});
});
+151
View File
@@ -0,0 +1,151 @@
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: "multi-select",
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(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: 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(),
};
});
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>,
};
});
function setupThreeBlocks() {
const blocks: Block[] = [
{
id: "blk_1",
type: "text",
props: {
text: "블록 1",
align: "left",
size: "base",
},
} as any,
{
id: "blk_2",
type: "text",
props: {
text: "블록 2",
align: "left",
size: "base",
},
} as any,
{
id: "blk_3",
type: "text",
props: {
text: "블록 3",
align: "left",
size: "base",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = null;
}
describe("EditorPage - 멀티 선택", () => {
it("Cmd/Ctrl+클릭으로 여러 블록을 동시에 선택할 수 있어야 한다", () => {
setupThreeBlocks();
render(<EditorPage />);
const blocksEls = screen.getAllByTestId("editor-block") as HTMLElement[];
// 첫 번째와 두 번째 블록을 Cmd/Ctrl+클릭
fireEvent.click(blocksEls[0], { metaKey: true });
fireEvent.click(blocksEls[1], { metaKey: true });
const selectedEls = blocksEls.filter((el) => el.dataset.selected === "true");
expect(selectedEls.length).toBe(2);
const selectedIds = selectedEls.map((el) => el.getAttribute("data-block-id") ?? "");
expect(new Set(selectedIds)).toEqual(new Set(["blk_1", "blk_2"]));
});
it("멀티 선택 상태에서 Backspace 로 선택된 모든 블록이 삭제되어야 한다", () => {
setupThreeBlocks();
render(<EditorPage />);
const blocksEls = screen.getAllByTestId("editor-block") as HTMLElement[];
// 블록 1, 2 를 멀티 선택
fireEvent.click(blocksEls[0], { metaKey: true });
fireEvent.click(blocksEls[1], { metaKey: true });
// Backspace 로 삭제
const event = new KeyboardEvent("keydown", { key: "Backspace" });
window.dispatchEvent(event);
// replaceBlocks 가 한 번 호출되고, 남은 블록은 blk_3 만 있어야 한다.
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
const arg = mockState.replaceBlocks.mock.calls[0][0] as Block[];
expect(arg.length).toBe(1);
expect(arg[0].id).toBe("blk_3");
});
});
+88
View File
@@ -0,0 +1,88 @@
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: "bg-test",
canvasPreset: "full",
canvasBgColorHex: "#111111",
bodyBgColorHex: "#222222",
} 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(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: 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(),
};
});
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>,
};
});
describe("EditorPage - 페이지/캔버스 배경", () => {
it("main 요소는 bodyBgColorHex 로 직접 배경색을 갖지 않아야 하고, 캔버스 래퍼만 배경색을 가져야 한다", () => {
const { container } = render(<EditorPage />);
const mainEl = container.querySelector("main") as HTMLElement | null;
expect(mainEl).not.toBeNull();
// main 은 bodyBgColorHex 기반 배경색을 직접 갖지 않는다.
expect(mainEl!.style.backgroundColor).toBe("");
const canvasOuter = screen.getByTestId("editor-canvas") as HTMLElement;
expect(canvasOuter.style.backgroundColor).toBe("rgb(34, 34, 34)");
});
});
+201
View File
@@ -0,0 +1,201 @@
import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
// EditorPage 전역 단축키 TDD
// - Undo/Redo, 삭제/복제, 선택 이동(ArrowUp/Down) 단축키가 editorStore 액션을 호출하는지 검증한다.
// - 입력 포커스가 input/textarea 인 경우에는 단축키가 동작하지 않아야 한다.
const undo = vi.fn();
const redo = vi.fn();
const removeBlock = vi.fn();
const duplicateBlock = vi.fn();
const selectBlock = vi.fn();
let mockState: any;
beforeEach(() => {
mockState = {
// 블록 2개를 가진 기본 상태
blocks: [
{ id: "blk_1", type: "text", props: { text: "첫 번째" } },
{ id: "blk_2", type: "text", props: { text: "두 번째" } },
],
selectedBlockId: "blk_1",
selectedListItemId: null,
undo,
redo,
removeBlock,
duplicateBlock,
selectBlock,
// EditorPage 가 참조하지만 이 테스트에서 중요하지 않은 액션들: no-op
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(),
selectListItem: vi.fn(),
indentSelectedListItem: vi.fn(),
outdentSelectedListItem: vi.fn(),
moveSelectedListItemUp: vi.fn(),
moveSelectedListItemDown: vi.fn(),
replaceBlocks: vi.fn(),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
projectConfig: {
title: "테스트 페이지",
slug: "test-page",
canvasPreset: "full",
},
};
undo.mockReset();
redo.mockReset();
removeBlock.mockReset();
duplicateBlock.mockReset();
selectBlock.mockReset();
});
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>,
};
});
afterEach(() => {
cleanup();
});
describe("EditorPage - 전역 단축키", () => {
it("Cmd/Ctrl+Z 는 undo 액션을 호출해야 한다", () => {
// Mac 플랫폼으로 강제 설정
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
render(<EditorPage />);
const event = new KeyboardEvent("keydown", { key: "z", metaKey: true });
window.dispatchEvent(event);
expect(undo).toHaveBeenCalledTimes(1);
if (originalPlatform) {
Object.defineProperty(navigator, "platform", originalPlatform);
}
});
it("Cmd/Ctrl+Shift+Z 는 redo 액션을 호출해야 한다", () => {
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
render(<EditorPage />);
const event = new KeyboardEvent("keydown", { key: "z", metaKey: true, shiftKey: true });
window.dispatchEvent(event);
expect(redo).toHaveBeenCalledTimes(1);
if (originalPlatform) {
Object.defineProperty(navigator, "platform", originalPlatform);
}
});
it("Delete/Backspace 는 선택된 블록을 removeBlock 으로 삭제해야 한다", () => {
mockState.selectedBlockId = "blk_1";
render(<EditorPage />);
const event = new KeyboardEvent("keydown", { key: "Backspace" });
window.dispatchEvent(event);
expect(removeBlock).toHaveBeenCalledTimes(1);
expect(removeBlock).toHaveBeenCalledWith("blk_1");
});
it("Cmd/Ctrl+D 는 선택된 블록을 duplicateBlock 으로 복제해야 한다", () => {
const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform");
Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
mockState.selectedBlockId = "blk_2";
render(<EditorPage />);
const event = new KeyboardEvent("keydown", { key: "d", metaKey: true });
window.dispatchEvent(event);
expect(duplicateBlock).toHaveBeenCalledTimes(1);
expect(duplicateBlock).toHaveBeenCalledWith("blk_2");
if (originalPlatform) {
Object.defineProperty(navigator, "platform", originalPlatform);
}
});
it("ArrowDown 은 선택이 없을 때 첫 번째 블록을 선택해야 한다", () => {
mockState.selectedBlockId = null;
render(<EditorPage />);
const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
window.dispatchEvent(event);
expect(selectBlock).toHaveBeenCalledTimes(1);
expect(selectBlock).toHaveBeenCalledWith("blk_1");
});
it("ArrowUp 은 선택이 없을 때 마지막 블록을 선택해야 한다", () => {
mockState.selectedBlockId = null;
render(<EditorPage />);
const event = new KeyboardEvent("keydown", { key: "ArrowUp" });
window.dispatchEvent(event);
expect(selectBlock).toHaveBeenCalledTimes(1);
expect(selectBlock).toHaveBeenCalledWith("blk_2");
});
it("입력 포커스(input) 상태에서는 Backspace 로 removeBlock 이 호출되면 안 된다", () => {
mockState.selectedBlockId = "blk_1";
render(<EditorPage />);
const input = document.createElement("input");
document.body.appendChild(input);
const event = new KeyboardEvent("keydown", { key: "Backspace", bubbles: true });
Object.defineProperty(event, "target", { value: input, configurable: true });
window.dispatchEvent(event);
expect(removeBlock).not.toHaveBeenCalled();
document.body.removeChild(input);
});
});
+201
View File
@@ -0,0 +1,201 @@
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";
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "섹션 레이아웃 테스트",
slug: "section-layout-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,
// 에디터 액션들은 모두 더미 함수로 채워 EditorPage 렌더링이 가능하도록 한다.
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>,
};
});
describe("EditorPage - 섹션 레이아웃 속성", () => {
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 editor-section 스타일에 반영되어야 한다", () => {
const section: Block = {
id: "sec_layout",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [
{ id: "sec_layout_col_1", span: 12 },
],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
backgroundColorCustom: "#123456",
paddingYPx: 80,
maxWidthPx: 960,
gapXPx: 32,
},
} as any;
const child: Block = {
id: "txt_1",
type: "text",
props: {
text: "섹션 안 텍스트",
align: "left",
size: "base",
},
sectionId: "sec_layout",
columnId: "sec_layout_col_1",
} as any;
mockState.blocks = [section, child];
mockState.selectedBlockId = "sec_layout";
render(<EditorPage />);
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
// 배경색: #123456 → rgb(18, 52, 86)
expect(sectionEl.style.backgroundColor).toBe("rgb(18, 52, 86)");
// 세로 패딩(px)이 스타일에 반영되어야 한다.
expect(sectionEl.style.paddingTop).toBe("80px");
expect(sectionEl.style.paddingBottom).toBe("80px");
// 내부 maxWidth
const inner = sectionEl.querySelector("div.mx-auto") as HTMLElement;
expect(inner).toBeTruthy();
expect(inner.style.maxWidth).toBe("960px");
// 컬럼 간 간격(gapXPx)
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
expect(columns).toBeTruthy();
expect(columns.style.columnGap).toBe("32px");
});
it("alignItems 값에 따라 섹션 컬럼 컨테이너가 items-*- 정렬 클래스를 가져야 한다", () => {
const section: Block = {
id: "sec_align",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [
{ id: "sec_align_col_1", span: 12 },
],
maxWidthMode: "normal",
gapX: "md",
alignItems: "center",
},
} as any;
const child: Block = {
id: "txt_align",
type: "text",
props: {
text: "세로 정렬 테스트",
align: "left",
size: "base",
},
sectionId: "sec_align",
columnId: "sec_align_col_1",
} as any;
mockState.blocks = [section, child];
mockState.selectedBlockId = "sec_align";
render(<EditorPage />);
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
expect(columns).toBeTruthy();
// alignItems="center" 인 경우 items-center 클래스가 포함되어야 한다.
expect(columns.className).toContain("items-center");
});
it("Hero 템플릿 섹션의 레이아웃 속성이 EditorPage 섹션 렌더에 반영되어야 한다", () => {
const sectionId = "hero_section_layout";
const createId = (() => {
let i = 0;
return () => `hero_${++i}`;
})();
const { blocks } = createHeroTemplateBlocks({ sectionId, createId });
mockState.blocks = blocks as Block[];
mockState.selectedBlockId = sectionId;
render(<EditorPage />);
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
// Hero 템플릿 배경색(#020617)과 패딩, 최대 폭, gap 이 에디터 섹션에도 반영되어야 한다.
expect(sectionEl.style.backgroundColor).toBe("rgb(2, 6, 23)");
expect(sectionEl.style.paddingTop).toBe("96px");
expect(sectionEl.style.paddingBottom).toBe("96px");
const inner = sectionEl.querySelector("div.mx-auto") as HTMLElement;
expect(inner.style.maxWidth).toBe("960px");
const columns = sectionEl.querySelector("div.flex") as HTMLElement;
expect(columns.style.columnGap).toBe("40px");
});
});
+180
View File
@@ -0,0 +1,180 @@
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: "selection-focus",
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>,
};
});
describe("EditorPage - 선택 포커스", () => {
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "blk_1",
type: "text",
props: {
text: "첫 번째 블록",
align: "left",
size: "base",
},
} as any,
{
id: "blk_2",
type: "text",
props: {
text: "두 번째 블록",
align: "left",
size: "base",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "blk_2";
render(<EditorPage />);
const allBlocks = screen.getAllByTestId("editor-block") as HTMLElement[];
const selected = allBlocks.find((el) => el.dataset.selected === "true");
expect(selected).toBeTruthy();
expect(selected!.className).toContain("border-sky-500");
expect(selected!.className).toContain("bg-slate-900/80");
});
it("섹션 블록 자체를 선택하면 섹션 wrapper 가 강조되어야 한다", () => {
const section: Block = {
id: "sec_1",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [
{
id: "sec_1_col_1",
span: 12,
},
],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
},
} as any;
mockState.blocks = [section];
mockState.selectedBlockId = "sec_1";
render(<EditorPage />);
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
expect(sectionEl.className).toContain("border-sky-500");
});
it("섹션 자식 블록을 선택해도 섹션 wrapper 가 강조되어야 한다", () => {
const section: Block = {
id: "sec_1",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [
{
id: "sec_1_col_1",
span: 12,
},
],
maxWidthMode: "normal",
gapX: "md",
alignItems: "top",
},
} as any;
const child: Block = {
id: "txt_child",
type: "text",
props: {
text: "섹션 안 텍스트",
align: "left",
size: "base",
},
sectionId: "sec_1",
columnId: "sec_1_col_1",
} as any;
mockState.blocks = [section, child];
mockState.selectedBlockId = "txt_child";
render(<EditorPage />);
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
expect(sectionEl.className).toContain("border-sky-500");
});
});
+113
View File
@@ -0,0 +1,113 @@
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: "editor-text-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>,
};
});
describe("EditorPage - 텍스트 블록 스타일", () => {
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 에디터 텍스트 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "txt_style_1",
type: "text",
props: {
text: "스타일 테스트 텍스트",
align: "center",
size: "base",
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
maxWidthCustom: "320px",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "txt_style_1";
render(<EditorPage />);
const blockEl = screen.getByTestId("editor-block") as HTMLElement;
const textEl = blockEl.querySelector("div.whitespace-pre-wrap") as HTMLElement;
expect(textEl).toBeTruthy();
// 텍스트 색상: #ff0000 → rgb(255, 0, 0)
expect(textEl.style.color).toBe("rgb(255, 0, 0)");
// 배경색: #123456 → rgb(18, 52, 86)
expect(textEl.style.backgroundColor).toBe("rgb(18, 52, 86)");
// 최대 너비: maxWidthCustom 이 style.maxWidth 로 반영되어야 한다.
expect(textEl.style.maxWidth).toBe("320px");
// 정렬 클래스는 pb-text-center 로 유지되어야 한다.
expect(blockEl.className).toContain("pb-text-center");
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FormControllerPanel } from "@/app/editor/forms/FormControllerPanel";
import type { Block, FormBlockProps } from "@/features/editor/state/editorStore";
// FormControllerPanel 컨트롤 TDD
// - 성공/에러 메시지 인풋이 updateBlock 을 통해 FormBlockProps.successMessage / errorMessage 를 갱신하는지 검증한다.
function makeFormBlock(id: string, props: Partial<FormBlockProps> = {}): Block {
const base: FormBlockProps = {
kind: "contact",
submitTarget: "internal",
fieldIds: [],
submitButtonId: null,
} as any;
return {
id,
type: "form",
props: { ...base, ...props } as any,
} as any;
}
describe("FormControllerPanel", () => {
afterEach(() => {
cleanup();
});
it("성공 메시지 인풋 변경 시 updateBlock 이 successMessage 로 호출되어야 한다", () => {
const formBlock = makeFormBlock("form-1", {
successMessage: "기존 성공 메시지",
});
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock]}
selectedBlockId="form-1"
updateBlock={updateBlock}
/>,
);
const input = screen.getByLabelText("성공 메시지");
fireEvent.change(input, { target: { value: "새 성공 메시지" } });
expect(updateBlock).toHaveBeenCalledWith(
"form-1",
expect.objectContaining({ successMessage: "새 성공 메시지" }),
);
});
it("에러 메시지 인풋 변경 시 updateBlock 이 errorMessage 로 호출되어야 한다", () => {
const formBlock = makeFormBlock("form-2", {
errorMessage: "기존 에러 메시지",
});
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock]}
selectedBlockId="form-2"
updateBlock={updateBlock}
/>,
);
const input = screen.getByLabelText("에러 메시지");
fireEvent.change(input, { target: { value: "새 에러 메시지" } });
expect(updateBlock).toHaveBeenCalledWith(
"form-2",
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
);
});
});
@@ -0,0 +1,371 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FormInputPropertiesPanel } from "@/app/editor/forms/FormInputPropertiesPanel";
import { FormSelectPropertiesPanel } from "@/app/editor/forms/FormSelectPropertiesPanel";
import { FormCheckboxPropertiesPanel } from "@/app/editor/forms/FormCheckboxPropertiesPanel";
import { FormRadioPropertiesPanel } from "@/app/editor/forms/FormRadioPropertiesPanel";
import type {
Block,
FormInputBlockProps,
FormSelectBlockProps,
FormCheckboxBlockProps,
FormRadioBlockProps,
} from "@/features/editor/state/editorStore";
// Form*PropertiesPanel 컨트롤 TDD
// - 각 패널의 색상 HEX 인풋과 필드 너비 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다.
function makeBlock<T extends Block["props"]>(id: string, type: Block["type"], props: T): Block {
return { id, type, props } as any;
}
describe("FormInputPropertiesPanel", () => {
const baseProps: FormInputBlockProps = {
label: "이메일",
formFieldName: "email",
} as any;
afterEach(() => {
cleanup();
});
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormInputBlockProps>("f-input-1", "formInput", {
...baseProps,
textColorCustom: "#ffffff",
});
render(
<FormInputPropertiesPanel
block={block}
selectedBlockId="f-input-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-input-1",
expect.objectContaining({ textColorCustom: "#112233" }),
);
});
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormInputBlockProps>("f-input-2", "formInput", {
...baseProps,
fillColorCustom: "#000000",
});
render(
<FormInputPropertiesPanel
block={block}
selectedBlockId="f-input-2"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("필드 채움 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#445566" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-input-2",
expect.objectContaining({ fillColorCustom: "#445566" }),
);
});
it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormInputBlockProps>("f-input-3", "formInput", {
...baseProps,
strokeColorCustom: "#000000",
});
render(
<FormInputPropertiesPanel
block={block}
selectedBlockId="f-input-3"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("필드 테두리 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#778899" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-input-3",
expect.objectContaining({ strokeColorCustom: "#778899" }),
);
});
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormInputBlockProps>("f-input-4", "formInput", {
...baseProps,
widthMode: "auto",
fullWidth: false,
});
render(
<FormInputPropertiesPanel
block={block}
selectedBlockId="f-input-4"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("너비");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-input-4",
expect.objectContaining({ widthMode: "fixed" }),
);
});
});
describe("FormSelectPropertiesPanel", () => {
const baseProps: FormSelectBlockProps = {
label: "카테고리",
formFieldName: "category",
options: [{ label: "A", value: "a" }],
} as any;
afterEach(() => {
cleanup();
});
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormSelectBlockProps>("f-select-1", "formSelect", {
...baseProps,
textColorCustom: "#ffffff",
});
render(
<FormSelectPropertiesPanel
block={block}
selectedBlockId="f-select-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("셀렉트 텍스트 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-select-1",
expect.objectContaining({ textColorCustom: "#112233" }),
);
});
it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormSelectBlockProps>("f-select-2", "formSelect", {
...baseProps,
fillColorCustom: "#000000",
});
render(
<FormSelectPropertiesPanel
block={block}
selectedBlockId="f-select-2"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("셀렉트 채움 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#445566" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-select-2",
expect.objectContaining({ fillColorCustom: "#445566" }),
);
});
it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormSelectBlockProps>("f-select-3", "formSelect", {
...baseProps,
strokeColorCustom: "#000000",
});
render(
<FormSelectPropertiesPanel
block={block}
selectedBlockId="f-select-3"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("셀렉트 테두리 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#778899" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-select-3",
expect.objectContaining({ strokeColorCustom: "#778899" }),
);
});
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormSelectBlockProps>("f-select-4", "formSelect", {
...baseProps,
widthMode: "auto",
fullWidth: false,
});
render(
<FormSelectPropertiesPanel
block={block}
selectedBlockId="f-select-4"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("필드 너비");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-select-4",
expect.objectContaining({ widthMode: "fixed" }),
);
});
});
describe("FormCheckboxPropertiesPanel", () => {
const baseProps: FormCheckboxBlockProps = {
groupLabel: "옵션들",
formFieldName: "features",
options: [{ label: "옵션 1", value: "opt1" }],
} as any;
afterEach(() => {
cleanup();
});
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormCheckboxBlockProps>("f-check-1", "formCheckbox", {
...baseProps,
textColorCustom: "#ffffff",
});
render(
<FormCheckboxPropertiesPanel
block={block}
selectedBlockId="f-check-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("체크박스 텍스트 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-check-1",
expect.objectContaining({ textColorCustom: "#112233" }),
);
});
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormCheckboxBlockProps>("f-check-2", "formCheckbox", {
...baseProps,
widthMode: "auto",
fullWidth: false,
});
render(
<FormCheckboxPropertiesPanel
block={block}
selectedBlockId="f-check-2"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("필드 너비");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-check-2",
expect.objectContaining({ widthMode: "fixed" }),
);
});
});
describe("FormRadioPropertiesPanel", () => {
const baseProps: FormRadioBlockProps = {
groupLabel: "플랜",
formFieldName: "plan",
options: [{ label: "플랜 A", value: "a" }],
} as any;
afterEach(() => {
cleanup();
});
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormRadioBlockProps>("f-radio-1", "formRadio", {
...baseProps,
textColorCustom: "#ffffff",
});
render(
<FormRadioPropertiesPanel
block={block}
selectedBlockId="f-radio-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("라디오 텍스트 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-radio-1",
expect.objectContaining({ textColorCustom: "#112233" }),
);
});
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormRadioBlockProps>("f-radio-2", "formRadio", {
...baseProps,
widthMode: "auto",
fullWidth: false,
});
render(
<FormRadioPropertiesPanel
block={block}
selectedBlockId="f-radio-2"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("필드 너비");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
"f-radio-2",
expect.objectContaining({ widthMode: "fixed" }),
);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ImagePropertiesPanel } from "@/app/editor/panels/ImagePropertiesPanel";
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
// ImagePropertiesPanel 컨트롤 TDD
// - 카드 배경색(backgroundColorCustom) HEX 인풋 변경 시 updateBlock 이 올바르게 호출되는지
describe("ImagePropertiesPanel", () => {
const baseProps: ImageBlockProps = {
src: "/images/example.png",
alt: "예시 이미지",
align: "center",
widthMode: "auto",
borderRadius: "md",
};
afterEach(() => {
cleanup();
});
it("카드 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={{ ...baseProps, backgroundColorCustom: "#111111" }}
selectedBlockId="image-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("이미지 카드 배경색 HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"image-1",
expect.objectContaining({ backgroundColorCustom: "#112233" }),
);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect, afterEach, vi, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import PreviewPage from "@/app/preview/page";
import type { ProjectConfig, Block } from "@/features/editor/state/editorStore";
// PreviewPage 캔버스 프리셋/폭/배경색 TDD
// - canvasPreset / canvasWidthPx / canvasBgColorHex / bodyBgColorHex 가
// preview-canvas-inner 및 main 배경에 올바르게 반영되는지 검증한다.
let mockState: any;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
title: "프리뷰 캔버스 테스트",
slug: "preview-canvas-test",
canvasPreset: "full",
canvasBgColorHex: "#0f172a",
bodyBgColorHex: "#020617",
};
mockState = {
blocks: [] as Block[],
projectConfig: baseProjectConfig,
};
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
return {
__esModule: true,
useEditorStore,
};
});
vi.mock("next/link", () => {
return {
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
};
});
afterEach(() => {
cleanup();
});
describe("PreviewPage - canvasPreset / canvasWidthPx", () => {
it("mobile 프리셋 + canvasWidthPx=390 은 preview-canvas-inner maxWidth 를 390px 로 설정해야 한다", () => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
canvasPreset: "mobile",
canvasWidthPx: 390,
} as ProjectConfig;
render(<PreviewPage />);
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
expect(inner.style.maxWidth).toBe("390px");
});
it("tablet 프리셋 + canvasWidthPx=768 은 maxWidth 를 768px 로 설정해야 한다", () => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
canvasPreset: "tablet",
canvasWidthPx: 768,
} as ProjectConfig;
render(<PreviewPage />);
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
expect(inner.style.maxWidth).toBe("768px");
});
it("desktop 프리셋 + canvasWidthPx=1200 은 maxWidth 를 1200px 로 설정해야 한다", () => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
canvasPreset: "desktop",
canvasWidthPx: 1200,
} as ProjectConfig;
render(<PreviewPage />);
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
expect(inner.style.maxWidth).toBe("1200px");
});
it("full 프리셋에서 canvasWidthPx 가 없으면 maxWidth 는 비워져 있어야 한다", () => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
canvasPreset: "full",
canvasWidthPx: undefined,
} as ProjectConfig;
render(<PreviewPage />);
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
expect(inner.style.maxWidth).toBe("");
});
it("custom 프리셋 + canvasWidthPx=1024 는 maxWidth 를 1024px 로 설정해야 한다", () => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
canvasPreset: "custom",
canvasWidthPx: 1024,
} as ProjectConfig;
render(<PreviewPage />);
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
expect(inner.style.maxWidth).toBe("1024px");
});
it("canvasBgColorHex 와 bodyBgColorHex 가 각각 preview-canvas-inner 및 main 배경에 적용되어야 한다", () => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
canvasPreset: "full",
canvasBgColorHex: "#111111",
bodyBgColorHex: "#222222",
} as ProjectConfig;
const { container } = render(<PreviewPage />);
const inner = screen.getByTestId("preview-canvas-inner") as HTMLElement;
const main = container.querySelector("main") as HTMLElement;
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
expect(main.style.backgroundColor).toBe("rgb(34, 34, 34)");
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ProjectPropertiesPanel } from "@/app/editor/panels/ProjectPropertiesPanel";
import type { ProjectConfig } from "@/features/editor/state/editorStore";
let mockState: { projectConfig: ProjectConfig; updateProjectConfig: ReturnType<typeof vi.fn> };
beforeEach(() => {
const baseConfig: ProjectConfig = {
title: "프로젝트",
slug: "project",
canvasPreset: "full",
canvasWidthPx: 1024,
canvasBgColorHex: "#0f172a",
bodyBgColorHex: "#020617",
} as ProjectConfig;
mockState = {
projectConfig: baseConfig,
updateProjectConfig: vi.fn(),
};
});
afterEach(() => {
cleanup();
});
vi.mock("@/features/editor/state/editorStore", () => {
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
return {
__esModule: true,
useEditorStore,
};
});
describe("ProjectPropertiesPanel", () => {
it("캔버스 너비 프리셋을 변경하면 canvasPreset/canvasWidthPx 가 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "mobile" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "mobile", canvasWidthPx: 390 });
});
it("태블릿 프리셋을 선택하면 canvasPreset=tablet 과 canvasWidthPx=768 로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "tablet" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "tablet", canvasWidthPx: 768 });
});
it("데스크톱 프리셋을 선택하면 canvasPreset=desktop 과 canvasWidthPx=1200 으로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const select = screen.getByLabelText("캔버스 너비 프리셋") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "desktop" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "desktop", canvasWidthPx: 1200 });
});
it("캔버스 너비를 390 으로 입력하면 mobile 프리셋으로 동기화되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "390" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "mobile", canvasWidthPx: 390 });
});
it("캔버스 너비를 768 로 입력하면 tablet 프리셋으로 동기화되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "768" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "tablet", canvasWidthPx: 768 });
});
it("캔버스 너비를 1200 으로 입력하면 desktop 프리셋으로 동기화되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "1200" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "desktop", canvasWidthPx: 1200 });
});
it("캔버스 너비를 커스텀 값으로 입력하면 canvasPreset=custom 과 함께 canvasWidthPx 가 설정되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 너비 커스텀 (px)") as HTMLInputElement;
fireEvent.change(input, { target: { value: "1000" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasPreset: "custom", canvasWidthPx: 1000 });
});
it("캔버스 배경색 HEX 입력을 변경하면 canvasBgColorHex 가 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("캔버스 배경색 HEX") as HTMLInputElement;
fireEvent.change(input, { target: { value: "#123456" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasBgColorHex: "#123456" });
});
it("페이지 배경색 HEX 입력을 변경하면 bodyBgColorHex 가 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("페이지 배경색 HEX") as HTMLInputElement;
fireEvent.change(input, { target: { value: "#654321" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "#654321" });
});
});
@@ -0,0 +1,55 @@
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";
// 프리뷰 렌더러가 상위 컨테이너(프리뷰 캔버스)의 배경색을 덮어쓰지 않도록 하는 TDD
// - 루트 컨테이너는 레이아웃/텍스트 색상만 담당하고, 고정 배경색(bg-slate-950)은 사용하지 않아야 한다.
describe("PublicPageRenderer - 배경색 위임", () => {
it("루트 컨테이너는 bg-slate-950 같은 고정 배경 클래스 없이 렌더링되어야 한다", () => {
const blocks: Block[] = [];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const root = container.firstElementChild as HTMLElement | null;
expect(root).not.toBeNull();
const className = root!.getAttribute("class") ?? "";
expect(className).not.toContain("bg-slate-950");
});
it("루트 텍스트/버튼 블록 영역은 고정 bg-slate-950 배경 대신 캔버스 배경을 그대로 보여야 한다", () => {
const blocks: Block[] = [
{
id: "text_root",
type: "text",
props: {
text: "텍스트",
align: "left",
size: "base",
},
} as any,
{
id: "button_root",
type: "button",
props: {
label: "버튼",
href: "#",
align: "left",
},
} as any,
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
// 섹션 블록이 하나도 없으므로, 첫 번째 <section> 은 루트 텍스트/버튼 블록을 감싸는 래퍼다.
const firstSection = container.querySelector("section");
expect(firstSection).not.toBeNull();
const className = firstSection!.getAttribute("class") ?? "";
expect(className).not.toContain("bg-slate-950");
});
});
@@ -0,0 +1,192 @@
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";
// 블록 배경색 확장 TDD
// - backgroundColorCustom 이 설정된 경우에만 프리뷰에서 배경색이 적용되어야 한다.
// - 설정되지 않은 경우에는 기본 배경(투명)으로 남아야 한다.
describe("PublicPageRenderer - 블록 배경색", () => {
it("text 블록은 backgroundColorCustom 이 설정된 경우에만 배경색 스타일을 가져야 한다", () => {
const blocksWithBg: Block[] = [
{
id: "text_with_bg",
type: "text",
props: {
text: "배경 있는 텍스트",
align: "left",
size: "base",
// TDD: 새 배경색 속성
backgroundColorCustom: "#123456",
},
} as any,
];
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const pWithBg = container.querySelector("p") as HTMLElement | null;
expect(pWithBg).not.toBeNull();
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
expect(pWithBg!.style.backgroundColor).toBe("rgb(18, 52, 86)");
const blocksWithoutBg: Block[] = [
{
id: "text_no_bg",
type: "text",
props: {
text: "배경 없는 텍스트",
align: "left",
size: "base",
},
} as any,
];
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
const pNoBg = container.querySelector("p") as HTMLElement | null;
expect(pNoBg).not.toBeNull();
// 기본값은 inline style 이 설정되지 않은 상태여야 한다.
expect(pNoBg!.style.backgroundColor === "" || pNoBg!.style.backgroundColor === "transparent").toBe(true);
});
it("list 블록은 backgroundColorCustom 이 설정된 경우에만 리스트 컨테이너에 배경색이 적용되어야 한다", () => {
const blocksWithBg: Block[] = [
{
id: "list_with_bg",
type: "list",
props: {
items: ["아이템 1"],
ordered: false,
// TDD: 새 배경색 속성
backgroundColorCustom: "#00ff88",
},
} as any,
];
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const listWithBg = (container.querySelector("ul,ol") as HTMLElement | null);
expect(listWithBg).not.toBeNull();
expect(listWithBg!.style.backgroundColor).toBe("rgb(0, 255, 136)");
const blocksWithoutBg: Block[] = [
{
id: "list_no_bg",
type: "list",
props: {
items: ["아이템 1"],
ordered: false,
},
} as any,
];
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
const listNoBg = (container.querySelector("ul,ol") as HTMLElement | null);
expect(listNoBg).not.toBeNull();
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
});
it("form 컨트롤러 블록은 backgroundColorCustom 이 설정된 경우에만 폼 래퍼에 배경색이 적용되어야 한다", () => {
const blocksWithBg: Block[] = [
{
id: "form_with_bg",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
fieldIds: [],
formWidthMode: "auto",
// TDD: 새 배경색 속성
backgroundColorCustom: "#111111",
},
} as any,
];
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const formWithBg = getByTestId("preview-form-controller") as HTMLFormElement;
expect(formWithBg.style.backgroundColor).toBe("rgb(17, 17, 17)");
const blocksWithoutBg: Block[] = [
{
id: "form_no_bg",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
fieldIds: [],
formWidthMode: "auto",
},
} as any,
];
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
const formNoBg = getByTestId("preview-form-controller") as HTMLFormElement;
expect(formNoBg.style.backgroundColor === "" || formNoBg.style.backgroundColor === "transparent").toBe(true);
});
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
const blocksWithBg: Block[] = [
{
id: "sec_1",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_1_col_1", span: 12 }],
backgroundColorCustom: "#123456",
},
} as any,
{
id: "sec_1_text",
type: "text",
sectionId: "sec_1",
columnId: "sec_1_col_1",
props: {
text: "섹션 안 텍스트",
align: "left",
size: "base",
},
} as any,
];
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const sectionWithBg = getByTestId("preview-section") as HTMLElement;
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
expect(sectionWithBg.style.backgroundColor).toBe("rgb(18, 52, 86)");
const blocksWithoutBg: Block[] = [
{
id: "sec_1",
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [{ id: "sec_1_col_1", span: 12 }],
},
} as any,
{
id: "sec_1_text",
type: "text",
sectionId: "sec_1",
columnId: "sec_1_col_1",
props: {
text: "섹션 안 텍스트",
align: "left",
size: "base",
},
} as any,
];
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
const sectionNoBg = getByTestId("preview-section") as HTMLElement;
expect(sectionNoBg.style.backgroundColor === "" || sectionNoBg.style.backgroundColor === "transparent").toBe(true);
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 버튼 스타일 TDD
// - fillColorCustom / strokeColorCustom / textColorCustom
// - widthMode=fixed + widthPx, paddingX/paddingY
// - borderRadius 토큰에 따른 둥글기
describe("PublicPageRenderer - 버튼 블록 스타일", () => {
afterEach(() => {
cleanup();
});
it("버튼 색상 커스텀 값은 배경/외곽선/텍스트 색상 스타일에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "btn_colors",
type: "button",
props: {
label: "컬러 버튼",
href: "#",
align: "center",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
textColorCustom: "#00ff00",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
// 배경색: #123456 → rgb(18, 52, 86)
expect(link!.style.backgroundColor).toBe("rgb(18, 52, 86)");
// 외곽선 색상: #ff0000 → rgb(255, 0, 0)
expect(link!.style.borderColor).toBe("rgb(255, 0, 0)");
// strokeColorCustom 이 있을 때 border-width/style 도 설정되어야 한다.
expect(link!.style.borderWidth).toBe("1px");
expect(link!.style.borderStyle).toBe("solid");
// 텍스트 색상: #00ff00 → rgb(0, 255, 0)
expect(link!.style.color).toBe("rgb(0, 255, 0)");
});
it("widthMode=fixed 인 버튼은 widthPx/paddingX/paddingY 가 em 단위 스타일로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "btn_layout",
type: "button",
props: {
label: "레이아웃 버튼",
href: "#",
align: "left",
widthMode: "fixed",
widthPx: 320,
paddingX: 16,
paddingY: 8,
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
// width: 320px / 16 = 20em
expect(link!.style.width).toBe("20em");
// padding-inline: 16px / 16 = 1em
expect(link!.style.paddingInline).toBe("1em");
// padding-block: 8px / 16 = 0.5em
expect(link!.style.paddingBlock).toBe("0.5em");
});
it("borderRadius 토큰은 em 단위 둥글기로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "btn_radius",
type: "button",
props: {
label: "둥근 버튼",
href: "#",
borderRadius: "lg",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
// lg → 12px → 12 / 16 = 0.75em
expect(link!.style.borderRadius).toBe("0.75em");
});
});
@@ -0,0 +1,115 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block } from "@/features/editor/state/editorStore";
// divider 스타일 TDD
// - colorHex 에 따른 라인 색상
// - marginY/marginYPx 에 따른 상하 여백 em 스케일
// - widthMode/widthPx 에 따른 길이 모드
afterEach(() => {
cleanup();
});
describe("PublicPageRenderer - divider 스타일", () => {
it("colorHex 가 지정되면 구분선 라인에 해당 색상이 적용되어야 한다", () => {
const blocksWithColor: Block[] = [
{
id: "div_color",
type: "divider",
props: {
align: "center",
thickness: "thin",
colorHex: "#123456",
},
} as any,
];
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithColor} />);
const lineWithColor = getByTestId("preview-divider-line") as HTMLElement;
// JSDOM 은 #123456 을 rgb(18, 52, 86) 으로 노출한다.
expect(lineWithColor.style.backgroundColor).toBe("rgb(18, 52, 86)");
const blocksWithoutColor: Block[] = [
{
id: "div_default",
type: "divider",
props: {
align: "center",
thickness: "thin",
},
} as any,
];
rerender(<PublicPageRenderer blocks={blocksWithoutColor} />);
const lineDefault = getByTestId("preview-divider-line") as HTMLElement;
// 기본 색상은 #475569 이며, JSDOM 에서는 rgb(71, 85, 105) 로 표시된다.
expect(lineDefault.style.backgroundColor).toBe("rgb(71, 85, 105)");
});
it("marginYPx 이 있으면 divider 컨테이너 상하 여백이 em 스케일로 적용되어야 한다", () => {
const blocks: Block[] = [
{
id: "div_margin_px",
type: "divider",
props: {
align: "center",
thickness: "thin",
marginYPx: 32,
},
} as any,
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const wrapper = getByTestId("preview-divider") as HTMLDivElement;
// 32px / 16 = 2em
expect(wrapper.style.marginTop).toBe("2em");
expect(wrapper.style.marginBottom).toBe("2em");
});
it("marginY 토큰만 있을 때도 적절한 px 값을 em 으로 변환해 상하 여백에 적용해야 한다", () => {
const blocks: Block[] = [
{
id: "div_margin_token",
type: "divider",
props: {
align: "center",
thickness: "thin",
marginY: "lg", // 24px 로 매핑
},
} as any,
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const wrapper = getByTestId("preview-divider") as HTMLDivElement;
// 24px / 16 = 1.5em
expect(wrapper.style.marginTop).toBe("1.5em");
expect(wrapper.style.marginBottom).toBe("1.5em");
});
it("widthMode 가 fixed 이고 widthPx 가 있으면 divider 라인의 width 가 pxToEm 으로 변환되어야 한다", () => {
const blocks: Block[] = [
{
id: "div_fixed_width",
type: "divider",
props: {
align: "center",
thickness: "thin",
widthMode: "fixed",
widthPx: 480,
},
} as any,
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const line = getByTestId("preview-divider-line") as HTMLDivElement;
// 480px / 16 = 30em
expect(line.style.width).toBe("30em");
});
});
@@ -0,0 +1,192 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block } from "@/features/editor/state/editorStore";
// 폼 필드 스타일 TDD
// - formInput: 색상/패딩/너비/둥글기 스타일
// - formSelect: 색상/패딩/너비/둥글기 스타일
// - formCheckbox/formRadio: 옵션 컨테이너 색상/패딩/둥글기 스타일
function getFirstLabel(root: HTMLElement): HTMLLabelElement {
const label = root.querySelector("label");
if (!label) {
throw new Error("label element not found");
}
return label as HTMLLabelElement;
}
describe("PublicPageRenderer - 폼 필드 스타일", () => {
afterEach(() => {
cleanup();
});
it("formInput 블록은 색상/패딩/너비/둥글기 스타일을 반영해야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_styles",
type: "formInput",
props: {
label: "이메일",
formFieldName: "email",
align: "center",
widthMode: "fixed",
widthPx: 320,
paddingX: 16,
paddingY: 8,
textColorCustom: "#112233",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const wrapper = getByTestId("preview-form-input-wrapper") as HTMLElement;
const input = wrapper.querySelector('[data-testid="preview-form-input"]') as HTMLElement | null;
expect(input).not.toBeNull();
// 정렬
expect((input as HTMLInputElement).style.textAlign).toBe("center");
// width: 320px / 16 = 20em
expect((input as HTMLInputElement).style.width).toBe("20em");
// padding-inline/block: 16px/8px -> 1em / 0.5em
expect((input as HTMLInputElement).style.paddingInline).toBe("1em");
expect((input as HTMLInputElement).style.paddingBlock).toBe("0.5em");
// 색상: hex -> rgb 변환
expect((input as HTMLInputElement).style.color).toBe("rgb(17, 34, 51)");
expect((input as HTMLInputElement).style.backgroundColor).toBe("rgb(18, 52, 86)");
expect((input as HTMLInputElement).style.borderColor).toBe("rgb(255, 0, 0)");
// borderRadius: lg -> 6px
expect((input as HTMLInputElement).style.borderRadius).toBe("6px");
});
it("formSelect 블록은 색상/패딩/너비/둥글기 스타일을 반영해야 한다", () => {
const blocks: Block[] = [
{
id: "form_select_styles",
type: "formSelect",
props: {
label: "카테고리",
formFieldName: "category",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
widthMode: "fixed",
widthPx: 240,
paddingX: 16,
paddingY: 8,
textColorCustom: "#00ff00",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const wrapper = getByTestId("preview-form-select") as HTMLElement;
const select = wrapper.querySelector("select") as HTMLSelectElement | null;
expect(select).not.toBeNull();
// width: 240px / 16 = 15em
expect(wrapper.style.width).toBe("15em");
// padding-inline/block: 16px/8px -> 1em / 0.5em
expect(select!.style.paddingInline).toBe("1em");
expect(select!.style.paddingBlock).toBe("0.5em");
// 색상
expect(select!.style.color).toBe("rgb(0, 255, 0)");
expect(select!.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(select!.style.borderColor).toBe("rgb(255, 0, 0)");
// 둥글기
expect(select!.style.borderRadius).toBe("6px");
});
it("formCheckbox 블록은 옵션 컨테이너에 색상/패딩/둥글기 스타일을 반영해야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_styles",
type: "formCheckbox",
props: {
groupLabel: "옵션들",
formFieldName: "features",
options: [{ label: "옵션 1", value: "opt1" }],
widthMode: "fixed",
widthPx: 320,
paddingX: 8,
paddingY: 4,
optionGapPx: 16,
textColorCustom: "#ffffff",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const firstLabel = getFirstLabel(group);
// width: 320px / 16 = 20em
expect(group.style.width).toBe("20em");
// 옵션 간 간격: 16px -> 1em
const optionsContainer = group.querySelector('[data-testid="preview-form-checkbox-options"]') as HTMLElement | null;
expect(optionsContainer).not.toBeNull();
expect(optionsContainer!.style.rowGap).toBe("1em");
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
expect(firstLabel.style.paddingInline).toBe("0.5em");
expect(firstLabel.style.paddingBlock).toBe("0.25em");
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
expect(firstLabel.style.borderRadius).toBe("6px");
});
it("formRadio 블록은 옵션 컨테이너에 색상/패딩/둥글기 스타일을 반영해야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_styles",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [{ label: "플랜 A", value: "a" }],
widthMode: "fixed",
widthPx: 320,
paddingX: 8,
paddingY: 4,
optionGapPx: 16,
textColorCustom: "#ffffff",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-radio-group") as HTMLElement;
const firstLabel = getFirstLabel(group);
// width: 320px / 16 = 20em
expect(group.style.width).toBe("20em");
// 옵션 간 간격: 16px -> 1em
const optionsContainer = group.querySelector('[data-testid="preview-form-radio-options"]') as HTMLElement | null;
expect(optionsContainer).not.toBeNull();
expect(optionsContainer!.style.rowGap).toBe("1em");
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
expect(firstLabel.style.paddingInline).toBe("0.5em");
expect(firstLabel.style.paddingBlock).toBe("0.25em");
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
expect(firstLabel.style.borderRadius).toBe("6px");
});
});
@@ -0,0 +1,95 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 폼 제출 UX TDD
// - 성공 응답 시 FormBlockProps 의 successMessage 를 success 스타일로 표시해야 한다.
// - 실패 응답 시 FormBlockProps 의 errorMessage 를 error 스타일로 표시해야 한다.
describe("PublicPageRenderer - 폼 제출 메시지", () => {
afterEach(() => {
cleanup();
// fetch 목 초기화
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).fetch = undefined;
});
it("성공 응답 시 config.successMessage 를 success 스타일로 표시해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_success",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
fieldIds: [],
submitButtonId: null,
} as any,
},
];
const fetchMock = vi.fn(async () =>
new Response("ok", {
status: 200,
headers: { "Content-Type": "text/plain" },
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).fetch = fetchMock;
render(<PublicPageRenderer blocks={blocks} />);
const form = screen.getByTestId("preview-form-controller");
fireEvent.submit(form);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const messageEl = await screen.findByText("폼 성공 메시지 (config)");
expect(messageEl.textContent).toBe("폼 성공 메시지 (config)");
expect(messageEl.className).toContain("text-emerald-400");
});
it("에러 응답 시 config.errorMessage 를 error 스타일로 표시해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_error",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
fieldIds: [],
submitButtonId: null,
} as any,
},
];
const fetchMock = vi.fn(async () =>
new Response("error", {
status: 500,
headers: { "Content-Type": "text/plain" },
}),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(global as any).fetch = fetchMock;
render(<PublicPageRenderer blocks={blocks} />);
const form = screen.getByTestId("preview-form-controller");
fireEvent.submit(form);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const messageEl = await screen.findByText("폼 에러 메시지 (config)");
expect(messageEl.textContent).toBe("폼 에러 메시지 (config)");
expect(messageEl.className).toContain("text-rose-400");
});
});
@@ -0,0 +1,75 @@
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";
// 폼 관련 블록(formSelect/formCheckbox/formRadio)의 textColorCustom 이
// 프리뷰 렌더러에서 실제 텍스트 color 스타일에 반영되는지 검증한다.
describe("PublicPageRenderer - 폼 텍스트 색상", () => {
it("formSelect 블록의 textColorCustom 은 셀렉트 텍스트 색상에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_select_color",
type: "formSelect",
props: {
label: "셀렉트 라벨",
formFieldName: "category",
options: [
{ label: "옵션 A", value: "a" },
{ label: "옵션 B", value: "b" },
],
textColorCustom: "#ff0000",
},
} as any,
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const wrapper = getByTestId("preview-form-select") as HTMLElement;
const select = wrapper.querySelector("select") as HTMLSelectElement | null;
expect(select).not.toBeNull();
expect(select!.style.color).toBe("rgb(255, 0, 0)");
});
it("formCheckbox 블록의 textColorCustom 은 옵션 텍스트 색상에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_color",
type: "formCheckbox",
props: {
groupLabel: "체크박스 그룹",
formFieldName: "features",
options: [{ label: "옵션 1", value: "opt1" }],
textColorCustom: "#00ff00",
},
} as any,
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const optionSpan = getByTestId("preview-form-checkbox-option") as HTMLElement;
expect(optionSpan.style.color).toBe("rgb(0, 255, 0)");
});
it("formRadio 블록의 textColorCustom 은 옵션 텍스트 색상에 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_color",
type: "formRadio",
props: {
groupLabel: "라디오 그룹",
formFieldName: "plan",
options: [{ label: "플랜 A", value: "a" }],
textColorCustom: "#0000ff",
},
} as any,
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const optionSpan = getByTestId("preview-form-radio-option") as HTMLElement;
expect(optionSpan.style.color).toBe("rgb(0, 0, 255)");
});
})
@@ -0,0 +1,107 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 이미지 블록 스타일 TDD
// - backgroundColorCustom 카드 배경 적용 여부
// - widthMode=fixed, widthPx 의 em 변환
// - borderRadiusPx 의 em 변환
describe("PublicPageRenderer - 이미지 블록 스타일", () => {
afterEach(() => {
cleanup();
});
it("image 블록은 backgroundColorCustom 이 설정된 경우에만 wrapper 에 배경색이 적용되어야 한다", () => {
const blocksWithBg: Block[] = [
{
id: "img_with_bg",
type: "image",
props: {
src: "/images/example.png",
alt: "배경 있는 이미지",
align: "center",
widthMode: "auto",
backgroundColorCustom: "#123456",
} as any,
},
];
const { container, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const imgWithBg = container.querySelector("img") as HTMLImageElement | null;
expect(imgWithBg).not.toBeNull();
const wrapperWithBg = imgWithBg!.parentElement as HTMLElement | null;
expect(wrapperWithBg).not.toBeNull();
// JSDOM 은 #123456 을 rgb(18, 52, 86) 형태로 노출한다.
expect(wrapperWithBg!.style.backgroundColor).toBe("rgb(18, 52, 86)");
const blocksWithoutBg: Block[] = [
{
id: "img_no_bg",
type: "image",
props: {
src: "/images/example2.png",
alt: "배경 없는 이미지",
align: "center",
widthMode: "auto",
} as any,
},
];
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
const imgNoBg = container.querySelector("img") as HTMLImageElement | null;
expect(imgNoBg).not.toBeNull();
const wrapperNoBg = imgNoBg!.parentElement as HTMLElement | null;
expect(wrapperNoBg).not.toBeNull();
expect(
wrapperNoBg!.style.backgroundColor === "" || wrapperNoBg!.style.backgroundColor === "transparent",
).toBe(true);
});
it("widthMode 가 fixed 이고 widthPx 가 설정된 경우 img width 는 em 단위로 설정되어야 한다", () => {
const blocks: Block[] = [
{
id: "img_fixed_width",
type: "image",
props: {
src: "/images/example.png",
alt: "고정 너비 이미지",
align: "center",
widthMode: "fixed",
widthPx: 480,
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const img = container.querySelector("img") as HTMLImageElement | null;
expect(img).not.toBeNull();
// 480px / 16 = 30em
expect(img!.style.width).toBe("30em");
});
it("borderRadiusPx 가 설정된 경우 img borderRadius 는 em 단위로 설정되어야 한다", () => {
const blocks: Block[] = [
{
id: "img_radius",
type: "image",
props: {
src: "/images/example.png",
alt: "둥근 이미지",
borderRadiusPx: 32,
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const img = container.querySelector("img") as HTMLImageElement | null;
expect(img).not.toBeNull();
// 32px / 16 = 2em
expect(img!.style.borderRadius).toBe("2em");
});
});
@@ -0,0 +1,64 @@
import { describe, it, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
import type { Block } from "@/features/editor/state/editorStore";
// PublicPageRenderer 템플릿 TDD
// - Hero / Features / CTA 템플릿이 섹션 + 텍스트/버튼 구조로 올바르게 렌더되는지 검증한다.
function createIdFactory() {
let i = 0;
return () => `tpl_${++i}`;
}
describe("PublicPageRenderer - 섹션 템플릿", () => {
afterEach(() => {
cleanup();
});
it("Hero 템플릿 섹션은 섹션과 헤드라인/서브텍스트/버튼을 렌더해야 한다", () => {
const sectionId = "hero_section_1";
const { blocks } = createHeroTemplateBlocks({ sectionId, createId: createIdFactory() });
render(<PublicPageRenderer blocks={blocks as Block[]} />);
// 헤드라인/서브텍스트 텍스트가 존재해야 한다.
screen.getByText("Hero 제목을 여기에 입력하세요");
screen.getByText("제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.");
// CTA 버튼 라벨 텍스트가 존재해야 한다.
screen.getByText("지금 시작하기");
});
it("Features 템플릿 섹션은 3컬럼 Feature 제목/설명 텍스트를 렌더해야 한다", () => {
const sectionId = "features_section_1";
const { blocks } = createFeaturesTemplateBlocks({ sectionId, createId: createIdFactory() });
render(<PublicPageRenderer blocks={blocks as Block[]} />);
// 각 컬럼의 Feature 제목과 설명 텍스트가 모두 존재해야 한다.
screen.getByText("Feature 1 제목");
screen.getByText("Feature 2 제목");
screen.getByText("Feature 3 제목");
const descText = "해당 기능을 간단히 설명하는 텍스트입니다.";
// 설명 텍스트는 3번 등장해야 한다.
const allDesc = screen.getAllByText(descText);
if (allDesc.length !== 3) {
throw new Error(`기대하는 설명 텍스트 개수(3)가 아니고 ${allDesc.length}개가 렌더되었습니다.`);
}
});
it("CTA 템플릿 섹션은 텍스트와 CTA 버튼을 렌더해야 한다", () => {
const sectionId = "cta_section_1";
const { blocks } = createCtaTemplateBlocks({ sectionId, createId: createIdFactory() });
render(<PublicPageRenderer blocks={blocks as Block[]} />);
// CTA 본문 텍스트와 버튼 라벨이 존재해야 한다.
screen.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
screen.getByText("CTA 버튼");
});
});
+10 -3
View File
@@ -44,15 +44,22 @@ describe("SectionPropertiesPanel - layout presets", () => {
render(
<SectionPropertiesPanel
sectionProps={baseProps}
sectionProps={{
...baseProps,
columns: [
{ id: "col-1", span: 6 },
{ id: "col-2", span: 6 },
],
}}
selectedBlockId="section-layout-2"
updateBlock={updateBlock}
/>,
);
const input = screen.getByLabelText("1열 폭 (1~12)");
const inputs = screen.getAllByLabelText("1열 폭 (1~11)");
const numberInput = inputs.find((el) => (el as HTMLInputElement).tagName === "INPUT" && (el as HTMLInputElement).type === "number") as HTMLInputElement;
fireEvent.change(input, { target: { value: "8" } });
fireEvent.change(numberInput, { target: { value: "8" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-layout-2",
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { TextPropertiesPanel } from "@/app/editor/panels/TextPropertiesPanel";
import { ListPropertiesPanel } from "@/app/editor/panels/ListPropertiesPanel";
import { FormControllerPanel } from "@/app/editor/forms/FormControllerPanel";
import type { Block } from "@/features/editor/state/editorStore";
// Text/List/Form 패널에 추가되는 블록 배경색(backgroundColorCustom) 컨트롤에 대한 최소 TDD
describe("Text/List/Form 패널 - 블록 배경색 컨트롤", () => {
it("TextPropertiesPanel 에서 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={{
text: "텍스트",
align: "left",
size: "base",
} as any}
selectedBlockId="text-1"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
const hexInput = screen.getByLabelText("텍스트 블록 배경색 HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"text-1",
expect.objectContaining({ backgroundColorCustom: "#112233" }),
);
});
it("ListPropertiesPanel 에서 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{
items: ["아이템 1"],
ordered: false,
align: "left",
} as any}
selectedBlockId="list-1"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("리스트 배경색 HEX");
fireEvent.change(hexInput, { target: { value: "#445566" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-1",
expect.objectContaining({ backgroundColorCustom: "#445566" }),
);
});
it("FormControllerPanel 에서 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
const formBlock: Block = {
id: "form-1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
} as any,
};
render(
<FormControllerPanel
block={formBlock}
blocks={[]}
selectedBlockId="form-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("폼 배경색 HEX");
fireEvent.change(hexInput, { target: { value: "#778899" } });
expect(updateBlock).toHaveBeenCalledWith(
"form-1",
expect.objectContaining({ backgroundColorCustom: "#778899" }),
);
});
});
+220
View File
@@ -708,6 +708,161 @@ describe("editorStore", () => {
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();
@@ -1191,6 +1346,71 @@ describe("editorStore", () => {
expect(updatedFormProps.submitButtonId).toBe(submitButtonId);
});
it("addHeroTemplateSection 호출 시 Hero 템플릿 섹션과 블록들이 추가되고 마지막 블록이 선택되어야 한다", () => {
const store = createEditorStore();
const stateAny = store.getState() as any;
stateAny.addHeroTemplateSection();
const { blocks, selectedBlockId } = store.getState();
expect(blocks.length).toBeGreaterThanOrEqual(4);
const section = blocks.find((b) => b.type === "section");
expect(section).toBeTruthy();
const heroBlocks = blocks.filter((b) => b.sectionId === section!.id);
// 헤드라인/서브텍스트/버튼 3개가 섹션 내부에 있어야 한다.
expect(heroBlocks).toHaveLength(3);
expect(heroBlocks.map((b) => b.type)).toEqual(["text", "text", "button"]);
// 마지막 블록이 선택 상태여야 한다.
const lastHeroBlock = heroBlocks[heroBlocks.length - 1];
expect(selectedBlockId).toBe(lastHeroBlock.id);
});
it("addFeaturesTemplateSection 호출 시 3컬럼 Feature 텍스트 블록들이 추가되고 마지막 블록이 선택되어야 한다", () => {
const store = createEditorStore();
const stateAny = store.getState() as any;
stateAny.addFeaturesTemplateSection();
const { blocks, selectedBlockId } = store.getState();
// 섹션 1개 + 각 컬럼당 제목/설명 2개씩, 총 7개 블록이 생성된다.
expect(blocks.length).toBe(7);
const section = blocks.find((b) => b.type === "section");
expect(section).toBeTruthy();
const featureBlocks = blocks.filter((b) => b.sectionId === section!.id);
expect(featureBlocks).toHaveLength(6);
expect(featureBlocks.every((b) => b.type === "text")).toBe(true);
// 마지막 Feature 블록이 선택되어야 한다.
const lastFeature = featureBlocks[featureBlocks.length - 1];
expect(selectedBlockId).toBe(lastFeature.id);
});
it("addCtaTemplateSection 호출 시 CTA 섹션과 텍스트/버튼이 추가되고 버튼이 선택되어야 한다", () => {
const store = createEditorStore();
const stateAny = store.getState() as any;
stateAny.addCtaTemplateSection();
const { blocks, selectedBlockId } = store.getState();
expect(blocks.length).toBe(3);
const section = blocks.find((b) => b.type === "section");
expect(section).toBeTruthy();
const ctaBlocks = blocks.filter((b) => b.sectionId === section!.id);
expect(ctaBlocks).toHaveLength(2);
expect(ctaBlocks.map((b) => b.type).sort()).toEqual(["button", "text"].sort());
const buttonBlock = ctaBlocks.find((b) => b.type === "button");
expect(buttonBlock).toBeTruthy();
expect(selectedBlockId).toBe(buttonBlock!.id);
});
it("projectConfig 기본값과 updateProjectConfig 액션으로 프로젝트 설정을 관리할 수 있어야 한다", () => {
const store = createEditorStore();
const stateAny = store.getState() as any;