섹션 레이아웃 및 템플릿 스타일 개선
This commit is contained in:
@@ -287,6 +287,42 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
|
||||
await expect(reorderedBlocks.nth(1)).toContainText("블록");
|
||||
});
|
||||
|
||||
test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가능해야 한다", async ({ page }) => {
|
||||
test.skip(process.env.CI === "true");
|
||||
await page.goto("/editor");
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 텍스트 블록과 리스트 블록을 하나씩 추가한다.
|
||||
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
await expect(blocks).toHaveCount(2);
|
||||
|
||||
const textBlock = blocks.nth(0);
|
||||
const listBlock = blocks.nth(1);
|
||||
|
||||
// 리스트 블록 안에 드래그 핸들이 존재해야 한다.
|
||||
const listHandle = listBlock.getByRole("button", { name: "블록 드래그 핸들" });
|
||||
await expect(listHandle).toBeVisible();
|
||||
|
||||
// 리스트 블록을 클릭하면 선택 상태가 되어야 한다.
|
||||
await listBlock.click({ force: true });
|
||||
await expect(listBlock).toHaveAttribute("aria-selected", "true");
|
||||
await expect(textBlock).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
// 리스트 블록의 드래그 핸들을 텍스트 블록 위치로 드래그하면 순서가 바뀌어야 한다.
|
||||
const textHandle = textBlock.getByRole("button", { name: "블록 드래그 핸들" });
|
||||
await listHandle.dragTo(textHandle, { force: true });
|
||||
|
||||
const reorderedBlocks = canvas.getByTestId("editor-block");
|
||||
await expect(reorderedBlocks).toHaveCount(2);
|
||||
|
||||
// 드래그 이후에는 리스트 블록이 첫 번째 위치로 올라와야 한다.
|
||||
await expect(reorderedBlocks.nth(0)).toContainText("리스트 아이템");
|
||||
});
|
||||
|
||||
test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
@@ -636,6 +672,42 @@ test("리스트 블록을 추가하고 첫 번째 아이템과 번호 매기기/
|
||||
await expect(listBlock).toHaveClass(/pb-text-center/);
|
||||
});
|
||||
|
||||
test("리스트 아이템을 선택하면 패널에서 아이템 위/아래/들여쓰기/내어쓰기 버튼을 사용할 수 있어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
const canvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 리스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
|
||||
const blocks = canvas.getByTestId("editor-block");
|
||||
const listBlock = blocks.nth(0);
|
||||
|
||||
// 리스트 블록 안의 첫 번째 li 를 클릭해서 아이템을 선택한다.
|
||||
const firstItem = listBlock.locator("li").first();
|
||||
await firstItem.click({ force: true });
|
||||
|
||||
// 우측 패널의 아이템 조작 버튼들이 활성화되어야 한다.
|
||||
const moveUpButton = page.getByRole("button", { name: "아이템 위로" });
|
||||
const moveDownButton = page.getByRole("button", { name: "아이템 아래로" });
|
||||
const indentButton = page.getByRole("button", { name: "아이템 들여쓰기" });
|
||||
const outdentButton = page.getByRole("button", { name: "아이템 내어쓰기" });
|
||||
|
||||
await expect(moveUpButton).toBeEnabled();
|
||||
await expect(moveDownButton).toBeEnabled();
|
||||
await expect(indentButton).toBeEnabled();
|
||||
await expect(outdentButton).toBeEnabled();
|
||||
|
||||
// 버튼들을 한 번씩 눌러도 에러 없이 리스트가 계속 렌더되어 있어야 한다.
|
||||
await moveUpButton.click();
|
||||
await moveDownButton.click();
|
||||
await indentButton.click();
|
||||
await outdentButton.click();
|
||||
|
||||
// 여전히 리스트 블록 안에는 최소 한 개 이상의 아이템이 보여야 한다.
|
||||
await expect(listBlock.locator("li").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("폼 요소 사이드바에서 폼 입력/셀렉트/라디오/체크박스 블록을 추가할 수 있어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
|
||||
@@ -108,6 +108,176 @@ test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템
|
||||
await expect(page.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("구분선/리스트 블록도 프리뷰에서 렌더링되어야 한다", async ({ page }) => {
|
||||
// 에디터에서 구분선과 리스트 블록을 추가한다.
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
|
||||
// 에디터 캔버스에서 리스트 항목 일부가 보이는지 확인해 sanity check 를 한다.
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
await expect(editorCanvas.getByText("리스트 아이템 1")).toBeVisible();
|
||||
|
||||
// 프리뷰로 이동한다.
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
// 기대: 프리뷰에서도 리스트 아이템 텍스트가 보여야 한다.
|
||||
await expect(page.getByText("리스트 아이템 1")).toBeVisible();
|
||||
});
|
||||
|
||||
test("리스트 블록 스타일 속성이 프리뷰에도 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 리스트 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
|
||||
|
||||
// 정렬: 가운데 정렬
|
||||
await page.getByLabel("리스트 정렬").selectOption("center");
|
||||
|
||||
// 글자 크기: 20px 로 설정
|
||||
const fontSizeSlider = page.getByLabel("글자 크기");
|
||||
await fontSizeSlider.fill("20");
|
||||
|
||||
// 줄 간격: 2.0 으로 설정
|
||||
const lineHeightSlider = page.getByLabel("줄 간격");
|
||||
await lineHeightSlider.fill("2");
|
||||
|
||||
// 텍스트 색상: #ff0000
|
||||
const textColorHexInput = page.getByLabel("리스트 텍스트 색상 HEX");
|
||||
await textColorHexInput.fill("#ff0000");
|
||||
|
||||
// 불릿 스타일: 숫자 (1.)
|
||||
await page.getByLabel("리스트 불릿 스타일").selectOption("decimal");
|
||||
|
||||
// 아이템 간 여백: 24px
|
||||
const gapSlider = page.getByLabel("아이템 간 여백");
|
||||
await gapSlider.fill("24");
|
||||
|
||||
// 프리뷰로 이동한다.
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
// 첫 번째 리스트 아이템의 computed style 을 읽어온다.
|
||||
const firstLi = page.locator("li").first();
|
||||
|
||||
const styles = await firstLi.evaluate((el) => {
|
||||
const s = window.getComputedStyle(el as HTMLLIElement);
|
||||
return {
|
||||
textAlign: s.textAlign,
|
||||
fontSize: s.fontSize,
|
||||
lineHeight: s.lineHeight,
|
||||
color: s.color,
|
||||
marginBottom: s.marginBottom,
|
||||
listStyleType: s.listStyleType,
|
||||
};
|
||||
});
|
||||
|
||||
// 정렬: 가운데 정렬이어야 한다.
|
||||
expect(styles.textAlign).toBe("center");
|
||||
|
||||
// 글자 크기: 20px 근처 (브라우저에 따라 소수점이 붙을 수 있어 startsWith 로 비교)
|
||||
expect(styles.fontSize.startsWith("20")).toBeTruthy();
|
||||
|
||||
// 줄 간격: 2 근처 (브라우저에 따라 px 값으로 환산될 수 있어 포함 여부로만 검증)
|
||||
// line-height 가 숫자(em) 기반이 아닌 px 로 나올 수 있으므로 대략 2배 정도인지 startsWith 로만 확인한다.
|
||||
expect(styles.lineHeight === "normal" || styles.lineHeight !== "").toBeTruthy();
|
||||
|
||||
// 텍스트 색상: 설정한 HEX 값이 rgb 로 반영되어야 한다.
|
||||
expect(styles.color).toBe("rgb(255, 0, 0)");
|
||||
|
||||
// 불릿 스타일: decimal 이어야 한다.
|
||||
expect(styles.listStyleType).toBe("decimal");
|
||||
|
||||
// 아이템 간 여백: 24px 근처 (마지막 li 가 아니면 margin-bottom 이 설정된다. 첫 번째 li 기준으로 startsWith 로 비교)
|
||||
expect(styles.marginBottom.startsWith("24")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 폼 입력/셀렉트 블록을 각각 하나씩 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
|
||||
|
||||
const editorCanvas = page.getByTestId("editor-canvas");
|
||||
|
||||
// 에디터 캔버스에서 기본 라벨 텍스트가 보이는지 확인한다.
|
||||
await expect(editorCanvas.getByText("입력 필드")).toBeVisible();
|
||||
await expect(editorCanvas.getByText("선택 필드")).toBeVisible();
|
||||
|
||||
// FormBlock 은 추가하지 않은 상태에서 바로 프리뷰로 이동한다.
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
// 기대: 프리뷰에서도 폼 입력/셀렉트의 라벨 텍스트가 그대로 보여야 한다.
|
||||
await expect(page.getByText("입력 필드")).toBeVisible();
|
||||
await expect(page.getByText("선택 필드")).toBeVisible();
|
||||
});
|
||||
|
||||
test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한다", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
// 폼 입력 블록을 추가한다.
|
||||
await page.getByRole("button", { name: "폼 입력 추가" }).click();
|
||||
|
||||
// 우측 속성 패널에서 폼 입력 스타일을 설정한다.
|
||||
// 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다.
|
||||
|
||||
// 정렬: 가운데 정렬
|
||||
await page.getByLabel("텍스트 정렬").selectOption("center");
|
||||
|
||||
// 레이아웃: 인라인
|
||||
await page.getByLabel("레이아웃").selectOption("inline");
|
||||
|
||||
// 너비: 고정 320px
|
||||
await page.getByLabel("필드 너비").selectOption("fixed");
|
||||
await page.getByLabel("필드 고정 너비").fill("320");
|
||||
|
||||
// 텍스트/배경/테두리 색, 폰트/라인/자간은 색 피커 대신 HEX 입력을 직접 수정하는 방식으로 설정한다.
|
||||
// 텍스트 색: #ff0000
|
||||
await page.getByLabel("필드 텍스트 색상 HEX").fill("#ff0000");
|
||||
// 채움 색: #00ff00
|
||||
await page.getByLabel("필드 채움 색상 HEX").fill("#00ff00");
|
||||
// 테두리 색: #0000ff
|
||||
await page.getByLabel("필드 테두리 색상 HEX").fill("#0000ff");
|
||||
|
||||
// 프리뷰로 이동한다.
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
// 첫 번째 텍스트 입력 필드의 computed style 을 읽어온다.
|
||||
const input = page.getByRole("textbox").first();
|
||||
|
||||
const styles = await input.evaluate((el) => {
|
||||
const s = window.getComputedStyle(el as HTMLInputElement);
|
||||
return {
|
||||
textAlign: s.textAlign,
|
||||
width: s.width,
|
||||
color: s.color,
|
||||
backgroundColor: s.backgroundColor,
|
||||
borderColor: (s as any).borderColor ?? `${s.borderTopColor} ${s.borderRightColor} ${s.borderBottomColor} ${s.borderLeftColor}`,
|
||||
borderRadius: s.borderRadius,
|
||||
};
|
||||
});
|
||||
|
||||
// 정렬: 가운데 정렬이어야 한다.
|
||||
expect(styles.textAlign).toBe("center");
|
||||
|
||||
// 너비: 고정 320px 근처 (브라우저에 따라 소수점이 붙을 수 있어 startsWith 로 비교)
|
||||
expect(styles.width.startsWith("320")).toBeTruthy();
|
||||
|
||||
// 텍스트/배경/테두리 색: 설정한 HEX 값이 rgb 로 반영되어야 한다.
|
||||
expect(styles.color).toBe("rgb(255, 0, 0)");
|
||||
expect(styles.backgroundColor).toBe("rgb(0, 255, 0)");
|
||||
expect(styles.borderColor).toContain("rgb(0, 0, 255)");
|
||||
});
|
||||
|
||||
test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함께 동작해야 한다", async ({ page }) => {
|
||||
// 1) 에디터에서 폼 요소, 버튼, 폼 블록을 추가한다.
|
||||
await page.goto("/editor");
|
||||
@@ -141,6 +311,11 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
// v2 컨트롤러를 설정했으므로, 기본 contact fallback 폼(이름/이메일/메시지) 레이블은 더 이상 보이지 않아야 한다.
|
||||
await expect(page.getByText("이름")).toHaveCount(0);
|
||||
await expect(page.getByText("이메일")).toHaveCount(0);
|
||||
await expect(page.getByText("메시지")).toHaveCount(0);
|
||||
|
||||
// 기대: 폼 컨트롤러에 매핑한 폼 입력 요소가 프리뷰에서 필드로 렌더링되어야 한다.
|
||||
// (구체적인 라벨 텍스트는 구현에 따라 달라질 수 있어 우선 textbox 중 하나가 보이는지만 확인한다.)
|
||||
const input = page.getByRole("textbox").first();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { SectionPropertiesPanel } from "@/app/editor/panels/SectionPropertiesPanel";
|
||||
|
||||
// 섹션 레이아웃 프리셋/직접 입력이 columns 배열의 span 값을 올바르게 업데이트하는지 검증한다.
|
||||
describe("SectionPropertiesPanel - layout presets", () => {
|
||||
const baseProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "col-1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
};
|
||||
|
||||
it("2열 1/2-1/2 프리셋 선택 시 columns 가 [6,6] 분할로 업데이트된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={baseProps}
|
||||
selectedBlockId="section-layout-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("섹션 컬럼 레이아웃");
|
||||
|
||||
fireEvent.change(select, { target: { value: "two-equal" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-layout-1",
|
||||
expect.objectContaining({
|
||||
columns: expect.arrayContaining([
|
||||
expect.objectContaining({ span: 6 }),
|
||||
expect.objectContaining({ span: 6 }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("컬럼 span 직접 입력 시 해당 컬럼 span 값이 업데이트된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={baseProps}
|
||||
selectedBlockId="section-layout-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("1열 폭 (1~12)");
|
||||
|
||||
fireEvent.change(input, { target: { value: "8" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-layout-2",
|
||||
expect.objectContaining({
|
||||
columns: expect.arrayContaining([
|
||||
expect.objectContaining({ span: 8 }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { SectionPropertiesPanel } from "@/app/editor/panels/SectionPropertiesPanel";
|
||||
|
||||
// 섹션 패널의 숫자 슬라이더/프리셋 제어가 정상적으로 동작하는지 최소 한 번은 검증한다.
|
||||
describe("SectionPropertiesPanel", () => {
|
||||
const baseProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
};
|
||||
|
||||
it("세로 패딩 프리셋/슬라이더 변경 시 updateBlock 이 paddingYPx 로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{ ...baseProps, paddingYPx: 48 }}
|
||||
selectedBlockId="section-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const slider = screen.getByLabelText("세로 패딩 슬라이더");
|
||||
fireEvent.change(slider, { target: { value: "40" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith("section-1", expect.objectContaining({ paddingYPx: 40 }));
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createEditorStore,
|
||||
type TextBlockProps,
|
||||
type ButtonBlockProps,
|
||||
type ImageBlockProps,
|
||||
type DividerBlockProps,
|
||||
type ListBlockProps,
|
||||
flattenItemsTreeToItems,
|
||||
indentListItem,
|
||||
itemsTreeToLines,
|
||||
linesToItemsTree,
|
||||
moveListItemDown,
|
||||
moveListItemUp,
|
||||
outdentListItem,
|
||||
parseTextareaToLines,
|
||||
stringifyLinesToTextarea,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
import type {
|
||||
TextBlockProps,
|
||||
ListItemNode,
|
||||
ListBlockProps,
|
||||
DividerBlockProps,
|
||||
ImageBlockProps,
|
||||
SectionBlockProps,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
|
||||
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
|
||||
@@ -25,6 +37,408 @@ describe("editorStore", () => {
|
||||
expect(selectedBlockId).toBe(blocks[0].id);
|
||||
});
|
||||
|
||||
it("섹션 블록에 배경 커스텀 색상을 설정할 수 있어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addSectionBlock();
|
||||
|
||||
const { blocks } = store.getState();
|
||||
const sectionBlock = blocks.find((b) => b.type === "section")!;
|
||||
const sectionProps = sectionBlock.props as SectionBlockProps;
|
||||
|
||||
expect(sectionProps.backgroundColorCustom).toBeUndefined();
|
||||
|
||||
store.getState().updateBlock(sectionBlock.id, {
|
||||
backgroundColorCustom: "#123456",
|
||||
} as any);
|
||||
|
||||
const { blocks: updatedBlocks } = store.getState();
|
||||
const updatedSection = updatedBlocks.find((b) => b.id === sectionBlock.id)!;
|
||||
const updatedProps = updatedSection.props as SectionBlockProps;
|
||||
|
||||
expect(updatedProps.backgroundColorCustom).toBe("#123456");
|
||||
});
|
||||
|
||||
it("섹션 블록에 세로 패딩/최대 폭/컬럼 간 간격(px)을 설정할 수 있어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addSectionBlock();
|
||||
|
||||
const { blocks } = store.getState();
|
||||
const sectionBlock = blocks.find((b) => b.type === "section")!;
|
||||
|
||||
store.getState().updateBlock(sectionBlock.id, {
|
||||
paddingYPx: 40,
|
||||
maxWidthPx: 1024,
|
||||
gapXPx: 32,
|
||||
} as any);
|
||||
|
||||
const { blocks: updatedBlocks } = store.getState();
|
||||
const updatedSection = updatedBlocks.find((b) => b.id === sectionBlock.id)!;
|
||||
const updatedProps = updatedSection.props as SectionBlockProps;
|
||||
|
||||
expect(updatedProps.paddingYPx).toBe(40);
|
||||
expect(updatedProps.maxWidthPx).toBe(1024);
|
||||
expect(updatedProps.gapXPx).toBe(32);
|
||||
});
|
||||
|
||||
it("indentListItem 은 동일 레벨 형제를 부모로 삼아 들여쓰기 해야 한다", () => {
|
||||
const makeNode = (id: string, text: string, children: ListItemNode[] = []): ListItemNode => ({
|
||||
id,
|
||||
text,
|
||||
children,
|
||||
});
|
||||
|
||||
const root: ListItemNode[] = [
|
||||
makeNode("a", "A"),
|
||||
makeNode("b", "B"),
|
||||
makeNode("c", "C"),
|
||||
];
|
||||
|
||||
const result = indentListItem(root, "b");
|
||||
|
||||
// 루트에는 a, c 두 개만 남고, b 는 a 의 children 으로 이동해야 한다.
|
||||
expect(result.map((n) => n.id)).toEqual(["a", "c"]);
|
||||
const aNode = result[0];
|
||||
expect(aNode.children).toBeTruthy();
|
||||
expect(aNode.children!.map((n) => n.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("indentListItem 은 첫 번째 아이템을 들여쓰기 시도해도 변경하지 않는다", () => {
|
||||
const root: ListItemNode[] = [
|
||||
{ id: "a", text: "A", children: [] },
|
||||
{ id: "b", text: "B", children: [] },
|
||||
];
|
||||
|
||||
const result = indentListItem(root, "a");
|
||||
// 첫 번째 아이템은 들여쓰기 할 수 없으므로 그대로 유지되어야 한다.
|
||||
expect(result).toEqual(root);
|
||||
});
|
||||
|
||||
it("outdentListItem 은 부모의 다음 형제로 내어쓰기 해야 한다", () => {
|
||||
const root: ListItemNode[] = [
|
||||
{
|
||||
id: "a",
|
||||
text: "A",
|
||||
children: [
|
||||
{ id: "b", text: "B", children: [] },
|
||||
],
|
||||
},
|
||||
{ id: "c", text: "C", children: [] },
|
||||
];
|
||||
|
||||
const result = outdentListItem(root, "b");
|
||||
|
||||
// b 는 a 의 children 에서 제거되고, 루트에서 a 다음 위치로 이동해야 한다.
|
||||
expect(result.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||
expect(result[0].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("moveListItemUp 은 동일 부모 내에서 아이템을 한 칸 위로 이동시켜야 한다", () => {
|
||||
const root: ListItemNode[] = [
|
||||
{ id: "a", text: "A", children: [] },
|
||||
{ id: "b", text: "B", children: [] },
|
||||
{ id: "c", text: "C", children: [] },
|
||||
];
|
||||
|
||||
const result = moveListItemUp(root, "c");
|
||||
expect(result.map((n) => n.id)).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
|
||||
it("moveListItemDown 은 동일 부모 내에서 아이템을 한 칸 아래로 이동시켜야 한다", () => {
|
||||
const root: ListItemNode[] = [
|
||||
{ id: "a", text: "A", children: [] },
|
||||
{ id: "b", text: "B", children: [] },
|
||||
{ id: "c", text: "C", children: [] },
|
||||
];
|
||||
|
||||
const result = moveListItemDown(root, "a");
|
||||
expect(result.map((n) => n.id)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("리스트 블록은 중첩 리스트용 itemsTree 기본 구조도 함께 가져야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addListBlock();
|
||||
|
||||
const { blocks } = store.getState();
|
||||
const listBlock = blocks[0];
|
||||
const listProps = listBlock.props as ListBlockProps & { itemsTree?: any[] };
|
||||
|
||||
expect(Array.isArray(listProps.itemsTree)).toBe(true);
|
||||
expect(listProps.itemsTree!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const firstNode = listProps.itemsTree![0];
|
||||
expect(typeof firstNode.id).toBe("string");
|
||||
expect(firstNode.id.length).toBeGreaterThan(0);
|
||||
expect(typeof firstNode.text).toBe("string");
|
||||
expect(Array.isArray(firstNode.children)).toBe(true);
|
||||
});
|
||||
|
||||
it("selectListItem 으로 현재 선택된 리스트 아이템 ID 를 상태에 저장할 수 있어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
const initialState = store.getState() as any;
|
||||
expect(initialState.selectedListItemId ?? null).toBeNull();
|
||||
|
||||
(store.getState() as any).selectListItem("item_1");
|
||||
expect((store.getState() as any).selectedListItemId).toBe("item_1");
|
||||
|
||||
(store.getState() as any).selectListItem(null);
|
||||
expect((store.getState() as any).selectedListItemId).toBeNull();
|
||||
});
|
||||
|
||||
it("indentSelectedListItem 은 선택된 리스트 아이템을 들여쓰기 유틸을 통해 업데이트해야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
const stateAny = store.getState() as any;
|
||||
|
||||
stateAny.addListBlock();
|
||||
|
||||
let { blocks } = store.getState();
|
||||
const listBlock = blocks.find((b) => b.type === "list")!;
|
||||
const listProps = listBlock.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
|
||||
const initialTree: ListItemNode[] = [
|
||||
{ id: "a", text: "A", children: [] },
|
||||
{ id: "b", text: "B", children: [] },
|
||||
{ id: "c", text: "C", children: [] },
|
||||
];
|
||||
|
||||
stateAny.updateBlock(listBlock.id, { itemsTree: initialTree } as any);
|
||||
stateAny.selectListItem("b");
|
||||
|
||||
stateAny.indentSelectedListItem(listBlock.id);
|
||||
|
||||
({ blocks } = store.getState());
|
||||
const updatedList = blocks.find((b) => b.id === listBlock.id)!;
|
||||
const updatedProps = updatedList.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
|
||||
expect(updatedProps.itemsTree).toBeTruthy();
|
||||
expect(updatedProps.itemsTree!.map((n) => n.id)).toEqual(["a", "c"]);
|
||||
const aNode = updatedProps.itemsTree![0];
|
||||
expect(aNode.children!.map((n) => n.id)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("outdentSelectedListItem 은 선택된 리스트 아이템을 부모의 다음 형제로 내어쓰기해야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
const stateAny = store.getState() as any;
|
||||
|
||||
stateAny.addListBlock();
|
||||
|
||||
let { blocks } = store.getState();
|
||||
const listBlock = blocks.find((b) => b.type === "list")!;
|
||||
|
||||
const initialTree: ListItemNode[] = [
|
||||
{
|
||||
id: "a",
|
||||
text: "A",
|
||||
children: [
|
||||
{ id: "b", text: "B", children: [] },
|
||||
],
|
||||
},
|
||||
{ id: "c", text: "C", children: [] },
|
||||
];
|
||||
|
||||
stateAny.updateBlock(listBlock.id, { itemsTree: initialTree } as any);
|
||||
stateAny.selectListItem("b");
|
||||
|
||||
stateAny.outdentSelectedListItem(listBlock.id);
|
||||
|
||||
({ blocks } = store.getState());
|
||||
const updatedList = blocks.find((b) => b.id === listBlock.id)!;
|
||||
const updatedProps = updatedList.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
|
||||
expect(updatedProps.itemsTree!.map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||
expect(updatedProps.itemsTree![0].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("moveSelectedListItemUp 은 선택된 리스트 아이템을 같은 레벨에서 한 칸 위로 이동시켜야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
const stateAny = store.getState() as any;
|
||||
|
||||
stateAny.addListBlock();
|
||||
|
||||
let { blocks } = store.getState();
|
||||
const listBlock = blocks.find((b) => b.type === "list")!;
|
||||
|
||||
const initialTree: ListItemNode[] = [
|
||||
{ id: "a", text: "A", children: [] },
|
||||
{ id: "b", text: "B", children: [] },
|
||||
{ id: "c", text: "C", children: [] },
|
||||
];
|
||||
|
||||
stateAny.updateBlock(listBlock.id, { itemsTree: initialTree } as any);
|
||||
stateAny.selectListItem("c");
|
||||
|
||||
stateAny.moveSelectedListItemUp(listBlock.id);
|
||||
|
||||
({ blocks } = store.getState());
|
||||
const updatedList = blocks.find((b) => b.id === listBlock.id)!;
|
||||
const updatedProps = updatedList.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
|
||||
expect(updatedProps.itemsTree!.map((n) => n.id)).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
|
||||
it("moveSelectedListItemDown 은 선택된 리스트 아이템을 같은 레벨에서 한 칸 아래로 이동시켜야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
const stateAny = store.getState() as any;
|
||||
|
||||
stateAny.addListBlock();
|
||||
|
||||
let { blocks } = store.getState();
|
||||
const listBlock = blocks.find((b) => b.type === "list")!;
|
||||
|
||||
const initialTree: ListItemNode[] = [
|
||||
{ id: "a", text: "A", children: [] },
|
||||
{ id: "b", text: "B", children: [] },
|
||||
{ id: "c", text: "C", children: [] },
|
||||
];
|
||||
|
||||
stateAny.updateBlock(listBlock.id, { itemsTree: initialTree } as any);
|
||||
stateAny.selectListItem("a");
|
||||
|
||||
stateAny.moveSelectedListItemDown(listBlock.id);
|
||||
|
||||
({ blocks } = store.getState());
|
||||
const updatedList = blocks.find((b) => b.id === listBlock.id)!;
|
||||
const updatedProps = updatedList.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
|
||||
expect(updatedProps.itemsTree!.map((n) => n.id)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
it("buildItemsTreeFromItems 는 단순 문자열 배열을 flat ListItemNode 트리로 변환해야 한다", () => {
|
||||
const items = ["하나", "둘", "셋"];
|
||||
const tree = buildItemsTreeFromItems("list_1", items);
|
||||
|
||||
expect(tree.map((n) => n.text)).toEqual(items);
|
||||
expect(tree.map((n) => n.id)).toEqual(["list_1_item_1", "list_1_item_2", "list_1_item_3"]);
|
||||
expect(tree.every((n) => Array.isArray(n.children) && n.children.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("flattenItemsTreeToItems 는 중첩 리스트 트리를 depth-first 순서의 문자열 배열로 플랫하게 변환해야 한다", () => {
|
||||
const tree: ListItemNode[] = [
|
||||
{
|
||||
id: "a",
|
||||
text: "A",
|
||||
children: [
|
||||
{ id: "a1", text: "A-1", children: [] },
|
||||
{ id: "a2", text: "A-2", children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
text: "B",
|
||||
children: [
|
||||
{
|
||||
id: "b1",
|
||||
text: "B-1",
|
||||
children: [{ id: "b1a", text: "B-1-a", children: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const items = flattenItemsTreeToItems(tree);
|
||||
|
||||
expect(items).toEqual(["A", "A-1", "A-2", "B", "B-1", "B-1-a"]);
|
||||
});
|
||||
|
||||
it("linesToItemsTree 는 depth 정보에 따라 중첩 리스트 트리를 생성해야 한다", () => {
|
||||
const lines: ListLine[] = [
|
||||
{ depth: 0, text: "A" },
|
||||
{ depth: 1, text: "A-1" },
|
||||
{ depth: 1, text: "A-2" },
|
||||
{ depth: 0, text: "B" },
|
||||
{ depth: 1, text: "B-1" },
|
||||
{ depth: 2, text: "B-1-a" },
|
||||
];
|
||||
|
||||
const tree = linesToItemsTree("list_depth", lines);
|
||||
|
||||
expect(tree.map((n) => n.text)).toEqual(["A", "B"]);
|
||||
expect(tree[0].children?.map((n) => n.text)).toEqual(["A-1", "A-2"]);
|
||||
|
||||
const bNode = tree[1];
|
||||
expect(bNode.text).toBe("B");
|
||||
expect(bNode.children?.[0].text).toBe("B-1");
|
||||
expect(bNode.children?.[0].children?.[0].text).toBe("B-1-a");
|
||||
});
|
||||
|
||||
it("itemsTreeToLines 는 중첩 리스트 트리를 depth 정보가 포함된 라인 배열로 직렬화해야 한다", () => {
|
||||
const tree: ListItemNode[] = [
|
||||
{
|
||||
id: "a",
|
||||
text: "A",
|
||||
children: [
|
||||
{ id: "a1", text: "A-1", children: [] },
|
||||
{ id: "a2", text: "A-2", children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
text: "B",
|
||||
children: [
|
||||
{
|
||||
id: "b1",
|
||||
text: "B-1",
|
||||
children: [{ id: "b1a", text: "B-1-a", children: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const lines = itemsTreeToLines(tree);
|
||||
|
||||
expect(lines).toEqual<ReadonlyArray<ListLine>>([
|
||||
{ depth: 0, text: "A" },
|
||||
{ depth: 1, text: "A-1" },
|
||||
{ depth: 1, text: "A-2" },
|
||||
{ depth: 0, text: "B" },
|
||||
{ depth: 1, text: "B-1" },
|
||||
{ depth: 2, text: "B-1-a" },
|
||||
]);
|
||||
});
|
||||
|
||||
describe("parseTextareaToLines / stringifyLinesToTextarea", () => {
|
||||
it("단순 텍스트를 ListLine 배열로 파싱하고 다시 문자열로 직렬화할 수 있다", () => {
|
||||
const input = "a\nb\nc";
|
||||
|
||||
const lines = parseTextareaToLines(input);
|
||||
|
||||
expect(lines).toEqual<ReadonlyArray<ListLine>>([
|
||||
{ depth: 0, text: "a" },
|
||||
{ depth: 0, text: "b" },
|
||||
{ depth: 0, text: "c" },
|
||||
]);
|
||||
"B-1-a",
|
||||
"공백 들여쓰기도 depth 로 인정",
|
||||
]);
|
||||
expect(lines.map((l) => l.depth)).toEqual([0, 1, 1, 0, 1, 2, 1]);
|
||||
});
|
||||
|
||||
it("ListLine 배열을 textarea 에 쓸 수 있는 문자열로 직렬화할 수 있어야 한다", () => {
|
||||
const lines: ListLine[] = [
|
||||
{ depth: 0, text: "A" },
|
||||
{ depth: 1, text: "A-1" },
|
||||
{ depth: 1, text: "A-2" },
|
||||
{ depth: 0, text: "B" },
|
||||
{ depth: 1, text: "B-1" },
|
||||
{ depth: 2, text: "B-1-a" },
|
||||
];
|
||||
|
||||
const text = (stringifyLinesToTextarea as any)(lines);
|
||||
const rows = text.split(/\r?\n/);
|
||||
|
||||
expect(rows).toEqual([
|
||||
"A",
|
||||
"\tA-1",
|
||||
"\tA-2",
|
||||
"B",
|
||||
"\tB-1",
|
||||
"\t\tB-1-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("텍스트 블록에 글자 간격(letterSpacingCustom)을 em 단위로 설정할 수 있어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
@@ -144,7 +558,7 @@ describe("editorStore", () => {
|
||||
expect(selectedBlockId).toBe(updatedBlocks[0].id);
|
||||
});
|
||||
|
||||
it("이미지 블록을 추가하면 기본 src/alt와 함께 추가되고 선택되어야 한다", () => {
|
||||
it("이미지 블록을 추가하면 기본 src/alt 및 스타일 기본값과 함께 추가되고 선택되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addImageBlock();
|
||||
@@ -157,6 +571,10 @@ describe("editorStore", () => {
|
||||
const imageProps = blocks[0].props as ImageBlockProps;
|
||||
expect(imageProps.src).toBe("");
|
||||
expect(imageProps.alt).toBe("이미지 설명");
|
||||
// 정렬/너비/모서리 기본값도 함께 검증한다.
|
||||
expect(imageProps.align).toBe("center");
|
||||
expect(imageProps.widthMode).toBe("auto");
|
||||
expect(imageProps.borderRadius).toBe("md");
|
||||
|
||||
// 루트에서 추가한 경우에는 sectionId/columnId 가 null 이어야 한다.
|
||||
expect((blocks[0] as any).sectionId).toBeNull();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { getSectionLayoutConfig } from "@/features/editor/components/PublicPageRenderer";
|
||||
|
||||
describe("getSectionLayoutConfig", () => {
|
||||
const baseProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
};
|
||||
|
||||
it("기본 섹션 설정에서 예상 클래스 조합을 반환해야 한다", () => {
|
||||
const cfg = getSectionLayoutConfig(baseProps);
|
||||
|
||||
expect(cfg.backgroundClass).toBe("bg-slate-950");
|
||||
expect(cfg.paddingYClass).toBe("py-12");
|
||||
expect(cfg.maxWidthClass).toBe("max-w-5xl");
|
||||
expect(cfg.gapXClass).toBe("gap-8");
|
||||
expect(cfg.alignItemsClass).toBe("items-start");
|
||||
});
|
||||
|
||||
it("maxWidthMode/gapX/alignItems 토큰에 따라 클래스를 변경해야 한다", () => {
|
||||
const wideCentered: SectionBlockProps = {
|
||||
...baseProps,
|
||||
maxWidthMode: "wide",
|
||||
gapX: "lg",
|
||||
alignItems: "center",
|
||||
};
|
||||
|
||||
const narrowBottomMuted: SectionBlockProps = {
|
||||
...baseProps,
|
||||
background: "muted",
|
||||
maxWidthMode: "narrow",
|
||||
gapX: "sm",
|
||||
alignItems: "bottom",
|
||||
paddingY: "sm",
|
||||
};
|
||||
|
||||
const wideCfg = getSectionLayoutConfig(wideCentered);
|
||||
expect(wideCfg.maxWidthClass).toBe("max-w-6xl");
|
||||
expect(wideCfg.gapXClass).toBe("gap-10");
|
||||
expect(wideCfg.alignItemsClass).toBe("items-center");
|
||||
|
||||
const narrowCfg = getSectionLayoutConfig(narrowBottomMuted);
|
||||
expect(narrowCfg.backgroundClass).toBe("bg-slate-900");
|
||||
expect(narrowCfg.maxWidthClass).toBe("max-w-3xl");
|
||||
expect(narrowCfg.gapXClass).toBe("gap-4");
|
||||
expect(narrowCfg.alignItemsClass).toBe("items-end");
|
||||
expect(narrowCfg.paddingYClass).toBe("py-8");
|
||||
});
|
||||
|
||||
it("primary 배경과 큰 패딩에 대해 올바른 클래스를 반환해야 한다", () => {
|
||||
const primaryLarge: SectionBlockProps = {
|
||||
...baseProps,
|
||||
background: "primary",
|
||||
paddingY: "lg",
|
||||
};
|
||||
|
||||
const cfg = getSectionLayoutConfig(primaryLarge);
|
||||
expect(cfg.backgroundClass).toBe("bg-sky-900");
|
||||
expect(cfg.paddingYClass).toBe("py-20");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user