TDD,E2E 개선, 미적용 스타일 개선
CI / test (push) Failing after 2m54s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-07 09:52:42 +09:00
parent e726f43f7c
commit a5b432fb7b
167 changed files with 7397 additions and 663 deletions
@@ -0,0 +1,156 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import AllProjectSubmissionsPage from "@/app/projects/submissions/page";
// /projects/submissions 페이지 유닛 테스트
// - /api/projects/submissions 응답을 테이블로 렌더링하는지
// - 401/404/기타 에러에 대해 올바른 메시지를 표시하는지
// - 상단에 "프로젝트 목록" 링크가 있고 클릭 시 /projects 로 이동하는지
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
it("/api/projects/submissions 응답을 프로젝트/필드 정보가 포함된 테이블로 렌더링해야 한다", async () => {
const submissions = [
{
id: "1",
createdAt: "2025-01-01T12:00:00.000Z",
projectSlug: "proj-1",
projectTitle: "프로젝트 1",
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
{
id: "2",
createdAt: "2025-01-02T08:30:00.000Z",
projectSlug: "proj-2",
projectTitle: "프로젝트 2",
payload: {
name: "김철수",
email: "another@example.com",
phone: "010-0000-0000",
birthdate: "1995-05-05",
message: "두 번째 문의입니다.",
},
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(submissions), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const [url, options] = fetchMock.mock.calls[0] as any;
expect(url).toBe("/api/projects/submissions");
expect(options).toBeUndefined();
expect(await screen.findByText("전체 폼 제출 내역")).toBeTruthy();
expect(screen.getByText("프로젝트 1")).toBeTruthy();
expect(screen.getByText("proj-1")).toBeTruthy();
expect(screen.getByText("홍길동")).toBeTruthy();
expect(screen.getByText("user@example.com")).toBeTruthy();
expect(screen.getByText("010-1234-5678")).toBeTruthy();
expect(screen.getByText("1990-01-01")).toBeTruthy();
expect(screen.getByText("message: 안녕하세요")).toBeTruthy();
expect(screen.getByText("프로젝트 2")).toBeTruthy();
expect(screen.getByText("proj-2")).toBeTruthy();
expect(screen.getByText("김철수")).toBeTruthy();
expect(screen.getByText("another@example.com")).toBeTruthy();
expect(screen.getByText("010-0000-0000")).toBeTruthy();
expect(screen.getByText("1995-05-05")).toBeTruthy();
expect(screen.getByText("message: 두 번째 문의입니다.")).toBeTruthy();
});
it("API 가 401 을 반환하면 로그인 페이지로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." }),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
it("API 가 500 등을 반환하면 에러 메시지를 화면에 표시해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "서버 에러" }), {
status: 500,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
const errorText = await screen.findByText(
"폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
);
expect(errorText).toBeTruthy();
});
it("상단에 '프로젝트 목록' 링크가 있고 클릭 시 /projects 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(backLink).toBeTruthy();
backLink.click();
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
});
+220
View File
@@ -179,4 +179,224 @@ describe("ButtonPropertiesPanel", () => {
}),
);
});
it("버튼 텍스트 변경 시 updateBlock 이 label 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={baseProps}
selectedBlockId="btn-label"
updateBlock={updateBlock}
/>,
);
const textarea = screen.getByLabelText("버튼 텍스트");
fireEvent.change(textarea, { target: { value: "새 버튼" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-label",
expect.objectContaining({ label: "새 버튼" }),
);
});
it("가로 패딩 슬라이더 변경 시 updateBlock 이 paddingX 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, paddingX: 16 }}
selectedBlockId="btn-padding-x"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("가로 패딩 (px) 슬라이더");
fireEvent.change(slider, { target: { value: "24" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-padding-x",
expect.objectContaining({ paddingX: 24 }),
);
});
it("세로 패딩 슬라이더 변경 시 updateBlock 이 paddingY 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, paddingY: 10 }}
selectedBlockId="btn-padding-y"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("세로 패딩 (px) 슬라이더");
fireEvent.change(slider, { target: { value: "18" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-padding-y",
expect.objectContaining({ paddingY: 18 }),
);
});
it("버튼 링크 인풋 변경 시 updateBlock 이 href 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, href: "#" }}
selectedBlockId="btn-href"
updateBlock={updateBlock}
/>,
);
const input = screen.getByLabelText("버튼 링크");
fireEvent.change(input, { target: { value: "/signup" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-href",
expect.objectContaining({ href: "/signup" }),
);
});
it("버튼 정렬 셀렉트 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, align: "left" }}
selectedBlockId="btn-align"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("버튼 정렬");
fireEvent.change(select, { target: { value: "center" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-align",
expect.objectContaining({ align: "center" }),
);
});
it("버튼 스타일 셀렉트 변경 시 updateBlock 이 variant 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, variant: "solid" }}
selectedBlockId="btn-variant"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("버튼 스타일");
fireEvent.change(select, { target: { value: "outline" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-variant",
expect.objectContaining({ variant: "outline" }),
);
});
it("모서리 둥글기 슬라이더 변경 시 updateBlock 이 borderRadius 토큰으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, borderRadius: "md" }}
selectedBlockId="btn-radius"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("모서리 둥글기 슬라이더");
fireEvent.change(slider, { target: { value: "4" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-radius",
expect.objectContaining({ borderRadius: "full" }),
);
});
it("widthMode 가 fixed 일 때 고정 너비 슬라이더 변경 시 updateBlock 이 widthPx 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, widthMode: "fixed", widthPx: 240 }}
selectedBlockId="btn-width-fixed"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("버튼 고정 너비 슬라이더");
fireEvent.change(slider, { target: { value: "300" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-width-fixed",
expect.objectContaining({ widthPx: 300 }),
);
});
it("버튼 크기 슬라이더 변경 시 updateBlock 이 fontSizeCustom 을 px 문자열로 호출해야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, fontSizeCustom: "16px" }}
selectedBlockId="btn-font-size"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("버튼 크기 슬라이더");
fireEvent.change(slider, { target: { value: "20" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-font-size",
expect.objectContaining({ fontSizeCustom: "20px" }),
);
});
it("줄 간격 슬라이더 변경 시 updateBlock 이 lineHeightCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, lineHeightCustom: "1.4" }}
selectedBlockId="btn-line-height"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("줄 간격 슬라이더");
fireEvent.change(slider, { target: { value: "1.8" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-line-height",
expect.objectContaining({ lineHeightCustom: "1.8" }),
);
});
it("글자 간격 슬라이더 변경 시 updateBlock 이 letterSpacingCustom 을 em 단위로 호출해야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, letterSpacingCustom: "0em" }}
selectedBlockId="btn-letter-spacing"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("글자 간격 슬라이더");
fireEvent.change(slider, { target: { value: "16" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-letter-spacing",
expect.objectContaining({ letterSpacingCustom: "1em" }),
);
});
});
@@ -61,6 +61,46 @@ describe("DividerPropertiesPanel", () => {
);
});
it("길이 모드 select 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<DividerPropertiesPanel
dividerProps={{ ...baseProps, widthMode: "full" }}
selectedBlockId="divider-5"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("구분선 길이 모드");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
"divider-5",
expect.objectContaining({ widthMode: "fixed" }),
);
});
it("고정 길이 (px) 커스텀 인풋 변경 시 updateBlock 이 widthPx 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<DividerPropertiesPanel
dividerProps={{ ...baseProps, widthMode: "fixed", widthPx: 320 }}
selectedBlockId="divider-6"
updateBlock={updateBlock}
/>,
);
const widthInput = screen.getByLabelText("고정 길이 (px) 커스텀 (px)");
fireEvent.change(widthInput, { target: { value: "480" } });
expect(updateBlock).toHaveBeenCalledWith(
"divider-6",
expect.objectContaining({ widthPx: 480 }),
);
});
it("색상 HEX 인풋 변경 시 updateBlock 이 colorHex 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
+8
View File
@@ -111,4 +111,12 @@ describe("EditorPage - 상단 메뉴 (Export 미리보기 제거)", () => {
const projectListItem = screen.getByText("프로젝트 목록");
expect(projectListItem).toBeTruthy();
});
it("헤더의 '프리뷰 열기' 링크는 현재 프로젝트 slug 를 쿼리로 포함한 /preview?slug=... 형식이어야 한다", () => {
render(<EditorPage />);
const previewLink = screen.getByRole("link", { name: "프리뷰 열기" });
expect(previewLink).toBeTruthy();
expect(previewLink.getAttribute("href")).toBe("/preview?slug=export-preview-test");
});
});
+374 -8
View File
@@ -119,7 +119,7 @@ describe("EditorPage - 폼 블록 스타일", () => {
render(<EditorPage />);
const field = screen.getByTestId("form-input-field") as HTMLElement;
const field = screen.getByTestId("form-input-field") as HTMLInputElement;
expect(field.style.color).toBe(hexToRgb("#ff0000"));
expect(field.style.backgroundColor).toBe(hexToRgb("#123456"));
@@ -129,6 +129,91 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(field.style.borderRadius).toBe("9999px");
});
it("formInput: labelDisplay 가 hidden 이면 라벨 텍스트는 렌더되지 않지만 getByLabelText 로 접근 가능해야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_hidden",
type: "formInput",
props: {
label: "숨김 라벨",
formFieldName: "hidden_label",
labelDisplay: "hidden",
inputType: "text",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_input_hidden";
render(<EditorPage />);
// 시각적으로 라벨 텍스트는 별도의 span 으로 렌더되지 않아야 한다.
const visibleLabel = screen.queryByText("숨김 라벨");
expect(visibleLabel).toBeNull();
// 대신 인풋은 aria-label 로 동일한 라벨 이름을 노출해야 한다.
const input = screen.getByLabelText("숨김 라벨") as HTMLInputElement;
expect(input).toBeTruthy();
});
it("formInput: labelDisplay 가 floating 이면 placeholder 는 공백(\" \")으로 설정되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_floating_editor",
type: "formInput",
props: {
label: "플로팅 라벨",
formFieldName: "floating_label",
labelDisplay: "floating",
placeholder: "플로팅 플레이스홀더",
inputType: "text",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_input_floating_editor";
render(<EditorPage />);
const input = screen.getByTestId("form-input-field") as HTMLInputElement;
expect(input.placeholder).toBe(" ");
const field = input.parentElement as HTMLElement | null;
expect(field).not.toBeNull();
const floatingLabel = field!.querySelector(
'[data-testid="editor-form-input-floating-label"]',
) as HTMLSpanElement | null;
expect(floatingLabel).not.toBeNull();
// 포커스된 플로팅 라벨 상태와 유사하게, top 이 음수이고 작은 폰트 크기를 사용해야 한다.
expect(floatingLabel!.style.top).toBe("-0.3rem");
});
it("formInput: 기본 인풋 높이는 pb-input 과 동일한 padding(px-3/py-2)을 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_height",
type: "formInput",
props: {
label: "높이 테스트",
formFieldName: "height_test",
inputType: "text",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_input_height";
render(<EditorPage />);
const input = screen.getByLabelText("높이 테스트") as HTMLInputElement;
// 프리뷰/퍼블릭과 동일한 pb-input 클래스를 사용해야 한다.
expect(input.className).toContain("pb-input");
});
it("formSelect: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -155,14 +240,39 @@ describe("EditorPage - 폼 블록 스타일", () => {
render(<EditorPage />);
const select = screen.getByLabelText("폼 셀렉트") as HTMLSelectElement;
const wrapper = select.parentElement as HTMLElement; // style 이 적용된 div
const select = screen.getByTestId("form-select-field") as HTMLSelectElement;
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 (에디터 구현 기준)
expect(select.style.color).toBe(hexToRgb("#ff0000"));
expect(select.style.backgroundColor).toBe(hexToRgb("#123456"));
expect(select.style.borderColor).toBe(hexToRgb("#654321"));
expect(select.style.width).toBe("260px");
expect(select.style.borderRadius).toBe("9999px"); // lg → 9999 (에디터 구현 기준)
});
it("formSelect: 기본 셀렉트 높이는 pb-select 와 동일한 padding(px-3/py-2)을 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_select_height",
type: "formSelect",
props: {
label: "셀렉트 높이",
formFieldName: "select_height",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_select_height";
render(<EditorPage />);
const select = screen.getByLabelText("셀렉트 높이") as HTMLSelectElement;
// 프리뷰/퍼블릭과 동일한 pb-select 클래스를 사용해야 한다.
expect(select.className).toContain("pb-select");
});
it("formRadio: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
@@ -240,4 +350,260 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(groupContainer.style.width).toBe("300px");
expect(groupContainer.style.borderRadius).toBe("9999px");
});
it("formRadio: 그룹 타이틀 레이아웃이 inline 이면 에디터에서도 라벨과 필드 컨테이너가 가로 정렬되고 columnGap 이 labelGapPx 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_label_inline",
type: "formRadio",
props: {
formFieldName: "radio-inline",
groupLabel: "라디오 그룹 인라인",
labelLayout: "inline",
labelGapPx: 16,
options: [
{ label: "옵션 A", value: "a" },
{ label: "옵션 B", value: "b" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_label_inline";
render(<EditorPage />);
const label = screen.getByText("라디오 그룹 인라인");
const groupContainer = label.parentElement as HTMLElement;
expect(groupContainer.className).toContain("flex");
expect(groupContainer.className).toContain("flex-row");
expect(groupContainer.className).toContain("items-center");
expect(groupContainer.style.columnGap).toBe("16px");
});
it("formCheckbox: 그룹 타이틀 레이아웃이 inline 이면 에디터에서도 라벨과 필드 컨테이너가 가로 정렬되고 columnGap 이 labelGapPx 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_label_inline",
type: "formCheckbox",
props: {
formFieldName: "check-inline",
groupLabel: "체크 그룹 인라인",
labelLayout: "inline",
labelGapPx: 12,
options: [
{ label: "체크 1", value: "c1" },
{ label: "체크 2", value: "c2" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_checkbox_label_inline";
render(<EditorPage />);
const label = screen.getByText("체크 그룹 인라인");
const groupContainer = label.closest("div") as HTMLElement;
expect(groupContainer.className).toContain("flex");
expect(groupContainer.className).toContain("flex-row");
expect(groupContainer.className).toContain("items-center");
expect(groupContainer.style.columnGap).toBe("12px");
});
it("formRadio 블록의 옵션 컨테이너는 optionLayout 에 따라 pb-form-options--stacked/inline 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_stacked",
type: "formRadio",
props: {
formFieldName: "radio-stacked",
groupLabel: "라디오 세로",
optionLayout: "stacked",
options: [
{ label: "옵션 A", value: "a" },
{ label: "옵션 B", value: "b" },
],
},
} as any,
{
id: "form_radio_inline",
type: "formRadio",
props: {
formFieldName: "radio-inline",
groupLabel: "라디오 인라인",
optionLayout: "inline",
options: [
{ label: "옵션 C", value: "c" },
{ label: "옵션 D", value: "d" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_stacked";
render(<EditorPage />);
const stackedLabel = screen.getByText("라디오 세로");
const stackedOptionsContainer = stackedLabel.nextElementSibling as HTMLElement;
expect(stackedOptionsContainer.className).toContain("pb-form-options");
expect(stackedOptionsContainer.className).toContain("pb-form-options--stacked");
const inlineLabel = screen.getByText("라디오 인라인");
const inlineOptionsContainer = inlineLabel.nextElementSibling as HTMLElement;
expect(inlineOptionsContainer.className).toContain("pb-form-options");
expect(inlineOptionsContainer.className).toContain("pb-form-options--inline");
});
it("formRadio: optionGapPx 값이 에디터 옵션 컨테이너의 rowGap/columnGap 으로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_option_gap",
type: "formRadio",
props: {
formFieldName: "radio-gap",
groupLabel: "라디오 옵션 간격",
optionLayout: "inline",
optionGapPx: 12,
options: [
{ label: "옵션 A", value: "a" },
{ label: "옵션 B", value: "b" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_option_gap";
render(<EditorPage />);
const label = screen.getByText("라디오 옵션 간격");
const optionsContainer = label.nextElementSibling as HTMLElement;
expect(optionsContainer.className).toContain("pb-form-options");
expect(optionsContainer.style.rowGap).toBe("12px");
expect(optionsContainer.style.columnGap).toBe("12px");
});
it("formCheckbox: optionGapPx 값이 에디터 옵션 컨테이너의 rowGap/columnGap 으로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_option_gap",
type: "formCheckbox",
props: {
formFieldName: "check-gap",
groupLabel: "체크 옵션 간격",
optionLayout: "inline",
optionGapPx: 10,
options: [
{ label: "체크 1", value: "c1" },
{ label: "체크 2", value: "c2" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_checkbox_option_gap";
render(<EditorPage />);
const label = screen.getByText("체크 옵션 간격");
const groupContainer = label.closest("div") as HTMLElement;
const optionsContainer = groupContainer.querySelector(".pb-form-options") as HTMLElement;
expect(optionsContainer).not.toBeNull();
expect(optionsContainer.style.rowGap).toBe("10px");
expect(optionsContainer.style.columnGap).toBe("10px");
});
it("formCheckbox 블록의 옵션 컨테이너는 optionLayout 에 따라 pb-form-options--stacked/inline 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_stacked",
type: "formCheckbox",
props: {
formFieldName: "check-stacked",
groupLabel: "체크 세로",
optionLayout: "stacked",
options: [
{ label: "체크 1", value: "c1" },
{ label: "체크 2", value: "c2" },
],
},
} as any,
{
id: "form_checkbox_inline",
type: "formCheckbox",
props: {
formFieldName: "check-inline",
groupLabel: "체크 인라인",
optionLayout: "inline",
options: [
{ label: "체크 3", value: "c3" },
{ label: "체크 4", value: "c4" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_checkbox_stacked";
render(<EditorPage />);
const stackedLabel = screen.getByText("체크 세로");
const stackedGroup = stackedLabel.closest("div") as HTMLElement;
const stackedOptionsContainer = stackedGroup.querySelector("div.pb-form-options") as HTMLElement;
expect(stackedOptionsContainer).toBeTruthy();
expect(stackedOptionsContainer.className).toContain("pb-form-options--stacked");
const inlineLabel = screen.getByText("체크 인라인");
const inlineGroup = inlineLabel.closest("div") as HTMLElement;
const inlineOptionsContainer = inlineGroup.querySelector("div.pb-form-options") as HTMLElement;
expect(inlineOptionsContainer).toBeTruthy();
expect(inlineOptionsContainer.className).toContain("pb-form-options--inline");
});
it("formRadio/formCheckbox 블록의 각 옵션 라벨은 builder.css 의 pb-form-option 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_options",
type: "formRadio",
props: {
formFieldName: "radio-group",
groupLabel: "라디오 옵션 그룹",
options: [
{ label: "옵션 A", value: "a" },
{ label: "옵션 B", value: "b" },
],
},
} as any,
{
id: "form_checkbox_options",
type: "formCheckbox",
props: {
formFieldName: "check-group",
groupLabel: "체크 옵션 그룹",
options: [
{ label: "체크 1", value: "c1" },
{ label: "체크 2", value: "c2" },
],
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_options";
render(<EditorPage />);
const allOptionLabels = document.querySelectorAll("label.pb-form-option");
expect(allOptionLabels.length).toBeGreaterThan(0);
});
});
File diff suppressed because it is too large Load Diff
+154
View File
@@ -38,4 +38,158 @@ describe("ImagePropertiesPanel", () => {
expect.objectContaining({ backgroundColorCustom: "#112233" }),
);
});
it("이미지 소스 셀렉트에서 url 선택 시 sourceType 이 externalUrl 이 되고 assetId 가 null 로 설정되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={{
...baseProps,
src: "/api/image/asset-1",
sourceType: "asset",
assetId: "asset-1",
} as any}
selectedBlockId="image-source-1"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("이미지 소스");
fireEvent.change(select, { target: { value: "url" } });
expect(updateBlock).toHaveBeenCalledWith(
"image-source-1",
expect.objectContaining({ sourceType: "externalUrl", assetId: null }),
);
});
it("이미지 URL 인풋 변경 시 src / sourceType / assetId 가 externalUrl/null 로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={{
...baseProps,
src: "https://example.com/before.png",
sourceType: "externalUrl",
assetId: "old-asset",
} as any}
selectedBlockId="image-url-1"
updateBlock={updateBlock}
/>,
);
const urlInput = screen.getByLabelText("이미지 URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/after.png" } });
expect(updateBlock).toHaveBeenCalledWith(
"image-url-1",
expect.objectContaining({
src: "https://example.com/after.png",
sourceType: "externalUrl",
assetId: null,
}),
);
});
it("대체 텍스트 인풋 변경 시 updateBlock 이 alt 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={baseProps}
selectedBlockId="image-alt-1"
updateBlock={updateBlock}
/>,
);
const altInput = screen.getByLabelText("대체 텍스트");
fireEvent.change(altInput, { target: { value: "새 대체 텍스트" } });
expect(updateBlock).toHaveBeenCalledWith(
"image-alt-1",
expect.objectContaining({ alt: "새 대체 텍스트" }),
);
});
it("이미지 정렬 셀렉트 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={{ ...baseProps, align: "center" }}
selectedBlockId="image-align-1"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("이미지 정렬");
fireEvent.change(select, { target: { value: "left" } });
expect(updateBlock).toHaveBeenCalledWith(
"image-align-1",
expect.objectContaining({ align: "left" }),
);
});
it("너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={{ ...baseProps, widthMode: "auto" }}
selectedBlockId="image-width-mode-1"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("이미지 너비 모드");
fireEvent.change(select, { target: { value: "fixed" } });
expect(updateBlock).toHaveBeenCalledWith(
"image-width-mode-1",
expect.objectContaining({ widthMode: "fixed" }),
);
});
it("너비 모드가 fixed 일 때 고정 너비 슬라이더 변경 시 updateBlock 이 widthPx 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={{ ...baseProps, widthMode: "fixed", widthPx: 320 }}
selectedBlockId="image-width-fixed-1"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("고정 너비 (px) 슬라이더");
fireEvent.change(slider, { target: { value: "480" } });
expect(updateBlock).toHaveBeenCalledWith(
"image-width-fixed-1",
expect.objectContaining({ widthPx: 480 }),
);
});
it("모서리 둥글기 슬라이더 변경 시 updateBlock 이 borderRadius 와 borderRadiusPx 를 함께 업데이트해야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={{ ...baseProps, borderRadius: "md", borderRadiusPx: 60 }}
selectedBlockId="image-radius-1"
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("모서리 둥글기 슬라이더");
fireEvent.change(slider, { target: { value: "24" } });
expect(updateBlock).toHaveBeenCalledWith(
"image-radius-1",
expect.objectContaining({ borderRadius: "sm", borderRadiusPx: 24 }),
);
});
});
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ListPropertiesPanel } from "@/app/editor/panels/ListPropertiesPanel";
import type { ListBlockProps } from "@/features/editor/state/editorStore";
describe("ListPropertiesPanel", () => {
const baseProps: ListBlockProps = {
items: ["아이템 1", "아이템 2"],
ordered: false,
align: "left",
};
afterEach(() => {
cleanup();
});
it("리스트 아이템 textarea 변경 시 items 와 itemsTree 로 updateBlock 이 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={baseProps}
selectedBlockId="list-1"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const textarea = screen.getByLabelText("리스트 아이템들");
fireEvent.change(textarea, { target: { value: "첫째\n둘째" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-1",
expect.objectContaining({
items: ["첫째", "둘째"],
}),
);
});
it("리스트 정렬 셀렉트 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, align: "left" }}
selectedBlockId="list-align"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("리스트 정렬");
fireEvent.change(select, { target: { value: "center" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-align",
expect.objectContaining({ align: "center" }),
);
});
it("글자 크기 슬라이더 변경 시 updateBlock 이 fontSizeCustom 을 px 문자열로 호출해야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, fontSizeCustom: "14px" }}
selectedBlockId="list-font-size"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("글자 크기 (px) 슬라이더");
fireEvent.change(slider, { target: { value: "20" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-font-size",
expect.objectContaining({ fontSizeCustom: "20px" }),
);
});
it("줄 간격 슬라이더 변경 시 updateBlock 이 lineHeightCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, lineHeightCustom: "1.5" }}
selectedBlockId="list-line-height"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("줄 간격 슬라이더");
fireEvent.change(slider, { target: { value: "1.8" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-line-height",
expect.objectContaining({ lineHeightCustom: "1.8" }),
);
});
it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, textColorCustom: "#ffffff" }}
selectedBlockId="list-text-color"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("리스트 텍스트 색상 HEX");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-text-color",
expect.objectContaining({ textColorCustom: "#112233" }),
);
});
it("불릿 스타일 셀렉트 변경 시 bulletStyle 과 ordered 가 함께 업데이트되어야 한다 (decimal)", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, bulletStyle: "disc", ordered: false }}
selectedBlockId="list-bullet-decimal"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("리스트 불릿 스타일");
fireEvent.change(select, { target: { value: "decimal" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-bullet-decimal",
expect.objectContaining({ bulletStyle: "decimal", ordered: true }),
);
});
it("불릿 스타일 셀렉트에서 none 선택 시 ordered 가 false 로 설정되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, bulletStyle: "decimal", ordered: true }}
selectedBlockId="list-bullet-none"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("리스트 불릿 스타일");
fireEvent.change(select, { target: { value: "none" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-bullet-none",
expect.objectContaining({ bulletStyle: "none", ordered: false }),
);
});
it("아이템 간 여백 슬라이더 변경 시 updateBlock 이 gapYPx 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, gapYPx: 8 }}
selectedBlockId="list-gapY"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const slider = screen.getByLabelText("아이템 간 여백 (px) 슬라이더");
fireEvent.change(slider, { target: { value: "16" } });
expect(updateBlock).toHaveBeenCalledWith(
"list-gapY",
expect.objectContaining({ gapYPx: 16 }),
);
});
});
+45
View File
@@ -100,4 +100,49 @@ describe("PreviewPage - 자동 복원", () => {
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig as ProjectConfig);
});
it("URL 쿼리의 slug 가 projectConfig.slug 와 달라도 URL slug 기준 autosave 를 복원해야 한다", () => {
const savedBlocks: Block[] = [
{
id: "saved_query_1",
type: "text",
props: {
text: "쿼리 슬러그 기반 프리뷰 블록",
align: "center",
size: "lg",
},
} as any,
];
const savedConfig: ProjectConfig = {
title: "쿼리 슬러그 프리뷰 프로젝트",
slug: "preview-autosave-from-query",
canvasPreset: "full",
} as ProjectConfig;
const payload = {
blocks: savedBlocks,
projectConfig: savedConfig,
};
// projectConfig.slug 는 다른 값으로 시작하지만,
// /preview?slug=preview-autosave-from-query URL 기준으로 autosave 를 복원해야 한다.
(mockState.projectConfig as ProjectConfig).slug = "other-slug" as any;
window.localStorage.setItem(
"pb:autosave:preview-autosave-from-query",
JSON.stringify(payload),
);
// jsdom 환경에서 URL 쿼리 슬러그를 시뮬레이션한다.
window.history.pushState({}, "", "/preview?slug=preview-autosave-from-query");
render(<PreviewPage />);
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig as ProjectConfig);
});
});
@@ -134,4 +134,28 @@ describe("ProjectSubmissionsPage - 폼 제출 내역", () => {
expect(errorText).toBeTruthy();
});
it("상단에 '프로젝트 목록' 링크가 있고 클릭 시 /projects 로 이동해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectSubmissionsPage />);
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(backLink).toBeTruthy();
backLink.click();
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
});
});
});
+32
View File
@@ -80,6 +80,38 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(previewLinks[1].closest("a")?.getAttribute("href")).toBe("/preview?slug=test-project-b");
});
it("헤더에 전체 제출 내역 페이지(/projects/submissions)로 이동하는 링크가 있어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const link = screen.getByText("전체 제출 내역");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
});
it("각 프로젝트 행에는 폼 제출 내역 페이지(/projects/[slug]/submissions)로 이동하는 링크가 있어야 한다", async () => {
const projects = [
{
@@ -108,9 +108,7 @@ describe("PublicPageRenderer - 블록 배경색", () => {
const { queryByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
const formWithBg = queryByTestId("preview-form-controller") as HTMLElement | null;
expect(formWithBg).not.toBeNull();
// JSDOM 은 #111111 을 rgb(17, 17, 17) 형태로 노출한다.
expect(formWithBg!.style.backgroundColor).toBe("rgb(17, 17, 17)");
expect(formWithBg).toBeNull();
const blocksWithoutBg: Block[] = [
{
@@ -129,10 +127,7 @@ describe("PublicPageRenderer - 블록 배경색", () => {
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
const formNoBg = queryByTestId("preview-form-controller") as HTMLElement | null;
expect(formNoBg).not.toBeNull();
expect(
formNoBg!.style.backgroundColor === "" || formNoBg!.style.backgroundColor === "transparent",
).toBe(true);
expect(formNoBg).toBeNull();
});
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
@@ -63,6 +63,165 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect((input as HTMLInputElement).style.borderRadius).toBe("6px");
});
it("formInput 블록에서 inputType/email 과 formFieldName 은 type/name/placeholder 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_email_attrs",
type: "formInput",
props: {
label: "이메일",
formFieldName: "email_field",
inputType: "email",
placeholder: "이메일 주소",
} as any,
},
];
const { getByLabelText } = render(<PublicPageRenderer blocks={blocks} />);
const input = getByLabelText("이메일") as HTMLInputElement;
expect(input.type).toBe("email");
expect(input.name).toBe("email_field");
expect(input.placeholder).toBe("이메일 주소");
});
it("formInput 블록의 타이포그래피 커스텀 값은 em 단위 스타일로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_typography",
type: "formInput",
props: {
label: "커스텀 타이포",
formFieldName: "typo",
fontSizeCustom: "24px",
lineHeightCustom: "30px",
letterSpacingCustom: "2px",
} 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 HTMLInputElement | null;
expect(input).not.toBeNull();
expect(input!.style.fontSize).toBe("1.5em");
expect(input!.style.lineHeight).toBe("1.875em");
expect(input!.style.letterSpacing).toBe("0.125em");
});
it("formInput 블록에서 라벨 표시 방식이 hidden 이면 sr-only 클래스로 시각적으로 숨겨야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_hidden_label",
type: "formInput",
props: {
label: "숨김 라벨",
formFieldName: "hidden_label",
labelDisplay: "hidden",
} as any,
},
];
const { getByTestId, getByLabelText } = render(<PublicPageRenderer blocks={blocks} />);
const wrapper = getByTestId("preview-form-input-wrapper") as HTMLElement;
const span = wrapper.querySelector("span");
expect(span).not.toBeNull();
expect((span as HTMLSpanElement).className).toContain("sr-only");
// label 이 숨김이더라도 접근성 측면에서 getByLabelText 로는 접근 가능해야 한다.
const input = getByLabelText("숨김 라벨") as HTMLInputElement;
expect(input).toBeTruthy();
});
it("formInput 블록에서 라벨 표시 방식이 floating 이면 pb-form-field/pb-form-label 기반 플로팅 라벨 마크업을 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_floating_label",
type: "formInput",
props: {
label: "플로팅 라벨",
formFieldName: "floating_label",
labelDisplay: "floating",
} as any,
},
];
const { container, getByLabelText } = render(<PublicPageRenderer blocks={blocks} />);
const field = container.querySelector(
'[data-testid="preview-form-input-wrapper"]',
) as HTMLDivElement | null;
expect(field).not.toBeNull();
expect(field!.className).toContain("pb-form-field");
expect(field!.className).toContain("pb-form-field--floating");
const firstChild = field!.firstElementChild as HTMLElement | null;
const lastChild = field!.lastElementChild as HTMLElement | null;
expect(firstChild).not.toBeNull();
expect(lastChild).not.toBeNull();
// 플로팅 라벨 CSS 는 input/textarea 다음 형제 label 을 기준으로 동작하므로,
// DOM 상에서 input 이 먼저, label 이 나중에 와야 한다.
expect(firstChild!.getAttribute("data-testid")).toBe("preview-form-input");
expect(lastChild!.tagName).toBe("LABEL");
const label = lastChild as HTMLLabelElement;
expect(label.className).toContain("pb-form-label");
const input = getByLabelText("플로팅 라벨") as HTMLInputElement;
expect(input.className).toContain("pb-input");
// 플로팅 라벨 모드에서는 placeholder 를 공백으로 두고, 라벨만 인풋 안/위에서 이동시킨다.
expect(input.placeholder).toBe(" ");
// formFieldName 은 name/id/htmlFor 로 연결되어야 한다.
expect(input.name).toBe("floating_label");
expect(input.id).toBe("floating_label");
expect(label.htmlFor).toBe("floating_label");
});
it("formInput 플로팅 라벨에서 paddingY 값은 pb-form-field--floating 의 --pb-input-padding-y CSS 변수로 전달되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_floating_padding",
type: "formInput",
props: {
label: "플로팅 패딩",
formFieldName: "floating_padding",
labelDisplay: "floating",
paddingY: 16,
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const field = getByTestId("preview-form-input-wrapper") as HTMLDivElement;
// paddingY: 16px -> 1rem 으로 변환되어 CSS 변수에 설정되어야 한다.
expect(field.style.getPropertyValue("--pb-input-padding-y")).toBe("1rem");
});
it("formInput 플로팅 라벨에서 label 과 placeholder 가 같으면 placeholder 텍스트를 인풋 안에 표시하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_floating_same_placeholder",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
labelDisplay: "floating",
placeholder: "이름",
} as any,
},
];
const { getByLabelText } = render(<PublicPageRenderer blocks={blocks} />);
const input = getByLabelText("이름") as HTMLInputElement;
// 플로팅 라벨 모드에서는 placeholder 를 공백으로 두고, 라벨만 인풋 안/위에서 이동시킨다.
expect(input.placeholder).toBe(" ");
});
it("formSelect 블록은 색상/패딩/너비/둥글기 스타일을 반영해야 한다", () => {
const blocks: Block[] = [
{
@@ -106,6 +265,55 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(select!.style.borderRadius).toBe("6px");
});
it("formSelect 블록에서 라벨 표시 방식이 hidden 이면 sr-only 클래스로 시각적으로 숨겨야 한다", () => {
const blocks: Block[] = [
{
id: "form_select_hidden_label",
type: "formSelect",
props: {
label: "숨김 셀렉트 라벨",
formFieldName: "select_hidden",
options: [
{ label: "A", value: "a" },
],
labelDisplay: "hidden",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const label = container.querySelector("label") as HTMLLabelElement | null;
expect(label).not.toBeNull();
const span = label!.querySelector("span");
expect(span).not.toBeNull();
expect((span as HTMLSpanElement).className).toContain("sr-only");
});
it("formSelect 블록의 select 요소는 formFieldName 을 name 속성으로 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_select_name_attr",
type: "formSelect",
props: {
label: "카테고리",
formFieldName: "category_field",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} 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!.name).toBe("category_field");
});
it("formCheckbox 블록은 옵션 컨테이너에 색상/패딩/둥글기 스타일을 반영해야 한다", () => {
const blocks: Block[] = [
{
@@ -148,6 +356,255 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(firstLabel.style.borderRadius).toBe("6px");
});
it("formRadio 블록의 옵션 input 은 type/name/value 및 FormBlock.requiredFieldIds 기반 required 를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "form_ctrl_radio",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
requiredFieldIds: ["radio_required"],
} as any,
},
{
id: "radio_required",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-radio-group") as HTMLElement;
const inputs = group.querySelectorAll("input[type='radio']");
expect(inputs.length).toBe(2);
const first = inputs[0] as HTMLInputElement;
const second = inputs[1] as HTMLInputElement;
expect(first.type).toBe("radio");
expect(first.name).toBe("plan");
expect(first.value).toBe("a");
expect(first.required).toBe(true);
expect(second.type).toBe("radio");
expect(second.name).toBe("plan");
expect(second.value).toBe("b");
expect(second.required).toBe(false);
});
it("formCheckbox 블록의 각 옵션 라벨은 builder.css 의 pb-form-option 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_pb_option_class",
type: "formCheckbox",
props: {
groupLabel: "옵션들",
formFieldName: "features",
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const optionLabels = group.querySelectorAll("label");
expect(optionLabels.length).toBeGreaterThan(0);
optionLabels.forEach((label) => {
expect((label as HTMLLabelElement).className).toContain("pb-form-option");
});
});
it("formCheckbox 블록에서 optionLayout 이 stacked 이면 옵션 컨테이너가 pb-form-options--stacked 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_layout_stacked",
type: "formCheckbox",
props: {
groupLabel: "옵션들",
formFieldName: "features",
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
optionLayout: "stacked",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const optionsContainer = getByTestId(
"preview-form-checkbox-options",
) as HTMLElement;
expect(optionsContainer.className).toContain("pb-form-options");
expect(optionsContainer.className).toContain("pb-form-options--stacked");
});
it("formCheckbox 블록에서 optionLayout 이 inline 이면 옵션 컨테이너가 pb-form-options--inline 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_layout_inline",
type: "formCheckbox",
props: {
groupLabel: "옵션들",
formFieldName: "features",
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
optionLayout: "inline",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const optionsContainer = getByTestId(
"preview-form-checkbox-options",
) as HTMLElement;
expect(optionsContainer.className).toContain("pb-form-options");
expect(optionsContainer.className).toContain("pb-form-options--inline");
});
it("formCheckbox 블록에서 그룹 타이틀 표시 방식이 hidden 이면 sr-only 클래스로 시각적으로 숨겨야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_hidden_group_label",
type: "formCheckbox",
props: {
groupLabel: "숨김 체크박스 그룹",
formFieldName: "features_hidden",
options: [{ label: "옵션 1", value: "opt1" }],
groupLabelDisplay: "hidden",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const spans = group.querySelectorAll("span");
const spanArray = Array.from(spans) as HTMLSpanElement[];
const groupLabelSpan = spanArray.find((s) => s.textContent === "숨김 체크박스 그룹");
expect(groupLabelSpan).toBeTruthy();
expect(groupLabelSpan!.className).toContain("sr-only");
});
it("formCheckbox 블록의 옵션 input 은 type/ name/value 및 FormBlock.requiredFieldIds 기반 required 를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "form_ctrl_checkbox",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
requiredFieldIds: ["checkbox_required"],
} as any,
},
{
id: "checkbox_required",
type: "formCheckbox",
props: {
groupLabel: "기능",
formFieldName: "feature",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const inputs = group.querySelectorAll("input[type='checkbox']");
expect(inputs.length).toBe(2);
const first = inputs[0] as HTMLInputElement;
const second = inputs[1] as HTMLInputElement;
expect(first.type).toBe("checkbox");
expect(first.name).toBe("feature");
expect(first.value).toBe("a");
expect(first.required).toBe(true);
expect(second.type).toBe("checkbox");
expect(second.name).toBe("feature");
expect(second.value).toBe("b");
expect(second.required).toBe(false);
});
it("formRadio 블록에서 그룹 타이틀 레이아웃이 inline 이면 그룹 컨테이너가 가로 정렬되고 세로 중앙 정렬 및 columnGap 이 labelGapPx 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_label_inline_preview",
type: "formRadio",
props: {
groupLabel: "플랜 인라인",
formFieldName: "plan-inline",
groupLabelDisplay: "visible",
labelLayout: "inline",
labelGapPx: 16,
options: [
{ label: "플랜 A", value: "a" },
{ label: "플랜 B", value: "b" },
],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-radio-group") as HTMLElement;
expect(group.className).toContain("flex");
expect(group.className).toContain("flex-row");
expect(group.className).toContain("items-center");
// labelGapPx: 16px → 1em
expect(group.style.columnGap).toBe("1em");
});
it("formCheckbox 블록에서 그룹 타이틀 레이아웃이 inline 이면 그룹 컨테이너가 가로 정렬되고 세로 중앙 정렬 및 columnGap 이 labelGapPx 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_label_inline_preview",
type: "formCheckbox",
props: {
groupLabel: "체크 인라인",
formFieldName: "features-inline",
groupLabelDisplay: "visible",
labelLayout: "inline",
labelGapPx: 12,
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
expect(group.className).toContain("flex");
expect(group.className).toContain("flex-row");
expect(group.className).toContain("items-center");
// labelGapPx: 12px → 0.75em
expect(group.style.columnGap).toBe("0.75em");
});
it("formRadio 블록은 옵션 컨테이너에 색상/패딩/둥글기 스타일을 반영해야 한다", () => {
const blocks: Block[] = [
{
@@ -189,4 +646,30 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
expect(firstLabel.style.borderRadius).toBe("6px");
});
it("formRadio 블록의 각 옵션 라벨은 builder.css 의 pb-form-option 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_pb_option_class",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [
{ label: "플랜 A", value: "a" },
{ label: "플랜 B", value: "b" },
],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-radio-group") as HTMLElement;
const optionLabels = group.querySelectorAll("label");
expect(optionLabels.length).toBeGreaterThan(0);
optionLabels.forEach((label) => {
expect((label as HTMLLabelElement).className).toContain("pb-form-option");
});
});
});
@@ -25,11 +25,26 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
fields: [
{ id: "f1", name: "name", label: "이름", type: "text", required: true },
],
fieldIds: [],
submitButtonId: null,
fieldIds: ["field_name"],
requiredFieldIds: ["field_name"],
submitButtonId: "submit_btn",
} as any,
},
{
id: "field_name",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
required: true,
} as any,
},
{
id: "submit_btn",
type: "button",
props: {
label: "제출하기",
href: "#",
} as any,
},
];
@@ -45,16 +60,13 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
render(<PublicPageRenderer blocks={blocks} />);
const form = screen.getByTestId("preview-form-controller") as HTMLFormElement;
expect(form).toBeTruthy();
const nameInput = screen.getByLabelText("이름") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "홍길동" } });
const input = form.querySelector('input[name="name"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
const submitButton = screen.getByRole("button", { name: "폼 전송" });
const submitButton = screen.getByRole("link", { name: "제출하기" });
expect(submitButton).toBeTruthy();
fireEvent.submit(form);
fireEvent.click(submitButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -76,11 +88,26 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
fields: [
{ id: "f1", name: "name", label: "이름", type: "text", required: true },
],
fieldIds: [],
submitButtonId: null,
fieldIds: ["field_name"],
requiredFieldIds: ["field_name"],
submitButtonId: "submit_btn",
} as any,
},
{
id: "field_name",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
required: true,
} as any,
},
{
id: "submit_btn",
type: "button",
props: {
label: "제출하기",
href: "#",
} as any,
},
];
@@ -96,13 +123,13 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
render(<PublicPageRenderer blocks={blocks} />);
const form = screen.getByTestId("preview-form-controller") as HTMLFormElement;
expect(form).toBeTruthy();
const nameInput = screen.getByLabelText("이름") as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "홍길동" } });
const submitButton = screen.getByRole("button", { name: "폼 전송" });
const submitButton = screen.getByRole("link", { name: "제출하기" });
expect(submitButton).toBeTruthy();
fireEvent.submit(form);
fireEvent.click(submitButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -113,4 +140,124 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
expect(errorMsg).toBeTruthy();
});
});
it("필수 필드가 비어 있고 errorMessage 가 없으면 기본 문구에 필수 항목 라벨을 포함해야 한다", async () => {
const blocks: Block[] = [
{
id: "form_required_default_msg",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
fieldIds: ["field_email"],
requiredFieldIds: ["field_email"],
submitButtonId: "submit_btn",
} as any,
},
{
id: "field_email",
type: "formInput",
props: {
label: "이메일",
formFieldName: "email",
required: true,
} as any,
},
{
id: "submit_btn",
type: "button",
props: {
label: "제출하기",
href: "#",
} 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
(globalThis as any).fetch = fetchMock;
render(<PublicPageRenderer blocks={blocks} />);
const submitButton = screen.getByRole("link", { name: "제출하기" });
expect(submitButton).toBeTruthy();
fireEvent.click(submitButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(0);
});
await waitFor(() => {
const errorMsg = screen.getByText(/다음 필수 항목을 입력해 주세요:\s*-\s*이메일/);
expect(errorMsg).toBeTruthy();
});
});
it("필수 필드가 비어 있으면 요청을 보내지 않고 필수 항목 안내 메시지를 표시해야 한다 (errorMessage 설정 시)", async () => {
const blocks: Block[] = [
{
id: "form_required",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
successMessage: "폼 성공 메시지 (config)",
errorMessage: "폼 에러 메시지 (config)",
fieldIds: ["field_name"],
requiredFieldIds: ["field_name"],
submitButtonId: "submit_btn",
} as any,
},
{
id: "field_name",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
required: true,
} as any,
},
{
id: "submit_btn",
type: "button",
props: {
label: "제출하기",
href: "#",
} 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
(globalThis as any).fetch = fetchMock;
render(<PublicPageRenderer blocks={blocks} />);
const submitButton = screen.getByRole("link", { name: "제출하기" });
expect(submitButton).toBeTruthy();
// 이름 필드를 비운 채로 제출한다.
fireEvent.click(submitButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(0);
});
await waitFor(() => {
const errorMsg = screen.getByText(/다음 필수 항목을 입력해 주세요:\s*-\s*이름/);
expect(errorMsg).toBeTruthy();
});
});
});
@@ -5,7 +5,7 @@ import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor
// PublicPageRenderer 섹션/루트 레이아웃 TDD
// - Export 레이어의 pb-root / pb-section-* 구조와 개념적으로 대응되는 프리뷰 레이아웃을 고정한다.
// - 정확한 px 값 비교 대신, 주요 Tailwind 레이아웃 클래스 존재 여부를 검증한다.
// - Tailwind 유틸 대신 builder.css 의 pb-* 레이아웃 클래스를 기준으로 검증한다.
describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
afterEach(() => {
@@ -47,7 +47,7 @@ describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
return [section, text];
};
it("섹션 레이아웃은 mx-auto / max-w / px-* 및 flex 컬럼/갭 구조로 렌더되어야 한다", () => {
it("섹션 레이아웃은 pb-section / pb-section-inner / pb-section-columns / pb-section-column 구조로 렌더되어야 한다", () => {
const blocks: Block[] = makeSectionWithText();
render(<PublicPageRenderer blocks={blocks} />);
@@ -56,20 +56,12 @@ describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
const columns = screen.getByTestId("preview-section-columns") as HTMLDivElement;
// Export 의 pb-section-inner / pb-section-columns 에 대응되는 레이아웃 구조를 최소한으로 보장한다.
expect(inner.className).toContain("mx-auto");
expect(inner.className).toContain("px-4");
expect(inner.className).toMatch(/max-w-/);
expect(inner.className).toContain("pb-section-inner");
expect(columns.className).toContain("pb-section-columns");
expect(columns.className).toContain("flex");
// gapX 기본값(md)에 대해 gap-8 이 사용되는 현재 구현을 고정한다.
expect(columns.className).toContain("gap-8");
// 컬럼 래퍼는 flex-col 과 일정한 세로 간격을 가진다.
const columnWrapper = columns.querySelector("div.flex.flex-col") as HTMLDivElement | null;
// 컬럼 래퍼는 pb-section-column 클래스를 가지며, 섹션 텍스트가 그 안에 포함되어야 한다.
const columnWrapper = columns.querySelector(".pb-section-column") as HTMLDivElement | null;
expect(columnWrapper).not.toBeNull();
if (columnWrapper) {
expect(columnWrapper.className).toContain("gap-4");
}
// 섹션 텍스트가 실제로 섹션 안에 렌더되는지 확인한다.
const textEl = screen.getByText("섹션 레이아웃 텍스트");
@@ -77,7 +69,7 @@ describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
expect(section).not.toBeNull();
});
it("루트 블록은 상단 섹션 내 max-width 컨테이너 안에서 렌더되어야 한다", () => {
it("루트 블록은 pb-root / pb-root-inner 구조 안에서 렌더되어야 한다", () => {
const rootTextProps: TextBlockProps = {
text: "루트 레이아웃 텍스트",
align: "left",
@@ -98,23 +90,15 @@ describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
const rootTextEl = screen.getByText("루트 레이아웃 텍스트") as HTMLParagraphElement;
// 루트 텍스트는 상단 섹션(py-12) 안에 존재해야 한다.
// 루트 텍스트는 상단 pb-root 섹션 안에 존재해야 한다.
const rootSection = rootTextEl.closest("section");
expect(rootSection).not.toBeNull();
if (!rootSection) return;
expect(rootSection.className).toContain("py-12");
expect(rootSection.className).toContain("pb-root");
// 섹션 바로 아래의 max-width 컨테이너 구조를 확인한다.
const innerContainer = rootSection.querySelector("div");
// 섹션 바로 아래의 pb-root-inner 컨테이너 구조를 확인한다.
const innerContainer = rootSection.querySelector(".pb-root-inner");
expect(innerContainer).not.toBeNull();
if (!innerContainer) return;
const className = innerContainer.className;
expect(className).toContain("mx-auto");
expect(className).toContain("max-w-3xl");
expect(className).toContain("px-4");
expect(className).toContain("flex");
expect(className).toContain("gap-4");
});
});
@@ -25,17 +25,17 @@ describe("PublicPageRenderer - 텍스트 블록 스타일", () => {
} as any;
};
it("기본 텍스트 블록은 text-left / text-base 및 기본 색상으로 렌더되어야 한다", () => {
it("기본 텍스트 블록은 pb-text-left / pb-text-base 및 기본 색상(pb-text-color-strong)으로 렌더되어야 한다", () => {
const blocks: Block[] = [makeTextBlock()];
render(<PublicPageRenderer blocks={blocks} />);
const el = screen.getByText("퍼블릭 텍스트 스타일 테스트") as HTMLParagraphElement;
expect(el.className).toContain("text-left");
expect(el.className).toContain("text-base");
// 기본 색상은 Tailwind text-slate-50 클래스로 고정되어 있다.
expect(el.className).toContain("text-slate-50");
expect(el.className).toContain("pb-text-left");
expect(el.className).toContain("pb-text-base");
// 기본 색상은 builder.css 의 pb-text-color-strong 클래스로 표현된다.
expect(el.className).toContain("pb-text-color-strong");
});
it("fontSizeMode=custom, fontSizeCustom, colorCustom, backgroundColorCustom 이 설정되면 스타일이 인라인으로 반영되어야 한다", () => {
@@ -53,8 +53,8 @@ describe("PublicPageRenderer - 텍스트 블록 스타일", () => {
const el = screen.getByText("퍼블릭 텍스트 스타일 테스트") as HTMLParagraphElement;
// 정렬 클래스
expect(el.className).toContain("text-center");
// 정렬 클래스는 pb-text-center 로 반영된다.
expect(el.className).toContain("pb-text-center");
// 커스텀 폰트 크기: sizeClass 는 비워지고, 인라인 스타일로 설정된다.
expect(el.style.fontSize).toBe("1.5em");
// 커스텀 색상/배경색
@@ -17,6 +17,29 @@ describe("SectionPropertiesPanel", () => {
cleanup();
});
it("섹션 배경 색상 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundColorCustom: "#111111",
}}
selectedBlockId="section-bg-1"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("섹션 배경 색상 HEX 입력");
fireEvent.change(hexInput, { target: { value: "#112233" } });
expect(updateBlock).toHaveBeenCalledWith(
"section-bg-1",
expect.objectContaining({ backgroundColorCustom: "#112233" }),
);
});
it("가로 위치 프리셋 드롭다운 변경 시 backgroundImagePositionXPercent 가 0/50/100 등으로 설정된다", () => {
const updateBlock = vi.fn();
+16
View File
@@ -42,6 +42,22 @@ describe("dividerHelpers.computeDividerExportTokens", () => {
expect(zeroTokens.style).toContain("margin:1em 0;");
});
it("widthMode=\"fixed\" 인 경우 widthPx 는 em 단위 width 로 Export style 에 반영되어야 한다", () => {
const tokens = computeDividerExportTokens(
{
...baseProps,
align: "center",
widthMode: "fixed",
widthPx: 480,
},
escapers,
);
expect(tokens.style).toContain("width:30em");
expect(tokens.style).toContain("margin-left:auto");
expect(tokens.style).toContain("margin-right:auto");
});
it("Export 구분선 style 에서는 border-bottom 두께를 제외하고 px 단위가 없어야 한다", () => {
const tokens = computeDividerExportTokens({
...baseProps,
+5 -7
View File
@@ -400,6 +400,7 @@ describe("formHelpers.computeFormControllerPublicTokens", () => {
kind: "contact",
submitTarget: "internal",
fieldIds: ["input-1", "checkbox-1"],
requiredFieldIds: ["input-1", "checkbox-1"],
submitButtonId: "btn-1",
formWidthMode: "fixed",
formWidthPx: 320,
@@ -473,7 +474,7 @@ describe("formHelpers.computeFormControllerPublicTokens", () => {
expect(tokens.submitLabel).toBe("컨트롤러 버튼");
});
it("fields 가 있을 때는 fields 를 사용하고, 없으면 빈 배열을 반환해야 한다", () => {
it("fieldIds 가 없으면 fields 설정과 관계없이 빈 배열을 반환해야 한다", () => {
const formWithFields: Block = {
id: "form-2",
type: "form",
@@ -494,12 +495,9 @@ describe("formHelpers.computeFormControllerPublicTokens", () => {
const tokensWithFields = computeFormControllerPublicTokens(formWithFields, [formWithFields]);
expect(tokensWithFields.fields).toHaveLength(1);
expect(tokensWithFields.fields[0].id).toBe("f1");
expect(tokensWithFields.fields[0].name).toBe("email");
expect(tokensWithFields.fields[0].label).toBe("이메일");
expect(tokensWithFields.fields[0].type).toBe("email");
expect(tokensWithFields.fields[0].required).toBe(true);
// 프리뷰/퍼블릭 렌더러에서는 fieldIds 로 연결된 필드 블록만 사용하고,
// props.fields(v1 설정)는 무시해야 한다.
expect(tokensWithFields.fields).toHaveLength(0);
const formFallback: Block = {
id: "form-3",
+4 -4
View File
@@ -150,14 +150,14 @@ describe("listHelpers.computeListEditorTokens", () => {
});
describe("listHelpers.computeListPublicTokens", () => {
it("align 값에 따라 alignClass 가 text-left/center/right 로 설정되어야 한다", () => {
it("align 값에 따라 alignClass 가 pb-text-left/center/right 로 설정되어야 한다", () => {
const leftTokens = computeListPublicTokens({ ...baseProps, align: "left" });
const centerTokens = computeListPublicTokens({ ...baseProps, align: "center" });
const rightTokens = computeListPublicTokens({ ...baseProps, align: "right" });
expect(leftTokens.alignClass).toBe("text-left");
expect(centerTokens.alignClass).toBe("text-center");
expect(rightTokens.alignClass).toBe("text-right");
expect(leftTokens.alignClass).toBe("pb-text-left");
expect(centerTokens.alignClass).toBe("pb-text-center");
expect(rightTokens.alignClass).toBe("pb-text-right");
});
it("gapYPx 와 gapY 토큰에 따라 gapEm 이 px/16 값으로 계산되어야 한다", () => {
+28
View File
@@ -75,6 +75,32 @@ describe("sectionHelpers.computeSectionExportTokens", () => {
expect(tokens.sectionStyleParts).toContain("overflow:hidden");
});
it("paddingYPx / maxWidthPx / gapXPx 는 Export 토큰 styleParts 에 em 단위로 반영되어야 한다", () => {
const tokens = computeSectionExportTokens({
...baseProps,
backgroundColorCustom: "#123456",
paddingYPx: 80,
maxWidthPx: 960,
gapXPx: 32,
} as SectionBlockProps);
// 섹션 wrapper: 배경색 + 세로 패딩(em)
expect(tokens.sectionStyleParts).toContain("background-color:#123456");
expect(tokens.sectionStyleParts).toContain("padding-top:5em");
expect(tokens.sectionStyleParts).toContain("padding-bottom:5em");
// 내부 래퍼: 최대 폭(em)
expect(tokens.innerWrapperStyleParts).toContain("max-width:60em");
// 컬럼 컨테이너: column-gap(em)
expect(tokens.columnsStyleParts).toContain("column-gap:2em");
// px 단위는 어디에도 포함되면 안 된다.
expectNoPxInStyleParts(tokens.sectionStyleParts);
expectNoPxInStyleParts(tokens.innerWrapperStyleParts);
expectNoPxInStyleParts(tokens.columnsStyleParts);
});
it("Export 섹션 sectionStyleParts 에는 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeSectionExportTokens({
...baseProps,
@@ -88,6 +114,8 @@ describe("sectionHelpers.computeSectionExportTokens", () => {
} as SectionBlockProps);
expectNoPxInStyleParts(tokens.sectionStyleParts);
expectNoPxInStyleParts(tokens.innerWrapperStyleParts);
expectNoPxInStyleParts(tokens.columnsStyleParts);
});
});