에디터 정리 및 버그 수정
CI / test (push) Failing after 13m50s
CI / pr_and_merge (push) Has been cancelled

This commit is contained in:
2025-12-01 13:34:16 +09:00
parent 5f541e9524
commit 6ad731b6e2
76 changed files with 1348 additions and 29 deletions
+80
View File
@@ -99,4 +99,84 @@ describe("ButtonPropertiesPanel", () => {
expect.objectContaining({ widthMode: "fixed" }),
);
});
it("버튼 이미지 URL 인풋 변경 시 updateBlock 이 imageSrc 와 imageSourceType(externalUrl) 으로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{
...baseProps,
imageSrc: "https://example.com/old.png",
imageSourceType: "externalUrl",
} as any}
selectedBlockId="btn-img-url"
updateBlock={updateBlock}
/>,
);
const urlInput = screen.getByLabelText("버튼 이미지 URL");
fireEvent.change(urlInput, { target: { value: "https://example.com/new.png" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-img-url",
expect.objectContaining({
imageSrc: "https://example.com/new.png",
imageSourceType: "externalUrl",
}),
);
});
it("버튼 이미지 위치 셀렉트 변경 시 updateBlock 이 imagePlacement 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{
...baseProps,
imagePlacement: "left",
imageSrc: "https://example.com/icon.png",
imageSourceType: "externalUrl",
} as any}
selectedBlockId="btn-img-pos"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("버튼 이미지 위치");
fireEvent.change(select, { target: { value: "right" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-img-pos",
expect.objectContaining({ imagePlacement: "right" }),
);
});
it("버튼 이미지 소스 셀렉트에서 URL/업로드를 전환할 수 있어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{
...baseProps,
imageSrc: "/api/image/test-id",
imageSourceType: "asset",
} as any}
selectedBlockId="btn-img-source"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("버튼 이미지 소스");
// 업로드 → URL 로 전환
fireEvent.change(select, { target: { value: "url" } });
expect(updateBlock).toHaveBeenCalledWith(
"btn-img-source",
expect.objectContaining({
imageSourceType: "externalUrl",
imageAssetId: null,
}),
);
});
});
+72 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render, cleanup, waitFor } from "@testing-library/react";
import EditorPage from "@/app/editor/page";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
@@ -63,6 +63,7 @@ beforeEach(() => {
addFooterTemplateSection: vi.fn(),
updateBlock: vi.fn(),
updateProjectConfig: vi.fn(),
resetHistory: vi.fn(),
};
});
@@ -94,6 +95,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -114,7 +118,7 @@ describe("EditorPage - 자동저장", () => {
expect(parsed.projectConfig.slug).toBe("autosave-test");
});
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", () => {
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", async () => {
const savedBlocks: Block[] = [
{
id: "saved_1",
@@ -142,10 +146,75 @@ describe("EditorPage - 자동저장", () => {
render(<EditorPage />);
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
});
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
// autosave 로 전체 프로젝트를 복원한 경우 history/future 도 초기화해야 한다.
expect(mockState.resetHistory).toHaveBeenCalledTimes(1);
});
it("savedByUserId 가 다른 autosave 스냅샷은 현재 로그인 유저가 불러오지 않아야 한다", async () => {
const savedBlocks: Block[] = [
{
id: "saved_1",
type: "text",
props: {
text: "다른 유저가 저장한 블록",
align: "left",
size: "base",
},
} as any,
];
const savedConfig: ProjectConfig = {
title: "다른 유저 프로젝트",
slug: "autosave-test",
canvasPreset: "full",
} as ProjectConfig;
const payload = {
blocks: savedBlocks,
projectConfig: savedConfig,
savedByUserId: "user-a",
};
window.localStorage.setItem("pb:autosave:autosave-test", JSON.stringify(payload));
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({ id: "user-b", email: "user-b@example.com", tokenVersion: 1 }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<EditorPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
// 다른 유저의 autosave 는 무시되므로 history 초기화도 호출되지 않아야 한다.
expect(mockState.resetHistory).not.toHaveBeenCalled();
});
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -92,6 +92,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -96,6 +96,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -82,6 +82,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -10,6 +10,9 @@ vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -79,6 +79,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -95,6 +95,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+224 -3
View File
@@ -9,6 +9,7 @@ import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
let mockState: any;
let searchParamsSlug: string | null = null;
beforeEach(() => {
const baseProjectConfig: ProjectConfig = {
@@ -32,14 +33,17 @@ beforeEach(() => {
projectConfig: baseProjectConfig,
selectedBlockId: null as string | null,
selectedListItemId: null as string | null,
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
// EditorPage 가 참조하는 액션들: 기본적으로 mock 으로 채우되,
// replaceBlocks 는 실제 store.blocks 도 업데이트하도록 구현한다.
undo: vi.fn(),
redo: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
selectBlock: vi.fn(),
selectListItem: vi.fn(),
replaceBlocks: vi.fn(),
replaceBlocks: vi.fn((nextBlocks: Block[]) => {
mockState.blocks = nextBlocks;
}),
reorderBlocks: vi.fn(),
moveBlock: vi.fn(),
addTextBlock: vi.fn(),
@@ -69,9 +73,11 @@ beforeEach(() => {
...partial,
} as ProjectConfig;
}),
resetHistory: vi.fn(),
};
window.localStorage.clear();
searchParamsSlug = null;
});
afterEach(() => {
@@ -102,6 +108,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: (key: string) => (key === "slug" ? searchParamsSlug : null),
}),
};
});
@@ -277,6 +286,12 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
expect(
(mockState.updateProjectConfig as any).mock.calls.some(
([arg]: any[]) => arg && arg.title === "로컬 프로젝트" && arg.slug === slug,
),
).toBe(true);
const serverCalls = fetchMock.mock.calls.filter(
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
);
@@ -304,7 +319,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ slug, contentJson: serverBlocks }),
json: async () => ({ slug, title: "서버 프로젝트", contentJson: serverBlocks }),
} as any);
vi.stubGlobal("fetch", fetchMock);
@@ -341,10 +356,145 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
});
expect(
(mockState.updateProjectConfig as any).mock.calls.some(
([arg]: any[]) => arg && arg.title === "서버 프로젝트" && arg.slug === slug,
),
).toBe(true);
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
expect(message).toBeTruthy();
});
it("서버에서 복잡한 프로젝트 contentJson 을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
const slug = "roundtrip-slug";
const complexBlocks: Block[] = [
{
id: "text_1",
type: "text",
props: {
text: "복잡한 텍스트",
align: "center",
size: "xl",
colorCustom: "#ff0000",
backgroundColorCustom: "#123456",
},
} as any,
{
id: "button_1",
type: "button",
props: {
label: "CTA 버튼",
href: "/pricing",
variant: "outline",
size: "lg",
align: "right",
widthMode: "fixed",
widthPx: 320,
borderRadius: "full",
colorPalette: "secondary",
textColorCustom: "#111111",
fillColorCustom: "#222222",
strokeColorCustom: "#333333",
},
} as any,
{
id: "section_1",
type: "section",
props: {
paddingY: "lg",
background: "image",
backgroundImageUrl: "https://example.com/bg.png",
columns: [
{ id: "col_1", span: 8 },
{ id: "col_2", span: 4 },
],
},
} as any,
{
id: "form_input_1",
type: "formInput",
props: {
label: "이메일",
inputType: "email",
required: true,
labelMode: "text",
labelLayout: "stacked",
labelGapPx: 12,
widthMode: "custom",
widthPx: 360,
borderRadius: "lg",
paddingX: 16,
paddingY: 12,
textColorCustom: "#fafafa",
fillColorCustom: "#0f172a",
strokeColorCustom: "#1e293b",
formFieldName: "email-1",
},
} as any,
];
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({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
if (url === `/api/projects/${slug}` && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ slug, title: "복잡한 프로젝트", contentJson: complexBlocks }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
mockState.projectConfig.slug = slug;
render(<EditorPage />);
const menuButton = screen.getByText("메뉴");
fireEvent.click(menuButton);
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
fireEvent.click(projectMenuItem);
const loadButton = screen.getByText("불러오기");
fireEvent.click(loadButton);
await waitFor(() => {
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
});
const [loadedBlocks] = (mockState.replaceBlocks as any).mock.calls[0];
expect(loadedBlocks).toEqual(complexBlocks as any);
expect(
(mockState.updateProjectConfig as any).mock.calls.some(
([arg]: any[]) => arg && arg.title === "복잡한 프로젝트" && arg.slug === slug,
),
).toBe(true);
});
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
const slug = "delete-test-slug";
mockState.projectConfig.slug = slug;
@@ -391,4 +541,75 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
confirmSpy.mockRestore();
});
it("URL 쿼리의 slug 가 있으면 해당 slug 프로젝트를 자동으로 서버에서 불러와야 한다", async () => {
const slug = "auto-load-slug";
const serverBlocks: Block[] = [
{
id: "server_1",
type: "text",
props: {
text: "쿼리 로드 블록",
align: "left",
size: "base",
},
} as any,
];
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({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
if (url === `/api/projects/${slug}` && method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ slug, title: "쿼리 로드 프로젝트", contentJson: serverBlocks }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
}
return Promise.resolve(new Response(null, { status: 200 }));
});
vi.stubGlobal("fetch", fetchMock as any);
searchParamsSlug = slug;
render(<EditorPage />);
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([url, options]: any[]) => url === `/api/projects/${slug}` && (options?.method ?? "GET") === "GET",
),
).toBe(true);
});
await waitFor(() => {
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
});
expect(
(mockState.updateProjectConfig as any).mock.calls.some(
([arg]: any[]) => arg && arg.title === "쿼리 로드 프로젝트" && arg.slug === slug,
),
).toBe(true);
});
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -82,6 +82,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+3
View File
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
useRouter: () => ({
push: vi.fn(),
}),
useSearchParams: () => ({
get: () => null,
}),
};
});
+76
View File
@@ -75,4 +75,80 @@ describe("FormControllerPanel", () => {
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
);
});
it("폼 필드 매핑 UI 에서 필드 라벨 대신 전송 키(formFieldName)를 우선적으로 표시해야 한다", () => {
const formBlock = makeFormBlock("form-controller", {
fieldIds: ["input-1", "checkbox-1"],
});
const inputBlock: Block = {
id: "input-1",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
} as any,
} as any;
const checkboxBlock: Block = {
id: "checkbox-1",
type: "formCheckbox",
props: {
groupLabel: "옵션",
formFieldName: "options",
options: [],
} as any,
} as any;
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock, inputBlock, checkboxBlock]}
selectedBlockId="form-controller"
updateBlock={updateBlock}
/>,
);
const nameLabel = screen.getByText("name (이름)");
const optionsLabel = screen.getByText("options (옵션)");
expect(nameLabel).toBeTruthy();
expect(optionsLabel).toBeTruthy();
});
it("Submit 버튼 셀렉트에서 버튼 블록의 전송 키(formFieldName)를 기준으로 표시해야 한다", () => {
const formBlock = makeFormBlock("form-controller", {
submitButtonId: "btn-1",
});
const buttonBlock: Block = {
id: "btn-1",
type: "button",
props: {
label: "제출하기",
formFieldName: "submit-main",
} as any,
} as any;
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock, buttonBlock]}
selectedBlockId="form-controller"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("Submit 버튼") as HTMLSelectElement;
const submitMainOption = screen.getByText("submit-main (제출하기)");
expect(submitMainOption).toBeTruthy();
// 기본 선택 값은 FormBlock.submitButtonId 이어야 한다.
expect(select.value).toBe("btn-1");
});
});
@@ -102,4 +102,63 @@ describe("PublicPageRenderer - 버튼 블록 스타일", () => {
// lg → 12px → 12 / 16 = 0.75em
expect(link!.style.borderRadius).toBe("0.75em");
});
it("버튼에 imageSrc 가 있으면 이미지와 텍스트를 함께 렌더링해야 한다 (좌측 배치)", () => {
const blocks: Block[] = [
{
id: "btn_img_left",
type: "button",
props: {
label: "이미지 버튼",
href: "#",
imageSrc: "https://example.com/icon.png",
imageAlt: "아이콘",
imagePlacement: "left",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
const img = link!.querySelector("img") as HTMLImageElement | null;
expect(img).not.toBeNull();
expect(img!.src).toContain("https://example.com/icon.png");
expect(img!.alt).toBe("아이콘");
const labelNode = Array.from(link!.querySelectorAll("span")).find((el) =>
el.textContent?.includes("이미지 버튼"),
);
expect(labelNode).toBeTruthy();
});
it("imagePlacement 가 top 인 경우 이미지가 텍스트 위쪽에 렌더링되어야 한다", () => {
const blocks: Block[] = [
{
id: "btn_img_top",
type: "button",
props: {
label: "위쪽 이미지 버튼",
href: "#",
imageSrc: "https://example.com/icon-top.png",
imageAlt: "위쪽 아이콘",
imagePlacement: "top",
} as any,
},
];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
const wrapper = link!.querySelector("span") as HTMLSpanElement | null;
expect(wrapper).not.toBeNull();
expect(wrapper!.className).toContain("flex-col");
const firstChild = wrapper!.firstElementChild as HTMLElement | null;
expect(firstChild?.tagName.toLowerCase()).toBe("img");
});
});
+274
View File
@@ -1164,4 +1164,278 @@ describe("editorStore", () => {
expect(custom.projectConfig.canvasPreset).toBe("custom");
expect(custom.projectConfig.canvasWidthPx).toBe(1024);
});
it("여러 블록/템플릿 추가 액션은 history 에 스냅샷을 기록하고 undo/redo 로 되돌릴 수 있어야 한다", () => {
const actionNames = [
"addTextBlock",
"addButtonBlock",
"addImageBlock",
"addVideoBlock",
"addDividerBlock",
"addListBlock",
"addSectionBlock",
"addFormBlock",
"addFormInputBlock",
"addFormSelectBlock",
"addFormCheckboxBlock",
"addFormRadioBlock",
"addHeroTemplateSection",
"addFeaturesTemplateSection",
"addCtaTemplateSection",
"addFaqTemplateSection",
"addPricingTemplateSection",
"addTestimonialsTemplateSection",
"addBlogTemplateSection",
"addTeamTemplateSection",
"addFooterTemplateSection",
] as const;
actionNames.forEach((name) => {
const store = createEditorStore();
const stateAny = store.getState() as any;
expect(stateAny.blocks).toHaveLength(0);
expect(stateAny.history).toHaveLength(0);
// 각 액션을 한 번 호출하면 직전 blocks 스냅샷이 history 에 쌓여야 한다.
stateAny[name]();
const after = store.getState() as any;
// 최소 한 개 이상의 블록이 생성되어야 한다.
expect(after.blocks.length).toBeGreaterThan(0);
// 직전 상태 스냅샷이 history 에 1개 쌓여야 한다.
if (after.history.length !== 1) {
throw new Error(`history length mismatch for action ${name}: ${after.history.length}`);
}
const blocksAfterAdd = after.blocks;
const expectedLength = blocksAfterAdd.length;
stateAny.undo();
const afterUndo = store.getState() as any;
// 처음 상태가 빈 배열이었으므로 undo 후에는 다시 0개가 되어야 한다.
expect(afterUndo.blocks.length).toBe(0);
stateAny.redo();
const afterRedo = store.getState() as any;
expect(afterRedo.blocks.length).toBe(expectedLength);
// redo 이후 블록 타입 배열도 대략 동일해야 한다.
expect(afterRedo.blocks.map((b: any) => b.type)).toEqual(
blocksAfterAdd.map((b: any) => b.type),
);
});
});
it("updateBlock 으로 블록 속성을 변경한 뒤 undo 하면 이전 속성으로 되돌아가야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
let { blocks, history } = store.getState();
expect(blocks).toHaveLength(1);
const blockId = blocks[0].id;
const initialText = (blocks[0].props as TextBlockProps).text;
expect(history.length).toBe(1);
store.getState().updateBlock(blockId, { text: "변경된 텍스트" } as any);
({ blocks, history } = store.getState());
expect((blocks[0].props as TextBlockProps).text).toBe("변경된 텍스트");
// updateBlock 이 호출되면서 history 스택이 1개 늘어나야 한다.
expect(history.length).toBe(2);
store.getState().undo();
({ blocks, history } = store.getState());
expect((blocks[0].props as TextBlockProps).text).toBe(initialText);
expect(history.length).toBe(1);
});
it("removeBlock 호출 후 undo 하면 삭제된 블록이 복원되어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
store.getState().addTextBlock();
let { blocks, history } = store.getState();
expect(blocks).toHaveLength(2);
const firstId = blocks[0].id;
const secondId = blocks[1].id;
(store.getState() as any).removeBlock(secondId);
({ blocks, history } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([firstId]);
// 텍스트 블록 2개 추가(2번) + removeBlock(1번) 이므로 history 스택 길이는 3이어야 한다.
expect(history.length).toBe(3);
(store.getState() as any).undo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([firstId, secondId]);
});
it("duplicateBlock 호출 후 undo 하면 복제된 블록이 제거되어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
let { blocks } = store.getState();
expect(blocks).toHaveLength(1);
const originalId = blocks[0].id;
(store.getState() as any).duplicateBlock(originalId);
({ blocks } = store.getState());
expect(blocks).toHaveLength(2);
(store.getState() as any).undo();
({ blocks } = store.getState());
expect(blocks).toHaveLength(1);
expect(blocks[0].id).toBe(originalId);
});
it("reorderBlocks 호출 후 undo/redo 로 블록 순서를 되돌릴 수 있어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
store.getState().addTextBlock();
store.getState().addTextBlock();
let { blocks } = store.getState();
const [aId, bId, cId] = blocks.map((b) => b.id);
// B 를 맨 앞으로 이동시킨다.
store.getState().reorderBlocks(bId, aId);
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
store.getState().undo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([aId, bId, cId]);
store.getState().redo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
});
it("moveBlock 호출 후 undo/redo 로 블록 위치(sectionId/columnId)를 되돌릴 수 있어야 한다", () => {
const store = createEditorStore();
// 섹션 블록과 텍스트 블록을 준비한다.
store.getState().addSectionBlock();
let { blocks } = store.getState();
const sectionBlock = blocks.find((b) => b.type === "section")!;
const sectionProps = sectionBlock.props as any;
// 섹션을 2컬럼 레이아웃으로 변경한다.
const updatedColumns = [
{ id: `${sectionBlock.id}_col_1`, span: 6 },
{ id: `${sectionBlock.id}_col_2`, span: 6 },
];
store.getState().updateBlock(sectionBlock.id, { columns: updatedColumns } as any);
store.getState().selectBlock(sectionBlock.id);
store.getState().addTextBlock();
({ blocks } = store.getState());
const textBlock = blocks.find((b) => b.type === "text") as any;
expect(textBlock.sectionId).toBe(sectionBlock.id);
expect(textBlock.columnId).toBe(updatedColumns[0].id ?? sectionProps.columns[0].id);
// 두 번째 컬럼으로 이동
store.getState().moveBlock(textBlock.id, sectionBlock.id, updatedColumns[1].id);
({ blocks } = store.getState());
let moved = blocks.find((b) => b.id === textBlock.id) as any;
expect(moved.columnId).toBe(updatedColumns[1].id);
store.getState().undo();
({ blocks } = store.getState());
moved = blocks.find((b) => b.id === textBlock.id) as any;
expect(moved.columnId).toBe(updatedColumns[0].id ?? sectionProps.columns[0].id);
store.getState().redo();
({ blocks } = store.getState());
moved = blocks.find((b) => b.id === textBlock.id) as any;
expect(moved.columnId).toBe(updatedColumns[1].id);
});
it("replaceBlocks 호출 후 undo/redo 로 이전 블록 배열을 복원할 수 있어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
let { blocks } = store.getState();
expect(blocks).toHaveLength(1);
const originalIds = blocks.map((b) => b.id);
const replacedBlocks = blocks.map((b) => ({
...b,
id: `${b.id}_replaced`,
}));
const replacedIds = replacedBlocks.map((b) => b.id);
(store.getState() as any).replaceBlocks(replacedBlocks as any);
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual(replacedIds);
(store.getState() as any).undo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual(originalIds);
(store.getState() as any).redo();
({ blocks } = store.getState());
expect(blocks.map((b) => b.id)).toEqual(replacedIds);
});
it("resetHistory 를 호출하면 history/future 가 비워지고 undo/redo 가 이전 스냅샷으로 돌아가지 않아야 한다", () => {
const store = createEditorStore();
// 블록을 여러 개 추가해 history 스택을 만든다.
store.getState().addTextBlock();
store.getState().addTextBlock();
let { blocks, history, future } = store.getState();
expect(blocks.length).toBe(2);
expect(history.length).toBeGreaterThan(0);
expect(future.length).toBe(0);
const snapshotAfterEdits = blocks;
// 히스토리 초기화
(store.getState() as any).resetHistory();
({ blocks, history, future } = store.getState());
expect(history).toHaveLength(0);
expect(future).toHaveLength(0);
// undo/redo 를 호출해도 더 이상 이전 스냅샷으로는 돌아가지 않아야 한다.
store.getState().undo();
({ blocks, history, future } = store.getState());
expect(blocks).toEqual(snapshotAfterEdits);
expect(history).toHaveLength(0);
expect(future).toHaveLength(0);
store.getState().redo();
({ blocks, history, future } = store.getState());
expect(blocks).toEqual(snapshotAfterEdits);
expect(history).toHaveLength(0);
expect(future).toHaveLength(0);
});
});