테마 적용
CI / test (push) Failing after 5m22s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-09 18:53:21 +09:00
parent 9d8c4538c7
commit 676e58cad7
71 changed files with 3394 additions and 498 deletions
+16
View File
@@ -259,6 +259,22 @@ describe("/api/export", () => {
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
});
it("bodyBgColorHex 가 빈 문자열이면 buildStaticHtml 의 body style 에 background-color 가 포함되지 않아야 한다", () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
title: "배경색 비어 있음 테스트",
slug: "body-bg-empty-test",
canvasPreset: "full",
canvasBgColorHex: "#0f172a",
bodyBgColorHex: "",
} as ProjectConfig;
const html = buildStaticHtml(blocks, projectConfig);
// body style 에 background-color 가 없어야 한다.
expect(html).toContain('<body style="margin:0;padding:0;"');
});
it("projectConfig.headHtml / trackingScript 가 head 와 body 에 반영되어야 한다", async () => {
const blocks: Block[] = [];
@@ -171,6 +171,56 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
expect(submissionsLink.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
});
it("전체 제출 내역 테이블은 라이트 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", async () => {
const submissions = [
{
id: "1",
createdAt: "2025-01-01T12:00:00.000Z",
projectSlug: "proj-1",
projectTitle: "프로젝트 1",
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(submissions), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
const { container } = render(<AllProjectSubmissionsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const headerRow = container.querySelector("thead tr") as HTMLElement | null;
expect(headerRow).not.toBeNull();
const headerClass = headerRow!.className;
expect(headerClass).toContain("border-slate-200");
expect(headerClass).toContain("text-slate-600");
expect(headerClass).toContain("dark:border-slate-800");
expect(headerClass).toContain("dark:text-slate-400");
const nameCellNode = await screen.findByText("홍길동");
const nameCell = nameCellNode.closest("td") as HTMLElement | null;
expect(nameCell).not.toBeNull();
const nameClass = nameCell!.className;
expect(nameClass).toContain("text-slate-900");
expect(nameClass).toContain("dark:text-slate-100");
});
it("GNB에서 현재 페이지인 '전체 제출 내역' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
+72
View File
@@ -210,5 +210,77 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(templateToggle);
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
});
it("블록 추가 버튼들은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<BlocksSidebar />);
const textButton = screen.getByRole("button", { name: "텍스트" });
const className = textButton.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
expect(className).toContain("bg-slate-50");
expect(className).toContain("text-slate-900");
// 다크 모드: 기존 다크 스타일 유지
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("히어로 템플릿 카드는 라이트/다크 테마에 맞는 카드 배경/테두리 클래스를 사용해야 한다", () => {
render(<BlocksSidebar />);
const heroDescription = screen.getByText(
"페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)",
);
const card = heroDescription.closest("div");
expect(card).not.toBeNull();
const className = card!.getAttribute("class") ?? "";
expect(className).toContain("bg-slate-50");
expect(className).toContain("border-slate-200");
expect(className).toContain("dark:border-slate-800");
expect(className).toContain("dark:bg-slate-950/50");
});
it("히어로 템플릿 추가 버튼은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<BlocksSidebar />);
const heroButton = screen.getByRole("button", { name: "Hero 템플릿" });
const className = heroButton.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
expect(className).toContain("bg-slate-50");
expect(className).toContain("text-slate-900");
// 다크 모드: 기존 다크 톤 유지
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("히어로 템플릿 썸네일 프레임은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
render(<BlocksSidebar />);
const thumb = screen.getByTestId("template-preview-hero") as HTMLDivElement;
const className = thumb.getAttribute("class") ?? "";
// 라이트 모드: 밝은 카드 배경 + 연한 테두리
expect(className).toContain("bg-slate-100");
expect(className).toContain("border-slate-300");
// 다크 모드: 기존 다크 카드 톤 유지
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:border-slate-700");
});
it("사이드바 루트는 라이트/다크 테마에 맞는 배경/테두리 클래스를 사용해야 한다", () => {
const { container } = render(<BlocksSidebar />);
const aside = container.querySelector("aside") as HTMLElement | null;
expect(aside).not.toBeNull();
const className = aside!.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("border-slate-200");
expect(className).toContain("dark:border-slate-800");
expect(className).toContain("dark:bg-slate-950/40");
});
});
+101
View File
@@ -80,6 +80,46 @@ describe("ButtonPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, textColorCustom: "#ff0000" }}
selectedBlockId="btn-text-none"
updateBlock={updateBlock}
/>,
);
// 첫 번째 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(
"btn-text-none",
expect.objectContaining({ textColorCustom: "" }),
);
});
it("textColorCustom 이 비어 있으면 버튼 텍스트 색상 HEX 인풋 값은 빈 문자열이어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, textColorCustom: "" }}
selectedBlockId="btn-text-empty"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("버튼 텍스트 색상 HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
it("버튼 너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
@@ -200,6 +240,28 @@ describe("ButtonPropertiesPanel", () => {
);
});
it("버튼 텍스트 textarea 는 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={baseProps}
selectedBlockId="btn-theme"
updateBlock={updateBlock}
/>,
);
const textarea = screen.getByLabelText("버튼 텍스트") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("가로 패딩 슬라이더 변경 시 updateBlock 이 paddingX 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
@@ -399,4 +461,43 @@ describe("ButtonPropertiesPanel", () => {
expect.objectContaining({ letterSpacingCustom: "1em" }),
);
});
it("버튼 주요 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={baseProps as any}
selectedBlockId="btn-theme"
updateBlock={updateBlock}
/>,
);
const labelTextarea = screen.getByLabelText("버튼 텍스트") as HTMLTextAreaElement;
const labelClass = labelTextarea.getAttribute("class") ?? "";
expect(labelClass).toContain("bg-white");
expect(labelClass).toContain("text-slate-900");
expect(labelClass).toContain("border-slate-300");
expect(labelClass).toContain("dark:bg-slate-900");
expect(labelClass).toContain("dark:text-slate-100");
expect(labelClass).toContain("dark:border-slate-700");
const imageSourceSelect = screen.getByLabelText("버튼 이미지 소스") as HTMLSelectElement;
const imageSourceClass = imageSourceSelect.getAttribute("class") ?? "";
expect(imageSourceClass).toContain("bg-white");
expect(imageSourceClass).toContain("text-slate-900");
expect(imageSourceClass).toContain("border-slate-300");
expect(imageSourceClass).toContain("dark:bg-slate-900");
expect(imageSourceClass).toContain("dark:text-slate-100");
expect(imageSourceClass).toContain("dark:border-slate-700");
const alignSelect = screen.getByLabelText("버튼 정렬") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
expect(alignClass).toContain("border-slate-300");
expect(alignClass).toContain("dark:bg-slate-900");
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
describe("ColorPickerField - 테마", () => {
it("HEX 텍스트 인풋은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(
<ColorPickerField
label="색상"
ariaLabelColorInput="컬러 입력"
ariaLabelHexInput="HEX 입력"
value="#ff0000"
onChange={() => {}}
palette={TEXT_COLOR_PALETTE}
/>,
);
const hexInput = screen.getByLabelText("HEX 입력") as HTMLInputElement;
const className = hexInput.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("팔레트 드롭다운 요약 버튼은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(
<ColorPickerField
label="색상"
ariaLabelColorInput="컬러 입력"
ariaLabelHexInput="HEX 입력"
value="#ff0000"
onChange={() => {}}
palette={TEXT_COLOR_PALETTE}
/>,
);
const [label] = screen.getAllByText("색상 팔레트");
const button = label.closest("button") as HTMLButtonElement | null;
expect(button).not.toBeNull();
const className = button!.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("팔레트 드롭다운 항목은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
render(
<ColorPickerField
label="색상"
ariaLabelColorInput="컬러 입력"
ariaLabelHexInput="HEX 입력"
value="#ff0000"
onChange={() => {}}
palette={TEXT_COLOR_PALETTE}
/>,
);
// 드롭다운을 연다
const [label] = screen.getAllByText("색상 팔레트");
const toggleButton = label.closest("button") as HTMLButtonElement | null;
expect(toggleButton).not.toBeNull();
fireEvent.click(toggleButton!);
// 팔레트 첫 항목("기본") 버튼을 찾는다
const itemButton = screen.getByRole("button", { name: "기본" }) as HTMLButtonElement;
const className = itemButton.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
// 색상 불렛(span)은 테두리를 가져야 한다 (흰색/투명 색상도 배경과 구분되도록)
const bullet = itemButton.querySelector("span span") as HTMLSpanElement | null;
expect(bullet).not.toBeNull();
const bulletClass = bullet!.getAttribute("class") ?? "";
expect(bulletClass).toContain("rounded-full");
expect(bulletClass).toContain("border");
expect(bulletClass).toContain("border-slate-300");
expect(bulletClass).toContain("dark:border-slate-700");
});
});
@@ -143,4 +143,43 @@ describe("DividerPropertiesPanel", () => {
expect.objectContaining({ marginYPx: 24 }),
);
});
it("DividerPropertiesPanel 주요 셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<DividerPropertiesPanel
dividerProps={baseProps}
selectedBlockId="divider-theme-1"
updateBlock={updateBlock}
/>,
);
const alignSelect = screen.getByLabelText("구분선 정렬") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
expect(alignClass).toContain("border-slate-300");
expect(alignClass).toContain("dark:bg-slate-900");
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
const thicknessSelect = screen.getByLabelText("구분선 두께") as HTMLSelectElement;
const thicknessClass = thicknessSelect.getAttribute("class") ?? "";
expect(thicknessClass).toContain("bg-white");
expect(thicknessClass).toContain("text-slate-900");
expect(thicknessClass).toContain("border-slate-300");
expect(thicknessClass).toContain("dark:bg-slate-900");
expect(thicknessClass).toContain("dark:text-slate-100");
expect(thicknessClass).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");
});
});
+27 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { EditorCanvas } from "@/app/editor/EditorCanvas";
import { EditorCanvas, EditorCanvasDragPreview } from "@/app/editor/EditorCanvas";
// 에디터 캔버스/페이지 배경색 적용 규칙에 대한 최소 TDD
// - canvasBgColorHex: 실제 캔버스 영역(editor-canvas-inner)의 배경색
@@ -73,4 +73,30 @@ describe("EditorCanvas - 캔버스/페이지 배경색", () => {
// custom 프리셋에서는 canvasWidthPx 값이 그대로 사용된다.
expect(renderWithConfig({ canvasPreset: "custom", canvasWidthPx: 1024 })).toBe("1024px");
});
it("DragOverlay 프리뷰는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const block: Block = {
id: "drag_1",
type: "text",
props: {
text: "드래그 미리보기",
align: "left",
size: "base",
},
} as any;
const { container } = render(<EditorCanvasDragPreview block={block} />);
const overlay = container.querySelector(
"div.pointer-events-none.rounded.border",
) as HTMLElement | null;
expect(overlay).not.toBeNull();
const className = overlay!.getAttribute("class") ?? "";
expect(className).toContain("border-sky-500");
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
+152
View File
@@ -214,6 +214,28 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(input.className).toContain("pb-input");
});
it("formInput 에디터 렌더에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_neutral_editor",
type: "formInput",
props: {
label: "폼 입력",
formFieldName: "input_neutral",
inputType: "text",
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_input_neutral_editor";
render(<EditorPage />);
const label = screen.getByText("폼 입력");
expect(label.className).not.toContain("text-slate-");
});
it("formSelect: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -275,6 +297,30 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(select.className).toContain("pb-select");
});
it("formSelect 에디터 렌더에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_select_neutral_editor",
type: "formSelect",
props: {
label: "폼 셀렉트",
formFieldName: "select_neutral",
options: [
{ label: "A", value: "a" },
],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_select_neutral_editor";
render(<EditorPage />);
const label = screen.getByText("폼 셀렉트");
expect(label.className).not.toContain("text-slate-");
});
it("formRadio: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -313,6 +359,87 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(groupContainer.style.borderRadius).toBe("6px");
});
it("formRadio 에디터 그룹 컨테이너에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_neutral_editor",
type: "formRadio",
props: {
formFieldName: "radio-neutral",
groupLabel: "라디오 그룹",
options: [
{ label: "옵션 A", value: "a" },
],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_neutral_editor";
render(<EditorPage />);
const label = screen.getByText("라디오 그룹");
const groupContainer = label.closest("div") as HTMLElement;
expect(groupContainer.className).not.toContain("text-slate-");
});
it("formRadio 에디터 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_os_editor",
type: "formRadio",
props: {
formFieldName: "radio-os",
groupLabel: "라디오 OS",
options: [{ label: "옵션 A", value: "a" }],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_os_editor";
render(<EditorPage />);
const label = screen.getByText("라디오 OS");
const groupContainer = label.closest("div") as HTMLElement;
const input = groupContainer.querySelector("input[type='radio']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formCheckbox 에디터 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_os_editor",
type: "formCheckbox",
props: {
formFieldName: "check-os",
groupLabel: "체크 OS",
options: [{ label: "체크 1", value: "c1" }],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_checkbox_os_editor";
render(<EditorPage />);
const label = screen.getByText("체크 OS");
const groupContainer = label.closest("div") as HTMLElement;
const input = groupContainer.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4,rounded 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formCheckbox: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -351,6 +478,31 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(groupContainer.style.borderRadius).toBe("6px");
});
it("formCheckbox 에디터 그룹 컨테이너에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_neutral_editor",
type: "formCheckbox",
props: {
formFieldName: "checkbox-neutral",
groupLabel: "체크 그룹",
options: [
{ label: "체크 1", value: "c1" },
],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_checkbox_neutral_editor";
render(<EditorPage />);
const label = screen.getByText("체크 그룹");
const groupContainer = label.closest("div") as HTMLElement;
expect(groupContainer.className).not.toContain("text-slate-");
});
it("formRadio: 그룹 타이틀 레이아웃이 inline 이면 에디터에서도 라벨과 필드 컨테이너가 가로 정렬되고 columnGap 이 labelGapPx 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
+97 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import EditorPage from "@/app/editor/page";
@@ -97,4 +97,100 @@ describe("EditorPage - 페이지/캔버스 배경", () => {
const canvasOuter = screen.getByTestId("editor-canvas") as HTMLElement;
expect(canvasOuter.style.backgroundColor).toBe("rgb(34, 34, 34)");
});
it("main 요소는 전역 라이트/다크 테마 클래스(bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50)를 사용해야 한다", () => {
const { container } = render(<EditorPage />);
const mainEl = container.querySelector("main") as HTMLElement | null;
expect(mainEl).not.toBeNull();
const className = mainEl!.getAttribute("class") ?? "";
expect(className).toContain("bg-slate-100");
expect(className).toContain("text-slate-900");
expect(className).toContain("dark:bg-slate-950");
expect(className).toContain("dark:text-slate-50");
});
it("프로젝트 저장/불러오기 모달 카드는 라이트/다크 테마에 맞는 카드/인풋 크롬을 사용해야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByRole("button", { name: /메뉴/ });
fireEvent.click(menuButton);
const projectMenuItem = screen.getByRole("button", { name: "프로젝트 저장/불러오기" });
fireEvent.click(projectMenuItem);
const heading = screen.getByText("프로젝트 저장 / 불러오기");
const headerDiv = heading.closest("div") as HTMLDivElement | null;
expect(headerDiv).not.toBeNull();
const card = headerDiv!.parentElement as HTMLDivElement | null;
expect(card).not.toBeNull();
const cardClass = card!.getAttribute("class") ?? "";
expect(cardClass).toContain("bg-white");
expect(cardClass).toContain("text-slate-900");
expect(cardClass).toContain("border-slate-200");
expect(cardClass).toContain("dark:bg-slate-900");
expect(cardClass).toContain("dark:text-slate-100");
expect(cardClass).toContain("dark:border-slate-700");
const titleSpan = Array.from(card!.querySelectorAll("span")).find(
(el) => el.textContent === "프로젝트 제목",
);
expect(titleSpan).not.toBeUndefined();
const titleWrapper = (titleSpan as HTMLSpanElement).parentElement as HTMLElement | null;
expect(titleWrapper).not.toBeNull();
const titleInput = titleWrapper!.querySelector("input") as HTMLInputElement | null;
expect(titleInput).not.toBeNull();
const inputClass = titleInput!.getAttribute("class") ?? "";
expect(inputClass).toContain("bg-white");
expect(inputClass).toContain("text-slate-900");
expect(inputClass).toContain("border-slate-300");
expect(inputClass).toContain("dark:bg-slate-900");
expect(inputClass).toContain("dark:text-slate-100");
expect(inputClass).toContain("dark:border-slate-700");
});
it("JSON Export / Import 모달 카드는 라이트/다크 테마에 맞는 카드/textarea 크롬을 사용해야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByRole("button", { name: /메뉴/ });
fireEvent.click(menuButton);
const jsonMenuItem = screen.getByRole("button", { name: "JSON 내보내기/불러오기" });
fireEvent.click(jsonMenuItem);
const heading = screen.getByText(/JSON Export \/ Import/);
const headerDiv = heading.closest("div") as HTMLDivElement | null;
expect(headerDiv).not.toBeNull();
const card = headerDiv!.parentElement as HTMLDivElement | null;
expect(card).not.toBeNull();
const cardClass = card!.getAttribute("class") ?? "";
expect(cardClass).toContain("bg-white");
expect(cardClass).toContain("text-slate-900");
expect(cardClass).toContain("border-slate-200");
expect(cardClass).toContain("dark:bg-slate-900");
expect(cardClass).toContain("dark:text-slate-100");
expect(cardClass).toContain("dark:border-slate-700");
const jsonLabel = screen.getByText("에디터 상태 JSON");
const jsonWrapper = jsonLabel.parentElement as HTMLElement | null;
expect(jsonWrapper).not.toBeNull();
const textarea = jsonWrapper!.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).not.toBeNull();
const textareaClass = textarea!.getAttribute("class") ?? "";
expect(textareaClass).toContain("bg-white");
expect(textareaClass).toContain("text-slate-900");
expect(textareaClass).toContain("border-slate-300");
expect(textareaClass).toContain("dark:bg-slate-900");
expect(textareaClass).toContain("dark:text-slate-100");
expect(textareaClass).toContain("dark:border-slate-700");
});
});
+118
View File
@@ -87,6 +87,90 @@ vi.mock("next/navigation", () => {
});
describe("EditorPage - 선택 포커스", () => {
it("선택/비선택 블록 셸은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "blk_1",
type: "text",
props: {
text: "첫 번째 블록",
align: "left",
size: "base",
},
} as any,
{
id: "blk_2",
type: "text",
props: {
text: "두 번째 블록",
align: "left",
size: "base",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "blk_2";
render(<EditorPage />);
const allBlocks = screen.getAllByTestId("editor-block") as HTMLElement[];
const selected = allBlocks.find((el) => el.dataset.selected === "true");
const nonSelected = allBlocks.find((el) => el.dataset.selected !== "true");
expect(selected).toBeTruthy();
expect(nonSelected).toBeTruthy();
const selectedClass = selected!.className;
const nonSelectedClass = nonSelected!.className;
// 선택된 블록: 하이라이트 보더 + 라이트/다크 배경/텍스트 듀얼 테마
expect(selectedClass).toContain("border-sky-500");
expect(selectedClass).toContain("bg-sky-50");
expect(selectedClass).toContain("text-slate-900");
expect(selectedClass).toContain("dark:bg-slate-900/80");
expect(selectedClass).toContain("dark:text-slate-100");
// 비선택 블록: 기본 카드 크롬(라이트/다크 듀얼)
expect(nonSelectedClass).toContain("border-slate-200");
expect(nonSelectedClass).toContain("bg-white");
expect(nonSelectedClass).toContain("text-slate-900");
expect(nonSelectedClass).toContain("dark:border-slate-700");
expect(nonSelectedClass).toContain("dark:bg-slate-900");
expect(nonSelectedClass).toContain("dark:text-slate-100");
});
it("블록 드래그 핸들은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "blk_1",
type: "text",
props: {
text: "첫 번째 블록",
align: "left",
size: "base",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "blk_1";
render(<EditorPage />);
const handle = screen.getByLabelText("블록 드래그 핸들") as HTMLButtonElement;
const className = handle.getAttribute("class") ?? "";
// 라이트 모드: 밝은 버튼 크롬
expect(className).toContain("border-slate-300");
expect(className).toContain("bg-slate-50");
expect(className).toContain("text-slate-500");
// 다크 모드: 기존 다크 톤 유지
expect(className).toContain("dark:border-slate-700");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-400");
});
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
const blocks: Block[] = [
{
@@ -150,6 +234,40 @@ describe("EditorPage - 선택 포커스", () => {
expect(sectionEl.className).toContain("border-sky-500");
});
it("EditorPage 레이아웃은 main 에 h-screen 컨테이너를 사용하고, 좌/중/우 컬럼이 각각 스크롤되어야 한다", () => {
mockState.blocks = [];
mockState.selectedBlockId = null;
const { container } = render(<EditorPage />);
const main = container.querySelector("main") as HTMLElement;
expect(main).toBeTruthy();
const mainClass = main.getAttribute("class") ?? "";
// 전체 에디터는 뷰포트 높이에 맞춘 h-screen 컨테이너 안에서 동작해야 한다.
expect(mainClass).toContain("h-screen");
expect(mainClass).not.toContain("min-h-screen");
expect(mainClass).toContain("flex");
expect(mainClass).toContain("overflow-hidden");
// 좌측 BlocksSidebar 는 자체 스크롤(overflow-y-auto)을 가져야 한다.
const blocksSidebar = screen.getByTestId("blocks-sidebar") as HTMLElement;
const blocksClass = blocksSidebar.getAttribute("class") ?? "";
expect(blocksClass).toContain("overflow-y-auto");
expect(blocksClass).toContain("pb-scroll");
// 중앙 EditorCanvas 는 자체 스크롤(overflow-auto)을 가져야 한다.
const canvas = screen.getByTestId("editor-canvas") as HTMLElement;
const canvasClass = canvas.getAttribute("class") ?? "";
expect(canvasClass).toContain("overflow-auto");
expect(canvasClass).toContain("pb-scroll");
// 우측 PropertiesSidebar 도 자체 스크롤(overflow-auto)을 가져야 한다.
const propsSidebar = screen.getByTestId("properties-sidebar") as HTMLElement;
const propsClass = propsSidebar.getAttribute("class") ?? "";
expect(propsClass).toContain("overflow-auto");
expect(propsClass).toContain("pb-scroll");
});
it("섹션 자식 블록을 선택해도 섹션 wrapper 가 강조되어야 한다", () => {
const section: Block = {
id: "sec_1",
+180 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
import { FormControllerPanel } from "@/app/editor/forms/FormControllerPanel";
import type { Block, FormBlockProps } from "@/features/editor/state/editorStore";
@@ -222,4 +222,183 @@ describe("FormControllerPanel", () => {
expect(scriptTextarea.value).toContain("params.email || \"\"");
expect(scriptTextarea.value).toContain("new Date()");
});
it("폼 컨트롤러 주요 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const formBlock = makeFormBlock("form-theme", {
submitTarget: "webhook",
} as any);
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock]}
selectedBlockId="form-theme"
updateBlock={updateBlock}
/>,
);
const submitTargetSelect = screen.getByLabelText("전송 대상") as HTMLSelectElement;
const submitTargetClass = submitTargetSelect.getAttribute("class") ?? "";
expect(submitTargetClass).toContain("bg-white");
expect(submitTargetClass).toContain("text-slate-900");
expect(submitTargetClass).toContain("border-slate-300");
expect(submitTargetClass).toContain("dark:bg-slate-900");
expect(submitTargetClass).toContain("dark:text-slate-100");
expect(submitTargetClass).toContain("dark:border-slate-700");
const formWidthModeSelect = screen.getByLabelText("폼 너비 모드") as HTMLSelectElement;
const formWidthModeClass = formWidthModeSelect.getAttribute("class") ?? "";
expect(formWidthModeClass).toContain("bg-white");
expect(formWidthModeClass).toContain("text-slate-900");
expect(formWidthModeClass).toContain("border-slate-300");
expect(formWidthModeClass).toContain("dark:bg-slate-900");
expect(formWidthModeClass).toContain("dark:text-slate-100");
expect(formWidthModeClass).toContain("dark:border-slate-700");
const successInput = screen.getByLabelText("성공 메시지") as HTMLInputElement;
const successClass = successInput.getAttribute("class") ?? "";
expect(successClass).toContain("bg-white");
expect(successClass).toContain("text-slate-900");
expect(successClass).toContain("border-slate-300");
expect(successClass).toContain("dark:bg-slate-900");
expect(successClass).toContain("dark:text-slate-100");
expect(successClass).toContain("dark:border-slate-700");
const errorInput = screen.getByLabelText("에러 메시지") as HTMLInputElement;
const errorClass = errorInput.getAttribute("class") ?? "";
expect(errorClass).toContain("bg-white");
expect(errorClass).toContain("text-slate-900");
expect(errorClass).toContain("border-slate-300");
expect(errorClass).toContain("dark:bg-slate-900");
expect(errorClass).toContain("dark:text-slate-100");
expect(errorClass).toContain("dark:border-slate-700");
const extraParamsTextarea = screen.getByLabelText(/추가 파라미터/) as HTMLTextAreaElement;
const extraParamsClass = extraParamsTextarea.getAttribute("class") ?? "";
expect(extraParamsClass).toContain("bg-white");
expect(extraParamsClass).toContain("text-slate-900");
expect(extraParamsClass).toContain("border-slate-300");
expect(extraParamsClass).toContain("dark:bg-slate-900");
expect(extraParamsClass).toContain("dark:text-slate-100");
expect(extraParamsClass).toContain("dark:border-slate-700");
});
it("Google Sheets 연동 가이드 모달 카드와 textarea 는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const formBlock = makeFormBlock("form-webhook-theme", {
submitTarget: "webhook",
} as any);
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock]}
selectedBlockId="form-webhook-theme"
updateBlock={updateBlock}
/>,
);
const guideButton = screen.getByRole("button", { name: "Google Sheets 연동 가이드" });
fireEvent.click(guideButton);
const heading = screen.getByRole("heading", { name: "Google Sheets 연동 가이드" });
const card = heading.closest("div")?.parentElement as HTMLDivElement | null;
expect(card).not.toBeNull();
const cardClass = card!.getAttribute("class") ?? "";
expect(cardClass).toContain("bg-white");
expect(cardClass).toContain("text-slate-900");
expect(cardClass).toContain("border-slate-200");
expect(cardClass).toContain("dark:bg-slate-900");
expect(cardClass).toContain("dark:text-slate-100");
expect(cardClass).toContain("dark:border-slate-700");
const scriptTextarea = screen.getByDisplayValue(/function doPost/) as HTMLTextAreaElement;
const scriptClass = scriptTextarea.getAttribute("class") ?? "";
expect(scriptClass).toContain("bg-white");
expect(scriptClass).toContain("text-slate-900");
expect(scriptClass).toContain("border-slate-300");
expect(scriptClass).toContain("dark:bg-slate-900");
expect(scriptClass).toContain("dark:text-slate-100");
expect(scriptClass).toContain("dark:border-slate-700");
});
it("폼 필드 매핑 체크박스와 필수 체크박스는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const formBlock = makeFormBlock("form-fields-theme", {
fieldIds: ["input-1"],
requiredFieldIds: ["input-1"],
} as any);
const inputBlock: Block = {
id: "input-1",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
} as any,
} as any;
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock, inputBlock]}
selectedBlockId="form-fields-theme"
updateBlock={updateBlock}
/>,
);
const fieldset = screen.getByRole("group", { name: "폼 필드 매핑" });
const checkboxes = within(fieldset).getAllByRole("checkbox");
const includeCheckbox = checkboxes[0] as HTMLInputElement;
const requiredCheckbox = checkboxes[1] as HTMLInputElement;
for (const checkbox of [includeCheckbox, requiredCheckbox]) {
const className = checkbox.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:border-slate-600");
}
});
it("Submit 버튼 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const formBlock = makeFormBlock("form-submit-theme", {
submitButtonId: "btn-1",
} as any);
const buttonBlock: Block = {
id: "btn-1",
type: "button",
props: {
label: "제출하기",
} as any,
} as any;
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock, buttonBlock]}
selectedBlockId="form-submit-theme"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("Submit 버튼") 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");
});
});
@@ -104,6 +104,56 @@ describe("FormInputPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 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("색상 팔레트");
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<FormInputBlockProps>("f-input-empty", "formInput", {
...baseProps,
textColorCustom: "",
} as any);
render(
<FormInputPropertiesPanel
block={block}
selectedBlockId="f-input-empty"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
@@ -266,6 +316,67 @@ describe("FormInputPropertiesPanel", () => {
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("라벨 타입") 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", () => {
@@ -679,6 +790,91 @@ describe("FormSelectPropertiesPanel", () => {
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("라벨 타입") 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<FormSelectBlockProps>("f-select-option-layout", "formSelect", {
...baseProps,
} as any);
render(
<FormSelectPropertiesPanel
block={block}
selectedBlockId="f-select-option-layout"
updateBlock={updateBlock}
/>,
);
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", () => {
@@ -1042,6 +1238,32 @@ describe("FormCheckboxPropertiesPanel", () => {
);
});
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("필드 너비") 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();
@@ -1452,4 +1674,65 @@ describe("FormCheckboxPropertiesPanel", () => {
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("그룹 타이틀") 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");
});
});
+39
View File
@@ -192,4 +192,43 @@ describe("ImagePropertiesPanel", () => {
expect.objectContaining({ borderRadius: "sm", borderRadiusPx: 24 }),
);
});
it("ImagePropertiesPanel 주요 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={baseProps as any}
selectedBlockId="image-theme-1"
updateBlock={updateBlock}
/>,
);
const sourceSelect = screen.getByLabelText("이미지 소스") as HTMLSelectElement;
const sourceClass = sourceSelect.getAttribute("class") ?? "";
expect(sourceClass).toContain("bg-white");
expect(sourceClass).toContain("text-slate-900");
expect(sourceClass).toContain("border-slate-300");
expect(sourceClass).toContain("dark:bg-slate-900");
expect(sourceClass).toContain("dark:text-slate-100");
expect(sourceClass).toContain("dark:border-slate-700");
const urlInput = screen.getByLabelText("이미지 URL") as HTMLInputElement;
const urlClass = urlInput.getAttribute("class") ?? "";
expect(urlClass).toContain("bg-white");
expect(urlClass).toContain("text-slate-900");
expect(urlClass).toContain("border-slate-300");
expect(urlClass).toContain("dark:bg-slate-900");
expect(urlClass).toContain("dark:text-slate-100");
expect(urlClass).toContain("dark:border-slate-700");
const alignSelect = screen.getByLabelText("이미지 정렬") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
expect(alignClass).toContain("border-slate-300");
expect(alignClass).toContain("dark:bg-slate-900");
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
});
});
+111
View File
@@ -121,6 +121,48 @@ describe("ListPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, textColorCustom: "#ff0000" }}
selectedBlockId="list-text-none"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
// 첫 번째 ColorPickerField(텍스트 색상)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 버튼 role 기준으로 선택한다 (불릿 스타일 select 의 option "없음" 과 구분).
const noneButtons = screen.getAllByRole("button", { name: "없음" });
fireEvent.click(noneButtons[0]);
expect(updateBlock).toHaveBeenCalledWith(
"list-text-none",
expect.objectContaining({ textColorCustom: "" }),
);
});
it("textColorCustom 이 비어 있으면 텍스트 색상 HEX 인풋 값은 빈 문자열이어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, textColorCustom: "" }}
selectedBlockId="list-text-empty"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("리스트 텍스트 색상 HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
it("불릿 스타일 셀렉트 변경 시 bulletStyle 과 ordered 가 함께 업데이트되어야 한다 (decimal)", () => {
const updateBlock = vi.fn();
@@ -183,4 +225,73 @@ describe("ListPropertiesPanel", () => {
expect.objectContaining({ gapYPx: 16 }),
);
});
it("리스트 정렬 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={baseProps}
selectedBlockId="list-align-theme"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
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("리스트 불릿 스타일 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={baseProps}
selectedBlockId="list-bullet-theme"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
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("리스트 아이템 textarea 는 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={baseProps}
selectedBlockId="list-theme"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const textarea = screen.getByLabelText("리스트 아이템들") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
+80
View File
@@ -147,4 +147,84 @@ describe("LoginPage", () => {
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
it("로그인 페이지는 전역 라이트/다크 테마 배경 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
const main = await screen.findByRole("main");
const className = (main as HTMLElement).className;
expect(className).toContain("bg-slate-100");
expect(className).toContain("dark:bg-slate-950");
});
it("비밀번호 입력 필드는 이메일 입력과 동일한 라이트/다크 테마 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const emailClass = emailInput.className;
const passwordClass = passwordInput.className;
expect(emailClass).toContain("border-slate-300");
expect(emailClass).toContain("bg-white");
expect(emailClass).toContain("text-slate-900");
expect(emailClass).toContain("dark:border-slate-700");
expect(emailClass).toContain("dark:bg-slate-950");
expect(emailClass).toContain("dark:text-slate-50");
expect(passwordClass).toBe(emailClass);
});
it("로그인 페이지에도 테마 전환 버튼이 있고 클릭 시 html 요소의 dark 클래스가 토글되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
});
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
expect(html.classList.contains("dark")).toBe(false);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(true);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(false);
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
describe("NumericPropertyControl - 테마", () => {
it("프리셋 셀렉트는 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(
<NumericPropertyControl
label="테스트 숫자"
unitLabel="px"
value={16}
min={0}
max={100}
step={1}
presets={[
{ id: "small", label: "작게", value: 12 },
{ id: "medium", label: "보통", value: 16 },
]}
onChangeValue={() => {}}
/>,
);
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("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
+12
View File
@@ -134,4 +134,16 @@ describe("PreviewPage - canvasPreset / canvasWidthPx", () => {
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
expect(main.style.backgroundColor).toBe("rgb(34, 34, 34)");
});
it("bodyBgColorHex 가 빈 문자열이면 PreviewPage main 에 background-color 가 들어가지 않아야 한다", () => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
bodyBgColorHex: "",
} as ProjectConfig;
const { container } = render(<PreviewPage />);
const main = container.querySelector("main") as HTMLElement;
// 명시적으로 빈 문자열을 지정한 경우에는 PreviewPage 가 어떤 기본 배경색도 강제로 설정하지 않아야 한다.
expect(main.style.backgroundColor).toBe("");
});
});
@@ -122,5 +122,46 @@ describe("ProjectPropertiesPanel", () => {
fireEvent.change(input, { target: { value: "#654321" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "#654321" });
});
it("캔버스 배경색 팔레트에서 \"없음\" 을 선택하면 canvasBgColorHex 가 빈 문자열로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
// 첫 번째 ColorPickerField(캔버스 배경색)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 선택한다.
const noneButton = screen.getByText("없음");
fireEvent.click(noneButton);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasBgColorHex: "" });
});
it("페이지 배경색 팔레트에서 \"없음\" 을 선택하면 bodyBgColorHex 가 빈 문자열로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
// 두 번째 ColorPickerField(페이지 배경색)의 팔레트 버튼은 기본 선택 팔레트 라벨("어두운 텍스트")를 기준으로 찾는다.
const darkLabel = screen.getByText("어두운 텍스트");
fireEvent.click(darkLabel.closest("button") as HTMLButtonElement);
const noneButton = screen.getByText("없음");
fireEvent.click(noneButton);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "" });
});
it("프로젝트 제목 입력은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("프로젝트 제목") as HTMLInputElement;
const className = input.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
+91 -3
View File
@@ -135,7 +135,7 @@ describe("ProjectSubmissionsPage - 폼 제출 내역", () => {
expect(errorText).toBeTruthy();
});
it("상단에 '프로젝트 목록' 링크가 있고 클릭 시 /projects 로 이동해야 한다", async () => {
it("상단에 '프로젝트 목록' 링크가 있고 href 가 /projects 이어야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
@@ -151,11 +151,99 @@ describe("ProjectSubmissionsPage - 폼 제출 내역", () => {
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(backLink).toBeTruthy();
expect(backLink.getAttribute("href")).toBe("/projects");
});
backLink.click();
it("상단 헤더에 공통 GNB와 테마 전환 버튼이 노출되고 테마 토글이 html 요소의 dark 클래스를 토글해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectSubmissionsPage />);
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(dashboardLink.getAttribute("href")).toBe("/dashboard");
expect(projectsLink.getAttribute("href")).toBe("/projects");
expect(submissionsLink.getAttribute("href")).toBe("/projects/submissions");
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
expect(html.classList.contains("dark")).toBe(false);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(true);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(false);
});
it("프로젝트별 제출 내역 테이블은 라이트 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const submissions = [
{
id: "1",
createdAt: "2025-01-01T12:00:00.000Z",
projectSlug: "test-project",
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(submissions), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
const { container } = render(<ProjectSubmissionsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const headerRow = container.querySelector("thead tr") as HTMLElement | null;
expect(headerRow).not.toBeNull();
const headerClass = headerRow!.className;
expect(headerClass).toContain("border-slate-200");
expect(headerClass).toContain("text-slate-600");
expect(headerClass).toContain("dark:border-slate-800");
expect(headerClass).toContain("dark:text-slate-400");
const nameCellNode = await screen.findByText("홍길동");
const nameCell = nameCellNode.closest("td") as HTMLElement | null;
expect(nameCell).not.toBeNull();
const nameClass = nameCell!.className;
expect(nameClass).toContain("text-slate-900");
expect(nameClass).toContain("dark:text-slate-100");
});
});
+108
View File
@@ -144,6 +144,114 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(link.closest("a")?.getAttribute("href")).toBe("/dashboard");
});
it("프로젝트 목록 테이블은 라이트/다크 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const titleCellNode = await screen.findByText("테스트 프로젝트 A");
const titleCell = titleCellNode.closest("td") as HTMLElement | null;
expect(titleCell).not.toBeNull();
const titleClassName = titleCell!.className;
expect(titleClassName).toContain("text-slate-900");
expect(titleClassName).toContain("dark:text-slate-100");
const slugCellNode = screen.getByText("test-project-a");
const slugCell = slugCellNode.closest("td") as HTMLElement | null;
expect(slugCell).not.toBeNull();
const slugClassName = slugCell!.className;
expect(slugClassName).toContain("text-slate-600");
expect(slugClassName).toContain("dark:text-slate-300");
});
it("상단 툴바/액션 링크/페이지네이션은 라이트/다크 테마 모두에서 읽기 쉬운 색상 클래스를 사용해야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const toolbar = await screen.findByTestId("projects-toolbar");
const toolbarClass = toolbar.className;
expect(toolbarClass).toContain("text-slate-600");
expect(toolbarClass).toContain("dark:text-slate-300");
const editLink = screen.getByText("편집").closest("a") as HTMLElement | null;
expect(editLink).not.toBeNull();
const editClass = editLink!.className;
expect(editClass).toContain("text-sky-600");
expect(editClass).toContain("dark:text-sky-300");
const previewLink = screen.getByText("미리보기").closest("a") as HTMLElement | null;
expect(previewLink).not.toBeNull();
const previewClass = previewLink!.className;
expect(previewClass).toContain("text-slate-600");
expect(previewClass).toContain("dark:text-slate-300");
const submissionsLink = screen.getByText("폼 제출 내역").closest("a") as HTMLElement | null;
expect(submissionsLink).not.toBeNull();
const submissionsClass = submissionsLink!.className;
expect(submissionsClass).toContain("text-emerald-600");
expect(submissionsClass).toContain("dark:text-emerald-300");
const deleteButton = screen.getByText("삭제").closest("button") as HTMLElement | null;
expect(deleteButton).not.toBeNull();
const deleteClass = deleteButton!.className;
expect(deleteClass).toContain("text-red-600");
expect(deleteClass).toContain("dark:text-red-300");
const prevButton = screen.getByRole("button", { name: "이전" });
const prevClass = prevButton.className;
expect(prevClass).toContain("border-slate-300");
expect(prevClass).toContain("text-slate-700");
expect(prevClass).toContain("dark:border-slate-700");
expect(prevClass).toContain("dark:text-slate-300");
});
it("GNB에서 현재 페이지인 '프로젝트 목록' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const projects = [
{
+85
View File
@@ -56,6 +56,30 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
expect(screen.getByText(/제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다/)).toBeDefined();
});
it("HELP 모달은 라이트/다크 테마에 맞는 카드 배경/테두리/텍스트 클래스를 사용해야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const helpButton = screen.getByRole("button", { name: "HELP" });
fireEvent.click(helpButton);
// 모달 카드 컨테이너 (제목 h3 → 헤더 div → 카드 div 순으로 감싸져 있으므로 parentElement.parentElement 사용)
const heading = screen.getByText("텍스트 블록 튜토리얼");
const modalCard = heading.parentElement?.parentElement as HTMLElement | null;
expect(modalCard).not.toBeNull();
const className = modalCard!.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트 + 연한 테두리
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-200");
// 다크 모드: 기존 다크 톤 유지
expect(className).toContain("dark:border-slate-700");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("모달의 닫기 버튼을 클릭하면 튜토리얼 모달이 닫혀야 한다", () => {
const textBlock = createTextBlock();
@@ -69,4 +93,65 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
expect(screen.queryByText("텍스트 블록 튜토리얼")).toBeNull();
});
it("텍스트 블록이 선택된 상태에서 상단 액션 버튼들은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const deleteButton = screen.getByRole("button", { name: "블록 삭제" });
const duplicateButton = screen.getByRole("button", { name: "블록 복제" });
const helpButton = screen.getByRole("button", { name: "HELP" });
for (const btn of [deleteButton, duplicateButton, helpButton]) {
const className = btn.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
expect(className).toContain("bg-slate-50");
expect(className).toContain("text-slate-900");
// 다크 모드: 기존 다크 톤 유지
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
}
});
it("텍스트 블록이 선택된 상태에서 내용 편집 textarea는 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const textarea = screen.getByLabelText("선택한 텍스트 블록 내용") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("속성 패널 루트는 라이트/다크 테마에 맞는 배경/테두리 클래스를 사용해야 한다", () => {
const props = defaultProps([], null);
render(<PropertiesSidebar {...props} />);
const aside = screen.getByTestId("properties-sidebar") as HTMLElement;
const className = aside.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("border-slate-200");
expect(className).toContain("dark:border-slate-800");
expect(className).toContain("dark:bg-slate-950/40");
});
it("속성 패널 루트는 pb-scroll 커스텀 스크롤바 클래스를 사용하지 않아야 한다", () => {
const props = defaultProps([], null);
render(<PropertiesSidebar {...props} />);
const aside = screen.getByTestId("properties-sidebar") as HTMLElement;
const className = aside.getAttribute("class") ?? "";
expect(className).not.toContain("pb-scroll");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
describe("PropertySliderField - 테마", () => {
it("텍스트 입력은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(
<PropertySliderField
label="테스트 슬라이더"
ariaLabelSlider="테스트 슬라이더"
ariaLabelInput="테스트 값 입력"
value={10}
min={0}
max={100}
step={1}
onChange={() => {}}
/>,
);
const input = screen.getByLabelText("테스트 값 입력") as HTMLInputElement;
const className = input.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
@@ -18,6 +18,7 @@ describe("PublicPageRenderer - 배경색 위임", () => {
const className = root!.getAttribute("class") ?? "";
expect(className).not.toContain("bg-slate-950");
expect(className).not.toContain("text-slate-50");
});
it("루트 텍스트/버튼 블록 영역은 고정 bg-slate-950 배경 대신 캔버스 배경을 그대로 보여야 한다", () => {
@@ -111,6 +111,24 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(input!.style.letterSpacing).toBe("0.125em");
});
it("formInput 기본 래퍼 클래스에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_neutral_wrapper",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const wrapper = getByTestId("preview-form-input-wrapper") as HTMLElement;
// 기본 래퍼에는 text-slate-* 색상 클래스를 사용하지 않고, 텍스트 색상은 상위/브라우저 기본값을 따른다.
expect(wrapper.className).not.toContain("text-slate-");
});
it("formInput 블록에서 라벨 표시 방식이 hidden 이면 sr-only 클래스로 시각적으로 숨겨야 한다", () => {
const blocks: Block[] = [
{
@@ -201,6 +219,27 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(field.style.getPropertyValue("--pb-input-padding-y")).toBe("1rem");
});
it("formInput 플로팅 라벨에서 strokeColorCustom 은 wrapper 의 --pb-input-border-color CSS 변수로 전달되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_floating_border_color",
type: "formInput",
props: {
label: "플로팅 보더",
formFieldName: "floating_border",
labelDisplay: "floating",
strokeColorCustom: "#ff0000",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const field = getByTestId("preview-form-input-wrapper") as HTMLDivElement;
// CSS 변수는 원본 hex 문자열 그대로 설정된다.
expect(field.style.getPropertyValue("--pb-input-border-color")).toBe("#ff0000");
});
it("formInput 플로팅 라벨에서 label 과 placeholder 가 같으면 placeholder 텍스트를 인풋 안에 표시하지 않아야 한다", () => {
const blocks: Block[] = [
{
@@ -338,28 +377,86 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const firstLabel = getFirstLabel(group);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const firstLabel = getFirstLabel(group);
// width: 320px / 16 = 20em
expect(group.style.width).toBe("20em");
// 옵션 간 간격: 16px -> 1em
const optionsContainer = group.querySelector('[data-testid="preview-form-checkbox-options"]') as HTMLElement | null;
expect(optionsContainer).not.toBeNull();
expect(optionsContainer!.style.rowGap).toBe("1em");
// width: 320px / 16 = 20em
expect(group.style.width).toBe("20em");
// 옵션 간 간격: 16px -> 1em
const optionsContainer = group.querySelector('[data-testid="preview-form-checkbox-options"]') as HTMLElement | null;
expect(optionsContainer).not.toBeNull();
expect(optionsContainer!.style.rowGap).toBe("1em");
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
expect(firstLabel.style.paddingInline).toBe("0.5em");
expect(firstLabel.style.paddingBlock).toBe("0.25em");
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
expect(firstLabel.style.borderRadius).toBe("6px");
});
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
expect(firstLabel.style.paddingInline).toBe("0.5em");
expect(firstLabel.style.paddingBlock).toBe("0.25em");
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
expect(firstLabel.style.borderRadius).toBe("6px");
});
it("formRadio 블록의 옵션 input 은 type/name/value 및 FormBlock.requiredFieldIds 기반 required 를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "form_ctrl_radio",
it("formCheckbox 그룹 래퍼 클래스에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_neutral_wrapper",
type: "formCheckbox",
props: {
groupLabel: "옵션들",
formFieldName: "features",
options: [{ label: "옵션 1", value: "opt1" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
// 체크박스 그룹 래퍼에는 text-slate-* 색상 클래스를 사용하지 않고, 텍스트 색상은 토큰/브라우저 기본값을 따른다.
expect(group.className).not.toContain("text-slate-");
});
it("formRadio 그룹 래퍼 클래스에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_neutral_wrapper",
type: "formRadio",
props: {
groupLabel: "라디오 그룹",
formFieldName: "radio_group",
options: [{ label: "옵션 A", value: "a" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-radio-group") as HTMLElement;
// 라디오 그룹 래퍼에는 text-slate-* 색상 클래스를 사용하지 않고, 텍스트 색상은 토큰/브라우저 기본값을 따른다.
expect(group.className).not.toContain("text-slate-");
});
it("formRadio 블록의 옵션 input 은 type/name/value 및 FormBlock.requiredFieldIds 기반 required 를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "form_ctrl_radio",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
requiredFieldIds: ["radio_required"],
} as any,
},
{
id: "radio_required",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} as any,
},
];
type: "form",
props: {
kind: "contact",
@@ -427,6 +524,29 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
});
});
it("formRadio 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_os_default",
type: "formRadio",
props: {
groupLabel: "라디오",
formFieldName: "radio_os",
options: [{ label: "옵션 A", value: "a" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-radio-group") as HTMLElement;
const input = group.querySelector("input[type='radio']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formCheckbox 블록에서 optionLayout 이 stacked 이면 옵션 컨테이너가 pb-form-options--stacked 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
@@ -547,6 +667,52 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(second.required).toBe(false);
});
it("formCheckbox 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_os_default",
type: "formCheckbox",
props: {
groupLabel: "체크",
formFieldName: "features_os",
options: [{ label: "옵션 1", value: "opt1" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const input = group.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4,rounded 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formCheckbox 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_os_default",
type: "formCheckbox",
props: {
groupLabel: "체크",
formFieldName: "features_os",
options: [{ label: "옵션 1", value: "opt1" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const input = group.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4,rounded 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formRadio 블록에서 그룹 타이틀 레이아웃이 inline 이면 그룹 컨테이너가 가로 정렬되고 세로 중앙 정렬 및 columnGap 이 labelGapPx 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -101,4 +101,47 @@ describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
const innerContainer = rootSection.querySelector(".pb-root-inner");
expect(innerContainer).not.toBeNull();
});
it("블록 배열 순서를 따라 루트 블록과 섹션 블록이 섞여 렌더되어야 한다", () => {
const rootBeforeProps: TextBlockProps = {
text: "루트1",
align: "left",
size: "base",
} as TextBlockProps;
const rootAfterProps: TextBlockProps = {
text: "루트2",
align: "left",
size: "base",
} as TextBlockProps;
const rootBefore: Block = {
id: "root_order_before",
type: "text",
props: rootBeforeProps,
} as any;
const sectionWithText = makeSectionWithText();
const rootAfter: Block = {
id: "root_order_after",
type: "text",
props: rootAfterProps,
} as any;
const blocks: Block[] = [rootBefore, ...sectionWithText, rootAfter];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const sections = Array.from(container.querySelectorAll("section"));
expect(sections.length).toBe(3);
expect(sections[0].className).toContain("pb-root");
expect(sections[1].className).toContain("pb-section");
expect(sections[2].className).toContain("pb-root");
expect(sections[0].textContent).toContain("루트1");
expect(sections[1].textContent).toContain("섹션 레이아웃 텍스트");
expect(sections[2].textContent).toContain("루트2");
});
});
@@ -363,4 +363,98 @@ describe("SectionPropertiesPanel", () => {
}),
);
});
it("SectionPropertiesPanel 배경 이미지/비디오 소스 및 URL 인풋은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
backgroundVideoSrc: "https://example.com/bg-video.mp4",
backgroundVideoSourceType: "externalUrl",
} as any}
selectedBlockId="section-theme-1"
updateBlock={updateBlock}
/>,
);
const bgImageSourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
const bgImageSourceClass = bgImageSourceSelect.getAttribute("class") ?? "";
expect(bgImageSourceClass).toContain("bg-white");
expect(bgImageSourceClass).toContain("text-slate-900");
expect(bgImageSourceClass).toContain("border-slate-300");
expect(bgImageSourceClass).toContain("dark:bg-slate-900");
expect(bgImageSourceClass).toContain("dark:text-slate-100");
expect(bgImageSourceClass).toContain("dark:border-slate-700");
const bgImageUrlInput = screen.getByLabelText("배경 이미지 URL") as HTMLInputElement;
const bgImageUrlClass = bgImageUrlInput.getAttribute("class") ?? "";
expect(bgImageUrlClass).toContain("bg-white");
expect(bgImageUrlClass).toContain("text-slate-900");
expect(bgImageUrlClass).toContain("border-slate-300");
expect(bgImageUrlClass).toContain("dark:bg-slate-900");
expect(bgImageUrlClass).toContain("dark:text-slate-100");
expect(bgImageUrlClass).toContain("dark:border-slate-700");
const bgVideoUrlInput = screen.getByLabelText("배경 비디오 URL") as HTMLInputElement;
const bgVideoUrlClass = bgVideoUrlInput.getAttribute("class") ?? "";
expect(bgVideoUrlClass).toContain("bg-white");
expect(bgVideoUrlClass).toContain("text-slate-900");
expect(bgVideoUrlClass).toContain("border-slate-300");
expect(bgVideoUrlClass).toContain("dark:bg-slate-900");
expect(bgVideoUrlClass).toContain("dark:text-slate-100");
expect(bgVideoUrlClass).toContain("dark:border-slate-700");
});
it("섹션 레이아웃 및 배경 이미지 레이아웃 셀렉트들은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
columns: [
{ id: "col_1", span: 6 },
{ id: "col_2", span: 6 },
],
alignItems: "top",
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
backgroundImagePositionMode: "preset",
backgroundImageSize: "cover",
backgroundImagePosition: "center",
backgroundImageRepeat: "no-repeat",
} as any}
selectedBlockId="section-theme-layout-1"
updateBlock={updateBlock}
/>,
);
const columnLayoutSelect = screen.getByLabelText("섹션 컬럼 레이아웃") as HTMLSelectElement;
const alignItemsSelect = screen.getByLabelText("섹션 컬럼 세로 정렬") as HTMLSelectElement;
const positionModeSelect = screen.getByLabelText("배경 이미지 위치 모드") as HTMLSelectElement;
const sizeSelect = screen.getByLabelText("배경 이미지 크기") as HTMLSelectElement;
const positionSelect = screen.getByLabelText("배경 이미지 위치") as HTMLSelectElement;
const repeatSelect = screen.getByLabelText("배경 이미지 반복") as HTMLSelectElement;
const assertDualTheme = (el: HTMLElement) => {
const className = el.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");
};
assertDualTheme(columnLayoutSelect);
assertDualTheme(alignItemsSelect);
assertDualTheme(positionModeSelect);
assertDualTheme(sizeSelect);
assertDualTheme(positionSelect);
assertDualTheme(repeatSelect);
});
});
+196
View File
@@ -56,10 +56,12 @@ describe("SignupPage", () => {
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const confirmInput = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
@@ -118,10 +120,12 @@ describe("SignupPage", () => {
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const confirmInput = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "dup@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
@@ -147,4 +151,196 @@ describe("SignupPage", () => {
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
it("회원가입 페이지는 전역 라이트/다크 테마 배경 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const main = await screen.findByRole("main");
const className = (main as HTMLElement).className;
expect(className).toContain("bg-slate-100");
expect(className).toContain("dark:bg-slate-950");
});
it("회원가입 폼의 비밀번호/비밀번호 확인 입력은 이메일 입력과 동일한 라이트/다크 테마 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const confirmInput = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
const emailClass = emailInput.className;
const passwordClass = passwordInput.className;
const confirmClass = confirmInput.className;
expect(emailClass).toContain("border-slate-300");
expect(emailClass).toContain("bg-white");
expect(emailClass).toContain("text-slate-900");
expect(emailClass).toContain("dark:border-slate-700");
expect(emailClass).toContain("dark:bg-slate-950");
expect(emailClass).toContain("dark:text-slate-50");
expect(passwordClass).toBe(emailClass);
expect(confirmClass).toBe(emailClass);
});
it("회원가입 폼에는 비밀번호 확인 입력 필드가 있어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const confirmInput = await screen.findByLabelText("비밀번호 확인");
expect(confirmInput).toBeTruthy();
});
it("비밀번호와 비밀번호 확인이 일치하지 않으면 회원가입 요청을 보내지 말고 오류 메시지를 표시해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/signup" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ id: "1", email: "new@example.com" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const confirmInput = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { target: { value: "differentPass2" } });
fireEvent.click(submitButton);
const errorText = await screen.findByText(/비밀번호와 비밀번호 확인이 일치하지 않습니다/);
expect(errorText).toBeTruthy();
const signupCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/signup" && options?.method === "POST";
});
expect(signupCall).toBeUndefined();
});
it("짧고 단순한 비밀번호는 난이도가 약함으로 표시되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "abc12345" } });
const weakText = await screen.findByText("비밀번호 난이도: 약함");
expect(weakText).toBeTruthy();
const bar = await screen.findByTestId("password-strength-bar");
const barClass = bar.className;
expect(barClass).toContain("w-1/3");
expect(barClass).toContain("bg-red-500");
});
it("대소문자와 숫자를 섞은 비밀번호는 난이도가 보통으로 표시되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "Abc12345" } });
const mediumText = await screen.findByText("비밀번호 난이도: 보통");
expect(mediumText).toBeTruthy();
const bar = await screen.findByTestId("password-strength-bar");
const barClass = bar.className;
expect(barClass).toContain("w-2/3");
expect(barClass).toContain("bg-amber-500");
});
it("대소문자/숫자/특수문자가 섞인 충분히 긴 비밀번호는 난이도가 강함으로 표시되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "Abc12345!@" } });
const strongText = await screen.findByText("비밀번호 난이도: 강함");
expect(strongText).toBeTruthy();
const bar = await screen.findByTestId("password-strength-bar");
const barClass = bar.className;
expect(barClass).toContain("w-full");
expect(barClass).toContain("bg-emerald-600");
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { TextPropertiesPanel } from "@/app/editor/panels/TextPropertiesPanel";
import type { TextBlockProps } from "@/features/editor/state/editorStore";
// TextPropertiesPanel 컨트롤 라이트/다크 테마 TDD
describe("TextPropertiesPanel", () => {
const baseProps: TextBlockProps = {
text: "텍스트",
align: "left",
size: "base",
} as any;
afterEach(() => {
cleanup();
});
it("텍스트 정렬 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={baseProps}
selectedBlockId="text-align"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
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("글자 간격 프리셋 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={baseProps}
selectedBlockId="text-letter-spacing"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
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("최대 너비 프리셋 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={baseProps}
selectedBlockId="text-max-width-select"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
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("최대 너비 커스텀 인풋은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={baseProps}
selectedBlockId="text-max-width-input"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
const input = screen.getByLabelText("최대 너비 커스텀") as HTMLInputElement;
const className = input.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");
});
});
+39
View File
@@ -293,4 +293,43 @@ describe("VideoPropertiesPanel", () => {
expect.objectContaining({ captionText: "새 캡션 텍스트" }),
);
});
it("VideoPropertiesPanel 주요 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, sourceType: "externalUrl" } as any}
selectedBlockId="video-theme-1"
updateBlock={updateBlock}
/>,
);
const sourceSelect = screen.getByLabelText("비디오 소스") as HTMLSelectElement;
const sourceClass = sourceSelect.getAttribute("class") ?? "";
expect(sourceClass).toContain("bg-white");
expect(sourceClass).toContain("text-slate-900");
expect(sourceClass).toContain("border-slate-300");
expect(sourceClass).toContain("dark:bg-slate-900");
expect(sourceClass).toContain("dark:text-slate-100");
expect(sourceClass).toContain("dark:border-slate-700");
const urlInput = screen.getByLabelText("비디오 URL") as HTMLInputElement;
const urlClass = urlInput.getAttribute("class") ?? "";
expect(urlClass).toContain("bg-white");
expect(urlClass).toContain("text-slate-900");
expect(urlClass).toContain("border-slate-300");
expect(urlClass).toContain("dark:bg-slate-900");
expect(urlClass).toContain("dark:text-slate-100");
expect(urlClass).toContain("dark:border-slate-700");
const alignSelect = screen.getByLabelText("비디오 정렬") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
expect(alignClass).toContain("border-slate-300");
expect(alignClass).toContain("dark:bg-slate-900");
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
});
});
+43
View File
@@ -300,6 +300,16 @@ describe("formHelpers - public tokens", () => {
expectNoPxInStyleObject(tokens.inputStyle, { allowKeys: ["borderRadius", "borderWidth"] });
});
it("computeFormInputPublicTokens: 색상 커스텀 값이 없으면 color/backgroundColor/borderColor 를 설정하지 않아야 한다", () => {
const tokens = computeFormInputPublicTokens({
...baseInput,
} as any);
expect(tokens.inputStyle.color).toBeUndefined();
expect(tokens.inputStyle.backgroundColor).toBeUndefined();
expect(tokens.inputStyle.borderColor).toBeUndefined();
});
it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormSelectPublicTokens({
...baseSelect,
@@ -327,6 +337,17 @@ describe("formHelpers - public tokens", () => {
expectNoPxInStyleObject(tokens.selectStyle, { allowKeys: ["borderRadius", "borderWidth"] });
});
it("computeFormSelectPublicTokens: 색상 커스텀 값이 없으면 color/backgroundColor/borderColor 를 설정하지 않아야 한다", () => {
const tokens = computeFormSelectPublicTokens({
...baseSelect,
} as any);
expect(tokens.wrapperStyle.color).toBeUndefined();
expect(tokens.selectStyle.color).toBeUndefined();
expect(tokens.selectStyle.backgroundColor).toBeUndefined();
expect(tokens.selectStyle.borderColor).toBeUndefined();
});
it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => {
const tokens = computeFormCheckboxPublicTokens({
...baseCheckbox,
@@ -362,6 +383,17 @@ describe("formHelpers - public tokens", () => {
expectNoPxInStyleObject(tokens.optionTextStyle);
});
it("computeFormCheckboxPublicTokens: 색상 커스텀 값이 없으면 텍스트/옵션 컨테이너에 색상 스타일을 설정하지 않아야 한다", () => {
const tokens = computeFormCheckboxPublicTokens({
...baseCheckbox,
} as any);
expect(tokens.groupTextStyle.color).toBeUndefined();
expect(tokens.optionTextStyle.color).toBeUndefined();
expect(tokens.optionContainerStyle.backgroundColor).toBeUndefined();
expect(tokens.optionContainerStyle.borderColor).toBeUndefined();
});
it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => {
const tokens = computeFormRadioPublicTokens({
...baseRadio,
@@ -389,6 +421,17 @@ describe("formHelpers - public tokens", () => {
expect(tokens.groupTextStyle.color).toBe("#ffffff");
expect(tokens.optionTextStyle.color).toBe("#ffffff");
});
it("computeFormRadioPublicTokens: 색상 커스텀 값이 없으면 텍스트/옵션 컨테이너에 색상 스타일을 설정하지 않아야 한다", () => {
const tokens = computeFormRadioPublicTokens({
...baseRadio,
} as any);
expect(tokens.groupTextStyle.color).toBeUndefined();
expect(tokens.optionTextStyle.color).toBeUndefined();
expect(tokens.optionContainerStyle.backgroundColor).toBeUndefined();
expect(tokens.optionContainerStyle.borderColor).toBeUndefined();
});
});
describe("formHelpers.computeFormControllerPublicTokens", () => {