에디터 MVP 골격 및 프로젝트 저장/로드 기능 구현

This commit is contained in:
2025-11-18 06:04:39 +09:00
commit 4b00e9a3f9
26 changed files with 11179 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
import "dotenv/config";
import { describe, it, expect } from "vitest";
import { POST as createProject } from "@/app/api/projects/route";
import { GET as getProjectBySlug } from "@/app/api/projects/[slug]/route";
const BASE_URL = "http://localhost";
describe("/api/projects", () => {
it("POST /api/projects 로 프로젝트를 생성하고 GET /api/projects/[slug] 로 조회할 수 있어야 한다", async () => {
const payload = {
title: "테스트 프로젝트",
slug: `test-slug-${Date.now()}`,
contentJson: [
{
id: "blk_test",
type: "text",
props: { text: "JSON에서 온 텍스트", align: "left", size: "base" },
},
],
};
const createResponse = await createProject(
new Request(`${BASE_URL}/api/projects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
if (createResponse.status !== 201) {
// 디버깅을 위해 에러 응답을 한번 출력해 둔다.
// eslint-disable-next-line no-console
console.error("createResponse status", createResponse.status, await createResponse.text());
}
expect(createResponse.status).toBe(201);
const created = (await createResponse.json()) as any;
expect(created.title).toBe(payload.title);
expect(created.slug).toBe(payload.slug);
const getResponse = await getProjectBySlug(
new Request(`${BASE_URL}/api/projects/${payload.slug}`),
{ params: { slug: payload.slug } } as any,
);
expect(getResponse.status).toBe(200);
const fetched = (await getResponse.json()) as any;
expect(fetched.slug).toBe(payload.slug);
expect(fetched.contentJson).toEqual(payload.contentJson);
});
});
+231
View File
@@ -0,0 +1,231 @@
import { test, expect } from "@playwright/test";
// 최초에는 실패하는 테스트: /editor 페이지와 헤더 텍스트가 아직 없음
test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한다", async ({ page }) => {
await page.goto("/editor");
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
});
test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
await expect(canvas.getByText("새 텍스트")).toBeVisible();
});
test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야 한다", async ({ page }) => {
await page.goto("/editor");
// 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByText("새 텍스트");
await block.dblclick();
// 편집 모드에서 텍스트를 변경한다.
const editor = page.getByRole("textbox", { name: "텍스트 블록 내용 편집" });
await editor.fill("수정된 텍스트");
await editor.press("Enter");
// 변경된 텍스트가 캔버스에 반영되어야 한다.
const canvasAfterEdit = page.getByTestId("editor-canvas");
await expect(canvasAfterEdit.getByText("수정된 텍스트")).toBeVisible();
});
test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔버스에 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
// 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
// 캔버스 블록을 클릭해서 선택한다.
const canvas = page.getByTestId("editor-canvas");
await canvas.getByText("새 텍스트").click();
// 우측 속성 패널에서 텍스트를 수정한다.
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
await sidebarEditor.fill("사이드바에서 수정된 텍스트");
// 캔버스에 수정된 텍스트가 보여야 한다.
const canvasAfterSidebarEdit = page.getByTestId("editor-canvas");
await expect(canvasAfterSidebarEdit.getByText("사이드바에서 수정된 텍스트")).toBeVisible();
});
test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과 선택 하이라이트가 바뀌어야 한다", async ({ page }) => {
await page.goto("/editor");
// 텍스트 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0);
const secondBlock = blocks.nth(1);
// 첫 번째 블록을 선택하고, 속성 패널에서 텍스트를 변경한다.
await firstBlock.click();
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
await sidebarEditor.fill("첫 번째 블록");
// 두 번째 블록을 선택하고, 다시 속성 패널에서 텍스트를 변경한다.
await secondBlock.click();
await sidebarEditor.fill("두 번째 블록");
// 캔버스에는 두 블록의 텍스트가 모두 보여야 한다.
await expect(canvas.getByText("첫 번째 블록")).toBeVisible();
await expect(canvas.getByText("두 번째 블록")).toBeVisible();
// 현재 선택된 블록은 두 번째 블록이어야 한다.
await expect(firstBlock).toHaveAttribute("data-selected", "false");
await expect(secondBlock).toHaveAttribute("data-selected", "true");
// 속성 패널 textarea에도 두 번째 블록 텍스트가 표시되어야 한다.
await expect(sidebarEditor).toHaveValue("두 번째 블록");
});
test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경하면 캔버스 스타일이 바뀌어야 한다", async ({ page }) => {
await page.goto("/editor");
// 텍스트 블록을 하나 추가하고 선택한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByTestId("editor-block").nth(0);
await block.click();
// 속성 패널에서 정렬을 가운데로 변경한다.
const alignSelect = page.getByRole("combobox", { name: "정렬" });
await alignSelect.selectOption("center");
// 속성 패널에서 글자 크기를 크게로 변경한다.
const sizeSelect = page.getByRole("combobox", { name: "글자 크기" });
await sizeSelect.selectOption("lg");
// 캔버스 블록에 text-center와 text-lg 클래스가 적용되어야 한다.
await expect(block).toHaveClass(/text-center/);
await expect(block).toHaveClass(/text-lg/);
});
test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한 블록 상태가 복원되어야 한다", async ({ page }) => {
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");
const firstBlock = blocks.nth(0);
const secondBlock = blocks.nth(1);
// 첫 번째 블록: 텍스트/정렬/크기 설정
await firstBlock.click();
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
await sidebarEditor.fill("첫 번째 JSON 블록");
const alignSelect = page.getByRole("combobox", { name: "정렬" });
await alignSelect.selectOption("left");
const sizeSelect = page.getByRole("combobox", { name: "글자 크기" });
await sizeSelect.selectOption("sm");
// 두 번째 블록: 텍스트/정렬/크기 설정
await secondBlock.click();
await sidebarEditor.fill("두 번째 JSON 블록");
await alignSelect.selectOption("right");
await sizeSelect.selectOption("lg");
// 현재 상태를 JSON으로 내보낸다.
await page.getByRole("button", { name: "JSON 내보내기" }).click();
const exportArea = page.getByRole("textbox", { name: "에디터 상태 JSON" });
const exportedJson = await exportArea.inputValue();
// 캔버스를 초기화한다.
await page.getByRole("button", { name: "캔버스 초기화" }).click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
// JSON을 다시 입력하고 불러온다.
const importArea = page.getByRole("textbox", { name: "JSON 불러오기" });
await importArea.fill(exportedJson);
await page.getByRole("button", { name: "JSON 적용" }).click();
// 복원된 캔버스에 두 블록이 모두 존재해야 한다.
const restoredBlocks = canvas.getByTestId("editor-block");
await expect(restoredBlocks).toHaveCount(2);
// 각 블록의 텍스트와 스타일이 원상 복구되었는지 확인한다.
const restoredFirst = restoredBlocks.nth(0);
const restoredSecond = restoredBlocks.nth(1);
await expect(restoredFirst).toContainText("첫 번째 JSON 블록");
await expect(restoredFirst).toHaveClass(/text-left/);
await expect(restoredFirst).toHaveClass(/text-sm/);
await expect(restoredSecond).toContainText("두 번째 JSON 블록");
await expect(restoredSecond).toHaveClass(/text-right/);
await expect(restoredSecond).toHaveClass(/text-lg/);
});
test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태가 aria-selected로 노출되어야 한다", async ({ page }) => {
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");
const firstBlock = blocks.nth(0);
const secondBlock = blocks.nth(1);
// 각 블록 안에 드래그 핸들이 존재해야 한다.
await expect(firstBlock.getByRole("button", { name: "블록 드래그 핸들" })).toBeVisible();
await expect(secondBlock.getByRole("button", { name: "블록 드래그 핸들" })).toBeVisible();
// 초기에는 마지막으로 추가된 두 번째 블록이 선택되어 있어야 한다.
await expect(firstBlock).toHaveAttribute("aria-selected", "false");
await expect(secondBlock).toHaveAttribute("aria-selected", "true");
// 첫 번째 블록을 클릭하면 선택 상태가 전환되어야 한다.
await firstBlock.click();
await expect(firstBlock).toHaveAttribute("aria-selected", "true");
await expect(secondBlock).toHaveAttribute("aria-selected", "false");
});
test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다", async ({ page }) => {
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");
const firstBlock = blocks.nth(0);
const secondBlock = blocks.nth(1);
// 텍스트를 A/B로 설정해서 순서를 식별한다.
await firstBlock.click();
const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" });
await sidebarEditor.fill("블록 A");
await secondBlock.click();
await sidebarEditor.fill("블록 B");
// 두 번째 블록의 드래그 핸들을 첫 번째 블록 위치로 드래그앤드롭 한다.
const secondHandle = secondBlock.getByRole("button", { name: "블록 드래그 핸들" });
const firstHandle = firstBlock.getByRole("button", { name: "블록 드래그 핸들" });
await secondHandle.dragTo(firstHandle);
// 드래그 후에는 순서가 [블록 B, 블록 A]가 되어야 한다.
const reorderedBlocks = canvas.getByTestId("editor-block");
await expect(reorderedBlocks.nth(0)).toContainText("블록 B");
await expect(reorderedBlocks.nth(1)).toContainText("블록 A");
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { createEditorStore } from "@/features/editor/state/editorStore";
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
describe("editorStore", () => {
it("addTextBlock 호출 시 텍스트 블록이 추가되고 선택되어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
const { blocks, selectedBlockId } = store.getState();
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("text");
expect(blocks[0].props.text).toBe("새 텍스트");
expect(selectedBlockId).toBe(blocks[0].id);
});
it("reorderBlocks 호출 시 블록 배열 순서가 변경되어야 한다", () => {
const store = createEditorStore();
// 블록 세 개를 추가하고 각기 다른 텍스트로 수정한다.
store.getState().addTextBlock();
store.getState().addTextBlock();
store.getState().addTextBlock();
let { blocks } = store.getState();
store.getState().updateBlock(blocks[0].id, { text: "블록 A" });
store.getState().updateBlock(blocks[1].id, { text: "블록 B" });
store.getState().updateBlock(blocks[2].id, { text: "블록 C" });
const [aId, bId, cId] = blocks.map((b) => b.id);
// B를 맨 앞으로 이동시키는 순서 변경을 수행한다.
store.getState().reorderBlocks(bId, aId);
blocks = store.getState().blocks;
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
expect(blocks.map((b) => b.props.text)).toEqual(["블록 B", "블록 A", "블록 C"]);
});
});