import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FormInputPropertiesPanel } from "@/app/editor/forms/FormInputPropertiesPanel";
import { FormSelectPropertiesPanel } from "@/app/editor/forms/FormSelectPropertiesPanel";
import { FormCheckboxPropertiesPanel } from "@/app/editor/forms/FormCheckboxPropertiesPanel";
import { FormRadioPropertiesPanel } from "@/app/editor/forms/FormRadioPropertiesPanel";
import type {
  Block,
  FormInputBlockProps,
  FormSelectBlockProps,
  FormCheckboxBlockProps,
  FormRadioBlockProps,
} from "@/features/editor/state/editorStore";

// Form*PropertiesPanel 컨트롤 TDD
// - 각 패널의 색상 HEX 인풋과 필드 너비 셀렉트가 updateBlock 을 올바르게 호출하는지 검증한다.

function makeBlock<T extends Block["props"]>(id: string, type: Block["type"], props: T): Block {
  return { id, type, props } as any;
}

describe("FormInputPropertiesPanel", () => {
  const baseProps: FormInputBlockProps = {
    label: "이메일",
    formFieldName: "email",
  } as any;

  afterEach(() => {
    cleanup();
  });

  it("텍스트 색상 HEX 인풋 변경 시 updateBlock 이 textColorCustom 으로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormInputBlockProps>("f-input-1", "formInput", {
      ...baseProps,
      textColorCustom: "#ffffff",
    });

    render(
      <FormInputPropertiesPanel
        block={block}
        selectedBlockId="f-input-1"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Field text color HEX");
    fireEvent.change(hexInput, { target: { value: "#112233" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-input-1",
      expect.objectContaining({ textColorCustom: "#112233" }),
    );
  });

  it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormInputBlockProps>("f-input-2", "formInput", {
      ...baseProps,
      fillColorCustom: "#000000",
    });

    render(
      <FormInputPropertiesPanel
        block={block}
        selectedBlockId="f-input-2"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Field fill color HEX");
    fireEvent.change(hexInput, { target: { value: "#445566" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-input-2",
      expect.objectContaining({ fillColorCustom: "#445566" }),
    );
  });

  it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormInputBlockProps>("f-input-3", "formInput", {
      ...baseProps,
      strokeColorCustom: "#000000",
    });

    render(
      <FormInputPropertiesPanel
        block={block}
        selectedBlockId="f-input-3"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Field border color HEX");
    fireEvent.change(hexInput, { target: { value: "#778899" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-input-3",
      expect.objectContaining({ strokeColorCustom: "#778899" }),
    );
  });

	it("텍스트 색상 팔레트에서 \"None\" 을 선택하면 FormInput textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
		const updateBlock = vi.fn();

		const block = makeBlock<FormInputBlockProps>("f-input-none", "formInput", {
			...baseProps,
			textColorCustom: "#ff0000",
		} as any);

		render(
			<FormInputPropertiesPanel
				block={block}
				selectedBlockId="f-input-none"
				updateBlock={updateBlock}
			/>,
		);

		// 첫 번째 ColorPickerField(필드 텍스트 색상)의 팔레트 버튼을 연다.
		const paletteButtons = screen.getAllByText("Color palette");
		fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);

		// 팔레트에서 "None" 항목을 버튼 role 기준으로 선택한다.
		const noneButtons = screen.getAllByRole("button", { name: "None" });
		fireEvent.click(noneButtons[0]);

		expect(updateBlock).toHaveBeenCalledWith(
			"f-input-none",
			expect.objectContaining({ textColorCustom: "" }),
		);
	});

	it("FormInput textColorCustom 이 비어 있으면 필드 텍스트 색상 HEX 인풋 값은 빈 문자열이어야 한다", () => {
		const updateBlock = vi.fn();

		const block = makeBlock<FormInputBlockProps>("f-input-empty", "formInput", {
			...baseProps,
			textColorCustom: "",
		} as any);

		render(
			<FormInputPropertiesPanel
				block={block}
				selectedBlockId="f-input-empty"
				updateBlock={updateBlock}
			/>,
		);

		const hexInput = screen.getByLabelText("Field text color HEX") as HTMLInputElement;
		expect(hexInput.value).toBe("");
	});

  it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormInputBlockProps>("f-input-4", "formInput", {
      ...baseProps,
      widthMode: "auto",
      fullWidth: false,
    });

    render(
      <FormInputPropertiesPanel
        block={block}
        selectedBlockId="f-input-4"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Width");
    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<FormInputBlockProps>("f-input-layout-visible", "formInput", {
      ...baseProps,
      labelDisplay: "visible",
      labelLayout: "inline",
    } as any);

    render(
      <FormInputPropertiesPanel
        block={visibleBlock}
        selectedBlockId="f-input-layout-visible"
        updateBlock={updateBlock}
      />,
    );

    const layoutSelect = screen.getByLabelText("Layout");
    expect(layoutSelect).toBeTruthy();

    cleanup();

    const floatingBlock = makeBlock<FormInputBlockProps>("f-input-layout-floating", "formInput", {
      ...baseProps,
      labelDisplay: "floating",
      labelLayout: "inline",
    } as any);

    render(
      <FormInputPropertiesPanel
        block={floatingBlock}
        selectedBlockId="f-input-layout-floating"
        updateBlock={updateBlock}
      />,
    );

    expect(screen.queryByLabelText("Layout")).toBeNull();
  });

  it("폼 입력 텍스트/줄간격/자간 px 컨트롤이 fontSizeCustom/lineHeightCustom/letterSpacingCustom 으로 저장되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormInputBlockProps>("f-input-typo-1", "formInput", {
      ...baseProps,
      fontSizeCustom: "14px",
      lineHeightCustom: "20px",
      letterSpacingCustom: "0px",
    } as any);

    render(
      <FormInputPropertiesPanel
        block={block}
        selectedBlockId="f-input-typo-1"
        updateBlock={updateBlock}
      />,
    );

    const fontSizeInput = screen.getByLabelText("Field text size 커스텀");
    fireEvent.change(fontSizeInput, { target: { value: "24" } });

    const lineHeightInput = screen.getByLabelText("Field line height 커스텀");
    fireEvent.change(lineHeightInput, { target: { value: "30" } });

    const letterSpacingInput = screen.getByLabelText("Field letter spacing 커스텀");
    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<FormInputBlockProps>("f-input-layout-1", "formInput", {
      ...baseProps,
      align: "left",
      widthMode: "auto",
      widthPx: 240,
      paddingX: 12,
      paddingY: 10,
    } as any);

    render(
      <FormInputPropertiesPanel
        block={block}
        selectedBlockId="f-input-layout-1"
        updateBlock={updateBlock}
      />,
    );

    const alignSelect = screen.getByLabelText("Text alignment");
    fireEvent.change(alignSelect, { target: { value: "center" } });

    const widthModeSelect = screen.getByLabelText("Width");
    fireEvent.change(widthModeSelect, { target: { value: "fixed" } });

    const widthPxInput = screen.getByLabelText("Field fixed width 커스텀");
    fireEvent.change(widthPxInput, { target: { value: "320" } });

    const paddingXInput = screen.getByLabelText("Field horizontal padding 커스텀");
    fireEvent.change(paddingXInput, { target: { value: "16" } });

    const paddingYInput = screen.getByLabelText("Field vertical padding 커스텀");
    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<FormInputBlockProps>("f-input-theme", "formInput", {
      ...baseProps,
    } as any);

    render(
      <FormInputPropertiesPanel
        block={block}
        selectedBlockId="f-input-theme"
        updateBlock={updateBlock}
      />,
    );

    const labelTypeSelect = screen.getByLabelText("Label type") 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("Field label") 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("Label display mode") 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("Field type") 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<FormSelectBlockProps>("f-select-1", "formSelect", {
      ...baseProps,
      textColorCustom: "#ffffff",
    });

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-1"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Select text color HEX");
    fireEvent.change(hexInput, { target: { value: "#112233" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-select-1",
      expect.objectContaining({ textColorCustom: "#112233" }),
    );
  });

  it("채움 색상 HEX 인풋 변경 시 updateBlock 이 fillColorCustom 으로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormSelectBlockProps>("f-select-2", "formSelect", {
      ...baseProps,
      fillColorCustom: "#000000",
    });

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-2"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Select fill color HEX");
    fireEvent.change(hexInput, { target: { value: "#445566" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-select-2",
      expect.objectContaining({ fillColorCustom: "#445566" }),
    );
  });

  it("테두리 색상 HEX 인풋 변경 시 updateBlock 이 strokeColorCustom 으로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormSelectBlockProps>("f-select-3", "formSelect", {
      ...baseProps,
      strokeColorCustom: "#000000",
    });

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-3"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Select border color HEX");
    fireEvent.change(hexInput, { target: { value: "#778899" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-select-3",
      expect.objectContaining({ strokeColorCustom: "#778899" }),
    );
  });

  it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormSelectBlockProps>("f-select-4", "formSelect", {
      ...baseProps,
      widthMode: "auto",
      fullWidth: false,
    });

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-4"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Field width");
    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<FormSelectBlockProps>("f-select-typo-1", "formSelect", {
      ...baseProps,
      fontSizeCustom: "14px",
      lineHeightCustom: "20px",
      letterSpacingCustom: "0px",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-typo-1"
        updateBlock={updateBlock}
      />,
    );

    const fontSizeInput = screen.getByLabelText("Select text size 커스텀");
    fireEvent.change(fontSizeInput, { target: { value: "24" } });

    const lineHeightInput = screen.getByLabelText("Select line height 커스텀");
    fireEvent.change(lineHeightInput, { target: { value: "30" } });

    const letterSpacingInput = screen.getByLabelText("Select letter spacing 커스텀");
    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<FormSelectBlockProps>("f-select-layout-1", "formSelect", {
      ...baseProps,
      widthMode: "fixed",
      widthPx: 240,
      paddingX: 12,
      paddingY: 10,
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-layout-1"
        updateBlock={updateBlock}
      />,
    );

    const widthPxInput = screen.getByLabelText("Field fixed width 커스텀");
    fireEvent.change(widthPxInput, { target: { value: "320" } });

    const paddingXInput = screen.getByLabelText("Select horizontal padding 커스텀");
    fireEvent.change(paddingXInput, { target: { value: "16" } });

    const paddingYInput = screen.getByLabelText("Select vertical padding 커스텀");
    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<FormSelectBlockProps>("f-select-layout-visible", "formSelect", {
      ...baseProps,
      labelDisplay: "visible",
      labelLayout: "inline",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={visibleBlock}
        selectedBlockId="f-select-layout-visible"
        updateBlock={updateBlock}
      />,
    );

    const layoutSelect = screen.getByLabelText("Layout");
    expect(layoutSelect).toBeTruthy();

    cleanup();

    const hiddenBlock = makeBlock<FormSelectBlockProps>("f-select-layout-hidden", "formSelect", {
      ...baseProps,
      labelDisplay: "hidden",
      labelLayout: "inline",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={hiddenBlock}
        selectedBlockId="f-select-layout-hidden"
        updateBlock={updateBlock}
      />,
    );

    expect(screen.queryByLabelText("Layout")).toBeNull();
  });

  it("라벨 타입 셀렉트 변경 시 updateBlock 이 labelMode 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormSelectBlockProps>("f-select-label-mode", "formSelect", {
      ...baseProps,
      labelMode: "text",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-label-mode"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Label type");
    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<FormSelectBlockProps>("f-select-label-display", "formSelect", {
      ...baseProps,
      labelDisplay: "visible",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-label-display"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Label display mode");
    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<FormSelectBlockProps>("f-select-label-layout", "formSelect", {
      ...baseProps,
      labelDisplay: "visible",
      labelLayout: "stacked",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-label-layout"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Layout");
    fireEvent.change(select, { target: { value: "inline" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-select-label-layout",
      expect.objectContaining({ labelLayout: "inline" }),
    );
  });

  it("라벨/필드 간격 컨트롤 변경 시 updateBlock 이 labelGapPx 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormSelectBlockProps>("f-select-label-gap", "formSelect", {
      ...baseProps,
      labelDisplay: "visible",
      labelLayout: "inline",
      labelGapPx: 8,
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-label-gap"
        updateBlock={updateBlock}
      />,
    );

    const gapInput = screen.getByLabelText("Label/field gap 커스텀");
    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<FormSelectBlockProps>("f-select-label", "formSelect", {
      ...baseProps,
      label: "카테고리",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-label"
        updateBlock={updateBlock}
      />,
    );

    const input = screen.getByLabelText("Field label");
    fireEvent.change(input, { target: { value: "새 카테고리" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-select-label",
      expect.objectContaining({ label: "새 카테고리" }),
    );
  });

  it("전송 키 인풋 변경 시 updateBlock 이 formFieldName 으로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormSelectBlockProps>("f-select-name", "formSelect", {
      ...baseProps,
      formFieldName: "category",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-name"
        updateBlock={updateBlock}
      />,
    );

    const input = screen.getByLabelText("Submit key");
    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<FormSelectBlockProps>("f-select-radius", "formSelect", {
      ...baseProps,
      borderRadius: "md",
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-radius"
        updateBlock={updateBlock}
      />,
    );

    const radiusInput = screen.getByLabelText("Field border radius 커스텀");
    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<FormSelectBlockProps>("f-select-theme", "formSelect", {
      ...baseProps,
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-theme"
        updateBlock={updateBlock}
      />,
    );

    const labelTypeSelect = screen.getByLabelText("Label type") 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("Field label") 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("Label display mode") 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("Field width") 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("Label") 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<FormSelectBlockProps>("f-select-option-layout", "formSelect", {
      ...baseProps,
    } as any);

    render(
      <FormSelectPropertiesPanel
        block={block}
        selectedBlockId="f-select-option-layout"
        updateBlock={updateBlock}
      />,
    );

    const optionLabelInput = screen.getByPlaceholderText("Label") 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<FormCheckboxBlockProps>("f-check-1", "formCheckbox", {
      ...baseProps,
      textColorCustom: "#ffffff",
    });

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-1"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Checkbox text color 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<FormCheckboxBlockProps>("f-check-typo-1", "formCheckbox", {
      ...baseProps,
      fontSizeCustom: "14px",
      lineHeightCustom: "20px",
      letterSpacingCustom: "0px",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-typo-1"
        updateBlock={updateBlock}
      />,
    );

    const fontSizeInput = screen.getByLabelText("Checkbox text size 커스텀");
    fireEvent.change(fontSizeInput, { target: { value: "24" } });

    const lineHeightInput = screen.getByLabelText("Checkbox line height 커스텀");
    fireEvent.change(lineHeightInput, { target: { value: "30" } });

    const letterSpacingInput = screen.getByLabelText("Checkbox letter spacing 커스텀");
    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<FormCheckboxBlockProps>("f-check-layout-1", "formCheckbox", {
      ...baseProps,
      widthMode: "fixed",
      widthPx: 240,
      paddingX: 12,
      paddingY: 10,
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-layout-1"
        updateBlock={updateBlock}
      />,
    );

    const widthPxInput = screen.getByLabelText("Field fixed width 커스텀");
    fireEvent.change(widthPxInput, { target: { value: "320" } });

    const paddingXInput = screen.getByLabelText("Checkbox horizontal padding 커스텀");
    fireEvent.change(paddingXInput, { target: { value: "16" } });

    const paddingYInput = screen.getByLabelText("Checkbox vertical padding 커스텀");
    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<FormCheckboxBlockProps>("f-check-layout-visible", "formCheckbox", {
      ...baseProps,
      groupLabelDisplay: "visible",
      labelLayout: "inline",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={visibleBlock}
        selectedBlockId="f-check-layout-visible"
        updateBlock={updateBlock}
      />,
    );

    const layoutSelect = screen.getByLabelText("Layout");
    expect(layoutSelect).toBeTruthy();

    cleanup();

    const hiddenBlock = makeBlock<FormCheckboxBlockProps>("f-check-layout-hidden", "formCheckbox", {
      ...baseProps,
      groupLabelDisplay: "hidden",
      labelLayout: "inline",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={hiddenBlock}
        selectedBlockId="f-check-layout-hidden"
        updateBlock={updateBlock}
      />,
    );

    expect(screen.queryByLabelText("Layout")).toBeNull();
  });

  it("그룹 타이틀 타입 셀렉트 변경 시 updateBlock 이 groupLabelMode 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormCheckboxBlockProps>("f-check-group-mode", "formCheckbox", {
      ...baseProps,
      groupLabelMode: "text",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-group-mode"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Group title type");
    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<FormCheckboxBlockProps>("f-check-group-display", "formCheckbox", {
      ...baseProps,
      groupLabelDisplay: "visible",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-group-display"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Group title display mode");
    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<FormCheckboxBlockProps>("f-check-label-layout", "formCheckbox", {
      ...baseProps,
      groupLabelDisplay: "visible",
      labelLayout: "stacked",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-label-layout"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Layout");
    fireEvent.change(select, { target: { value: "inline" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-check-label-layout",
      expect.objectContaining({ labelLayout: "inline" }),
    );
  });

  it("라벨/필드 간격 컨트롤 변경 시 updateBlock 이 labelGapPx 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormCheckboxBlockProps>("f-check-label-gap", "formCheckbox", {
      ...baseProps,
      groupLabelDisplay: "visible",
      labelLayout: "inline",
      labelGapPx: 8,
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-label-gap"
        updateBlock={updateBlock}
      />,
    );

    const gapInput = screen.getByLabelText("Label/field gap 커스텀");
    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<FormCheckboxBlockProps>("f-check-group-label", "formCheckbox", {
      ...baseProps,
      groupLabel: "옵션들",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-group-label"
        updateBlock={updateBlock}
      />,
    );

    const input = screen.getByLabelText("Group title");
    fireEvent.change(input, { target: { value: "새 옵션들" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-check-group-label",
      expect.objectContaining({ groupLabel: "새 옵션들" }),
    );
  });

  it("체크박스 옵션 간격 컨트롤 변경 시 updateBlock 이 optionGapPx 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormCheckboxBlockProps>("f-check-option-gap", "formCheckbox", {
      ...baseProps,
      optionGapPx: 4,
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-option-gap"
        updateBlock={updateBlock}
      />,
    );

    const gapInput = screen.getByLabelText("Checkbox option gap 커스텀");
    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<FormCheckboxBlockProps>("f-check-option-layout", "formCheckbox", {
      ...baseProps,
      optionLayout: "stacked",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-option-layout"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Option layout");
    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<FormCheckboxBlockProps>("f-check-radius", "formCheckbox", {
      ...baseProps,
      borderRadius: "md",
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-radius"
        updateBlock={updateBlock}
      />,
    );

    const radiusInput = screen.getByLabelText("Field border radius 커스텀");
    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<FormCheckboxBlockProps>("f-check-theme-width", "formCheckbox", {
      ...baseProps,
    } as any);

    render(
      <FormCheckboxPropertiesPanel
        block={block}
        selectedBlockId="f-check-theme-width"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Field width") 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<FormRadioBlockProps>("f-radio-typo-1", "formRadio", {
      ...baseProps,
      fontSizeCustom: "14px",
      lineHeightCustom: "20px",
      letterSpacingCustom: "0px",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-typo-1"
        updateBlock={updateBlock}
      />,
    );

    const fontSizeInput = screen.getByLabelText("Radio text size 커스텀");
    fireEvent.change(fontSizeInput, { target: { value: "24" } });

    const lineHeightInput = screen.getByLabelText("Radio line height 커스텀");
    fireEvent.change(lineHeightInput, { target: { value: "30" } });

    const letterSpacingInput = screen.getByLabelText("Radio letter spacing 커스텀");
    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<FormRadioBlockProps>("f-radio-layout-1", "formRadio", {
      ...baseProps,
      widthMode: "fixed",
      widthPx: 240,
      paddingX: 8,
      paddingY: 4,
      optionGapPx: 8,
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-layout-1"
        updateBlock={updateBlock}
      />,
    );

    const widthPxInput = screen.getByLabelText("Field fixed width 커스텀");
    fireEvent.change(widthPxInput, { target: { value: "320" } });

    const paddingXInput = screen.getByLabelText("Radio horizontal padding 커스텀");
    fireEvent.change(paddingXInput, { target: { value: "12" } });

    const paddingYInput = screen.getByLabelText("Radio vertical padding 커스텀");
    fireEvent.change(paddingYInput, { target: { value: "6" } });

    const optionGapInput = screen.getByLabelText("Radio option gap 커스텀");
    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<FormRadioBlockProps>("f-radio-layout-visible", "formRadio", {
      ...baseProps,
      groupLabelDisplay: "visible",
      labelLayout: "inline",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={visibleBlock}
        selectedBlockId="f-radio-layout-visible"
        updateBlock={updateBlock}
      />,
    );

    const layoutSelect = screen.getByLabelText("Group title layout");
    expect(layoutSelect).toBeTruthy();

    cleanup();

    const hiddenBlock = makeBlock<FormRadioBlockProps>("f-radio-layout-hidden", "formRadio", {
      ...baseProps,
      groupLabelDisplay: "hidden",
      labelLayout: "inline",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={hiddenBlock}
        selectedBlockId="f-radio-layout-hidden"
        updateBlock={updateBlock}
      />,
    );

    expect(screen.queryByLabelText("Group title layout")).toBeNull();
  });

  it("그룹 타이틀 타입 셀렉트 변경 시 updateBlock 이 groupLabelMode 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormRadioBlockProps>("f-radio-group-mode", "formRadio", {
      ...baseProps,
      groupLabelMode: "text",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-group-mode"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Group title type");
    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<FormRadioBlockProps>("f-radio-group-display", "formRadio", {
      ...baseProps,
      groupLabelDisplay: "visible",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-group-display"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Group title display mode");
    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<FormRadioBlockProps>("f-radio-label-layout", "formRadio", {
      ...baseProps,
      groupLabelDisplay: "visible",
      labelLayout: "stacked",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-label-layout"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Group title layout");
    fireEvent.change(select, { target: { value: "inline" } });

    expect(updateBlock).toHaveBeenCalledWith(
      "f-radio-label-layout",
      expect.objectContaining({ labelLayout: "inline" }),
    );
  });

  it("라벨/필드 간격 컨트롤 변경 시 updateBlock 이 labelGapPx 로 호출되어야 한다", () => {
    const updateBlock = vi.fn();

    const block = makeBlock<FormRadioBlockProps>("f-radio-label-gap", "formRadio", {
      ...baseProps,
      groupLabelDisplay: "visible",
      labelLayout: "inline",
      labelGapPx: 8,
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-label-gap"
        updateBlock={updateBlock}
      />,
    );

    const gapInput = screen.getByLabelText("Label/field gap 커스텀");
    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<FormRadioBlockProps>("f-radio-group-label", "formRadio", {
      ...baseProps,
      groupLabel: "플랜",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-group-label"
        updateBlock={updateBlock}
      />,
    );

    const input = screen.getByLabelText("Group title");
    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<FormRadioBlockProps>("f-radio-group-image-source", "formRadio", {
      ...baseProps,
      groupLabelMode: "image",
      groupLabelImageSource: "url",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-group-image-source"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Group title image source");
    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<FormRadioBlockProps>("f-radio-group-image-url", "formRadio", {
      ...baseProps,
      groupLabelMode: "image",
      groupLabelImageSource: "url",
      groupLabelImageUrl: "https://example.com/old.png",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-group-image-url"
        updateBlock={updateBlock}
      />,
    );

    const input = screen.getByLabelText("Group title image 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<FormRadioBlockProps>("f-radio-option-image-source", "formRadio", {
      ...baseProps,
      optionImageSource: "url",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-option-image-source"
        updateBlock={updateBlock}
      />,
    );

    const select = screen.getByLabelText("Option image source");
    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<FormRadioBlockProps>("f-radio-fill-color", "formRadio", {
      ...baseProps,
      fillColorCustom: "#000000",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-fill-color"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Radio background color 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<FormRadioBlockProps>("f-radio-stroke-color", "formRadio", {
      ...baseProps,
      strokeColorCustom: "#000000",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-stroke-color"
        updateBlock={updateBlock}
      />,
    );

    const hexInput = screen.getByLabelText("Radio border color 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<FormRadioBlockProps>("f-radio-radius", "formRadio", {
      ...baseProps,
      borderRadius: "md",
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-radius"
        updateBlock={updateBlock}
      />,
    );

    const radiusInput = screen.getByLabelText("Field border radius 커스텀");
    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<FormRadioBlockProps>("f-radio-theme", "formRadio", {
      ...baseProps,
    } as any);

    render(
      <FormRadioPropertiesPanel
        block={block}
        selectedBlockId="f-radio-theme"
        updateBlock={updateBlock}
      />,
    );

    const groupTitleInput = screen.getByLabelText("Group title") 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("Group title display mode") 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("Option layout") 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("Submit key") 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("Label") 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");
  });
});
