import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { FormInputPropertiesPanel } from "@/app/editor/forms/FormInputPropertiesPanel"; import { FormSelectPropertiesPanel } from "@/app/editor/forms/FormSelectPropertiesPanel"; import { FormCheckboxPropertiesPanel } from "@/app/editor/forms/FormCheckboxPropertiesPanel"; import { FormRadioPropertiesPanel } from "@/app/editor/forms/FormRadioPropertiesPanel"; import type { Block, FormInputBlockProps, FormSelectBlockProps, FormCheckboxBlockProps, FormRadioBlockProps, } from "@/features/editor/state/editorStore"; // Form*PropertiesPanel 컨트롤 TDD // - 각 패널의 색상 HEX 인풋과 필드 너비 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다. function makeBlock(id: string, type: Block["type"], props: T): Block { return { id, type, props } as any; } describe("FormInputPropertiesPanel", () => { const baseProps: FormInputBlockProps = { label: "이메일", formFieldName: "email", } as any; afterEach(() => { cleanup(); }); it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-1", "formInput", { ...baseProps, textColorCustom: "#ffffff", }); render( , ); const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#112233" } }); expect(updateBlock).toHaveBeenCalledWith( "f-input-1", expect.objectContaining({ textColorCustom: "#112233" }), ); }); it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-2", "formInput", { ...baseProps, fillColorCustom: "#000000", }); render( , ); const hexInput = screen.getByLabelText("필드 채움 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#445566" } }); expect(updateBlock).toHaveBeenCalledWith( "f-input-2", expect.objectContaining({ fillColorCustom: "#445566" }), ); }); it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-3", "formInput", { ...baseProps, strokeColorCustom: "#000000", }); render( , ); const hexInput = screen.getByLabelText("필드 테두리 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#778899" } }); expect(updateBlock).toHaveBeenCalledWith( "f-input-3", expect.objectContaining({ strokeColorCustom: "#778899" }), ); }); it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 FormInput textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-none", "formInput", { ...baseProps, textColorCustom: "#ff0000", } as any); render( , ); // 첫 번째 ColorPickerField(필드 텍스트 색상)의 팔레트 버튼을 연다. const paletteButtons = screen.getAllByText("색상 팔레트"); fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement); // 팔레트에서 "없음" 항목을 버튼 role 기준으로 선택한다. const noneButtons = screen.getAllByRole("button", { name: "없음" }); fireEvent.click(noneButtons[0]); expect(updateBlock).toHaveBeenCalledWith( "f-input-none", expect.objectContaining({ textColorCustom: "" }), ); }); it("FormInput textColorCustom 이 비어 있으면 필드 텍스트 색상 HEX 인풋 값은 빈 문자열이어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-empty", "formInput", { ...baseProps, textColorCustom: "", } as any); render( , ); const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX") as HTMLInputElement; expect(hexInput.value).toBe(""); }); it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-4", "formInput", { ...baseProps, widthMode: "auto", fullWidth: false, }); render( , ); const select = screen.getByLabelText("너비"); fireEvent.change(select, { target: { value: "fixed" } }); expect(updateBlock).toHaveBeenCalledWith( "f-input-4", expect.objectContaining({ widthMode: "fixed" }), ); }); it("FormInput 레이아웃 셀렉트는 라벨 표시 방식이 visible 일 때만 보여야 한다", () => { const updateBlock = vi.fn(); const visibleBlock = makeBlock("f-input-layout-visible", "formInput", { ...baseProps, labelDisplay: "visible", labelLayout: "inline", } as any); render( , ); const layoutSelect = screen.getByLabelText("레이아웃"); expect(layoutSelect).toBeTruthy(); cleanup(); const floatingBlock = makeBlock("f-input-layout-floating", "formInput", { ...baseProps, labelDisplay: "floating", labelLayout: "inline", } as any); render( , ); expect(screen.queryByLabelText("레이아웃")).toBeNull(); }); it("폼 입력 텍스트/줄간격/자간 px 컨트롤이 fontSizeCustom/lineHeightCustom/letterSpacingCustom 으로 저장되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-typo-1", "formInput", { ...baseProps, fontSizeCustom: "14px", lineHeightCustom: "20px", letterSpacingCustom: "0px", } as any); render( , ); const fontSizeInput = screen.getByLabelText("필드 텍스트 크기 (px) 커스텀 (px)"); fireEvent.change(fontSizeInput, { target: { value: "24" } }); const lineHeightInput = screen.getByLabelText("필드 줄간격 (px) 커스텀 (px)"); fireEvent.change(lineHeightInput, { target: { value: "30" } }); const letterSpacingInput = screen.getByLabelText("필드 자간 (px) 커스텀 (px)"); fireEvent.change(letterSpacingInput, { target: { value: "1.5" } }); expect(updateBlock).toHaveBeenCalledWith( "f-input-typo-1", expect.objectContaining({ fontSizeCustom: "24px" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-input-typo-1", expect.objectContaining({ lineHeightCustom: "30px" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-input-typo-1", expect.objectContaining({ letterSpacingCustom: "1.5px" }), ); }); it("폼 입력 정렬/고정 너비/패딩 컨트롤이 align/widthPx/paddingX/paddingY 로 저장되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-layout-1", "formInput", { ...baseProps, align: "left", widthMode: "auto", widthPx: 240, paddingX: 12, paddingY: 10, } as any); render( , ); const alignSelect = screen.getByLabelText("텍스트 정렬"); fireEvent.change(alignSelect, { target: { value: "center" } }); const widthModeSelect = screen.getByLabelText("너비"); fireEvent.change(widthModeSelect, { target: { value: "fixed" } }); const widthPxInput = screen.getByLabelText("필드 고정 너비 커스텀 (px)"); fireEvent.change(widthPxInput, { target: { value: "320" } }); const paddingXInput = screen.getByLabelText("필드 가로 패딩 (px) 커스텀 (px)"); fireEvent.change(paddingXInput, { target: { value: "16" } }); const paddingYInput = screen.getByLabelText("필드 세로 패딩 (px) 커스텀 (px)"); fireEvent.change(paddingYInput, { target: { value: "12" } }); expect(updateBlock).toHaveBeenCalledWith( "f-input-layout-1", expect.objectContaining({ align: "center" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-input-layout-1", expect.objectContaining({ widthMode: "fixed" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-input-layout-1", expect.objectContaining({ widthPx: 320 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-input-layout-1", expect.objectContaining({ paddingX: 16 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-input-layout-1", expect.objectContaining({ paddingY: 12 }), ); }); it("FormInput 기본 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-input-theme", "formInput", { ...baseProps, } as any); render( , ); const labelTypeSelect = screen.getByLabelText("라벨 타입") as HTMLSelectElement; const labelTypeClass = labelTypeSelect.getAttribute("class") ?? ""; expect(labelTypeClass).toContain("bg-white"); expect(labelTypeClass).toContain("text-slate-900"); expect(labelTypeClass).toContain("border-slate-300"); expect(labelTypeClass).toContain("dark:bg-slate-900"); expect(labelTypeClass).toContain("dark:text-slate-100"); expect(labelTypeClass).toContain("dark:border-slate-700"); const labelInput = screen.getByLabelText("필드 라벨") as HTMLInputElement; const labelInputClass = labelInput.getAttribute("class") ?? ""; expect(labelInputClass).toContain("bg-white"); expect(labelInputClass).toContain("text-slate-900"); expect(labelInputClass).toContain("border-slate-300"); expect(labelInputClass).toContain("dark:bg-slate-900"); expect(labelInputClass).toContain("dark:text-slate-100"); expect(labelInputClass).toContain("dark:border-slate-700"); const labelDisplaySelect = screen.getByLabelText("라벨 표시 방식") as HTMLSelectElement; const labelDisplayClass = labelDisplaySelect.getAttribute("class") ?? ""; expect(labelDisplayClass).toContain("bg-white"); expect(labelDisplayClass).toContain("text-slate-900"); expect(labelDisplayClass).toContain("border-slate-300"); expect(labelDisplayClass).toContain("dark:bg-slate-900"); expect(labelDisplayClass).toContain("dark:text-slate-100"); expect(labelDisplayClass).toContain("dark:border-slate-700"); const fieldTypeSelect = screen.getByLabelText("필드 타입") as HTMLSelectElement; const fieldTypeClass = fieldTypeSelect.getAttribute("class") ?? ""; expect(fieldTypeClass).toContain("bg-white"); expect(fieldTypeClass).toContain("text-slate-900"); expect(fieldTypeClass).toContain("border-slate-300"); expect(fieldTypeClass).toContain("dark:bg-slate-900"); expect(fieldTypeClass).toContain("dark:text-slate-100"); expect(fieldTypeClass).toContain("dark:border-slate-700"); const placeholderInput = screen.getByLabelText("Placeholder") as HTMLInputElement; const placeholderClass = placeholderInput.getAttribute("class") ?? ""; expect(placeholderClass).toContain("bg-white"); expect(placeholderClass).toContain("text-slate-900"); expect(placeholderClass).toContain("border-slate-300"); expect(placeholderClass).toContain("dark:bg-slate-900"); expect(placeholderClass).toContain("dark:text-slate-100"); expect(placeholderClass).toContain("dark:border-slate-700"); }); }); describe("FormSelectPropertiesPanel", () => { const baseProps: FormSelectBlockProps = { label: "카테고리", formFieldName: "category", options: [{ label: "A", value: "a" }], } as any; afterEach(() => { cleanup(); }); it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-1", "formSelect", { ...baseProps, textColorCustom: "#ffffff", }); render( , ); const hexInput = screen.getByLabelText("셀렉트 텍스트 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#112233" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-1", expect.objectContaining({ textColorCustom: "#112233" }), ); }); it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-2", "formSelect", { ...baseProps, fillColorCustom: "#000000", }); render( , ); const hexInput = screen.getByLabelText("셀렉트 채움 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#445566" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-2", expect.objectContaining({ fillColorCustom: "#445566" }), ); }); it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-3", "formSelect", { ...baseProps, strokeColorCustom: "#000000", }); render( , ); const hexInput = screen.getByLabelText("셀렉트 테두리 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#778899" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-3", expect.objectContaining({ strokeColorCustom: "#778899" }), ); }); it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-4", "formSelect", { ...baseProps, widthMode: "auto", fullWidth: false, }); render( , ); const select = screen.getByLabelText("필드 너비"); fireEvent.change(select, { target: { value: "fixed" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-4", expect.objectContaining({ widthMode: "fixed" }), ); }); it("폼 셀렉트 텍스트/줄간격/자간 px 컨트롤이 fontSizeCustom/lineHeightCustom/letterSpacingCustom 으로 저장되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-typo-1", "formSelect", { ...baseProps, fontSizeCustom: "14px", lineHeightCustom: "20px", letterSpacingCustom: "0px", } as any); render( , ); const fontSizeInput = screen.getByLabelText("셀렉트 텍스트 크기 (px) 커스텀 (px)"); fireEvent.change(fontSizeInput, { target: { value: "24" } }); const lineHeightInput = screen.getByLabelText("셀렉트 줄간격 (px) 커스텀 (px)"); fireEvent.change(lineHeightInput, { target: { value: "30" } }); const letterSpacingInput = screen.getByLabelText("셀렉트 자간 (px) 커스텀 (px)"); fireEvent.change(letterSpacingInput, { target: { value: "1.5" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-typo-1", expect.objectContaining({ fontSizeCustom: "24px" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-select-typo-1", expect.objectContaining({ lineHeightCustom: "30px" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-select-typo-1", expect.objectContaining({ letterSpacingCustom: "1.5px" }), ); }); it("폼 셀렉트 너비/패딩 컨트롤이 widthPx/paddingX/paddingY 로 저장되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-layout-1", "formSelect", { ...baseProps, widthMode: "fixed", widthPx: 240, paddingX: 12, paddingY: 10, } as any); render( , ); const widthPxInput = screen.getByLabelText("필드 고정 너비 커스텀 (px)"); fireEvent.change(widthPxInput, { target: { value: "320" } }); const paddingXInput = screen.getByLabelText("셀렉트 가로 패딩 (px) 커스텀 (px)"); fireEvent.change(paddingXInput, { target: { value: "16" } }); const paddingYInput = screen.getByLabelText("셀렉트 세로 패딩 (px) 커스텀 (px)"); fireEvent.change(paddingYInput, { target: { value: "12" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-layout-1", expect.objectContaining({ widthPx: 320 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-select-layout-1", expect.objectContaining({ paddingX: 16 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-select-layout-1", expect.objectContaining({ paddingY: 12 }), ); }); it("FormSelect 레이아웃 셀렉트는 라벨 표시 방식이 visible 일 때만 보여야 한다", () => { const updateBlock = vi.fn(); const visibleBlock = makeBlock("f-select-layout-visible", "formSelect", { ...baseProps, labelDisplay: "visible", labelLayout: "inline", } as any); render( , ); const layoutSelect = screen.getByLabelText("레이아웃"); expect(layoutSelect).toBeTruthy(); cleanup(); const hiddenBlock = makeBlock("f-select-layout-hidden", "formSelect", { ...baseProps, labelDisplay: "hidden", labelLayout: "inline", } as any); render( , ); expect(screen.queryByLabelText("레이아웃")).toBeNull(); }); it("라벨 타입 셀렉트 변경 시 updateBlock 이 labelMode 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-label-mode", "formSelect", { ...baseProps, labelMode: "text", } as any); render( , ); const select = screen.getByLabelText("라벨 타입"); fireEvent.change(select, { target: { value: "image" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-label-mode", expect.objectContaining({ labelMode: "image" }), ); }); it("라벨 표시 방식 셀렉트 변경 시 updateBlock 이 labelDisplay 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-label-display", "formSelect", { ...baseProps, labelDisplay: "visible", } as any); render( , ); const select = screen.getByLabelText("라벨 표시 방식"); fireEvent.change(select, { target: { value: "hidden" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-label-display", expect.objectContaining({ labelDisplay: "hidden" }), ); }); it("레이아웃 셀렉트 변경 시 updateBlock 이 labelLayout 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-label-layout", "formSelect", { ...baseProps, labelDisplay: "visible", labelLayout: "stacked", } as any); render( , ); const select = screen.getByLabelText("레이아웃"); fireEvent.change(select, { target: { value: "inline" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-label-layout", expect.objectContaining({ labelLayout: "inline" }), ); }); it("라벨/필드 간격 (px) 컨트롤 변경 시 updateBlock 이 labelGapPx 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-label-gap", "formSelect", { ...baseProps, labelDisplay: "visible", labelLayout: "inline", labelGapPx: 8, } as any); render( , ); const gapInput = screen.getByLabelText("라벨/필드 간격 (px) 커스텀 (px)"); fireEvent.change(gapInput, { target: { value: "16" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-label-gap", expect.objectContaining({ labelGapPx: 16 }), ); }); it("필드 라벨 인풋 변경 시 updateBlock 이 label 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-label", "formSelect", { ...baseProps, label: "카테고리", } as any); render( , ); const input = screen.getByLabelText("필드 라벨"); fireEvent.change(input, { target: { value: "새 카테고리" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-label", expect.objectContaining({ label: "새 카테고리" }), ); }); it("전송 키 인풋 변경 시 updateBlock 이 formFieldName 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-name", "formSelect", { ...baseProps, formFieldName: "category", } as any); render( , ); const input = screen.getByLabelText("전송 키"); fireEvent.change(input, { target: { value: "category_new" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-name", expect.objectContaining({ formFieldName: "category_new" }), ); }); it("필드 모서리 둥글기 컨트롤 변경 시 updateBlock 이 borderRadius 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-radius", "formSelect", { ...baseProps, borderRadius: "md", } as any); render( , ); const radiusInput = screen.getByLabelText("필드 모서리 둥글기 커스텀"); fireEvent.change(radiusInput, { target: { value: "8" } }); expect(updateBlock).toHaveBeenCalledWith( "f-select-radius", expect.objectContaining({ borderRadius: "full" }), ); }); it("FormSelect 기본 인풋/셀렉트/옵션 편집 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-theme", "formSelect", { ...baseProps, } as any); render( , ); const labelTypeSelect = screen.getByLabelText("라벨 타입") as HTMLSelectElement; const labelTypeClass = labelTypeSelect.getAttribute("class") ?? ""; expect(labelTypeClass).toContain("bg-white"); expect(labelTypeClass).toContain("text-slate-900"); expect(labelTypeClass).toContain("border-slate-300"); expect(labelTypeClass).toContain("dark:bg-slate-900"); expect(labelTypeClass).toContain("dark:text-slate-100"); expect(labelTypeClass).toContain("dark:border-slate-700"); const labelInput = screen.getByLabelText("필드 라벨") as HTMLInputElement; const labelInputClass = labelInput.getAttribute("class") ?? ""; expect(labelInputClass).toContain("bg-white"); expect(labelInputClass).toContain("text-slate-900"); expect(labelInputClass).toContain("border-slate-300"); expect(labelInputClass).toContain("dark:bg-slate-900"); expect(labelInputClass).toContain("dark:text-slate-100"); expect(labelInputClass).toContain("dark:border-slate-700"); const labelDisplaySelect = screen.getByLabelText("라벨 표시 방식") as HTMLSelectElement; const labelDisplayClass = labelDisplaySelect.getAttribute("class") ?? ""; expect(labelDisplayClass).toContain("bg-white"); expect(labelDisplayClass).toContain("text-slate-900"); expect(labelDisplayClass).toContain("border-slate-300"); expect(labelDisplayClass).toContain("dark:bg-slate-900"); expect(labelDisplayClass).toContain("dark:text-slate-100"); expect(labelDisplayClass).toContain("dark:border-slate-700"); const widthModeSelect = screen.getByLabelText("필드 너비") as HTMLSelectElement; const widthModeClass = widthModeSelect.getAttribute("class") ?? ""; expect(widthModeClass).toContain("bg-white"); expect(widthModeClass).toContain("text-slate-900"); expect(widthModeClass).toContain("border-slate-300"); expect(widthModeClass).toContain("dark:bg-slate-900"); expect(widthModeClass).toContain("dark:text-slate-100"); expect(widthModeClass).toContain("dark:border-slate-700"); const optionLabelInput = screen.getByPlaceholderText("라벨") as HTMLInputElement; const optionLabelClass = optionLabelInput.getAttribute("class") ?? ""; expect(optionLabelClass).toContain("bg-white"); expect(optionLabelClass).toContain("text-slate-900"); expect(optionLabelClass).toContain("border-slate-300"); expect(optionLabelClass).toContain("dark:bg-slate-900"); expect(optionLabelClass).toContain("dark:text-slate-100"); expect(optionLabelClass).toContain("dark:border-slate-700"); }); it("FormSelect 옵션 행은 라벨/값 인풋과 삭제 버튼을 위한 3열 그리드 레이아웃 클래스를 사용해야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-select-option-layout", "formSelect", { ...baseProps, } as any); render( , ); const optionLabelInput = screen.getByPlaceholderText("라벨") as HTMLInputElement; const rowDiv = optionLabelInput.closest("div") as HTMLDivElement | null; expect(rowDiv).not.toBeNull(); const rowClass = rowDiv!.getAttribute("class") ?? ""; expect(rowClass).toContain("grid"); expect(rowClass).toContain("grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"); }); }); describe("FormCheckboxPropertiesPanel", () => { const baseProps: FormCheckboxBlockProps = { groupLabel: "옵션들", formFieldName: "features", options: [{ label: "옵션 1", value: "opt1" }], } as any; afterEach(() => { cleanup(); }); it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-1", "formCheckbox", { ...baseProps, textColorCustom: "#ffffff", }); render( , ); const hexInput = screen.getByLabelText("체크박스 텍스트 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#112233" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-1", expect.objectContaining({ textColorCustom: "#112233" }), ); }); it("폼 체크박스 텍스트/줄간격/자간 px 컨트롤이 fontSizeCustom/lineHeightCustom/letterSpacingCustom 으로 저장되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-typo-1", "formCheckbox", { ...baseProps, fontSizeCustom: "14px", lineHeightCustom: "20px", letterSpacingCustom: "0px", } as any); render( , ); const fontSizeInput = screen.getByLabelText("체크박스 텍스트 크기 (px) 커스텀 (px)"); fireEvent.change(fontSizeInput, { target: { value: "24" } }); const lineHeightInput = screen.getByLabelText("체크박스 줄간격 (px) 커스텀 (px)"); fireEvent.change(lineHeightInput, { target: { value: "30" } }); const letterSpacingInput = screen.getByLabelText("체크박스 자간 (px) 커스텀 (px)"); fireEvent.change(letterSpacingInput, { target: { value: "1.5" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-typo-1", expect.objectContaining({ fontSizeCustom: "24px" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-check-typo-1", expect.objectContaining({ lineHeightCustom: "30px" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-check-typo-1", expect.objectContaining({ letterSpacingCustom: "1.5px" }), ); }); it("폼 체크박스 너비/패딩 컨트롤이 widthPx/paddingX/paddingY 로 저장되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-layout-1", "formCheckbox", { ...baseProps, widthMode: "fixed", widthPx: 240, paddingX: 12, paddingY: 10, } as any); render( , ); const widthPxInput = screen.getByLabelText("필드 고정 너비 커스텀 (px)"); fireEvent.change(widthPxInput, { target: { value: "320" } }); const paddingXInput = screen.getByLabelText("체크박스 가로 패딩 (px) 커스텀 (px)"); fireEvent.change(paddingXInput, { target: { value: "16" } }); const paddingYInput = screen.getByLabelText("체크박스 세로 패딩 (px) 커스텀 (px)"); fireEvent.change(paddingYInput, { target: { value: "12" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-layout-1", expect.objectContaining({ widthPx: 320 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-check-layout-1", expect.objectContaining({ paddingX: 16 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-check-layout-1", expect.objectContaining({ paddingY: 12 }), ); }); it("FormCheckbox 레이아웃 셀렉트는 그룹 라벨 표시 방식이 visible 일 때만 보여야 한다", () => { const updateBlock = vi.fn(); const visibleBlock = makeBlock("f-check-layout-visible", "formCheckbox", { ...baseProps, groupLabelDisplay: "visible", labelLayout: "inline", } as any); render( , ); const layoutSelect = screen.getByLabelText("레이아웃"); expect(layoutSelect).toBeTruthy(); cleanup(); const hiddenBlock = makeBlock("f-check-layout-hidden", "formCheckbox", { ...baseProps, groupLabelDisplay: "hidden", labelLayout: "inline", } as any); render( , ); expect(screen.queryByLabelText("레이아웃")).toBeNull(); }); it("그룹 타이틀 타입 셀렉트 변경 시 updateBlock 이 groupLabelMode 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-group-mode", "formCheckbox", { ...baseProps, groupLabelMode: "text", } as any); render( , ); const select = screen.getByLabelText("그룹 타이틀 타입"); fireEvent.change(select, { target: { value: "image" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-group-mode", expect.objectContaining({ groupLabelMode: "image" }), ); }); it("그룹 타이틀 표시 방식 셀렉트 변경 시 updateBlock 이 groupLabelDisplay 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-group-display", "formCheckbox", { ...baseProps, groupLabelDisplay: "visible", } as any); render( , ); const select = screen.getByLabelText("그룹 타이틀 표시 방식"); fireEvent.change(select, { target: { value: "hidden" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-group-display", expect.objectContaining({ groupLabelDisplay: "hidden" }), ); }); it("레이아웃 셀렉트 변경 시 updateBlock 이 labelLayout 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-label-layout", "formCheckbox", { ...baseProps, groupLabelDisplay: "visible", labelLayout: "stacked", } as any); render( , ); const select = screen.getByLabelText("레이아웃"); fireEvent.change(select, { target: { value: "inline" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-label-layout", expect.objectContaining({ labelLayout: "inline" }), ); }); it("라벨/필드 간격 (px) 컨트롤 변경 시 updateBlock 이 labelGapPx 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-label-gap", "formCheckbox", { ...baseProps, groupLabelDisplay: "visible", labelLayout: "inline", labelGapPx: 8, } as any); render( , ); const gapInput = screen.getByLabelText("라벨/필드 간격 (px) 커스텀 (px)"); fireEvent.change(gapInput, { target: { value: "16" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-label-gap", expect.objectContaining({ labelGapPx: 16 }), ); }); it("그룹 타이틀 인풋 변경 시 updateBlock 이 groupLabel 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-group-label", "formCheckbox", { ...baseProps, groupLabel: "옵션들", } as any); render( , ); const input = screen.getByLabelText("그룹 타이틀"); fireEvent.change(input, { target: { value: "새 옵션들" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-group-label", expect.objectContaining({ groupLabel: "새 옵션들" }), ); }); it("체크박스 옵션 간격 (px) 컨트롤 변경 시 updateBlock 이 optionGapPx 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-option-gap", "formCheckbox", { ...baseProps, optionGapPx: 4, } as any); render( , ); const gapInput = screen.getByLabelText("체크박스 옵션 간격 (px) 커스텀 (px)"); fireEvent.change(gapInput, { target: { value: "10" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-option-gap", expect.objectContaining({ optionGapPx: 10 }), ); }); it("체크박스 옵션 레이아웃 셀렉트 변경 시 updateBlock 이 optionLayout 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-option-layout", "formCheckbox", { ...baseProps, optionLayout: "stacked", } as any); render( , ); const select = screen.getByLabelText("옵션 레이아웃"); fireEvent.change(select, { target: { value: "inline" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-option-layout", expect.objectContaining({ optionLayout: "inline" }), ); }); it("체크박스 필드 모서리 둥글기 컨트롤 변경 시 updateBlock 이 borderRadius 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-radius", "formCheckbox", { ...baseProps, borderRadius: "md", } as any); render( , ); const radiusInput = screen.getByLabelText("필드 모서리 둥글기 커스텀"); fireEvent.change(radiusInput, { target: { value: "8" } }); expect(updateBlock).toHaveBeenCalledWith( "f-check-radius", expect.objectContaining({ borderRadius: "full" }), ); }); it("FormCheckbox 필드 너비 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-check-theme-width", "formCheckbox", { ...baseProps, } as any); render( , ); const select = screen.getByLabelText("필드 너비") as HTMLSelectElement; const className = select.getAttribute("class") ?? ""; expect(className).toContain("bg-white"); expect(className).toContain("text-slate-900"); expect(className).toContain("border-slate-300"); expect(className).toContain("dark:bg-slate-900"); expect(className).toContain("dark:text-slate-100"); expect(className).toContain("dark:border-slate-700"); }); it("폼 라디오 텍스트/줄간격/자간 px 컨트롤이 fontSizeCustom/lineHeightCustom/letterSpacingCustom 으로 저장되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-typo-1", "formRadio", { ...baseProps, fontSizeCustom: "14px", lineHeightCustom: "20px", letterSpacingCustom: "0px", } as any); render( , ); const fontSizeInput = screen.getByLabelText("라디오 텍스트 크기 (px) 커스텀 (px)"); fireEvent.change(fontSizeInput, { target: { value: "24" } }); const lineHeightInput = screen.getByLabelText("라디오 줄간격 (px) 커스텀 (px)"); fireEvent.change(lineHeightInput, { target: { value: "30" } }); const letterSpacingInput = screen.getByLabelText("라디오 자간 (px) 커스텀 (px)"); fireEvent.change(letterSpacingInput, { target: { value: "1.5" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-typo-1", expect.objectContaining({ fontSizeCustom: "24px" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-radio-typo-1", expect.objectContaining({ lineHeightCustom: "30px" }), ); expect(updateBlock).toHaveBeenCalledWith( "f-radio-typo-1", expect.objectContaining({ letterSpacingCustom: "1.5px" }), ); }); it("폼 라디오 너비/패딩/옵션 간격 컨트롤이 widthPx/paddingX/paddingY/optionGapPx 로 저장되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-layout-1", "formRadio", { ...baseProps, widthMode: "fixed", widthPx: 240, paddingX: 8, paddingY: 4, optionGapPx: 8, } as any); render( , ); const widthPxInput = screen.getByLabelText("필드 고정 너비 커스텀 (px)"); fireEvent.change(widthPxInput, { target: { value: "320" } }); const paddingXInput = screen.getByLabelText("라디오 가로 패딩 (px) 커스텀 (px)"); fireEvent.change(paddingXInput, { target: { value: "12" } }); const paddingYInput = screen.getByLabelText("라디오 세로 패딩 (px) 커스텀 (px)"); fireEvent.change(paddingYInput, { target: { value: "6" } }); const optionGapInput = screen.getByLabelText("라디오 옵션 간격 (px) 커스텀 (px)"); fireEvent.change(optionGapInput, { target: { value: "10" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-layout-1", expect.objectContaining({ widthPx: 320 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-radio-layout-1", expect.objectContaining({ paddingX: 12 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-radio-layout-1", expect.objectContaining({ paddingY: 6 }), ); expect(updateBlock).toHaveBeenCalledWith( "f-radio-layout-1", expect.objectContaining({ optionGapPx: 10 }), ); }); it("FormRadio 그룹 타이틀 레이아웃 셀렉트는 그룹 타이틀 표시 방식이 visible 일 때만 보여야 한다", () => { const updateBlock = vi.fn(); const visibleBlock = makeBlock("f-radio-layout-visible", "formRadio", { ...baseProps, groupLabelDisplay: "visible", labelLayout: "inline", } as any); render( , ); const layoutSelect = screen.getByLabelText("그룹 타이틀 레이아웃"); expect(layoutSelect).toBeTruthy(); cleanup(); const hiddenBlock = makeBlock("f-radio-layout-hidden", "formRadio", { ...baseProps, groupLabelDisplay: "hidden", labelLayout: "inline", } as any); render( , ); expect(screen.queryByLabelText("그룹 타이틀 레이아웃")).toBeNull(); }); it("그룹 타이틀 타입 셀렉트 변경 시 updateBlock 이 groupLabelMode 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-group-mode", "formRadio", { ...baseProps, groupLabelMode: "text", } as any); render( , ); const select = screen.getByLabelText("그룹 타이틀 타입"); fireEvent.change(select, { target: { value: "image" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-group-mode", expect.objectContaining({ groupLabelMode: "image" }), ); }); it("그룹 타이틀 표시 방식 셀렉트 변경 시 updateBlock 이 groupLabelDisplay 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-group-display", "formRadio", { ...baseProps, groupLabelDisplay: "visible", } as any); render( , ); const select = screen.getByLabelText("그룹 타이틀 표시 방식"); fireEvent.change(select, { target: { value: "hidden" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-group-display", expect.objectContaining({ groupLabelDisplay: "hidden" }), ); }); it("그룹 타이틀 레이아웃 셀렉트 변경 시 updateBlock 이 labelLayout 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-label-layout", "formRadio", { ...baseProps, groupLabelDisplay: "visible", labelLayout: "stacked", } as any); render( , ); const select = screen.getByLabelText("그룹 타이틀 레이아웃"); fireEvent.change(select, { target: { value: "inline" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-label-layout", expect.objectContaining({ labelLayout: "inline" }), ); }); it("라벨/필드 간격 (px) 컨트롤 변경 시 updateBlock 이 labelGapPx 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-label-gap", "formRadio", { ...baseProps, groupLabelDisplay: "visible", labelLayout: "inline", labelGapPx: 8, } as any); render( , ); const gapInput = screen.getByLabelText("라벨/필드 간격 (px) 커스텀 (px)"); fireEvent.change(gapInput, { target: { value: "16" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-label-gap", expect.objectContaining({ labelGapPx: 16 }), ); }); it("그룹 타이틀 인풋 변경 시 updateBlock 이 groupLabel 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-group-label", "formRadio", { ...baseProps, groupLabel: "플랜", } as any); render( , ); const input = screen.getByLabelText("그룹 타이틀"); fireEvent.change(input, { target: { value: "새 플랜" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-group-label", expect.objectContaining({ groupLabel: "새 플랜" }), ); }); it("그룹 타이틀 이미지 소스 셀렉트 변경 시 updateBlock 이 groupLabelImageSource 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-group-image-source", "formRadio", { ...baseProps, groupLabelMode: "image", groupLabelImageSource: "url", } as any); render( , ); const select = screen.getByLabelText("그룹 타이틀 이미지 소스"); fireEvent.change(select, { target: { value: "upload" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-group-image-source", expect.objectContaining({ groupLabelImageSource: "upload" }), ); }); it("그룹 타이틀 이미지 URL 인풋 변경 시 updateBlock 이 groupLabelImageUrl 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-group-image-url", "formRadio", { ...baseProps, groupLabelMode: "image", groupLabelImageSource: "url", groupLabelImageUrl: "https://example.com/old.png", } as any); render( , ); const input = screen.getByLabelText("그룹 타이틀 이미지 URL"); fireEvent.change(input, { target: { value: "https://example.com/new.png" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-group-image-url", expect.objectContaining({ groupLabelImageUrl: "https://example.com/new.png" }), ); }); it("옵션 이미지 소스 셀렉트 변경 시 updateBlock 이 optionImageSource 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-option-image-source", "formRadio", { ...baseProps, optionImageSource: "url", } as any); render( , ); const select = screen.getByLabelText("옵션 이미지 소스"); fireEvent.change(select, { target: { value: "upload" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-option-image-source", expect.objectContaining({ optionImageSource: "upload" }), ); }); it("라디오 배경 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-fill-color", "formRadio", { ...baseProps, fillColorCustom: "#000000", } as any); render( , ); const hexInput = screen.getByLabelText("라디오 배경 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#445566" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-fill-color", expect.objectContaining({ fillColorCustom: "#445566" }), ); }); it("라디오 테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-stroke-color", "formRadio", { ...baseProps, strokeColorCustom: "#000000", } as any); render( , ); const hexInput = screen.getByLabelText("라디오 테두리 색상 HEX"); fireEvent.change(hexInput, { target: { value: "#778899" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-stroke-color", expect.objectContaining({ strokeColorCustom: "#778899" }), ); }); it("라디오 필드 모서리 둥글기 컨트롤 변경 시 updateBlock 이 borderRadius 로 호출되어야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-radius", "formRadio", { ...baseProps, borderRadius: "md", } as any); render( , ); const radiusInput = screen.getByLabelText("필드 모서리 둥글기 커스텀"); fireEvent.change(radiusInput, { target: { value: "8" } }); expect(updateBlock).toHaveBeenCalledWith( "f-radio-radius", expect.objectContaining({ borderRadius: "full" }), ); }); it("FormRadio 기본 인풋/셀렉트/옵션 편집 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => { const updateBlock = vi.fn(); const block = makeBlock("f-radio-theme", "formRadio", { ...baseProps, } as any); render( , ); const groupTitleInput = screen.getByLabelText("그룹 타이틀") as HTMLInputElement; const groupTitleClass = groupTitleInput.getAttribute("class") ?? ""; expect(groupTitleClass).toContain("bg-white"); expect(groupTitleClass).toContain("text-slate-900"); expect(groupTitleClass).toContain("border-slate-300"); expect(groupTitleClass).toContain("dark:bg-slate-900"); expect(groupTitleClass).toContain("dark:text-slate-100"); expect(groupTitleClass).toContain("dark:border-slate-700"); const groupDisplaySelect = screen.getByLabelText("그룹 타이틀 표시 방식") as HTMLSelectElement; const groupDisplayClass = groupDisplaySelect.getAttribute("class") ?? ""; expect(groupDisplayClass).toContain("bg-white"); expect(groupDisplayClass).toContain("text-slate-900"); expect(groupDisplayClass).toContain("border-slate-300"); expect(groupDisplayClass).toContain("dark:bg-slate-900"); expect(groupDisplayClass).toContain("dark:text-slate-100"); expect(groupDisplayClass).toContain("dark:border-slate-700"); const optionLayoutSelect = screen.getByLabelText("옵션 레이아웃") as HTMLSelectElement; const optionLayoutClass = optionLayoutSelect.getAttribute("class") ?? ""; expect(optionLayoutClass).toContain("bg-white"); expect(optionLayoutClass).toContain("text-slate-900"); expect(optionLayoutClass).toContain("border-slate-300"); expect(optionLayoutClass).toContain("dark:bg-slate-900"); expect(optionLayoutClass).toContain("dark:text-slate-100"); expect(optionLayoutClass).toContain("dark:border-slate-700"); const formFieldNameInput = screen.getByLabelText("전송 키") as HTMLInputElement; const formFieldNameClass = formFieldNameInput.getAttribute("class") ?? ""; expect(formFieldNameClass).toContain("bg-white"); expect(formFieldNameClass).toContain("text-slate-900"); expect(formFieldNameClass).toContain("border-slate-300"); expect(formFieldNameClass).toContain("dark:bg-slate-900"); expect(formFieldNameClass).toContain("dark:text-slate-100"); expect(formFieldNameClass).toContain("dark:border-slate-700"); const optionLabelInput = screen.getByPlaceholderText("라벨") as HTMLInputElement; const optionLabelClass = optionLabelInput.getAttribute("class") ?? ""; expect(optionLabelClass).toContain("bg-white"); expect(optionLabelClass).toContain("text-slate-900"); expect(optionLabelClass).toContain("border-slate-300"); expect(optionLabelClass).toContain("dark:bg-slate-900"); expect(optionLabelClass).toContain("dark:text-slate-100"); expect(optionLabelClass).toContain("dark:border-slate-700"); }); });