TDD,E2E 개선, 미적용 스타일 개선
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 값으로 계산되어야 한다", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user