섹션 레이아웃 및 템플릿 스타일 개선
CI / test (push) Failing after 6m10s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-21 16:25:55 +09:00
parent 2f59e3781e
commit 546c961a31
25 changed files with 2843 additions and 151 deletions
+424 -6
View File
@@ -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();