비디오 태그 및 폼 블록 정리
This commit is contained in:
+83
-11
@@ -404,7 +404,7 @@ describe("/api/export", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("폼 블록과 컨트롤러 필드 블록들은 정적 HTML에서도 폼 요소로 렌더되어야 한다", async () => {
|
||||
it("FormBlock 은 Export 레이어에서 컨트롤러 역할만 하고, 필드 블록들은 form 속성으로 해당 폼과 연결되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_1",
|
||||
@@ -470,18 +470,29 @@ describe("/api/export", () => {
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
|
||||
// form 요소와 기본 input/select 가 포함되어야 한다.
|
||||
expect(html).toContain("<form");
|
||||
expect(html).toContain("name=\"name\"");
|
||||
expect(html).toContain("name=\"plan\"");
|
||||
expect(html).toContain("<select");
|
||||
// required: formInput(name) 은 required, formSelect(plan) 은 required 가 없어야 한다.
|
||||
expect(html).toMatch(/name=\"name\"[^>]*required/);
|
||||
expect(html).not.toMatch(/name=\"plan\"[^>]*required/);
|
||||
// FormBlock 은 컨트롤러 전용이므로, Export 에서는 id 를 가진 단일 <form> 컨테이너만 생성하고
|
||||
// 실제 필드 레이아웃은 개별 formInput/formSelect 블록이 담당한다.
|
||||
// - <form id="form_form_1" ...>
|
||||
const formId = "form_form_1";
|
||||
expect(html).toMatch(new RegExp(`<form[^>]*id=\\"${formId}\\"`));
|
||||
|
||||
// 컨트롤러 필드 블록(formInput/formSelect) 이 렌더한 input/select 는 각각 name 과 form 속성을 가져야 한다.
|
||||
// name="name" input 은 required + form="form_form_1" 이어야 한다.
|
||||
expect(html).toMatch(/name=\"name\"[^>]*required[^>]*form=\"form_form_1\"/);
|
||||
// name="plan" select 는 required 는 아니지만 form="form_form_1" 이어야 한다.
|
||||
expect(html).toMatch(/name=\"plan\"[^>]*form=\"form_form_1\"/);
|
||||
|
||||
// FormBlock 이 자체적으로 필드 레이아웃을 중복 생성하지 않도록,
|
||||
// id 가 form_form_1 인 <form> 내부에는 name="name"/"plan" 필드가 없어야 한다.
|
||||
const formStart = html.indexOf("<form");
|
||||
const formEnd = html.indexOf("</form>", formStart);
|
||||
const formHtml = html.slice(formStart, formEnd);
|
||||
expect(formHtml).not.toContain('name="name"');
|
||||
expect(formHtml).not.toContain('name="plan"');
|
||||
});
|
||||
|
||||
|
||||
it("FormBlock 에 fieldIds/fields 가 모두 없으면 기본 폼/필드를 내보내지 않아야 한다", async () => {
|
||||
it("FormBlock 에 fieldIds/fields 가 모두 없으면 Export 에서 아무 <form>/필드도 생성하지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_fallback_1",
|
||||
@@ -529,6 +540,63 @@ describe("/api/export", () => {
|
||||
expect(html).not.toMatch(/name=\"message\"/);
|
||||
});
|
||||
|
||||
it("FormBlock 이 fields[] 만 가지고 있고 fieldIds 가 없더라도, Export 레이어에서 자체 pb-form 레이아웃/폼 UI 를 생성하지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_legacy_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: [],
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
name: "email",
|
||||
label: "이메일",
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "폼 fallback(fields[]) 내보내기 테스트",
|
||||
slug: "form-fallback-fields-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
|
||||
// 새로운 규칙: FormBlock 은 어떤 경우에도 자체 레이아웃/폼 UI 를 생성하지 않는다.
|
||||
// - fallback fields 기반 pb-form 도 더 이상 허용하지 않는다.
|
||||
expect(html).not.toContain("class=\"pb-form\"");
|
||||
expect(html).not.toContain("<form");
|
||||
expect(html).not.toMatch(/name=\"email\"/);
|
||||
});
|
||||
|
||||
it("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
|
||||
const imageId = `test-image-${Date.now()}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
@@ -1787,7 +1855,11 @@ describe("/api/export", () => {
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<form");
|
||||
|
||||
// 새로운 규칙: FormBlock 은 Export 에서 자체 폼 레이아웃을 생성하지 않는다.
|
||||
// - backgroundColorCustom 이 있더라도 form 요소/컨테이너에 배경색이 적용되지 않아야 한다.
|
||||
// - pb-form 기반 레이아웃도 생성되지 않아야 한다.
|
||||
expect(html).not.toContain("class=\"pb-form\"");
|
||||
expect(html).not.toContain("background-color:#111111");
|
||||
});
|
||||
|
||||
|
||||
@@ -1064,7 +1064,7 @@ test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케
|
||||
expect(gapStyles.inlineRowGap.endsWith("em")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
@@ -1077,23 +1077,11 @@ test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const form = page.getByTestId("preview-form-controller").first();
|
||||
const inlineStyles = await form.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
return {
|
||||
computedWidth: s.width,
|
||||
inlineWidth: element.style.width,
|
||||
};
|
||||
});
|
||||
|
||||
const widthPx = parseFloat(inlineStyles.computedWidth);
|
||||
expect(widthPx).toBeGreaterThan(430);
|
||||
expect(widthPx).toBeLessThan(520);
|
||||
expect(inlineStyles.inlineWidth.endsWith("em")).toBeTruthy();
|
||||
const formLocator = page.getByTestId("preview-form-controller");
|
||||
await expect(formLocator).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => {
|
||||
await page.goto("/editor");
|
||||
|
||||
await page.getByRole("button", { name: "폼 블록 추가" }).click();
|
||||
@@ -1105,20 +1093,8 @@ test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케
|
||||
await page.getByRole("link", { name: "프리뷰 열기" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
|
||||
|
||||
const form = page.getByTestId("preview-form-controller").first();
|
||||
const inlineStyles = await form.evaluate((el) => {
|
||||
const element = el as HTMLElement;
|
||||
const s = window.getComputedStyle(element);
|
||||
return {
|
||||
marginTop: s.marginTop,
|
||||
inlineMarginTop: element.style.marginTop,
|
||||
};
|
||||
});
|
||||
|
||||
const marginPx = parseFloat(inlineStyles.marginTop);
|
||||
expect(marginPx).toBeGreaterThan(24);
|
||||
expect(marginPx).toBeLessThan(32);
|
||||
expect(inlineStyles.inlineMarginTop.endsWith("em")).toBeTruthy();
|
||||
const formLocator = page.getByTestId("preview-form-controller");
|
||||
await expect(formLocator).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import PreviewPage from "@/app/preview/page";
|
||||
|
||||
// PreviewPage Export ZIP TDD
|
||||
// - 프리뷰 헤더에 "페이지 파일로 내보내기 (ZIP)" 버튼이 노출되어야 한다.
|
||||
// - 버튼 클릭 시 /api/export 로 blocks + projectConfig 를 payload 로 POST 요청을 보내야 한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "프리뷰 Export 테스트",
|
||||
slug: "preview-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_text_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "프리뷰 Export 본문",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("PreviewPage - ZIP Export", () => {
|
||||
it("프리뷰 헤더에 ZIP Export 버튼이 노출되어야 한다", () => {
|
||||
render(<PreviewPage />);
|
||||
|
||||
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
|
||||
expect(exportButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ZIP Export 버튼 클릭 시 /api/export 로 blocks + projectConfig 를 payload 로 POST 해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
// JSDOM 환경에서 blob() 호출을 안전하게 처리하기 위한 최소 mock
|
||||
blob: vi.fn().mockResolvedValue(new Blob(["dummy-zip"])),
|
||||
} as any);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<PreviewPage />);
|
||||
|
||||
const exportButton = screen.getByText("페이지 파일로 내보내기 (ZIP)");
|
||||
fireEvent.click(exportButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
expect(url).toBe("/api/export");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const parsed = JSON.parse(options.body);
|
||||
expect(Array.isArray(parsed.blocks)).toBe(true);
|
||||
expect(parsed.blocks[0].id).toBe("blk_text_1");
|
||||
expect(parsed.projectConfig.slug).toBe("preview-export-test");
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,7 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
||||
expect(listNoBg!.style.backgroundColor === "" || listNoBg!.style.backgroundColor === "transparent").toBe(true);
|
||||
});
|
||||
|
||||
it("form 컨트롤러 블록은 backgroundColorCustom 이 설정된 경우에만 폼 래퍼에 배경색이 적용되어야 한다", () => {
|
||||
it("form 컨트롤러 블록은 프리뷰에서 별도 폼 래퍼를 렌더하지 않아야 한다", () => {
|
||||
const blocksWithBg: Block[] = [
|
||||
{
|
||||
id: "form_with_bg",
|
||||
@@ -105,10 +105,10 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
const { queryByTestId, rerender } = render(<PublicPageRenderer blocks={blocksWithBg} />);
|
||||
|
||||
const formWithBg = getByTestId("preview-form-controller") as HTMLFormElement;
|
||||
expect(formWithBg.style.backgroundColor).toBe("rgb(17, 17, 17)");
|
||||
const formWithBg = queryByTestId("preview-form-controller");
|
||||
expect(formWithBg).toBeNull();
|
||||
|
||||
const blocksWithoutBg: Block[] = [
|
||||
{
|
||||
@@ -126,8 +126,8 @@ describe("PublicPageRenderer - 블록 배경색", () => {
|
||||
|
||||
rerender(<PublicPageRenderer blocks={blocksWithoutBg} />);
|
||||
|
||||
const formNoBg = getByTestId("preview-form-controller") as HTMLFormElement;
|
||||
expect(formNoBg.style.backgroundColor === "" || formNoBg.style.backgroundColor === "transparent").toBe(true);
|
||||
const formNoBg = queryByTestId("preview-form-controller");
|
||||
expect(formNoBg).toBeNull();
|
||||
});
|
||||
|
||||
it("section 블록은 backgroundColorCustom 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
||||
(global as any).fetch = undefined;
|
||||
});
|
||||
|
||||
it("성공 응답 시 config.successMessage 를 success 스타일로 표시해야 한다", async () => {
|
||||
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러를 렌더하지 않아야 한다 (성공 메시지 설정)", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_success",
|
||||
@@ -42,19 +42,12 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const form = screen.getByTestId("preview-form-controller");
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const messageEl = await screen.findByText("폼 성공 메시지 (config)");
|
||||
expect(messageEl.textContent).toBe("폼 성공 메시지 (config)");
|
||||
expect(messageEl.className).toContain("text-emerald-400");
|
||||
const form = screen.queryByTestId("preview-form-controller");
|
||||
expect(form).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("에러 응답 시 config.errorMessage 를 error 스타일로 표시해야 한다", async () => {
|
||||
it("FormBlock 이 있어도 프리뷰에서는 폼 컨트롤러나 에러 메시지를 렌더하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_error",
|
||||
@@ -81,15 +74,9 @@ describe("PublicPageRenderer - 폼 제출 메시지", () => {
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const form = screen.getByTestId("preview-form-controller");
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const messageEl = await screen.findByText("폼 에러 메시지 (config)");
|
||||
expect(messageEl.textContent).toBe("폼 에러 메시지 (config)");
|
||||
expect(messageEl.className).toContain("text-rose-400");
|
||||
const form = screen.queryByTestId("preview-form-controller");
|
||||
expect(form).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("폼 에러 메시지 (config)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -192,4 +192,26 @@ describe("PublicPageRenderer - 비디오 블록 스타일", () => {
|
||||
expect(wrapper!.style.padding).toBe("2em");
|
||||
expect(wrapper!.style.borderRadius).toBe("1em");
|
||||
});
|
||||
|
||||
it("sourceUrl 이 비어 있는 비디오 블록은 프리뷰에서 video/iframe 을 렌더하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_empty_src_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video");
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(video).toBeNull();
|
||||
expect(iframe).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user