Files
page-builder/tests/unit/formHelpers.spec.ts
T
jaybe 0cf67b5619
CI / test (push) Failing after 7m55s
CI / pr_and_merge (push) Has been skipped
리스트/에디터 스타일 정리 및 버그 수정
2025-11-30 16:52:27 +09:00

618 lines
22 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants";
import {
computeFormInputExportTokens,
computeFormSelectExportTokens,
computeFormCheckboxExportTokens,
computeFormRadioExportTokens,
computeFormInputEditorTokens,
computeFormSelectEditorTokens,
computeFormOptionGroupEditorTokens,
computeFormInputPublicTokens,
computeFormSelectPublicTokens,
computeFormCheckboxPublicTokens,
computeFormRadioPublicTokens,
computeFormControllerPublicTokens,
computeFormBlockExportTokens,
} from "@/features/editor/utils/formHelpers";
import type {
Block,
FormBlockProps,
FormInputBlockProps,
FormSelectBlockProps,
FormCheckboxBlockProps,
FormRadioBlockProps,
} from "@/features/editor/state/editorStore";
const baseInput: FormInputBlockProps = {
label: "이름",
formFieldName: "name",
};
const baseSelect: FormSelectBlockProps = {
label: "플랜",
formFieldName: "plan",
options: [],
};
const baseCheckbox: FormCheckboxBlockProps = {
groupLabel: "옵션",
formFieldName: "options",
options: [],
};
const baseRadio: FormRadioBlockProps = {
groupLabel: "선택",
formFieldName: "choice",
options: [],
};
describe("formHelpers.computeFormInputExportTokens", () => {
it("텍스트 색상/배경/테두리/패딩/너비/둥글기 스타일을 계산해야 한다", () => {
const tokens = computeFormInputExportTokens({
...baseInput,
textColorCustom: " #ff0000 ",
fillColorCustom: " #111827 ",
strokeColorCustom: " #4b5563 ",
paddingX: 8,
paddingY: 4,
widthMode: "fixed",
widthPx: 300,
borderRadius: "lg",
});
expect(tokens.labelStyleAttr).toBe(' style="color:#ff0000"');
expect(tokens.inputStyleAttr).toContain("color:#ff0000");
expect(tokens.inputStyleAttr).toContain("background-color:#111827");
expect(tokens.inputStyleAttr).toContain("border-color:#4b5563");
expect(tokens.inputStyleAttr).toContain("padding-left:0.5em");
expect(tokens.inputStyleAttr).toContain("padding-right:0.5em");
expect(tokens.inputStyleAttr).toContain("padding-top:0.25em");
expect(tokens.inputStyleAttr).toContain("padding-bottom:0.25em");
expect(tokens.inputStyleAttr).toContain("width:18.75em");
expect(tokens.inputStyleAttr).toContain("border-radius:6px");
// Export 인풋 스타일에는 border-radius 등의 예외를 제외하고 px 단위가 포함되지 않아야 한다.
expectNoPxInStyleString(tokens.inputStyleAttr, {
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
});
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
const tokens = computeFormInputExportTokens(baseInput);
expect(tokens.labelStyleAttr).toBe("");
expect(tokens.inputStyleAttr).toBe(' style="border-radius:4px"');
});
});
describe("formHelpers.computeFormSelectExportTokens", () => {
it("텍스트 색상/배경/테두리/패딩/너비/둥글기 스타일을 계산해야 한다", () => {
const tokens = computeFormSelectExportTokens({
...baseSelect,
textColorCustom: " #00ff00 ",
fillColorCustom: " #020617 ",
strokeColorCustom: " #64748b ",
paddingX: 12,
paddingY: 6,
widthMode: "fixed",
widthPx: 240,
borderRadius: "full",
});
expect(tokens.labelStyleAttr).toBe(' style="color:#00ff00"');
expect(tokens.selectStyleAttr).toContain("color:#00ff00");
expect(tokens.selectStyleAttr).toContain("background-color:#020617");
expect(tokens.selectStyleAttr).toContain("border-color:#64748b");
expect(tokens.selectStyleAttr).toContain("padding-left:0.75em");
expect(tokens.selectStyleAttr).toContain("padding-right:0.75em");
expect(tokens.selectStyleAttr).toContain("padding-top:0.375em");
expect(tokens.selectStyleAttr).toContain("padding-bottom:0.375em");
expect(tokens.selectStyleAttr).toContain("width:15em");
expect(tokens.selectStyleAttr).toContain("border-radius:9999px");
// Export 셀렉트 스타일에는 border-radius 등의 예외를 제외하고 px 단위가 포함되지 않아야 한다.
expectNoPxInStyleString(tokens.selectStyleAttr, {
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
});
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
const tokens = computeFormSelectExportTokens(baseSelect);
expect(tokens.labelStyleAttr).toBe("");
expect(tokens.selectStyleAttr).toBe(' style="border-radius:4px"');
});
});
describe("formHelpers.computeFormCheckboxExportTokens", () => {
it("텍스트 색상/배경/테두리/패딩/둥글기 스타일을 계산해야 한다", () => {
const tokens = computeFormCheckboxExportTokens({
...baseCheckbox,
textColorCustom: " #38bdf8 ",
fillColorCustom: " #0f172a ",
strokeColorCustom: " #e5e7eb ",
paddingX: 10,
paddingY: 5,
borderRadius: "sm",
});
expect(tokens.labelStyleAttr).toBe(' style="color:#38bdf8"');
expect(tokens.optionStyleAttr).toContain("color:#38bdf8");
expect(tokens.optionStyleAttr).toContain("background-color:#0f172a");
expect(tokens.optionStyleAttr).toContain("border-color:#e5e7eb");
expect(tokens.optionStyleAttr).toContain("border-width:1px");
expect(tokens.optionStyleAttr).toContain("border-style:solid");
expect(tokens.optionStyleAttr).toContain("padding-left:0.625em");
expect(tokens.optionStyleAttr).toContain("padding-right:0.625em");
expect(tokens.optionStyleAttr).toContain("padding-top:0.3125em");
expect(tokens.optionStyleAttr).toContain("padding-bottom:0.3125em");
expect(tokens.optionStyleAttr).toContain("border-radius:2px");
// Export 체크박스 옵션 스타일에는 border/border-radius 를 제외하고 px 단위가 포함되지 않아야 한다.
expectNoPxInStyleString(tokens.optionStyleAttr, {
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
});
});
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
const tokens = computeFormCheckboxExportTokens(baseCheckbox);
expect(tokens.labelStyleAttr).toBe("");
expect(tokens.optionStyleAttr).toBe(' style="border-radius:4px"');
});
});
describe("formHelpers.computeFormRadioExportTokens", () => {
it("체크박스와 동일한 규칙으로 옵션 스타일을 계산해야 한다", () => {
const tokens = computeFormRadioExportTokens({
...baseRadio,
textColorCustom: " #fbbf24 ",
fillColorCustom: " #111827 ",
strokeColorCustom: " #facc15 ",
paddingX: 4,
paddingY: 2,
borderRadius: "md",
});
expect(tokens.labelStyleAttr).toBe(' style="color:#fbbf24"');
expect(tokens.optionStyleAttr).toContain("color:#fbbf24");
expect(tokens.optionStyleAttr).toContain("background-color:#111827");
expect(tokens.optionStyleAttr).toContain("border-color:#facc15");
expect(tokens.optionStyleAttr).toContain("border-width:1px");
expect(tokens.optionStyleAttr).toContain("border-style:solid");
expect(tokens.optionStyleAttr).toContain("padding-left:0.25em");
expect(tokens.optionStyleAttr).toContain("padding-right:0.25em");
expect(tokens.optionStyleAttr).toContain("padding-top:0.125em");
expect(tokens.optionStyleAttr).toContain("padding-bottom:0.125em");
expect(tokens.optionStyleAttr).toContain("border-radius:4px");
// Export 라디오 옵션 스타일에는 border/border-radius 를 제외하고 px 단위가 포함되지 않아야 한다.
expectNoPxInStyleString(tokens.optionStyleAttr, {
allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/],
});
});
});
describe("formHelpers - editor tokens", () => {
it("computeFormInputEditorTokens: 에디터 인풋 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormInputEditorTokens({
...baseInput,
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 240,
borderRadius: "full",
align: "center",
labelLayout: "stacked",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("240px");
expect(tokens.fieldStyle.borderRadius).toBe(9999);
});
it("computeFormSelectEditorTokens: 에디터 셀렉트 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormSelectEditorTokens({
...baseSelect,
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 260,
borderRadius: "lg",
paddingX: 12,
paddingY: 6,
fontSizeCustom: "14px",
lineHeightCustom: "20px",
letterSpacingCustom: "1px",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("260px");
expect(tokens.fieldStyle.borderRadius).toBe(9999);
expect(tokens.fieldStyle.paddingInline).toBe("12px");
expect(tokens.fieldStyle.paddingBlock).toBe("6px");
expect(tokens.fieldStyle.fontSize).toBe("14px");
expect(tokens.fieldStyle.lineHeight).toBe("20px");
expect(tokens.fieldStyle.letterSpacing).toBe("1px");
});
it("computeFormOptionGroupEditorTokens: 에디터 라디오/체크박스 그룹 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormOptionGroupEditorTokens({
...baseRadio,
textColorCustom: "#ff0000",
fillColorCustom: "#123456",
strokeColorCustom: "#654321",
widthMode: "fixed",
widthPx: 280,
borderRadius: "lg",
paddingX: 8,
paddingY: 4,
fontSizeCustom: "15px",
lineHeightCustom: "22px",
letterSpacingCustom: "0.5px",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("280px");
expect(tokens.fieldStyle.borderRadius).toBe(9999);
expect(tokens.fieldStyle.paddingInline).toBe("8px");
expect(tokens.fieldStyle.paddingBlock).toBe("4px");
expect(tokens.fieldStyle.fontSize).toBe("15px");
expect(tokens.fieldStyle.lineHeight).toBe("22px");
expect(tokens.fieldStyle.letterSpacing).toBe("0.5px");
});
});
describe("formHelpers - public tokens", () => {
it("computeFormInputPublicTokens: 퍼블릭 인풋 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormInputPublicTokens({
...baseInput,
align: "center",
widthMode: "fixed",
widthPx: 320,
paddingX: 16,
paddingY: 8,
textColorCustom: "#112233",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any);
expect(tokens.wrapperLayoutClass).toBe("flex flex-col gap-1");
expect(tokens.widthClass).toBe("");
expect(tokens.inputStyle.textAlign).toBe("center");
expect(tokens.inputStyle.width).toBe("20em");
expect(tokens.inputStyle.paddingInline).toBe("1em");
expect(tokens.inputStyle.paddingBlock).toBe("0.5em");
expect(tokens.inputStyle.color).toBe("#112233");
expect(tokens.inputStyle.backgroundColor).toBe("#123456");
expect(tokens.inputStyle.borderColor).toBe("#ff0000");
expect(tokens.inputStyle.borderRadius).toBe("6px");
// 퍼블릭 인풋 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
expectNoPxInStyleObject(tokens.wrapperStyle);
expectNoPxInStyleObject(tokens.inputStyle, { allowKeys: ["borderRadius", "borderWidth"] });
});
it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormSelectPublicTokens({
...baseSelect,
widthMode: "fixed",
widthPx: 240,
paddingX: 16,
paddingY: 8,
textColorCustom: "#00ff00",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any);
expect(tokens.widthClass).toBe("");
expect(tokens.wrapperStyle.width).toBe("15em");
expect(tokens.selectStyle.paddingInline).toBe("1em");
expect(tokens.selectStyle.paddingBlock).toBe("0.5em");
expect(tokens.selectStyle.color).toBe("#00ff00");
expect(tokens.selectStyle.backgroundColor).toBe("#123456");
expect(tokens.selectStyle.borderColor).toBe("#ff0000");
expect(tokens.selectStyle.borderRadius).toBe("6px");
// 퍼블릭 셀렉트 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
expectNoPxInStyleObject(tokens.wrapperStyle);
expectNoPxInStyleObject(tokens.selectStyle, { allowKeys: ["borderRadius", "borderWidth"] });
});
it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => {
const tokens = computeFormCheckboxPublicTokens({
...baseCheckbox,
widthMode: "fixed",
widthPx: 320,
paddingX: 8,
paddingY: 4,
optionGapPx: 16,
textColorCustom: "#ffffff",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any);
expect(tokens.widthClass).toBe("");
expect(tokens.groupStyle.width).toBe("20em");
expect(tokens.optionsStyle.rowGap).toBe("1em");
expect(tokens.optionContainerStyle.paddingInline).toBe("0.5em");
expect(tokens.optionContainerStyle.paddingBlock).toBe("0.25em");
expect(tokens.optionContainerStyle.backgroundColor).toBe("#123456");
expect(tokens.optionContainerStyle.borderColor).toBe("#ff0000");
expect(tokens.optionContainerStyle.borderWidth).toBe("1px");
expect(tokens.optionContainerStyle.borderStyle).toBe("solid");
expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
expect(tokens.groupTextStyle.color).toBe("#ffffff");
expect(tokens.optionTextStyle.color).toBe("#ffffff");
// 퍼블릭 체크박스 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
expectNoPxInStyleObject(tokens.groupStyle);
expectNoPxInStyleObject(tokens.optionsStyle);
expectNoPxInStyleObject(tokens.optionContainerStyle, { allowKeys: ["borderRadius", "borderWidth"] });
expectNoPxInStyleObject(tokens.groupTextStyle);
expectNoPxInStyleObject(tokens.optionTextStyle);
});
it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => {
const tokens = computeFormRadioPublicTokens({
...baseRadio,
widthMode: "fixed",
widthPx: 320,
paddingX: 8,
paddingY: 4,
optionGapPx: 16,
textColorCustom: "#ffffff",
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
} as any);
expect(tokens.widthClass).toBe("");
expect(tokens.groupStyle.width).toBe("20em");
expect(tokens.optionsStyle.rowGap).toBe("1em");
expect(tokens.optionContainerStyle.paddingInline).toBe("0.5em");
expect(tokens.optionContainerStyle.paddingBlock).toBe("0.25em");
expect(tokens.optionContainerStyle.backgroundColor).toBe("#123456");
expect(tokens.optionContainerStyle.borderColor).toBe("#ff0000");
expect(tokens.optionContainerStyle.borderWidth).toBe("1px");
expect(tokens.optionContainerStyle.borderStyle).toBe("solid");
expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
expect(tokens.groupTextStyle.color).toBe("#ffffff");
expect(tokens.optionTextStyle.color).toBe("#ffffff");
});
});
describe("formHelpers.computeFormControllerPublicTokens", () => {
it("fieldIds 를 기반으로 컨트롤러 필드를 매핑하고 레이아웃/submitLabel 을 계산해야 한다", () => {
const formBlock: Block = {
id: "form-1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fieldIds: ["input-1", "checkbox-1"],
submitButtonId: "btn-1",
formWidthMode: "fixed",
formWidthPx: 320,
marginYPx: 32,
backgroundColorCustom: " #123456 ",
} as FormBlockProps,
} as Block;
const inputBlock: Block = {
id: "input-1",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
inputType: "email",
required: true,
} as any,
} as Block;
const checkboxBlock: Block = {
id: "checkbox-1",
type: "formCheckbox",
props: {
groupLabel: "옵션",
formFieldName: "options",
required: true,
groupLabelMode: "image",
groupLabelImageUrl: "https://example.com/group.png",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} as any,
} as Block;
const buttonBlock: Block = {
id: "btn-1",
type: "button",
props: {
label: "컨트롤러 버튼",
} as any,
} as Block;
const blocks: Block[] = [formBlock, inputBlock, checkboxBlock, buttonBlock];
const tokens = computeFormControllerPublicTokens(formBlock, blocks);
expect(tokens.fields).toHaveLength(2);
const inputField = tokens.fields[0];
const checkboxField = tokens.fields[1];
expect(inputField.id).toBe("input-1");
expect(inputField.name).toBe("name");
expect(inputField.label).toBe("이름");
expect(inputField.type).toBe("text");
expect(inputField.required).toBe(true);
expect(checkboxField.id).toBe("checkbox-1");
expect(checkboxField.name).toBe("options");
expect(checkboxField.label).toBe("옵션");
expect(checkboxField.type).toBe("checkbox");
expect(checkboxField.required).toBe(true);
expect(checkboxField.options).toHaveLength(2);
expect(checkboxField.groupLabelMode).toBe("image");
expect(checkboxField.groupLabelImageUrl).toBe("https://example.com/group.png");
expect(tokens.formClassName).toBe("space-y-3");
// FormBlock 은 이제 레이아웃/배경 스타일을 가지지 않는 순수 컨트롤러이므로,
// formStyle 은 항상 빈 객체여야 한다.
expect(tokens.formStyle).toEqual({});
expect(tokens.submitLabel).toBe("컨트롤러 버튼");
});
it("fields 가 있을 때는 fields 를 사용하고, 없으면 빈 배열을 반환해야 한다", () => {
const formWithFields: Block = {
id: "form-2",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [
{
id: "f1",
name: "email",
label: "이메일",
type: "email",
required: true,
},
],
} as FormBlockProps,
} as Block;
const tokensWithFields = computeFormControllerPublicTokens(formWithFields, [formWithFields]);
expect(tokensWithFields.fields).toHaveLength(1);
expect(tokensWithFields.fields[0].id).toBe("f1");
expect(tokensWithFields.fields[0].name).toBe("email");
expect(tokensWithFields.fields[0].label).toBe("이메일");
expect(tokensWithFields.fields[0].type).toBe("email");
expect(tokensWithFields.fields[0].required).toBe(true);
const formFallback: Block = {
id: "form-3",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
} as FormBlockProps,
} as Block;
const tokensFallback = computeFormControllerPublicTokens(formFallback, [formFallback]);
expect(tokensFallback.fields).toHaveLength(0);
});
});
describe("formHelpers.computeFormBlockExportTokens", () => {
it("fieldIds 가 있을 때 컨트롤러 필드와 폼 배경 스타일을 계산해야 한다", () => {
const formBlock: Block = {
id: "form-export-1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fieldIds: ["input-1", "select-1"],
fields: [],
backgroundColorCustom: " #123456 ",
} as FormBlockProps,
} as Block;
const inputBlock: Block = {
id: "input-1",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
inputType: "email",
required: true,
} as any,
} as Block;
const selectBlock: Block = {
id: "select-1",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
} as any,
} as Block;
const blocks: Block[] = [formBlock, inputBlock, selectBlock];
const tokens = computeFormBlockExportTokens(formBlock, blocks);
expect(tokens.hasControllerFields).toBe(true);
expect(tokens.controllerFields).toHaveLength(2);
expect(tokens.controllerFields[0].id).toBe("input-1");
expect(tokens.controllerFields[1].id).toBe("select-1");
expect(tokens.fallbackFields).toHaveLength(0);
// FormBlock 은 Export 레이어에서도 컨테이너 배경/레이아웃 스타일을 가지지 않는다.
expect(tokens.formStyleParts).toEqual([]);
});
it("fieldIds 가 없고 fields 가 있을 때 fallbackFields 를 사용해야 한다", () => {
const formBlock: Block = {
id: "form-export-2",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fieldIds: [],
fields: [
{
id: "f1",
name: "email",
label: "이메일",
type: "email",
required: true,
},
],
backgroundColorCustom: undefined,
} as FormBlockProps,
} as Block;
const tokens = computeFormBlockExportTokens(formBlock, [formBlock]);
expect(tokens.hasControllerFields).toBe(false);
expect(tokens.controllerFields).toHaveLength(0);
expect(tokens.fallbackFields).toHaveLength(1);
expect(tokens.fallbackFields[0].id).toBe("f1");
expect(tokens.formStyleParts).toEqual([]);
});
it("fieldIds 와 fields 가 모두 없으면 fallbackFields 도 빈 배열이어야 한다", () => {
const formBlock: Block = {
id: "form-export-3",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
} as FormBlockProps,
} as Block;
const tokens = computeFormBlockExportTokens(formBlock, [formBlock]);
expect(tokens.hasControllerFields).toBe(false);
expect(tokens.controllerFields).toHaveLength(0);
expect(tokens.fallbackFields).toHaveLength(0);
});
});