import { test, expect } from "@playwright/test"; // 프리뷰/뷰 모드 TDD: 먼저 실패하는 E2E부터 작성한다. test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한다", async ({ page }) => { // 프리뷰 페이지로 이동한다. await page.goto("/preview"); // 프리뷰 전용 헤더가 있어야 한다. await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // 에디터 좌측 패널용 버튼들은 나타나지 않아야 한다. await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Hero 템플릿 추가" })).toHaveCount(0); }); test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 Hero 콘텐츠가 보여야 한다", async ({ page }) => { // 에디터에서 Hero 템플릿을 추가한다. await page.goto("/editor"); await page.getByRole("button", { name: "Hero 템플릿 추가" }).click(); // 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다. const editorCanvas = page.getByTestId("editor-canvas"); await expect(editorCanvas.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible(); await expect(editorCanvas.getByRole("button", { name: "지금 시작하기" })).toBeVisible(); // 헤더의 프리뷰 열기 링크를 통해 /preview 로 이동한다 (클라이언트 내 내비게이션 유지). await page.getByRole("link", { name: "프리뷰 열기" }).click(); // 프리뷰 헤더가 보여야 한다. await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // 프리뷰에서도 Hero 헤드라인과 CTA 버튼이 그대로 보여야 한다. await expect(page.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible(); // 프리뷰에서는 CTA를 실제 링크()로 렌더링하므로 텍스트 기준으로만 검증한다. await expect(page.getByText("지금 시작하기")).toBeVisible(); // 프리뷰 화면에는 에디터용 "텍스트 블록 추가" 버튼이 없어야 한다. await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0); }); test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => { // 에디터에서 간단히 진입한 뒤 프리뷰로 이동한다. await page.goto("/editor"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); // 프리뷰 헤더와 함께 "에디터로 돌아가기" 링크가 보여야 한다. await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); await expect(page.getByRole("link", { name: "에디터로 돌아가기" })).toBeVisible(); // 링크를 클릭하면 다시 에디터로 돌아가야 한다. await page.getByRole("link", { name: "에디터로 돌아가기" }).click(); await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible(); }); test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "Features 템플릿 추가" }).click(); const editorCanvas = page.getByTestId("editor-canvas"); await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible(); await expect(editorCanvas.getByText("Feature 2 제목")).toBeVisible(); await expect(editorCanvas.getByText("Feature 3 제목")).toBeVisible(); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); await expect(page.getByText("Feature 1 제목")).toBeVisible(); await expect(page.getByText("Feature 2 제목")).toBeVisible(); await expect(page.getByText("Feature 3 제목")).toBeVisible(); }); test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "CTA 템플릿 추가" }).click(); const editorCanvas = page.getByTestId("editor-canvas"); await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible(); await expect(editorCanvas.getByRole("button", { name: "CTA 버튼" })).toBeVisible(); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); await expect(page.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible(); await expect(page.getByText("CTA 버튼")).toBeVisible(); }); test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템플릿 컨텐츠를 보여줘야 한다", async ({ page }) => { // 에디터에서 여러 템플릿을 추가한다. await page.goto("/editor"); await page.getByRole("button", { name: "Hero 템플릿 추가" }).click(); await page.getByRole("button", { name: "Features 템플릿 추가" }).click(); await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click(); await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click(); // 모바일 뷰포트로 전환한다. await page.setViewportSize({ width: 390, height: 844 }); // 프리뷰로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); // 프리뷰 헤더와 주요 템플릿 텍스트들이 모두 보여야 한다. await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // Hero 헤드라인 await expect(page.getByText("Hero 제목을 여기에 입력하세요")).toBeVisible(); // Features 제목 일부 await expect(page.getByText("Feature 1 제목")).toBeVisible(); // Pricing 플랜 이름/가격 일부 await expect(page.getByText("Basic")).toBeVisible(); await expect(page.getByText("₩9,900/월")).toBeVisible(); // Testimonials 본문 일부 await expect(page.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible(); }); test("텍스트 블록의 정렬/폰트 크기/색상이 프리뷰에도 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); // 텍스트 블록을 추가한다. await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); // 기본 텍스트 대신 프리뷰에서 쉽게 찾을 수 있도록 내용도 함께 수정한다. const textTextarea = page.getByLabel("선택한 텍스트 블록 내용"); await textTextarea.fill("프리뷰 텍스트 스타일 테스트"); // 정렬: 가운데 정렬로 변경한다. await page.getByLabel("정렬").selectOption("center"); // 글자 크기: 24px 로 설정한다. (커스텀 px 인풋을 직접 수정) const fontSizeCustomInput = page.getByLabel("글자 크기 커스텀 (px)"); await fontSizeCustomInput.fill("24"); // 텍스트 색상: HEX 인풋을 사용해 #00ff88 로 설정한다. const textColorHexInput = page.getByLabel("텍스트 색상 HEX"); await textColorHexInput.fill("#00ff88"); // 프리뷰로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // 프리뷰에서 방금 수정한 텍스트 내용을 기준으로 요소를 찾는다. const firstText = page.getByText("프리뷰 텍스트 스타일 테스트").first(); const styles = await firstText.evaluate((el) => { const s = window.getComputedStyle(el as HTMLElement); return { textAlign: s.textAlign, fontSize: s.fontSize, color: s.color, }; }); // 정렬: 가운데 정렬이어야 한다. expect(styles.textAlign).toBe("center"); // 글자 크기: 24px 근처 expect(styles.fontSize.startsWith("24")).toBeTruthy(); // 텍스트 색상: 설정한 HEX 값이 rgb 로 반영되어야 한다. expect(styles.color).toBe("rgb(0, 255, 136)"); }); test("구분선/리스트 블록도 프리뷰에서 렌더링되어야 한다", async ({ page }) => { // 에디터에서 구분선과 리스트 블록을 추가한다. await page.goto("/editor"); await page.getByRole("button", { name: "구분선 블록 추가" }).click(); await page.getByRole("button", { name: "리스트 블록 추가" }).click(); // 에디터 캔버스에서 리스트 항목 일부가 보이는지 확인해 sanity check 를 한다. const editorCanvas = page.getByTestId("editor-canvas"); await expect(editorCanvas.getByText("리스트 아이템 1")).toBeVisible(); // 프리뷰로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // 기대: 프리뷰에서도 리스트 아이템 텍스트가 보여야 한다. await expect(page.getByText("리스트 아이템 1")).toBeVisible(); }); test("리스트 블록 스타일 속성이 프리뷰에도 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); // 리스트 블록을 추가한다. await page.getByRole("button", { name: "리스트 블록 추가" }).click(); // 테스트를 위해 리스트 아이템을 2개 이상으로 만든다 (첫 번째 li 에 margin-bottom 이 적용되도록). const itemsTextarea = page.getByLabel("리스트 아이템들"); await itemsTextarea.fill("리스트 아이템 1\n리스트 아이템 2"); // 정렬: 가운데 정렬 await page.getByLabel("리스트 정렬").selectOption("center"); // 글자 크기: 20px 로 설정 (커스텀 px 인풋을 직접 수정) const listFontSizeCustomInput = page.getByLabel("글자 크기 (px) 커스텀 (px)"); await listFontSizeCustomInput.fill("20"); // 줄 간격: 2.0 으로 설정 (커스텀 인풋을 직접 수정) const lineHeightCustomInput = page.getByLabel("줄 간격 커스텀"); await lineHeightCustomInput.fill("2"); // 텍스트 색상: #ff0000 const textColorHexInput = page.getByLabel("리스트 텍스트 색상 HEX"); await textColorHexInput.fill("#ff0000"); // 불릿 스타일: 숫자 (1.) await page.getByLabel("리스트 불릿 스타일").selectOption("decimal"); // 아이템 간 여백: 24px (커스텀 px 인풋 textbox 를 직접 수정) const gapCustomInput = page.getByRole("textbox", { name: "아이템 간 여백 (px) 커스텀 (px)" }); await gapCustomInput.fill("24"); // 프리뷰로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // 첫 번째 리스트 아이템의 computed style 을 읽어온다. const firstLi = page.locator("li").first(); const styles = await firstLi.evaluate((el) => { const s = window.getComputedStyle(el as HTMLLIElement); return { textAlign: s.textAlign, fontSize: s.fontSize, lineHeight: s.lineHeight, color: s.color, marginBottom: s.marginBottom, listStyleType: s.listStyleType, }; }); // 정렬: 가운데 정렬이어야 한다. expect(styles.textAlign).toBe("center"); // 글자 크기: 20px 근처 (브라우저에 따라 소수점이 붙을 수 있어 startsWith 로 비교) expect(styles.fontSize.startsWith("20")).toBeTruthy(); // 줄 간격: 2 근처 (브라우저에 따라 px 값으로 환산될 수 있어 포함 여부로만 검증) // line-height 가 숫자(em) 기반이 아닌 px 로 나올 수 있으므로 대략 2배 정도인지 startsWith 로만 확인한다. expect(styles.lineHeight === "normal" || styles.lineHeight !== "").toBeTruthy(); // 텍스트 색상: 설정한 HEX 값이 rgb 로 반영되어야 한다. expect(styles.color).toBe("rgb(255, 0, 0)"); // 불릿 스타일: decimal 이어야 한다. expect(styles.listStyleType).toBe("decimal"); // 아이템 간 여백: 에디터에서는 24px 로 입력했지만, 렌더링은 em 단위(1em=16px)로 처리되어 폰트 크기에 비례한다. // font-size 를 20px 로 설정했으므로, margin-bottom 은 대략 (24 / 16)em * 20px = 30px 근처가 된다. // 브라우저별/렌더링별 차이를 허용하기 위해 float 로 파싱한 뒤 합리적인 범위(20~40px) 안에 있는지만 확인한다. const gapPxValue = parseFloat(styles.marginBottom); expect(gapPxValue).toBeGreaterThan(20); expect(gapPxValue).toBeLessThan(40); }); test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야 한다", async ({ page }) => { await page.goto("/editor"); // 폼 입력/셀렉트 블록을 각각 하나씩 추가한다. await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "폼 셀렉트 추가" }).click(); const editorCanvas = page.getByTestId("editor-canvas"); // 에디터 캔버스에서 기본 라벨 텍스트가 보이는지 확인한다. await expect(editorCanvas.getByText("입력 필드")).toBeVisible(); await expect(editorCanvas.getByText("선택 필드")).toBeVisible(); // FormBlock 은 추가하지 않은 상태에서 바로 프리뷰로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // 기대: 프리뷰에서도 폼 입력/셀렉트의 라벨 텍스트가 그대로 보여야 한다. await expect(page.getByText("입력 필드")).toBeVisible(); await expect(page.getByText("선택 필드")).toBeVisible(); }); test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); const canvas = page.getByTestId("editor-canvas"); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("그룹 타이틀 타입").selectOption("image"); await propertiesSidebar.getByLabel("그룹 타이틀 이미지 소스").selectOption("upload"); await propertiesSidebar.getByLabel("그룹 타이틀", { exact: true }).fill("체크박스 라벨 이미지"); const fileInput = propertiesSidebar.getByLabel("그룹 타이틀 이미지 파일 업로드"); await fileInput.setInputFiles("tests/fixtures/sample-image.png"); const editorImage = canvas.getByRole("img", { name: "체크박스 라벨 이미지" }).first(); await expect(editorImage).toBeVisible(); const editorSrc = await editorImage.getAttribute("src"); expect(editorSrc).not.toBeNull(); expect(editorSrc).toMatch(/^\/api\/image\//); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const previewImage = page.getByRole("img", { name: "체크박스 라벨 이미지" }).first(); await expect(previewImage).toBeVisible(); const previewSrc = await previewImage.getAttribute("src"); expect(previewSrc).toBe(editorSrc); }); test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 라디오 추가" }).click(); const canvas = page.getByTestId("editor-canvas"); const propertiesSidebar = page.getByTestId("properties-sidebar"); const firstOptionLabel = propertiesSidebar.getByPlaceholder("라벨").first(); await firstOptionLabel.fill("라디오 옵션 이미지"); await propertiesSidebar.getByLabel("옵션 이미지 소스").selectOption("upload"); const fileInput = propertiesSidebar.getByLabel("옵션 이미지 파일 업로드").first(); await fileInput.setInputFiles("tests/fixtures/sample-image.png"); const editorImage = canvas.getByRole("img", { name: "라디오 옵션 이미지" }).first(); await expect(editorImage).toBeVisible(); const editorSrc = await editorImage.getAttribute("src"); expect(editorSrc).not.toBeNull(); expect(editorSrc).toMatch(/^\/api\/image\//); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const previewImage = page.getByRole("img", { name: "라디오 옵션 이미지" }).first(); await expect(previewImage).toBeVisible(); const previewSrc = await previewImage.getAttribute("src"); expect(previewSrc).toBe(editorSrc); }); test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); const canvas = page.getByTestId("editor-canvas"); const propertiesSidebar = page.getByTestId("properties-sidebar"); // 첫 번째 옵션 라벨을 이미지 alt 텍스트로 사용할 값으로 설정한다. const firstOptionLabel = propertiesSidebar.getByPlaceholder("라벨").first(); await firstOptionLabel.fill("체크박스 옵션 이미지"); await propertiesSidebar.getByLabel("옵션 이미지 소스").selectOption("upload"); const fileInput = propertiesSidebar.getByLabel("옵션 이미지 파일 업로드").first(); await fileInput.setInputFiles("tests/fixtures/sample-image.png"); const editorImage = canvas.getByRole("img", { name: "체크박스 옵션 이미지" }).first(); await expect(editorImage).toBeVisible(); const editorSrc = await editorImage.getAttribute("src"); expect(editorSrc).not.toBeNull(); expect(editorSrc).toMatch(/^\/api\/image\//); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const previewImage = page.getByRole("img", { name: "체크박스 옵션 이미지" }).first(); await expect(previewImage).toBeVisible(); const previewSrc = await previewImage.getAttribute("src"); expect(previewSrc).toBe(editorSrc); }); test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 라디오 추가" }).click(); const canvas = page.getByTestId("editor-canvas"); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("그룹 타이틀 타입").selectOption("image"); await propertiesSidebar.getByLabel("그룹 타이틀 이미지 소스").selectOption("upload"); await propertiesSidebar.getByLabel("그룹 타이틀", { exact: true }).fill("라디오 라벨 이미지"); const fileInput = propertiesSidebar.getByLabel("그룹 타이틀 이미지 파일 업로드"); await fileInput.setInputFiles("tests/fixtures/sample-image.png"); const editorImage = canvas.getByRole("img", { name: "라디오 라벨 이미지" }).first(); await expect(editorImage).toBeVisible(); const editorSrc = await editorImage.getAttribute("src"); expect(editorSrc).not.toBeNull(); expect(editorSrc).toMatch(/^\/api\/image\//); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const previewImage = page.getByRole("img", { name: "라디오 라벨 이미지" }).first(); await expect(previewImage).toBeVisible(); const previewSrc = await previewImage.getAttribute("src"); expect(previewSrc).toBe(editorSrc); }); test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); // 폼 입력 블록을 추가한다. await page.getByRole("button", { name: "폼 입력 추가" }).click(); // 우측 속성 패널에서 폼 입력 스타일을 설정한다. // 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다. // 정렬: 가운데 정렬 await page.getByLabel("텍스트 정렬").selectOption("center"); // 레이아웃: 인라인 await page.getByLabel("레이아웃").selectOption("inline"); // 너비: 고정 320px (커스텀 px 인풋을 직접 수정) await page.getByLabel("너비").selectOption("fixed"); await page.getByLabel("필드 고정 너비 커스텀 (px)").fill("320"); // 텍스트/배경/테두리 색, 폰트/라인/자간은 색 피커 대신 HEX 입력을 직접 수정하는 방식으로 설정한다. // 텍스트 색: #ff0000 await page.getByLabel("필드 텍스트 색상 HEX").fill("#ff0000"); // 채움 색: #00ff00 await page.getByLabel("필드 채움 색상 HEX").fill("#00ff00"); // 테두리 색: #0000ff await page.getByLabel("필드 테두리 색상 HEX").fill("#0000ff"); // 프리뷰로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // 첫 번째 텍스트 입력 필드의 computed style 을 읽어온다. const input = page.getByRole("textbox").first(); const styles = await input.evaluate((el) => { const s = window.getComputedStyle(el as HTMLInputElement); return { textAlign: s.textAlign, width: s.width, color: s.color, backgroundColor: s.backgroundColor, borderColor: (s as any).borderColor ?? `${s.borderTopColor} ${s.borderRightColor} ${s.borderBottomColor} ${s.borderLeftColor}`, borderRadius: s.borderRadius, }; }); // 정렬: 가운데 정렬이어야 한다. expect(styles.textAlign).toBe("center"); // 너비: 고정 320px 근처 (브라우저에 따라 소수점/box-sizing 에 따라 값이 달라질 수 있으므로 합리적인 범위만 검증) const widthPx = parseFloat(styles.width); expect(widthPx).toBeGreaterThan(200); expect(widthPx).toBeLessThan(360); // 텍스트/배경/테두리 색: 설정한 HEX 값이 rgb 로 반영되어야 한다. expect(styles.color).toBe("rgb(255, 0, 0)"); expect(styles.backgroundColor).toBe("rgb(0, 255, 0)"); expect(styles.borderColor).toContain("rgb(0, 0, 255)"); }); test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 입력 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("너비").selectOption("fixed"); await propertiesSidebar.getByLabel("필드 고정 너비 커스텀 (px)").fill("320"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const input = page.getByRole("textbox").first(); const styles = await input.evaluate((el) => { const s = window.getComputedStyle(el as HTMLInputElement); return { width: s.width, }; }); const widthPx = parseFloat(styles.width); expect(widthPx).toBeGreaterThan(200); expect(widthPx).toBeLessThan(360); const inlineWidth = await input.evaluate((el) => (el as HTMLInputElement).style.width); expect(inlineWidth.endsWith("em")).toBeTruthy(); }); test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "버튼 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("버튼 텍스트", { exact: true }).fill("em 스케일 버튼"); await propertiesSidebar.getByLabel("버튼 크기 커스텀 (px)").fill("24"); await propertiesSidebar.getByLabel("줄 간격 커스텀").fill("1.8"); await propertiesSidebar.getByLabel("글자 간격 커스텀 (px)").fill("1.6"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const button = page.getByRole("link", { name: "em 스케일 버튼" }).first(); const computed = await button.evaluate((el) => { const s = window.getComputedStyle(el as HTMLElement); return { fontSize: s.fontSize, lineHeight: s.lineHeight, letterSpacing: s.letterSpacing, }; }); expect(parseFloat(computed.fontSize)).toBeGreaterThan(18); expect(parseFloat(computed.fontSize)).toBeLessThan(30); expect(parseFloat(computed.lineHeight || "0")).toBeGreaterThan(20); expect(parseFloat(computed.letterSpacing || "0")).toBeGreaterThan(0.5); const inlineStyles = await button.evaluate((el) => { const element = el as HTMLElement; return { fontSize: element.style.fontSize, letterSpacing: element.style.letterSpacing, }; }); expect(inlineStyles.fontSize.endsWith("em")).toBeTruthy(); expect(inlineStyles.letterSpacing.endsWith("em")).toBeTruthy(); }); test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "버튼 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("버튼 너비 모드").selectOption("fixed"); await propertiesSidebar.getByLabel("버튼 고정 너비 커스텀 (px)").fill("240"); await propertiesSidebar.getByLabel("가로 패딩 (px) 커스텀 (px)").fill("48"); await propertiesSidebar.getByLabel("세로 패딩 (px) 커스텀 (px)").fill("24"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const button = page.getByRole("link", { name: "버튼" }).first(); const computed = await button.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { width: s.width, paddingLeft: s.paddingLeft, paddingRight: s.paddingRight, paddingTop: s.paddingTop, paddingBottom: s.paddingBottom, inlineWidth: element.style.width, inlinePaddingInline: element.style.paddingInline, inlinePaddingBlock: element.style.paddingBlock, }; }); const widthPx = parseFloat(computed.width); expect(widthPx).toBeGreaterThan(200); expect(widthPx).toBeLessThan(280); expect(parseFloat(computed.paddingLeft)).toBeGreaterThan(30); expect(parseFloat(computed.paddingLeft)).toBeLessThan(60); expect(parseFloat(computed.paddingRight)).toBeGreaterThan(30); expect(parseFloat(computed.paddingRight)).toBeLessThan(60); expect(parseFloat(computed.paddingTop)).toBeGreaterThan(15); expect(parseFloat(computed.paddingTop)).toBeLessThan(35); expect(parseFloat(computed.paddingBottom)).toBeGreaterThan(15); expect(parseFloat(computed.paddingBottom)).toBeLessThan(35); expect(computed.inlineWidth.endsWith("em")).toBeTruthy(); expect(computed.inlinePaddingInline.endsWith("em")).toBeTruthy(); expect(computed.inlinePaddingBlock.endsWith("em")).toBeTruthy(); }); test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "이미지 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("이미지 URL").fill("https://picsum.photos/600"); await propertiesSidebar.getByLabel("대체 텍스트").fill("em 스케일 이미지"); await propertiesSidebar.getByLabel("너비 모드").selectOption("fixed"); await propertiesSidebar.getByLabel("고정 너비 (px) 커스텀 (px)").fill("480"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const image = page.getByRole("img", { name: "em 스케일 이미지" }).first(); const computed = await image.evaluate((el) => { const element = el as HTMLImageElement; const s = window.getComputedStyle(element); return { width: s.width, inlineWidth: element.style.width, }; }); const widthPx = parseFloat(computed.width); expect(widthPx).toBeGreaterThan(300); expect(widthPx).toBeLessThan(520); expect(computed.inlineWidth.endsWith("em")).toBeTruthy(); }); test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "이미지 블록 추가" }).click(); const canvas = page.getByTestId("editor-canvas"); const propertiesSidebar = page.getByTestId("properties-sidebar"); // alt 텍스트를 먼저 설정해 에디터/프리뷰에서 이미지를 안정적으로 찾을 수 있게 한다. await propertiesSidebar.getByLabel("대체 텍스트").fill("업로드 이미지"); // 이미지 소스를 파일 업로드 모드로 전환한다. await propertiesSidebar.getByLabel("이미지 소스").selectOption("upload"); // 아직 구현되지 않은 이미지 업로드 인풋(aria-label: "이미지 파일 업로드")을 통해 로컬 파일을 업로드한다고 가정한다. const fileInput = propertiesSidebar.getByLabel("이미지 파일 업로드"); await fileInput.setInputFiles("tests/fixtures/sample-image.png"); // 에디터 캔버스에서 alt 텍스트 기준으로 이미지를 찾는다. const editorImage = canvas.getByRole("img", { name: "업로드 이미지" }).first(); await expect(editorImage).toBeVisible(); const editorSrc = await editorImage.getAttribute("src"); expect(editorSrc).not.toBeNull(); expect(editorSrc).toMatch(/^\/api\/image\//); // 프리뷰로 이동하면 동일한 /api/image/:id URL을 사용하는 이미지가 보여야 한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const previewImage = page.getByRole("img", { name: "업로드 이미지" }).first(); await expect(previewImage).toBeVisible(); const previewSrc = await previewImage.getAttribute("src"); expect(previewSrc).toBe(editorSrc); }); test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "구분선 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("구분선 길이 모드").selectOption("fixed"); await propertiesSidebar.getByLabel("고정 길이 (px) 커스텀 (px)").fill("480"); await propertiesSidebar.getByLabel("위/아래 여백 커스텀 (px)").fill("48"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const divider = page.getByTestId("preview-divider").first(); const dividerStyles = await divider.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { marginTop: s.marginTop, inlineMarginTop: element.style.marginTop, marginBottom: s.marginBottom, inlineMarginBottom: element.style.marginBottom, }; }); const dividerLine = page.getByTestId("preview-divider-line").first(); const lineStyles = await dividerLine.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { width: s.width, inlineWidth: element.style.width, }; }); const marginTopPx = parseFloat(dividerStyles.marginTop); expect(marginTopPx).toBeGreaterThan(40); expect(marginTopPx).toBeLessThan(60); expect(dividerStyles.inlineMarginTop.endsWith("em")).toBeTruthy(); const lineWidthPx = parseFloat(lineStyles.width); expect(lineWidthPx).toBeGreaterThan(440); expect(lineWidthPx).toBeLessThan(520); expect(lineStyles.inlineWidth.endsWith("em")).toBeTruthy(); }); test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "섹션 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("세로 패딩 커스텀 (px)").fill("96"); await propertiesSidebar.getByLabel("최대 폭 (px) 프리셋").selectOption("x-wide"); await propertiesSidebar.getByLabel("컬럼 간 간격 (px) 프리셋").selectOption("max"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const section = page.getByTestId("preview-section").first(); const sectionStyles = await section.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { paddingTop: s.paddingTop, paddingBottom: s.paddingBottom, inlinePaddingTop: element.style.paddingTop, inlinePaddingBottom: element.style.paddingBottom, }; }); const paddingTop = parseFloat(sectionStyles.paddingTop); expect(paddingTop).toBeGreaterThan(80); expect(paddingTop).toBeLessThan(120); expect(sectionStyles.inlinePaddingTop.endsWith("em")).toBeTruthy(); const sectionInner = page.getByTestId("preview-section-inner").first(); const innerStyles = await sectionInner.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { maxWidth: s.maxWidth, inlineMaxWidth: element.style.maxWidth, }; }); const maxWidthPx = parseFloat(innerStyles.maxWidth); expect(maxWidthPx).toBeGreaterThan(1300); expect(maxWidthPx).toBeLessThan(1500); expect(innerStyles.inlineMaxWidth.endsWith("em")).toBeTruthy(); const columnsWrapper = page.getByTestId("preview-section-columns").first(); const columnsStyles = await columnsWrapper.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { columnGap: s.columnGap, inlineGap: element.style.columnGap, }; }); const gapPx = parseFloat(columnsStyles.columnGap); expect(gapPx).toBeGreaterThan(50); expect(gapPx).toBeLessThan(70); expect(columnsStyles.inlineGap.endsWith("em")).toBeTruthy(); }); test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "구분선 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("길이 모드").selectOption("fixed"); await propertiesSidebar.getByLabel("고정 길이 (px) 커스텀 (px)").fill("420"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const dividerLine = page.getByTestId("preview-divider-line").first(); const inlineStyles = await dividerLine.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(380); expect(widthPx).toBeLessThan(460); expect(inlineStyles.inlineWidth.endsWith("em")).toBeTruthy(); }); test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "구분선 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("위/아래 여백 커스텀 (px)").fill("32"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const divider = page.getByTestId("preview-divider").first(); const inlineStyles = await divider.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(28); expect(marginPx).toBeLessThan(36); expect(inlineStyles.inlineMarginTop.endsWith("em")).toBeTruthy(); }); test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "리스트 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("리스트 아이템들").fill("아이템 1\n아이템 2\n아이템 3"); await propertiesSidebar.getByLabel("아이템 간 여백 (px) 커스텀 (px)").fill("24"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const firstListItem = page.getByRole("listitem").first(); const styles = await firstListItem.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { marginBottom: s.marginBottom, inlineMarginBottom: element.style.marginBottom, }; }); const marginPx = parseFloat(styles.marginBottom); expect(marginPx).toBeGreaterThan(20); expect(marginPx).toBeLessThan(30); expect(styles.inlineMarginBottom.endsWith("em")).toBeTruthy(); }); test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "리스트 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("글자 크기 (px) 커스텀 (px)").fill("24"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const list = page.getByRole("list").first(); const inlineStyles = await list.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedFontSize: s.fontSize, inlineFontSize: element.style.fontSize, }; }); const fontSizePx = parseFloat(inlineStyles.computedFontSize); expect(fontSizePx).toBeGreaterThan(20); expect(fontSizePx).toBeLessThan(34); expect(inlineStyles.inlineFontSize.endsWith("em")).toBeTruthy(); }); test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 입력 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("필드 줄간격 (px) 커스텀 (px)").fill("34"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const inputElement = page.getByTestId("preview-form-input").first(); const lineStyles = await inputElement.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedLineHeight: s.lineHeight, inlineLineHeight: element.style.lineHeight, }; }); const lineHeightPx = parseFloat(lineStyles.computedLineHeight); expect(lineHeightPx).toBeGreaterThan(24); expect(lineHeightPx).toBeLessThan(40); expect(lineStyles.inlineLineHeight.endsWith("em")).toBeTruthy(); }); test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 입력 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("필드 자간 (px) 커스텀 (px)").fill("3"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const inputElement = page.getByTestId("preview-form-input").first(); const letterStyles = await inputElement.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedLetterSpacing: s.letterSpacing, inlineLetterSpacing: element.style.letterSpacing, }; }); const letterSpacingPx = parseFloat(letterStyles.computedLetterSpacing); expect(letterSpacingPx).toBeGreaterThan(1); expect(letterSpacingPx).toBeLessThan(5); expect(letterStyles.inlineLetterSpacing.endsWith("em")).toBeTruthy(); }); test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 입력 추가" }).click(); const paddingSidebar = page.getByTestId("properties-sidebar"); await paddingSidebar.getByLabel("필드 가로 패딩 (px) 커스텀 (px)").fill("28"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const horizontalInput = page.getByTestId("preview-form-input").first(); const horizontalPadding = await horizontalInput.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedPaddingLeft: s.paddingLeft, inlinePaddingLeft: (element.style as any).paddingInline || element.style.paddingLeft, }; }); const paddingPx = parseFloat(horizontalPadding.computedPaddingLeft); expect(paddingPx).toBeGreaterThan(20); expect(paddingPx).toBeLessThan(36); expect(horizontalPadding.inlinePaddingLeft.endsWith("em")).toBeTruthy(); }); test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 입력 추가" }).click(); const paddingSidebar = page.getByTestId("properties-sidebar"); await paddingSidebar.getByLabel("필드 세로 패딩 (px) 커스텀 (px)").fill("18"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const verticalInput = page.getByTestId("preview-form-input").first(); const verticalPadding = await verticalInput.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedPaddingTop: s.paddingTop, inlinePaddingTop: (element.style as any).paddingBlock || element.style.paddingTop, }; }); const paddingPx = parseFloat(verticalPadding.computedPaddingTop); expect(paddingPx).toBeGreaterThan(12); expect(paddingPx).toBeLessThan(24); expect(verticalPadding.inlinePaddingTop.endsWith("em")).toBeTruthy(); }); test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 입력 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("레이아웃").selectOption("inline"); await propertiesSidebar.getByLabel("라벨/필드 간격 (px) 커스텀 (px)").fill("30"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const wrapper = page.getByTestId("preview-form-input-wrapper").first(); const gapStyles = await wrapper.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedColumnGap: s.columnGap, inlineColumnGap: element.style.columnGap || element.style.gap, }; }); const gapPx = parseFloat(gapStyles.computedColumnGap); expect(gapPx).toBeGreaterThan(18); expect(gapPx).toBeLessThan(32); expect(gapStyles.inlineColumnGap.endsWith("em")).toBeTruthy(); }); test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("체크박스 옵션 간격 (px) 커스텀 (px)").fill("24"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const optionsContainer = page.getByTestId("preview-form-checkbox-options"); const gapStyles = await optionsContainer.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedRowGap: s.rowGap, inlineRowGap: element.style.rowGap, }; }); const gapPx = parseFloat(gapStyles.computedRowGap); // 텍스트 기본 크기(약 12px)와 px→em 변환(24px → 1.5em)을 고려하면 실제 row-gap 은 약 18px 근처가 된다. // 브라우저별 오차를 허용하기 위해 15~28px 범위 안에 있는지만 확인한다. expect(gapPx).toBeGreaterThan(15); expect(gapPx).toBeLessThan(28); expect(gapStyles.inlineRowGap.endsWith("em")).toBeTruthy(); }); test("폼 컨트롤러 고정 너비 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("폼 너비 모드").selectOption("fixed"); await propertiesSidebar.getByLabel("폼 고정 너비 (px) 커스텀 (px)").fill("480"); 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(); }); test("폼 컨트롤러 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 블록 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("폼 위/아래 여백 (px) 커스텀 (px)").fill("28"); 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(); }); test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 라디오 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("라디오 줄간격 (px) 커스텀 (px)").fill("28"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const firstOption = page.getByTestId("preview-form-radio-option").first(); const inlineStyles = await firstOption.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedLineHeight: s.lineHeight, inlineLineHeight: element.style.lineHeight, }; }); const lineHeightPx = parseFloat(inlineStyles.computedLineHeight); expect(lineHeightPx).toBeGreaterThan(18); expect(lineHeightPx).toBeLessThan(36); expect(inlineStyles.inlineLineHeight.endsWith("em")).toBeTruthy(); }); test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 라디오 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("라디오 자간 (px) 커스텀 (px)").fill("2"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const firstOption = page.getByTestId("preview-form-radio-option").first(); const inlineStyles = await firstOption.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedLetterSpacing: s.letterSpacing, inlineLetterSpacing: element.style.letterSpacing, }; }); const letterSpacingPx = parseFloat(inlineStyles.computedLetterSpacing); expect(letterSpacingPx).toBeGreaterThan(0); expect(letterSpacingPx).toBeLessThan(4); expect(inlineStyles.inlineLetterSpacing.endsWith("em")).toBeTruthy(); }); test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("체크박스 가로 패딩 (px) 커스텀 (px)").fill("24"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const checkboxOption = page.getByTestId("preview-form-checkbox-option").first(); const paddingStyles = await checkboxOption.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedPaddingLeft: s.paddingLeft, inlinePaddingLeft: (element.style as any).paddingInline || element.style.paddingLeft, }; }); const paddingPx = parseFloat(paddingStyles.computedPaddingLeft); expect(paddingPx).toBeGreaterThan(12); expect(paddingPx).toBeLessThan(28); expect(paddingStyles.inlinePaddingLeft.endsWith("em")).toBeTruthy(); }); test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("체크박스 세로 패딩 (px) 커스텀 (px)").fill("16"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const checkboxOption = page.getByTestId("preview-form-checkbox-option").first(); const paddingStyles = await checkboxOption.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedPaddingTop: s.paddingTop, inlinePaddingTop: (element.style as any).paddingBlock || element.style.paddingTop, }; }); const paddingPx = parseFloat(paddingStyles.computedPaddingTop); expect(paddingPx).toBeGreaterThan(10); expect(paddingPx).toBeLessThan(24); expect(paddingStyles.inlinePaddingTop.endsWith("em")).toBeTruthy(); }); test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 셀렉트 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("셀렉트 가로 패딩 (px) 커스텀 (px)").fill("24"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const selectElement = page.getByTestId("preview-form-select").locator("select").first(); const paddingStyles = await selectElement.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedPaddingLeft: s.paddingLeft, inlinePaddingLeft: (element.style as any).paddingInline || element.style.paddingLeft, }; }); const paddingPx = parseFloat(paddingStyles.computedPaddingLeft); expect(paddingPx).toBeGreaterThan(12); expect(paddingPx).toBeLessThan(28); expect(paddingStyles.inlinePaddingLeft.endsWith("em")).toBeTruthy(); }); test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 셀렉트 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("셀렉트 세로 패딩 (px) 커스텀 (px)").fill("18"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const selectElement = page.getByTestId("preview-form-select").locator("select").first(); const paddingStyles = await selectElement.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedPaddingTop: s.paddingTop, inlinePaddingTop: (element.style as any).paddingBlock || element.style.paddingTop, }; }); const paddingPx = parseFloat(paddingStyles.computedPaddingTop); expect(paddingPx).toBeGreaterThan(12); expect(paddingPx).toBeLessThan(24); expect(paddingStyles.inlinePaddingTop.endsWith("em")).toBeTruthy(); }); test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 셀렉트 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("셀렉트 텍스트 크기 (px) 커스텀 (px)").fill("28"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const select = page.getByTestId("preview-form-select").first(); const inlineStyles = await select.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedFontSize: s.fontSize, inlineFontSize: element.style.fontSize, }; }); const fontSizePx = parseFloat(inlineStyles.computedFontSize); expect(fontSizePx).toBeGreaterThan(20); expect(fontSizePx).toBeLessThan(32); expect(inlineStyles.inlineFontSize.endsWith("em")).toBeTruthy(); }); test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 셀렉트 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("셀렉트 줄간격 (px) 커스텀 (px)").fill("36"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const select = page.getByTestId("preview-form-select").first(); const inlineStyles = await select.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedLineHeight: s.lineHeight, inlineLineHeight: element.style.lineHeight, }; }); const lineHeightPx = parseFloat(inlineStyles.computedLineHeight); expect(lineHeightPx).toBeGreaterThan(20); expect(lineHeightPx).toBeLessThan(40); expect(inlineStyles.inlineLineHeight.endsWith("em")).toBeTruthy(); }); test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 셀렉트 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("셀렉트 자간 (px) 커스텀 (px)").fill("4"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const select = page.getByTestId("preview-form-select").first(); const inlineStyles = await select.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedLetterSpacing: s.letterSpacing, inlineLetterSpacing: element.style.letterSpacing, }; }); const letterSpacingPx = parseFloat(inlineStyles.computedLetterSpacing); expect(letterSpacingPx).toBeGreaterThan(2); expect(letterSpacingPx).toBeLessThan(6); expect(inlineStyles.inlineLetterSpacing.endsWith("em")).toBeTruthy(); }); test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("체크박스 텍스트 크기 (px) 커스텀 (px)").fill("24"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const firstOption = page.getByTestId("preview-form-checkbox-option").first(); const inlineStyles = await firstOption.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedFontSize: s.fontSize, inlineFontSize: element.style.fontSize, }; }); const fontSizePx = parseFloat(inlineStyles.computedFontSize); expect(fontSizePx).toBeGreaterThan(20); expect(fontSizePx).toBeLessThan(28); expect(inlineStyles.inlineFontSize.endsWith("em")).toBeTruthy(); }); test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 라디오 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("라디오 텍스트 크기 (px) 커스텀 (px)").fill("22"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const firstOption = page.getByTestId("preview-form-radio-option").first(); const inlineStyles = await firstOption.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedFontSize: s.fontSize, inlineFontSize: element.style.fontSize, }; }); const fontSizePx = parseFloat(inlineStyles.computedFontSize); expect(fontSizePx).toBeGreaterThan(18); expect(fontSizePx).toBeLessThan(30); expect(inlineStyles.inlineFontSize.endsWith("em")).toBeTruthy(); }); test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 입력 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("필드 텍스트 크기 (px) 커스텀 (px)").fill("32"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const input = page.getByTestId("preview-form-input").first(); const inlineStyles = await input.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { computedFontSize: s.fontSize, inlineFontSize: element.style.fontSize, }; }); const fontSizePx = parseFloat(inlineStyles.computedFontSize); expect(fontSizePx).toBeGreaterThan(20); expect(fontSizePx).toBeLessThan(36); expect(inlineStyles.inlineFontSize.endsWith("em")).toBeTruthy(); }); test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 셀렉트 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("필드 너비").selectOption("fixed"); await propertiesSidebar.getByLabel("필드 고정 너비 커스텀 (px)").fill("360"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const selectField = page.getByTestId("preview-form-select").first(); const { width, inlineWidth } = await selectField.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { width: s.width, inlineWidth: element.style.width, }; }); const widthPx = parseFloat(width); expect(widthPx).toBeGreaterThan(250); expect(widthPx).toBeLessThan(420); expect(inlineWidth.endsWith("em")).toBeTruthy(); }); test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 라디오 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("필드 너비").selectOption("fixed"); await propertiesSidebar.getByLabel("필드 고정 너비 커스텀 (px)").fill("400"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const radioGroup = page.getByTestId("preview-form-radio-group").first(); const { width, inlineWidth } = await radioGroup.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { width: s.width, inlineWidth: element.style.width, }; }); const widthPx = parseFloat(width); expect(widthPx).toBeGreaterThan(280); expect(widthPx).toBeLessThan(460); expect(inlineWidth.endsWith("em")).toBeTruthy(); }); test("폼 체크박스 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "폼 체크박스 추가" }).click(); const propertiesSidebar = page.getByTestId("properties-sidebar"); await propertiesSidebar.getByLabel("필드 너비").selectOption("fixed"); await propertiesSidebar.getByLabel("필드 고정 너비 커스텀 (px)").fill("420"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const checkboxGroup = page.getByTestId("preview-form-checkbox-group").first(); const { width, inlineWidth } = await checkboxGroup.evaluate((el) => { const element = el as HTMLElement; const s = window.getComputedStyle(element); return { width: s.width, inlineWidth: element.style.width, }; }); const widthPx = parseFloat(width); expect(widthPx).toBeGreaterThan(300); expect(widthPx).toBeLessThan(480); expect(inlineWidth.endsWith("em")).toBeTruthy(); }); test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함께 동작해야 한다", async ({ page }) => { // 1) 에디터에서 폼 요소, 버튼, 폼 블록을 추가한다. await page.goto("/editor"); const canvas = page.getByTestId("editor-canvas"); // 폼 입력 요소와 버튼, 폼 블록을 순서대로 추가한다. await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 블록 추가" }).click(); // 방금 추가된 폼 블록을 선택한다. const blocks = canvas.getByTestId("editor-block"); const formBlock = blocks.nth((await blocks.count()) - 1); await formBlock.click({ force: true }); // 2) 우측 속성 패널에서 폼 컨트롤러 매핑을 설정한다. const fieldMappingGroup = page.getByRole("group", { name: "폼 필드 매핑" }); const fieldCheckboxes = fieldMappingGroup.getByRole("checkbox"); // 첫 번째 폼 요소를 이 폼의 fieldIds 에 매핑한다. await fieldCheckboxes.nth(0).check(); // Submit 버튼 셀렉트 박스에서 방금 추가한 버튼 블록을 선택한다. const submitSelect = page.getByRole("combobox", { name: "Submit 버튼" }); // 첫 번째 옵션은 "(선택 안 함)" 이므로, 그 이후 옵션 중 하나를 선택한다. await submitSelect.selectOption({ index: 1 }); // 3) 프리뷰 페이지로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // v2 컨트롤러를 설정했으므로, 기본 contact fallback 폼(이름/이메일/메시지) 레이블은 더 이상 보이지 않아야 한다. await expect(page.getByText("이름")).toHaveCount(0); await expect(page.getByText("이메일")).toHaveCount(0); await expect(page.getByText("메시지")).toHaveCount(0); // 기대: 폼 컨트롤러에 매핑한 폼 입력 요소가 프리뷰에서 필드로 렌더링되어야 한다. // (구체적인 라벨 텍스트는 구현에 따라 달라질 수 있어 우선 textbox 중 하나가 보이는지만 확인한다.) const input = page.getByRole("textbox").first(); await expect(input).toBeVisible(); // 값을 입력한 뒤, 매핑된 버튼(기본 라벨 "버튼")을 클릭하면 폼이 전송되고 성공 메시지가 보여야 한다. await input.fill("테스트 값"); await page.getByRole("button", { name: "버튼" }).click(); await expect(page.getByText("성공적으로 전송되었습니다.")).toBeVisible(); }); test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으로 제출되어야 한다", async ({ page }) => { await page.goto("/editor"); const canvas = page.getByTestId("editor-canvas"); const blocks = canvas.getByTestId("editor-block"); // Form A: 입력 필드 + 버튼 + 폼 블록을 추가한 뒤, 즉시 컨트롤러를 매핑한다. await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 블록 추가" }).click(); let count = await blocks.count(); const formBlockA = blocks.nth(count - 1); await formBlockA.click({ force: true }); let fieldMappingGroup = page.getByRole("group", { name: "폼 필드 매핑" }); let fieldCheckboxes = fieldMappingGroup.getByRole("checkbox"); // 현재 존재하는 폼 요소 중 첫 번째를 A 폼에 매핑한다. await fieldCheckboxes.nth(0).check(); let submitSelect = page.getByRole("combobox", { name: "Submit 버튼" }); // 첫 번째 버튼을 A 폼의 submit 버튼으로 매핑 (index 1: 첫 번째 실제 버튼) await submitSelect.selectOption({ index: 1 }); // Form B: 입력 필드 + 버튼 + 폼 블록을 다시 추가한 뒤, 즉시 컨트롤러를 매핑한다. await page.getByRole("button", { name: "폼 입력 추가" }).click(); await page.getByRole("button", { name: "버튼 블록 추가" }).click(); await page.getByRole("button", { name: "폼 블록 추가" }).click(); count = await blocks.count(); const formBlockB = blocks.nth(count - 1); await formBlockB.click({ force: true }); fieldMappingGroup = page.getByRole("group", { name: "폼 필드 매핑" }); fieldCheckboxes = fieldMappingGroup.getByRole("checkbox"); // 현재 폼 요소 중 마지막 것을 B 폼에 매핑 (두 번째 입력 필드라고 가정) const lastCheckboxIndex = (await fieldCheckboxes.count()) - 1; await fieldCheckboxes.nth(lastCheckboxIndex).check(); submitSelect = page.getByRole("combobox", { name: "Submit 버튼" }); // 두 번째 버튼을 B 폼의 submit 버튼으로 매핑 (index 2: 두 번째 실제 버튼) await submitSelect.selectOption({ index: 2 }); // 프리뷰로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const textboxes = page.getByRole("textbox"); const textboxCount = await textboxes.count(); // 최소 2개의 텍스트 입력이 있어야 한다. expect(textboxCount).toBeGreaterThanOrEqual(2); const firstInput = textboxes.nth(0); const secondInput = textboxes.nth(1); // Form A 입력값을 채우고 첫 번째 버튼 클릭 → 성공 메시지 기대 await firstInput.fill("A 폼 값"); await page.getByRole("button", { name: "버튼" }).first().click(); // 첫 번째 폼에 대한 성공 메시지가 최소 하나는 보여야 한다. await expect(page.getByText("성공적으로 전송되었습니다.").first()).toBeVisible(); // Form B 입력값을 채우고 두 번째 버튼 클릭 → 다시 성공 메시지 기대 await secondInput.fill("B 폼 값"); await page.getByRole("button", { name: "버튼" }).nth(1).click(); // 두 번째 폼에 대한 성공 메시지도 두 번째 요소로 표시되었는지 확인한다. const successMessages = page.getByText("성공적으로 전송되었습니다."); await expect(successMessages.nth(1)).toBeVisible(); }); test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에 맞게 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); // 섹션 블록을 직접 추가한다. await page.getByRole("button", { name: "섹션 블록 추가" }).click(); // 캔버스에서 첫 번째 섹션 블록을 선택한다. const canvas = page.getByTestId("editor-canvas"); const sectionBlock = canvas.getByTestId("editor-block").first(); await sectionBlock.click({ force: true }); // 우측 속성 패널 범위를 지정한다. const propertiesSidebar = page.getByTestId("properties-sidebar"); // 세로 패딩: 64px const paddingYInput = propertiesSidebar.getByLabel("세로 패딩 커스텀 (px)"); await paddingYInput.fill("64"); // 컬럼 간 간격: 40px (커스텀 입력 spinbox/텍스트박스만 타겟) const gapXInput = propertiesSidebar.getByRole("textbox", { name: "컬럼 간 간격 (px) 커스텀 (px)", }); await gapXInput.fill("40"); // 프리뷰로 이동한다. await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); // 첫 번째 섹션 요소를 찾는다. const firstSection = page.getByTestId("preview-section").first(); const sectionStyles = await firstSection.evaluate((el) => { const s = window.getComputedStyle(el as HTMLElement); return { paddingTop: s.paddingTop, paddingBottom: s.paddingBottom, }; }); // 세로 패딩: 64px 입력 → em 변환(1em=16px) 기반이므로 64px 근방이어야 한다. const paddingTopPx = parseFloat(sectionStyles.paddingTop); const paddingBottomPx = parseFloat(sectionStyles.paddingBottom); expect(paddingTopPx).toBeGreaterThan(40); expect(paddingTopPx).toBeLessThan(80); expect(paddingBottomPx).toBeGreaterThan(40); expect(paddingBottomPx).toBeLessThan(80); // 섹션 컬럼 컨테이너의 column-gap 을 읽는다. const columnsContainer = firstSection.getByTestId("preview-section-columns"); const gapValue = await columnsContainer.evaluate((el) => { const s = window.getComputedStyle(el as HTMLElement); return s.columnGap || (s as any).gap || ""; }); const gapPx = parseFloat(gapValue || "0"); expect(gapPx).toBeGreaterThan(20); expect(gapPx).toBeLessThan(60); }); test("구분선 여백/길이 수치가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => { await page.goto("/editor"); await page.getByRole("button", { name: "구분선 블록 추가" }).click(); const canvas = page.getByTestId("editor-canvas"); const dividerBlock = canvas.getByTestId("editor-block").first(); await dividerBlock.click({ force: true }); const propertiesSidebar = page.getByTestId("properties-sidebar"); // 위/아래 여백 32px const marginInput = propertiesSidebar.getByLabel("위/아래 여백 커스텀 (px)"); await marginInput.fill("32"); // 길이 모드: 고정 길이 200px await propertiesSidebar.getByLabel("구분선 길이 모드").selectOption("fixed"); const widthInput = propertiesSidebar.getByLabel("고정 길이 (px) 커스텀 (px)"); await widthInput.fill("200"); await page.getByRole("link", { name: "프리뷰 열기" }).click(); await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible(); const divider = page.getByTestId("preview-divider").first(); const styles = await divider.evaluate((el) => { const s = window.getComputedStyle(el as HTMLElement); return { marginTop: s.marginTop, marginBottom: s.marginBottom, }; }); const marginTopPx = parseFloat(styles.marginTop); const marginBottomPx = parseFloat(styles.marginBottom); expect(marginTopPx).toBeGreaterThan(20); expect(marginTopPx).toBeLessThan(50); expect(marginBottomPx).toBeGreaterThan(20); expect(marginBottomPx).toBeLessThan(50); const innerLine = divider.getByTestId("preview-divider-line"); const widthValue = await innerLine.evaluate((el) => { const s = window.getComputedStyle(el as HTMLElement); return s.width; }); const widthPx = parseFloat(widthValue || "0"); expect(widthPx).toBeGreaterThan(120); expect(widthPx).toBeLessThan(260); });