import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; import type { Block, ProjectConfig } from "@/features/editor/state/editorStore"; import EditorPage from "@/app/editor/page"; let mockState: any; beforeEach(() => { const baseProjectConfig: ProjectConfig = { title: "폼 블록 스타일 테스트", slug: "editor-form-style-test", canvasPreset: "full", canvasWidthPx: 1024, canvasBgColorHex: "#0f172a", bodyBgColorHex: "#020617", } as ProjectConfig; mockState = { blocks: [] as Block[], projectConfig: baseProjectConfig, selectedBlockId: null as string | null, selectedListItemId: null as string | null, undo: vi.fn(), redo: vi.fn(), removeBlock: vi.fn(), duplicateBlock: vi.fn(), selectBlock: vi.fn(), selectListItem: vi.fn(), addTextBlock: vi.fn(), addButtonBlock: vi.fn(), addImageBlock: vi.fn(), addDividerBlock: vi.fn(), addListBlock: vi.fn(), addSectionBlock: vi.fn(), addFormBlock: vi.fn(), addFormInputBlock: vi.fn(), addFormSelectBlock: vi.fn(), addFormCheckboxBlock: vi.fn(), addFormRadioBlock: vi.fn(), addHeroTemplateSection: vi.fn(), addFeaturesTemplateSection: vi.fn(), addCtaTemplateSection: vi.fn(), addFaqTemplateSection: vi.fn(), addPricingTemplateSection: vi.fn(), addTestimonialsTemplateSection: vi.fn(), addBlogTemplateSection: vi.fn(), addTeamTemplateSection: vi.fn(), addFooterTemplateSection: vi.fn(), updateBlock: vi.fn(), replaceBlocks: vi.fn(), reorderBlocks: vi.fn(), moveBlock: vi.fn(), }; }); afterEach(() => { cleanup(); }); vi.mock("@/features/editor/state/editorStore", () => { const useEditorStore = (selector: (state: any) => any) => selector(mockState); (useEditorStore as any).getState = () => mockState; return { __esModule: true, useEditorStore, }; }); vi.mock("next/link", () => { return { __esModule: true, default: ({ href, children }: any) => {children}, }; }); vi.mock("next/navigation", () => { return { __esModule: true, useRouter: () => ({ push: vi.fn(), }), useSearchParams: () => ({ get: () => null, }), }; }); function hexToRgb(hex: string) { const clean = hex.replace("#", ""); const int = parseInt(clean, 16); const r = (int >> 16) & 255; const g = (int >> 8) & 255; const b = int & 255; return `rgb(${r}, ${g}, ${b})`; } describe("EditorPage - 폼 블록 스타일", () => { it("formInput: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => { const blocks: Block[] = [ { id: "form_input_1", type: "formInput", props: { label: "폼 입력", inputType: "text", textColorCustom: "#ff0000", fillColorCustom: "#123456", strokeColorCustom: "#654321", widthMode: "fixed", widthPx: 240, borderRadius: "full", }, } as any, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_input_1"; render(); const field = screen.getByTestId("form-input-field") as HTMLInputElement; expect(field.style.color).toBe(hexToRgb("#ff0000")); expect(field.style.backgroundColor).toBe(hexToRgb("#123456")); expect(field.style.borderColor).toBe(hexToRgb("#654321")); expect(field.style.width).toBe("240px"); // borderRadius=full → 9999px 로 적용 expect(field.style.borderRadius).toBe("9999px"); }); it("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(); // 시각적으로 라벨 텍스트는 별도의 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(); 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(); const input = screen.getByLabelText("높이 테스트") as HTMLInputElement; // 프리뷰/퍼블릭과 동일한 pb-input 클래스를 사용해야 한다. expect(input.className).toContain("pb-input"); }); it("formInput 에디터 렌더에는 text-slate-* 색상 클래스가 없어야 한다", () => { const blocks: Block[] = [ { id: "form_input_neutral_editor", type: "formInput", props: { label: "폼 입력", formFieldName: "input_neutral", inputType: "text", } as any, }, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_input_neutral_editor"; render(); const label = screen.getByText("폼 입력"); expect(label.className).not.toContain("text-slate-"); }); it("formSelect: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => { const blocks: Block[] = [ { id: "form_select_1", type: "formSelect", props: { label: "폼 셀렉트", textColorCustom: "#ff0000", fillColorCustom: "#123456", strokeColorCustom: "#654321", widthMode: "fixed", widthPx: 260, borderRadius: "lg", options: [ { label: "A", value: "a" }, { label: "B", value: "b" }, ], }, } as any, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_select_1"; render(); const select = screen.getByTestId("form-select-field") as HTMLSelectElement; 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("6px"); // lg → 6 (Editor radius 스케일) }); 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(); const select = screen.getByLabelText("셀렉트 높이") as HTMLSelectElement; // 프리뷰/퍼블릭과 동일한 pb-select 클래스를 사용해야 한다. expect(select.className).toContain("pb-select"); }); it("formSelect 에디터 렌더에는 text-slate-* 색상 클래스가 없어야 한다", () => { const blocks: Block[] = [ { id: "form_select_neutral_editor", type: "formSelect", props: { label: "폼 셀렉트", formFieldName: "select_neutral", options: [ { label: "A", value: "a" }, ], } as any, }, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_select_neutral_editor"; render(); const label = screen.getByText("폼 셀렉트"); expect(label.className).not.toContain("text-slate-"); }); it("formRadio: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => { const blocks: Block[] = [ { id: "form_radio_1", type: "formRadio", props: { formFieldName: "radio-group", groupLabel: "라디오 그룹", textColorCustom: "#ff0000", fillColorCustom: "#123456", strokeColorCustom: "#654321", widthMode: "fixed", widthPx: 280, borderRadius: "lg", options: [ { label: "옵션 A", value: "a" }, { label: "옵션 B", value: "b" }, ], }, } as any, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_radio_1"; render(); const label = screen.getByText("라디오 그룹"); const groupContainer = label.closest("div") as HTMLElement; expect(groupContainer).toBeTruthy(); expect(groupContainer.style.color).toBe(hexToRgb("#ff0000")); expect(groupContainer.style.backgroundColor).toBe(hexToRgb("#123456")); expect(groupContainer.style.borderColor).toBe(hexToRgb("#654321")); expect(groupContainer.style.width).toBe("280px"); expect(groupContainer.style.borderRadius).toBe("6px"); }); it("formRadio 에디터 그룹 컨테이너에는 text-slate-* 색상 클래스가 없어야 한다", () => { const blocks: Block[] = [ { id: "form_radio_neutral_editor", type: "formRadio", props: { formFieldName: "radio-neutral", groupLabel: "라디오 그룹", options: [ { label: "옵션 A", value: "a" }, ], } as any, }, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_radio_neutral_editor"; render(); const label = screen.getByText("라디오 그룹"); const groupContainer = label.closest("div") as HTMLElement; expect(groupContainer.className).not.toContain("text-slate-"); }); it("formRadio 에디터 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => { const blocks: Block[] = [ { id: "form_radio_os_editor", type: "formRadio", props: { formFieldName: "radio-os", groupLabel: "라디오 OS", options: [{ label: "옵션 A", value: "a" }], } as any, }, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_radio_os_editor"; render(); const label = screen.getByText("라디오 OS"); const groupContainer = label.closest("div") as HTMLElement; const input = groupContainer.querySelector("input[type='radio']") as HTMLInputElement | null; expect(input).not.toBeNull(); // 크기/모양(h-4,w-4 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다. expect(input!.className).not.toContain("bg-"); expect(input!.className).not.toContain("text-"); expect(input!.className).not.toContain("border-"); }); it("formCheckbox 에디터 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => { const blocks: Block[] = [ { id: "form_checkbox_os_editor", type: "formCheckbox", props: { formFieldName: "check-os", groupLabel: "체크 OS", options: [{ label: "체크 1", value: "c1" }], } as any, }, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_checkbox_os_editor"; render(); const label = screen.getByText("체크 OS"); const groupContainer = label.closest("div") as HTMLElement; const input = groupContainer.querySelector("input[type='checkbox']") as HTMLInputElement | null; expect(input).not.toBeNull(); // 크기/모양(h-4,w-4,rounded 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다. expect(input!.className).not.toContain("bg-"); expect(input!.className).not.toContain("text-"); expect(input!.className).not.toContain("border-"); }); it("formCheckbox: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => { const blocks: Block[] = [ { id: "form_checkbox_1", type: "formCheckbox", props: { formFieldName: "check-group", groupLabel: "체크 그룹", textColorCustom: "#ff0000", fillColorCustom: "#123456", strokeColorCustom: "#654321", widthMode: "fixed", widthPx: 300, borderRadius: "lg", options: [ { label: "체크 1", value: "c1" }, { label: "체크 2", value: "c2" }, ], }, } as any, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_checkbox_1"; render(); // "체크 그룹" 텍스트를 포함하는 가장 가까운 컨테이너를 찾아 스타일을 검사한다. const label = screen.getByText("체크 그룹"); const groupContainer = label.closest("div") as HTMLElement; expect(groupContainer.style.color).toBe(hexToRgb("#ff0000")); expect(groupContainer.style.backgroundColor).toBe(hexToRgb("#123456")); expect(groupContainer.style.borderColor).toBe(hexToRgb("#654321")); expect(groupContainer.style.width).toBe("300px"); expect(groupContainer.style.borderRadius).toBe("6px"); }); it("formCheckbox 에디터 그룹 컨테이너에는 text-slate-* 색상 클래스가 없어야 한다", () => { const blocks: Block[] = [ { id: "form_checkbox_neutral_editor", type: "formCheckbox", props: { formFieldName: "checkbox-neutral", groupLabel: "체크 그룹", options: [ { label: "체크 1", value: "c1" }, ], } as any, }, ]; mockState.blocks = blocks; mockState.selectedBlockId = "form_checkbox_neutral_editor"; render(); const label = screen.getByText("체크 그룹"); const groupContainer = label.closest("div") as HTMLElement; expect(groupContainer.className).not.toContain("text-slate-"); }); 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(); 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(); 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(); 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(); 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(); 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(); 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(); const allOptionLabels = document.querySelectorAll("label.pb-form-option"); expect(allOptionLabels.length).toBeGreaterThan(0); }); });