From c5c9d17e8b9d09f4d98074b257a459fdad718810 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Thu, 27 Nov 2025 19:37:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=B9=84=EB=94=94=EC=98=A4=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=B0=8F=20=ED=8F=BC=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/export/route.ts | 140 ++++++------------ src/app/preview/page.tsx | 58 +++++++- .../editor/components/PublicPageRenderer.tsx | 8 + src/styles/builder.css | 6 + tests/api/export.spec.ts | 94 ++++++++++-- tests/e2e/preview.spec.ts | 36 +---- tests/unit/PreviewExport.spec.tsx | 91 ++++++++++++ ...PublicPageRendererBlockBackground.spec.tsx | 12 +- ...licPageRendererFormSubmitMessages.spec.tsx | 31 ++-- .../PublicPageRendererVideoStyles.spec.tsx | 22 +++ uploads/test-image-1764206623337 | 1 + uploads/test-image-1764208686337 | 1 + uploads/test-image-1764209184369 | 1 + uploads/test-image-1764210668417 | 1 + uploads/test-image-1764210974700 | 1 + uploads/test-image-1764236693581 | 1 + uploads/test-image-1764237905792 | 1 + uploads/test-image-1764237972345 | 1 + uploads/test-section-bg-1764206623341 | 1 + uploads/test-section-bg-1764208686343 | 1 + uploads/test-section-bg-1764209184385 | 1 + uploads/test-section-bg-1764210668421 | 1 + uploads/test-section-bg-1764210974704 | 1 + uploads/test-section-bg-1764236693585 | 1 + uploads/test-section-bg-1764237905803 | 1 + uploads/test-section-bg-1764237972349 | 1 + uploads/test-section-bg-video-1764206623348 | 1 + uploads/test-section-bg-video-1764208686349 | 1 + uploads/test-section-bg-video-1764209184431 | 1 + uploads/test-section-bg-video-1764210668439 | 1 + uploads/test-section-bg-video-1764210974710 | 1 + uploads/test-section-bg-video-1764236693590 | 1 + uploads/test-section-bg-video-1764237905811 | 1 + uploads/test-section-bg-video-1764237972355 | 1 + uploads/test-video-1764206623347 | 1 + uploads/test-video-1764208686347 | 1 + uploads/test-video-1764209184426 | 1 + uploads/test-video-1764210668433 | 1 + uploads/test-video-1764210974709 | 1 + uploads/test-video-1764236693589 | 1 + uploads/test-video-1764237905810 | 1 + uploads/test-video-1764237972353 | 1 + uploads/test-video-poster-1764206623339 | 1 + uploads/test-video-poster-1764208686339 | 1 + uploads/test-video-poster-1764209184377 | 1 + uploads/test-video-poster-1764210668419 | 1 + uploads/test-video-poster-1764210974702 | 1 + uploads/test-video-poster-1764236693583 | 1 + uploads/test-video-poster-1764237905797 | 1 + uploads/test-video-poster-1764237972347 | 1 + 50 files changed, 365 insertions(+), 173 deletions(-) create mode 100644 tests/unit/PreviewExport.spec.tsx create mode 100644 uploads/test-image-1764206623337 create mode 100644 uploads/test-image-1764208686337 create mode 100644 uploads/test-image-1764209184369 create mode 100644 uploads/test-image-1764210668417 create mode 100644 uploads/test-image-1764210974700 create mode 100644 uploads/test-image-1764236693581 create mode 100644 uploads/test-image-1764237905792 create mode 100644 uploads/test-image-1764237972345 create mode 100644 uploads/test-section-bg-1764206623341 create mode 100644 uploads/test-section-bg-1764208686343 create mode 100644 uploads/test-section-bg-1764209184385 create mode 100644 uploads/test-section-bg-1764210668421 create mode 100644 uploads/test-section-bg-1764210974704 create mode 100644 uploads/test-section-bg-1764236693585 create mode 100644 uploads/test-section-bg-1764237905803 create mode 100644 uploads/test-section-bg-1764237972349 create mode 100644 uploads/test-section-bg-video-1764206623348 create mode 100644 uploads/test-section-bg-video-1764208686349 create mode 100644 uploads/test-section-bg-video-1764209184431 create mode 100644 uploads/test-section-bg-video-1764210668439 create mode 100644 uploads/test-section-bg-video-1764210974710 create mode 100644 uploads/test-section-bg-video-1764236693590 create mode 100644 uploads/test-section-bg-video-1764237905811 create mode 100644 uploads/test-section-bg-video-1764237972355 create mode 100644 uploads/test-video-1764206623347 create mode 100644 uploads/test-video-1764208686347 create mode 100644 uploads/test-video-1764209184426 create mode 100644 uploads/test-video-1764210668433 create mode 100644 uploads/test-video-1764210974709 create mode 100644 uploads/test-video-1764236693589 create mode 100644 uploads/test-video-1764237905810 create mode 100644 uploads/test-video-1764237972353 create mode 100644 uploads/test-video-poster-1764206623339 create mode 100644 uploads/test-video-poster-1764208686339 create mode 100644 uploads/test-video-poster-1764209184377 create mode 100644 uploads/test-video-poster-1764210668419 create mode 100644 uploads/test-video-poster-1764210974702 create mode 100644 uploads/test-video-poster-1764236693583 create mode 100644 uploads/test-video-poster-1764237905797 create mode 100644 uploads/test-video-poster-1764237972347 diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 9ecc524..aa26aaf 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -160,6 +160,23 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const sectionBlocks = blocks.filter((b) => b.type === "section"); const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section"); + // Form 컨트롤러(FormBlock)와 개별 필드 블록(formInput/formSelect/...) 를 연결하기 위한 맵. + // - key: 필드 블록 id (fieldIds 에 포함된 id) + // - value: 해당 필드가 속한
요소의 DOM id (예: form_form_1) + const fieldIdToFormId = new Map(); + for (const block of blocks) { + if (block.type !== "form") continue; + const props = (block.props ?? {}) as FormBlockProps; + const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : []; + if (fieldIds.length === 0) continue; + const formDomId = `form_${block.id}`; + for (const fieldId of fieldIds) { + if (typeof fieldId === "string" && fieldId.trim() !== "") { + fieldIdToFormId.set(fieldId, formDomId); + } + } + } + const renderBlock = (block: Block): string => { if (block.type === "text") { const props = (block.props ?? {}) as TextBlockProps; @@ -316,107 +333,24 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const props = (block.props ?? {}) as FormBlockProps; const tokens = computeFormBlockExportTokens(block, blocks); const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : []; - const fallbackFields = tokens.fallbackFields; const hasControllerFields = controllerFields.length > 0; - const hasFallbackFields = fallbackFields.length > 0; - // FormBlock 이 컨트롤러/필드 정보를 전혀 가지고 있지 않으면 정적 HTML 에서는 아무 것도 렌더하지 않는다. - if (!hasControllerFields && !hasFallbackFields) { + // FormBlock 이 컨트롤러 필드 정보를 전혀 가지고 있지 않으면 + // Export 레이어에서는 아무 것도 렌더하지 않는다 (fallback fields 기반 pb-form 도 생성하지 않음). + if (!hasControllerFields) { return ""; } - const formParts: string[] = []; - const formStyleAttr = tokens.formStyleParts.length > 0 ? ` style="${tokens.formStyleParts.join(";")}"` : ""; + // 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다. + // - Export 에서는 레이아웃/스타일을 가지지 않고, id/메서드만 가지는 최소한의 요소만 만든다. + const formId = `form_${block.id}`; + const method = props.method ?? "POST"; + const methodAttr = ` method="${method.toLowerCase()}"`; - formParts.push(``); - - if (hasControllerFields) { - for (const fieldBlock of controllerFields) { - const anyProps: any = fieldBlock.props ?? {}; - const name = typeof anyProps.formFieldName === "string" ? anyProps.formFieldName : fieldBlock.id; - const label = - typeof anyProps.label === "string" || typeof anyProps.groupLabel === "string" - ? (anyProps.label ?? anyProps.groupLabel) - : name; - - const textColorRaw = - typeof anyProps.textColorCustom === "string" && anyProps.textColorCustom.trim() !== "" - ? anyProps.textColorCustom.trim() - : ""; - const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : ""; - const fieldTextStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : ""; - - formParts.push(`
`); - formParts.push(``); - - if (fieldBlock.type === "formInput") { - const type = anyProps.inputType === "email" ? "email" : "text"; - const required = anyProps.required ? " required" : ""; - formParts.push( - ``, - ); - } else if (fieldBlock.type === "formSelect") { - const required = anyProps.required ? " required" : ""; - const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : []; - formParts.push(``); - } else if (fieldBlock.type === "formCheckbox") { - const options = Array.isArray(anyProps.options) ? anyProps.options : []; - for (const opt of options) { - formParts.push( - ``, - ); - } - } else if (fieldBlock.type === "formRadio") { - const options = Array.isArray(anyProps.options) ? anyProps.options : []; - for (const opt of options) { - formParts.push( - ``, - ); - } - } - - formParts.push(`
`); - } - } else if (hasFallbackFields) { - for (const field of fallbackFields) { - const required = field.required ? " required" : ""; - const label = field.label ?? field.name; - formParts.push(`
`); - formParts.push(``); - if (field.type === "textarea") { - formParts.push( - ``, - ); - } else { - formParts.push( - ``, - ); - } - formParts.push(`
`); - } - } - - formParts.push(``); - formParts.push(``); - - return formParts.join(""); + const parts: string[] = []; + parts.push(`
`); + return parts.join(""); } if (block.type === "divider") { @@ -449,13 +383,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const required = props.required ? " required" : ""; const tokens = computeFormInputExportTokens(props, { escapeAttr }); + const formId = fieldIdToFormId.get(block.id); + const formAttr = formId ? ` form="${escapeAttr(formId)}"` : ""; return [ '
', ``, ``, + )}"${required}${tokens.inputStyleAttr}${formAttr} />`, "
", ].join(""); } @@ -469,11 +405,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : []; const tokens = computeFormSelectExportTokens(props, { escapeAttr }); + const formId = fieldIdToFormId.get(block.id); + const formAttr = formId ? ` form="${escapeAttr(formId)}"` : ""; const parts: string[] = []; parts.push('
'); parts.push(``); - parts.push(``, + ); for (const opt of options) { parts.push( ``, @@ -495,6 +435,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const options = Array.isArray(props.options) ? props.options : []; const tokens = computeFormCheckboxExportTokens(props, { escapeAttr }); + const formId = fieldIdToFormId.get(block.id); + const formAttr = formId ? ` form="${escapeAttr(formId)}"` : ""; const parts: string[] = []; parts.push('
'); @@ -503,7 +445,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): parts.push( ``, + )}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}`, ); } parts.push("
"); @@ -521,6 +463,8 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const options = Array.isArray(props.options) ? props.options : []; const tokens = computeFormRadioExportTokens(props, { escapeAttr }); + const formId = fieldIdToFormId.get(block.id); + const formAttr = formId ? ` form="${escapeAttr(formId)}"` : ""; const parts: string[] = []; parts.push('
'); @@ -529,7 +473,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): parts.push( ``, + )}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}`, ); } parts.push("
"); diff --git a/src/app/preview/page.tsx b/src/app/preview/page.tsx index 4363cd6..5134243 100644 --- a/src/app/preview/page.tsx +++ b/src/app/preview/page.tsx @@ -17,6 +17,41 @@ export default function PreviewPage() { mainStyle.backgroundColor = projectConfig.bodyBgColorHex; } + const handleExportZip = async () => { + try { + const payload = { + blocks, + projectConfig, + }; + + const response = await fetch("/api/export", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + return; + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const slug = (projectConfig?.slug ?? "").trim() || "page-builder-export"; + + const link = document.createElement("a"); + link.href = url; + link.download = `${slug}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error("프리뷰 ZIP 내보내기 중 오류", error); + } + }; + const widthPx = typeof projectConfig?.canvasWidthPx === "number" && projectConfig.canvasWidthPx > 0 ? projectConfig.canvasWidthPx @@ -43,12 +78,23 @@ export default function PreviewPage() {

Page Preview

빌더로 만든 페이지를 미리 보는 화면

- - 에디터로 돌아가기 - +
+ + + 에디터로 돌아가기 + +
{/* 에디터 크롬 없이 순수 페이지 형태로 블록들을 렌더링하되, projectConfig 에 따라 최대 폭을 제한한다. */}
diff --git a/src/features/editor/components/PublicPageRenderer.tsx b/src/features/editor/components/PublicPageRenderer.tsx index 0f28563..22f1d0c 100644 --- a/src/features/editor/components/PublicPageRenderer.tsx +++ b/src/features/editor/components/PublicPageRenderer.tsx @@ -103,6 +103,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) { const [formMessage, setFormMessage] = useState(""); const renderBlock = (block: Block) => { + if ((block as any).type === "form") { + return null; + } + if (block.type === "text") { const props = block.props as TextBlockProps; const tokens = computeTextPublicTokens(props); @@ -398,6 +402,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) { const props = block.props as VideoBlockProps; const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? ""); + if (!rawUrl) { + return null; + } + const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto"); const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true }); diff --git a/src/styles/builder.css b/src/styles/builder.css index 151b1c4..920673e 100644 --- a/src/styles/builder.css +++ b/src/styles/builder.css @@ -408,6 +408,12 @@ body { max-width: 40rem; } +/* Form 컨트롤러용 최소 폼: 레이아웃에 영향이 없도록 margin/padding 을 0 으로 고정한다. */ +.pb-form-controller { + margin: 0; + padding: 0; +} + .pb-form-field { display: flex; flex-direction: column; diff --git a/tests/api/export.spec.ts b/tests/api/export.spec.ts index d7522f6..e3c6300 100644 --- a/tests/api/export.spec.ts +++ b/tests/api/export.spec.ts @@ -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("]*required/); - expect(html).not.toMatch(/name=\"plan\"[^>]*required/); + // FormBlock 은 컨트롤러 전용이므로, Export 에서는 id 를 가진 단일
컨테이너만 생성하고 + // 실제 필드 레이아웃은 개별 formInput/formSelect 블록이 담당한다. + // - + const formId = "form_form_1"; + expect(html).toMatch(new RegExp(`]*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 인 내부에는 name="name"/"plan" 필드가 없어야 한다. + const formStart = html.indexOf("", 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 에서 아무 /필드도 생성하지 않아야 한다", 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(" { 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(" { +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 }) => { diff --git a/tests/unit/PreviewExport.spec.tsx b/tests/unit/PreviewExport.spec.tsx new file mode 100644 index 0000000..aef70a1 --- /dev/null +++ b/tests/unit/PreviewExport.spec.tsx @@ -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) => {children}, + }; +}); + +describe("PreviewPage - ZIP Export", () => { + it("프리뷰 헤더에 ZIP Export 버튼이 노출되어야 한다", () => { + render(); + + 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(); + + 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"); + }); +}); diff --git a/tests/unit/PublicPageRendererBlockBackground.spec.tsx b/tests/unit/PublicPageRendererBlockBackground.spec.tsx index cc92e6f..9314d22 100644 --- a/tests/unit/PublicPageRendererBlockBackground.spec.tsx +++ b/tests/unit/PublicPageRendererBlockBackground.spec.tsx @@ -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(); + const { queryByTestId, rerender } = render(); - 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(); - 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 이 설정된 경우에만 섹션 요소에 배경색이 적용되어야 한다", () => { diff --git a/tests/unit/PublicPageRendererFormSubmitMessages.spec.tsx b/tests/unit/PublicPageRendererFormSubmitMessages.spec.tsx index 8d2dd66..4244974 100644 --- a/tests/unit/PublicPageRendererFormSubmitMessages.spec.tsx +++ b/tests/unit/PublicPageRendererFormSubmitMessages.spec.tsx @@ -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(); - 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(); - 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(); }); }); diff --git a/tests/unit/PublicPageRendererVideoStyles.spec.tsx b/tests/unit/PublicPageRendererVideoStyles.spec.tsx index 8bd0caa..84681ac 100644 --- a/tests/unit/PublicPageRendererVideoStyles.spec.tsx +++ b/tests/unit/PublicPageRendererVideoStyles.spec.tsx @@ -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(); + + const video = container.querySelector("video"); + const iframe = container.querySelector("iframe"); + expect(video).toBeNull(); + expect(iframe).toBeNull(); + }); }); diff --git a/uploads/test-image-1764206623337 b/uploads/test-image-1764206623337 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764206623337 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764208686337 b/uploads/test-image-1764208686337 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764208686337 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764209184369 b/uploads/test-image-1764209184369 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764209184369 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764210668417 b/uploads/test-image-1764210668417 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764210668417 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764210974700 b/uploads/test-image-1764210974700 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764210974700 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764236693581 b/uploads/test-image-1764236693581 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764236693581 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764237905792 b/uploads/test-image-1764237905792 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764237905792 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764237972345 b/uploads/test-image-1764237972345 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764237972345 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764206623341 b/uploads/test-section-bg-1764206623341 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764206623341 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764208686343 b/uploads/test-section-bg-1764208686343 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764208686343 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764209184385 b/uploads/test-section-bg-1764209184385 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764209184385 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764210668421 b/uploads/test-section-bg-1764210668421 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764210668421 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764210974704 b/uploads/test-section-bg-1764210974704 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764210974704 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764236693585 b/uploads/test-section-bg-1764236693585 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764236693585 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764237905803 b/uploads/test-section-bg-1764237905803 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764237905803 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764237972349 b/uploads/test-section-bg-1764237972349 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764237972349 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764206623348 b/uploads/test-section-bg-video-1764206623348 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764206623348 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764208686349 b/uploads/test-section-bg-video-1764208686349 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764208686349 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764209184431 b/uploads/test-section-bg-video-1764209184431 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764209184431 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764210668439 b/uploads/test-section-bg-video-1764210668439 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764210668439 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764210974710 b/uploads/test-section-bg-video-1764210974710 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764210974710 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764236693590 b/uploads/test-section-bg-video-1764236693590 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764236693590 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764237905811 b/uploads/test-section-bg-video-1764237905811 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764237905811 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764237972355 b/uploads/test-section-bg-video-1764237972355 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764237972355 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-video-1764206623347 b/uploads/test-video-1764206623347 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764206623347 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764208686347 b/uploads/test-video-1764208686347 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764208686347 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764209184426 b/uploads/test-video-1764209184426 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764209184426 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764210668433 b/uploads/test-video-1764210668433 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764210668433 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764210974709 b/uploads/test-video-1764210974709 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764210974709 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764236693589 b/uploads/test-video-1764236693589 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764236693589 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764237905810 b/uploads/test-video-1764237905810 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764237905810 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764237972353 b/uploads/test-video-1764237972353 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764237972353 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-poster-1764206623339 b/uploads/test-video-poster-1764206623339 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764206623339 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764208686339 b/uploads/test-video-poster-1764208686339 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764208686339 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764209184377 b/uploads/test-video-poster-1764209184377 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764209184377 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764210668419 b/uploads/test-video-poster-1764210668419 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764210668419 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764210974702 b/uploads/test-video-poster-1764210974702 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764210974702 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764236693583 b/uploads/test-video-poster-1764236693583 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764236693583 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764237905797 b/uploads/test-video-poster-1764237905797 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764237905797 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764237972347 b/uploads/test-video-poster-1764237972347 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764237972347 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file From d4cf2f0225c656a377d0edaf62821cf9582c2735 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Thu, 27 Nov 2025 20:29:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?CI=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/previewExportParity.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/previewExportParity.spec.ts b/tests/unit/previewExportParity.spec.ts index 8ef0ffb..e6a6e0c 100644 --- a/tests/unit/previewExportParity.spec.ts +++ b/tests/unit/previewExportParity.spec.ts @@ -181,9 +181,6 @@ describe("프리뷰와 정적 내보내기 일치 (고수준)", () => { "섹션 안 텍스트", "섹션 버튼", "리스트 아이템 1", - "폼 이름", - "폼 이메일", - "폼 메시지", "입력(단독)", "선택(단독)", "체크 A",