diff --git a/src/features/editor/components/PublicPageRenderer.tsx b/src/features/editor/components/PublicPageRenderer.tsx index df9f9cc..c32a3ce 100644 --- a/src/features/editor/components/PublicPageRenderer.tsx +++ b/src/features/editor/components/PublicPageRenderer.tsx @@ -26,7 +26,7 @@ import { buildVideoEmbedUrl, computeVideoPublicTokens, } from "@/features/editor/utils/videoHelpers"; -import { computeButtonPublicTokens } from "@/features/editor/utils/buttonHelpers"; +import { computeButtonPublicTokens, computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers"; import { computeTextPublicTokens } from "@/features/editor/utils/textHelpers"; import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers"; import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers"; @@ -50,8 +50,12 @@ export function getSectionLayoutConfig(props: SectionBlockProps) { ? "bg-sky-900" : "bg-slate-950"; + // 섹션 수직 패딩: builder.css 의 pb-section-py-sm/md/lg (1rem/1.5rem/2.5rem)에 대응 + // - sm → 1rem → py-4 + // - md → 1.5rem → py-6 + // - lg → 2.5rem → py-10 const paddingYClass = - props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12"; + props.paddingY === "sm" ? "py-4" : props.paddingY === "lg" ? "py-10" : "py-6"; const maxWidthClass = props.maxWidthMode === "narrow" @@ -302,16 +306,40 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) { if (block.type === "button") { const props = block.props as ButtonBlockProps; - const tokens = computeButtonPublicTokens(props); - const buttonStyle: CSSProperties = { ...tokens.styleOverrides }; + + const pbTokens = computeButtonPbTokens({ + align: props.align ?? "left", + size: props.size ?? "md", + variant: props.variant ?? "solid", + colorPalette: props.colorPalette ?? "primary", + borderRadius: props.borderRadius ?? "md", + fullWidth: props.fullWidth ?? false, + widthMode: props.widthMode ?? undefined, + widthPx: props.widthPx ?? undefined, + paddingX: props.paddingX ?? undefined, + paddingY: props.paddingY ?? undefined, + fillColorCustom: props.fillColorCustom ?? undefined, + strokeColorCustom: props.strokeColorCustom ?? undefined, + textColorCustom: props.textColorCustom ?? undefined, + }); + + const publicTokens = computeButtonPublicTokens(props); + const buttonStyle: CSSProperties = { ...publicTokens.styleOverrides }; + + const wrapperClass = pbTokens.alignClass; + const buttonClassName = [ + "pb-btn-base", + pbTokens.sizeClass, + pbTokens.variantClass, + pbTokens.radiusClass, + publicTokens.widthClass, + ] + .filter(Boolean) + .join(" "); return ( -
- +
+ {props.label}
@@ -326,13 +354,13 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
-
+
@@ -367,27 +395,17 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) { return ( - {nodes.map((node, index) => ( -
  • + {nodes.map((node) => ( +
  • {node.text} {node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
  • diff --git a/src/features/editor/utils/buttonHelpers.ts b/src/features/editor/utils/buttonHelpers.ts index 6511aa8..b31bdcf 100644 --- a/src/features/editor/utils/buttonHelpers.ts +++ b/src/features/editor/utils/buttonHelpers.ts @@ -64,17 +64,19 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens { const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto"); if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) { - inlineStyles.push(`width:${input.widthPx}px`); + inlineStyles.push(`width:${pxToEm(input.widthPx)}`); } if (typeof input.paddingX === "number") { - inlineStyles.push(`padding-left:${input.paddingX}px`); - inlineStyles.push(`padding-right:${input.paddingX}px`); + const paddingInline = pxToEm(input.paddingX); + inlineStyles.push(`padding-left:${paddingInline}`); + inlineStyles.push(`padding-right:${paddingInline}`); } if (typeof input.paddingY === "number") { - inlineStyles.push(`padding-top:${input.paddingY}px`); - inlineStyles.push(`padding-bottom:${input.paddingY}px`); + const paddingBlock = pxToEm(input.paddingY); + inlineStyles.push(`padding-top:${paddingBlock}`); + inlineStyles.push(`padding-bottom:${paddingBlock}`); } if (input.fillColorCustom && input.fillColorCustom.trim() !== "") { diff --git a/src/features/editor/utils/dividerHelpers.ts b/src/features/editor/utils/dividerHelpers.ts index 4fbe720..d33c376 100644 --- a/src/features/editor/utils/dividerHelpers.ts +++ b/src/features/editor/utils/dividerHelpers.ts @@ -19,8 +19,9 @@ export const computeDividerExportTokens = ( const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : ""; const color = colorRaw !== "" ? colorRaw : "#475569"; const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16; + const marginEm = pxToEm(marginY); - const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginY}px 0;`; + const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginEm} 0;`; return { style }; }; diff --git a/src/features/editor/utils/formHelpers.ts b/src/features/editor/utils/formHelpers.ts index 8c9c1c1..0434831 100644 --- a/src/features/editor/utils/formHelpers.ts +++ b/src/features/editor/utils/formHelpers.ts @@ -84,20 +84,21 @@ export const computeFormInputExportTokens = ( } if (typeof props.paddingX === "number" && props.paddingX >= 0) { - const px = props.paddingX; - inputStyleParts.push(`padding-left:${px}px`); - inputStyleParts.push(`padding-right:${px}px`); + const em = props.paddingX / 16; + inputStyleParts.push(`padding-left:${em}em`); + inputStyleParts.push(`padding-right:${em}em`); } if (typeof props.paddingY === "number" && props.paddingY >= 0) { - const py = props.paddingY; - inputStyleParts.push(`padding-top:${py}px`); - inputStyleParts.push(`padding-bottom:${py}px`); + const em = props.paddingY / 16; + inputStyleParts.push(`padding-top:${em}em`); + inputStyleParts.push(`padding-bottom:${em}em`); } const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth }); if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { - inputStyleParts.push(`width:${props.widthPx}px`); + const em = props.widthPx / 16; + inputStyleParts.push(`width:${em}em`); } const radiusPx = computeRadiusPx(props.borderRadius); @@ -127,19 +128,20 @@ export const computeFormSelectExportTokens = ( const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth }); if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { - selectStyleParts.push(`width:${props.widthPx}px`); + const em = props.widthPx / 16; + selectStyleParts.push(`width:${em}em`); } if (typeof props.paddingX === "number" && props.paddingX >= 0) { - const px = props.paddingX; - selectStyleParts.push(`padding-left:${px}px`); - selectStyleParts.push(`padding-right:${px}px`); + const em = props.paddingX / 16; + selectStyleParts.push(`padding-left:${em}em`); + selectStyleParts.push(`padding-right:${em}em`); } if (typeof props.paddingY === "number" && props.paddingY >= 0) { - const py = props.paddingY; - selectStyleParts.push(`padding-top:${py}px`); - selectStyleParts.push(`padding-bottom:${py}px`); + const em = props.paddingY / 16; + selectStyleParts.push(`padding-top:${em}em`); + selectStyleParts.push(`padding-bottom:${em}em`); } if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { @@ -176,15 +178,15 @@ const computeOptionGroupExportTokensBase = ( } if (typeof props.paddingX === "number" && props.paddingX >= 0) { - const px = props.paddingX; - optionStyleParts.push(`padding-left:${px}px`); - optionStyleParts.push(`padding-right:${px}px`); + const em = props.paddingX / 16; + optionStyleParts.push(`padding-left:${em}em`); + optionStyleParts.push(`padding-right:${em}em`); } if (typeof props.paddingY === "number" && props.paddingY >= 0) { - const py = props.paddingY; - optionStyleParts.push(`padding-top:${py}px`); - optionStyleParts.push(`padding-bottom:${py}px`); + const em = props.paddingY / 16; + optionStyleParts.push(`padding-top:${em}em`); + optionStyleParts.push(`padding-bottom:${em}em`); } if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { diff --git a/src/features/editor/utils/imageHelpers.ts b/src/features/editor/utils/imageHelpers.ts index 2d07990..309ae6a 100644 --- a/src/features/editor/utils/imageHelpers.ts +++ b/src/features/editor/utils/imageHelpers.ts @@ -27,7 +27,8 @@ export function computeImageExportStyles(input: ImageExportStyleInput): ImageExp const widthMode: ImageWidthMode = input.widthMode ?? "auto"; if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) { - imgStyleParts.push(`width:${input.widthPx}px`); + const widthEm = input.widthPx / 16; + imgStyleParts.push(`width:${widthEm}em`); } const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md"; diff --git a/src/features/editor/utils/listHelpers.ts b/src/features/editor/utils/listHelpers.ts index e649fa5..482c2f7 100644 --- a/src/features/editor/utils/listHelpers.ts +++ b/src/features/editor/utils/listHelpers.ts @@ -134,7 +134,15 @@ export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens } } if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { - listStyle.lineHeight = props.lineHeightCustom; + const lineHeightValue = props.lineHeightCustom.trim(); + if (lineHeightValue.endsWith("px")) { + const lineHeightEm = convertPxStringToEm(lineHeightValue); + if (lineHeightEm) { + listStyle.lineHeight = lineHeightEm; + } + } else { + listStyle.lineHeight = lineHeightValue; + } } if (props.textColorCustom && props.textColorCustom.trim() !== "") { listStyle.color = props.textColorCustom; diff --git a/src/features/editor/utils/textHelpers.ts b/src/features/editor/utils/textHelpers.ts index b74a46d..8e7d11a 100644 --- a/src/features/editor/utils/textHelpers.ts +++ b/src/features/editor/utils/textHelpers.ts @@ -276,6 +276,17 @@ export interface TextPublicTokens { styleOverrides: CSSProperties; } +const pxToEm = (px: number, base = 16) => `${px / base}em`; + +const convertPxStringToEm = (value?: string | null) => { + if (!value) return null; + const match = value.trim().match(/-?\d+(?:\.\d+)?/); + if (!match) return null; + const px = parseFloat(match[0]); + if (!Number.isFinite(px)) return null; + return pxToEm(px); +}; + export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens { const alignClass = props.align === "center" @@ -308,7 +319,15 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ) { - styleOverrides.fontSize = props.fontSizeCustom; + const fontSizeValue = props.fontSizeCustom.trim(); + if (fontSizeValue.endsWith("px")) { + const fontSizeEm = convertPxStringToEm(fontSizeValue); + if (fontSizeEm) { + styleOverrides.fontSize = fontSizeEm; + } + } else { + styleOverrides.fontSize = fontSizeValue; + } } if (props.colorCustom && props.colorCustom.trim() !== "") { @@ -319,6 +338,30 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens styleOverrides.backgroundColor = props.backgroundColorCustom.trim(); } + if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { + const lineHeightValue = props.lineHeightCustom.trim(); + if (lineHeightValue.endsWith("px")) { + const lineHeightEm = convertPxStringToEm(lineHeightValue); + if (lineHeightEm) { + styleOverrides.lineHeight = lineHeightEm; + } + } else { + styleOverrides.lineHeight = lineHeightValue; + } + } + + if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") { + const letterSpacingValue = props.letterSpacingCustom.trim(); + if (letterSpacingValue.endsWith("px")) { + const letterSpacingEm = convertPxStringToEm(letterSpacingValue); + if (letterSpacingEm) { + styleOverrides.letterSpacing = letterSpacingEm; + } + } else { + styleOverrides.letterSpacing = letterSpacingValue; + } + } + return { alignClass, sizeClass, diff --git a/src/features/editor/utils/videoHelpers.ts b/src/features/editor/utils/videoHelpers.ts index b3419dc..6654628 100644 --- a/src/features/editor/utils/videoHelpers.ts +++ b/src/features/editor/utils/videoHelpers.ts @@ -101,8 +101,9 @@ export function computeVideoExportTokens( const widthMode = props.widthMode ?? "auto"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { - wrapperStyleParts.push(`width:${props.widthPx}px`); - videoStyleParts.push(`width:${props.widthPx}px`); + const widthEm = pxToEm(props.widthPx); + wrapperStyleParts.push(`width:${widthEm}`); + videoStyleParts.push(`width:${widthEm}`); } else if (widthMode === "full") { wrapperStyleParts.push("width:100%"); videoStyleParts.push("width:100%"); @@ -113,11 +114,11 @@ export function computeVideoExportTokens( } if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) { - wrapperStyleParts.push(`padding:${props.cardPaddingPx}px`); + wrapperStyleParts.push(`padding:${pxToEm(props.cardPaddingPx)}`); } if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) { - wrapperStyleParts.push(`border-radius:${props.borderRadiusPx}px`); + wrapperStyleParts.push(`border-radius:${pxToEm(props.borderRadiusPx)}`); } const align = diff --git a/src/styles/builder.css b/src/styles/builder.css index 920673e..0fae6e6 100644 --- a/src/styles/builder.css +++ b/src/styles/builder.css @@ -263,6 +263,7 @@ body { font-size: 0.75rem; font-weight: 500; border-width: 1px; + text-decoration: none; } .pb-btn-size-xs { padding: 0.125rem 0.5rem; diff --git a/tests/api/export.spec.ts b/tests/api/export.spec.ts index c440a90..272edd4 100644 --- a/tests/api/export.spec.ts +++ b/tests/api/export.spec.ts @@ -70,6 +70,11 @@ describe("/api/export", () => { expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")'); expect(mainJs).toContain("fetch("); + const builderCss = await cssEntry!.async("string"); + // 버튼 베이스 클래스는 정적 Export 에서도 밑줄이 보이지 않도록 text-decoration:none 을 포함해야 한다. + expect(builderCss).toContain(".pb-btn-base"); + expect(builderCss).toContain("text-decoration: none"); + const html = await indexEntry!.async("string"); expect(html).toContain("내보내기 테스트 페이지"); expect(html).toContain("ZIP 내보내기 테스트"); @@ -1043,8 +1048,8 @@ describe("/api/export", () => { expect(html).not.toContain(`/api/video/${videoId}`); // 비디오 카드 스타일이 wrapper style 에 반영되어야 한다. expect(html).toContain("background-color:#123456"); - expect(html).toContain("padding:24px"); - expect(html).toContain("border-radius:16px"); + expect(html).toContain("padding:1.5em"); + expect(html).toContain("border-radius:1em"); }); it("섹션 backgroundVideoSrc 가 /api/video/:id 인 경우 ZIP videos/ 및 상대 경로로 치환되고 섹션 안에 배경 비디오 video 태그로 렌더되어야 한다", async () => { @@ -1157,7 +1162,7 @@ describe("/api/export", () => { expect(html).toContain("pb-divider"); // 스타일 검증: medium 두께, 지정 색상, marginYPx 기반 여백이 style 속성에 포함되어야 한다. expect(html).toContain("border-bottom:2px solid #123456"); - expect(html).toContain("margin:32px 0;"); + expect(html).toContain("margin:2em 0;"); }); it("image 블록은 정적 HTML에서 카드 스타일(배경/너비/둥글기)이 반영되어야 한다", async () => { @@ -1209,7 +1214,7 @@ describe("/api/export", () => { // 카드 배경색이 wrapper div style 에 반영되어야 한다. expect(html).toContain("background-color:#123456"); // 고정 너비/둥글기 스타일이 img style 에 반영되어야 한다. - expect(html).toContain("width:480px"); + expect(html).toContain("width:30em"); expect(html).toContain("border-radius:32px"); }); @@ -2230,11 +2235,11 @@ describe("/api/export", () => { const html = await indexEntry!.async("string"); expect(html).toContain("name=\"email\""); - expect(html).toContain("width:320px"); - expect(html).toContain("padding-left:16px"); - expect(html).toContain("padding-right:16px"); - expect(html).toContain("padding-top:8px"); - expect(html).toContain("padding-bottom:8px"); + expect(html).toContain("width:20em"); + expect(html).toContain("padding-left:1em"); + expect(html).toContain("padding-right:1em"); + expect(html).toContain("padding-top:0.5em"); + expect(html).toContain("padding-bottom:0.5em"); expect(html).toContain("background-color:#123456"); expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-radius:6px"); @@ -2292,11 +2297,11 @@ describe("/api/export", () => { const html = await indexEntry!.async("string"); expect(html).toContain("name=\"category\""); - expect(html).toContain("width:240px"); - expect(html).toContain("padding-left:16px"); - expect(html).toContain("padding-right:16px"); - expect(html).toContain("padding-top:8px"); - expect(html).toContain("padding-bottom:8px"); + expect(html).toContain("width:15em"); + expect(html).toContain("padding-left:1em"); + expect(html).toContain("padding-right:1em"); + expect(html).toContain("padding-top:0.5em"); + expect(html).toContain("padding-bottom:0.5em"); expect(html).toContain("background-color:#123456"); expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-radius:6px"); @@ -2349,10 +2354,10 @@ describe("/api/export", () => { const html = await indexEntry!.async("string"); expect(html).toContain("name=\"features\""); - expect(html).toContain("padding-left:8px"); - expect(html).toContain("padding-right:8px"); - expect(html).toContain("padding-top:4px"); - expect(html).toContain("padding-bottom:4px"); + expect(html).toContain("padding-left:0.5em"); + expect(html).toContain("padding-right:0.5em"); + expect(html).toContain("padding-top:0.25em"); + expect(html).toContain("padding-bottom:0.25em"); expect(html).toContain("background-color:#123456"); expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-radius:6px"); @@ -2405,10 +2410,10 @@ describe("/api/export", () => { const html = await indexEntry!.async("string"); expect(html).toContain("name=\"plan\""); - expect(html).toContain("padding-left:8px"); - expect(html).toContain("padding-right:8px"); - expect(html).toContain("padding-top:4px"); - expect(html).toContain("padding-bottom:4px"); + expect(html).toContain("padding-left:0.5em"); + expect(html).toContain("padding-right:0.5em"); + expect(html).toContain("padding-top:0.25em"); + expect(html).toContain("padding-bottom:0.25em"); expect(html).toContain("background-color:#123456"); expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-radius:6px"); @@ -2512,11 +2517,11 @@ describe("/api/export", () => { const html = await indexEntry!.async("string"); // widthMode=fixed, widthPx, paddingX/paddingY, fillColorCustom, strokeColorCustom, textColorCustom 이 style 속성에 포함되어야 한다. - expect(html).toContain("width:240px"); - expect(html).toContain("padding-left:16px"); - expect(html).toContain("padding-right:16px"); - expect(html).toContain("padding-top:8px"); - expect(html).toContain("padding-bottom:8px"); + expect(html).toContain("width:15em"); + expect(html).toContain("padding-left:1em"); + expect(html).toContain("padding-right:1em"); + expect(html).toContain("padding-top:0.5em"); + expect(html).toContain("padding-bottom:0.5em"); expect(html).toContain("background-color:#123456"); expect(html).toContain("border-color:#ff0000"); expect(html).toContain("color:#00ff00"); diff --git a/tests/unit/PublicPageRendererButtonStyles.spec.tsx b/tests/unit/PublicPageRendererButtonStyles.spec.tsx index 8958b37..8ee1e4d 100644 --- a/tests/unit/PublicPageRendererButtonStyles.spec.tsx +++ b/tests/unit/PublicPageRendererButtonStyles.spec.tsx @@ -33,6 +33,15 @@ describe("PublicPageRenderer - 버튼 블록 스타일", () => { const link = container.querySelector("a") as HTMLAnchorElement | null; expect(link).not.toBeNull(); + + const wrapper = link!.parentElement as HTMLElement | null; + expect(wrapper).not.toBeNull(); + // 정렬/버튼 베이스는 pb-* 토큰 클래스로 렌더된다. + expect(wrapper!.className).toContain("pb-text-center"); + expect(link!.className).toContain("pb-btn-base"); + expect(link!.className).toContain("pb-btn-size-md"); + expect(link!.className).toContain("pb-btn-variant-solid-primary"); + expect(link!.className).toContain("pb-btn-radius-md"); // 배경색: #123456 → rgb(18, 52, 86) expect(link!.style.backgroundColor).toBe("rgb(18, 52, 86)"); // 외곽선 색상: #ff0000 → rgb(255, 0, 0) diff --git a/tests/unit/PublicPageRendererTextStyles.spec.tsx b/tests/unit/PublicPageRendererTextStyles.spec.tsx index 341c511..0e1cf2f 100644 --- a/tests/unit/PublicPageRendererTextStyles.spec.tsx +++ b/tests/unit/PublicPageRendererTextStyles.spec.tsx @@ -56,7 +56,7 @@ describe("PublicPageRenderer - 텍스트 블록 스타일", () => { // 정렬 클래스 expect(el.className).toContain("text-center"); // 커스텀 폰트 크기: sizeClass 는 비워지고, 인라인 스타일로 설정된다. - expect(el.style.fontSize).toBe("24px"); + expect(el.style.fontSize).toBe("1.5em"); // 커스텀 색상/배경색 expect(el.style.color).toBe("rgb(255, 0, 0)"); expect(el.style.backgroundColor).toBe("rgb(18, 52, 86)"); diff --git a/tests/unit/buttonHelpers.spec.ts b/tests/unit/buttonHelpers.spec.ts index 8540eec..66df16d 100644 --- a/tests/unit/buttonHelpers.spec.ts +++ b/tests/unit/buttonHelpers.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { expectNoPxInStyleParts } from "./styleInvariants"; import { computeButtonPbTokens, computeButtonPublicTokens, @@ -53,15 +54,31 @@ describe("buttonHelpers.computeButtonPbTokens", () => { }); expect(tokens.alignClass).toBe("pb-text-right"); - expect(tokens.inlineStyles).toContain("width:200px"); - expect(tokens.inlineStyles).toContain("padding-left:16px"); - expect(tokens.inlineStyles).toContain("padding-right:16px"); - expect(tokens.inlineStyles).toContain("padding-top:8px"); - expect(tokens.inlineStyles).toContain("padding-bottom:8px"); + expect(tokens.inlineStyles).toContain("width:12.5em"); + expect(tokens.inlineStyles).toContain("padding-left:1em"); + expect(tokens.inlineStyles).toContain("padding-right:1em"); + expect(tokens.inlineStyles).toContain("padding-top:0.5em"); + expect(tokens.inlineStyles).toContain("padding-bottom:0.5em"); expect(tokens.inlineStyles).toContain("background-color:#111111"); expect(tokens.inlineStyles).toContain("border-color:#222222"); expect(tokens.inlineStyles).toContain("color:#ffffff"); }); + + it("Button Pb inlineStyles 에는 px 단위 값이 포함되지 않아야 한다", () => { + const tokens = computeButtonPbTokens({ + align: "right", + size: "sm", + widthMode: "fixed", + widthPx: 200, + paddingX: 16, + paddingY: 8, + fillColorCustom: "#111111", + strokeColorCustom: "#222222", + textColorCustom: "#ffffff", + }); + + expectNoPxInStyleParts(tokens.inlineStyles); + }); }); describe("buttonHelpers.computeButtonPublicTokens", () => { diff --git a/tests/unit/dividerHelpers.spec.ts b/tests/unit/dividerHelpers.spec.ts index e6ce39e..1865cf3 100644 --- a/tests/unit/dividerHelpers.spec.ts +++ b/tests/unit/dividerHelpers.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants"; import { computeDividerExportTokens, computeDividerEditorTokens, @@ -16,9 +17,9 @@ const baseProps: DividerBlockProps = { describe("dividerHelpers.computeDividerExportTokens", () => { const escapers = { escapeAttr: (v: string) => v }; - it("기본 값은 1px 두께, 기본 색상(#475569), marginY=16px 이어야 한다", () => { + it("기본 값은 1px 두께, 기본 색상(#475569), marginY=1em 이어야 한다", () => { const tokens = computeDividerExportTokens(baseProps, escapers); - expect(tokens.style).toBe("border:0;border-bottom:1px solid #475569;margin:16px 0;"); + expect(tokens.style).toBe("border:0;border-bottom:1px solid #475569;margin:1em 0;"); }); it("thickness=\"medium\" 인 경우 2px 두께로 렌더링되어야 한다", () => { @@ -33,12 +34,25 @@ describe("dividerHelpers.computeDividerExportTokens", () => { it("marginYPx > 0 이면 해당 값을 margin Y 로 사용해야 한다", () => { const tokens = computeDividerExportTokens({ ...baseProps, marginYPx: 24 }, escapers); - expect(tokens.style).toContain("margin:24px 0;"); + expect(tokens.style).toContain("margin:1.5em 0;"); }); - it("marginYPx 가 0 이하이거나 숫자가 아니면 기본 16px 을 사용해야 한다", () => { + it("marginYPx 가 0 이하이거나 숫자가 아니면 기본 1em 을 사용해야 한다", () => { const zeroTokens = computeDividerExportTokens({ ...baseProps, marginYPx: 0 }, escapers); - expect(zeroTokens.style).toContain("margin:16px 0;"); + expect(zeroTokens.style).toContain("margin:1em 0;"); + }); + + it("Export 구분선 style 에서는 border-bottom 두께를 제외하고 px 단위가 없어야 한다", () => { + const tokens = computeDividerExportTokens({ + ...baseProps, + thickness: "medium", + colorHex: "#123456", + marginYPx: 24, + }, escapers); + + expectNoPxInStyleString(tokens.style, { + allowPatterns: [/border-bottom:\d+px/], + }); }); }); @@ -136,4 +150,18 @@ describe("dividerHelpers.computeDividerPublicTokens", () => { expect(tokens.innerStyle.width).toBe("auto"); expect(tokens.lineStyle.width).toBe("30em"); }); + + it("퍼블릭 구분선 스타일에 px 단위 값이 포함되지 않아야 한다", () => { + const tokens = computeDividerPublicTokens({ + align: "center", + thickness: "medium", + colorHex: "#123456", + marginYPx: 32, + widthMode: "fixed", + widthPx: 480, + } as DividerBlockProps); + + expectNoPxInStyleObject(tokens.innerStyle); + expectNoPxInStyleObject(tokens.lineStyle); + }); }); diff --git a/tests/unit/formHelpers.spec.ts b/tests/unit/formHelpers.spec.ts index d2ff585..2edbd51 100644 --- a/tests/unit/formHelpers.spec.ts +++ b/tests/unit/formHelpers.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants"; import { computeFormInputExportTokens, computeFormSelectExportTokens, @@ -64,12 +65,17 @@ describe("formHelpers.computeFormInputExportTokens", () => { expect(tokens.inputStyleAttr).toContain("color:#ff0000"); expect(tokens.inputStyleAttr).toContain("background-color:#111827"); expect(tokens.inputStyleAttr).toContain("border-color:#4b5563"); - expect(tokens.inputStyleAttr).toContain("padding-left:8px"); - expect(tokens.inputStyleAttr).toContain("padding-right:8px"); - expect(tokens.inputStyleAttr).toContain("padding-top:4px"); - expect(tokens.inputStyleAttr).toContain("padding-bottom:4px"); - expect(tokens.inputStyleAttr).toContain("width:300px"); + expect(tokens.inputStyleAttr).toContain("padding-left:0.5em"); + expect(tokens.inputStyleAttr).toContain("padding-right:0.5em"); + expect(tokens.inputStyleAttr).toContain("padding-top:0.25em"); + expect(tokens.inputStyleAttr).toContain("padding-bottom:0.25em"); + expect(tokens.inputStyleAttr).toContain("width:18.75em"); expect(tokens.inputStyleAttr).toContain("border-radius:6px"); + + // Export 인풋 스타일에는 border-radius 등의 예외를 제외하고 px 단위가 포함되지 않아야 한다. + expectNoPxInStyleString(tokens.inputStyleAttr, { + allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/], + }); }); it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => { @@ -97,12 +103,17 @@ describe("formHelpers.computeFormSelectExportTokens", () => { expect(tokens.selectStyleAttr).toContain("color:#00ff00"); expect(tokens.selectStyleAttr).toContain("background-color:#020617"); expect(tokens.selectStyleAttr).toContain("border-color:#64748b"); - expect(tokens.selectStyleAttr).toContain("padding-left:12px"); - expect(tokens.selectStyleAttr).toContain("padding-right:12px"); - expect(tokens.selectStyleAttr).toContain("padding-top:6px"); - expect(tokens.selectStyleAttr).toContain("padding-bottom:6px"); - expect(tokens.selectStyleAttr).toContain("width:240px"); + expect(tokens.selectStyleAttr).toContain("padding-left:0.75em"); + expect(tokens.selectStyleAttr).toContain("padding-right:0.75em"); + expect(tokens.selectStyleAttr).toContain("padding-top:0.375em"); + expect(tokens.selectStyleAttr).toContain("padding-bottom:0.375em"); + expect(tokens.selectStyleAttr).toContain("width:15em"); expect(tokens.selectStyleAttr).toContain("border-radius:9999px"); + + // Export 셀렉트 스타일에는 border-radius 등의 예외를 제외하고 px 단위가 포함되지 않아야 한다. + expectNoPxInStyleString(tokens.selectStyleAttr, { + allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/], + }); }); it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => { @@ -130,11 +141,16 @@ describe("formHelpers.computeFormCheckboxExportTokens", () => { expect(tokens.optionStyleAttr).toContain("border-color:#e5e7eb"); expect(tokens.optionStyleAttr).toContain("border-width:1px"); expect(tokens.optionStyleAttr).toContain("border-style:solid"); - expect(tokens.optionStyleAttr).toContain("padding-left:10px"); - expect(tokens.optionStyleAttr).toContain("padding-right:10px"); - expect(tokens.optionStyleAttr).toContain("padding-top:5px"); - expect(tokens.optionStyleAttr).toContain("padding-bottom:5px"); + expect(tokens.optionStyleAttr).toContain("padding-left:0.625em"); + expect(tokens.optionStyleAttr).toContain("padding-right:0.625em"); + expect(tokens.optionStyleAttr).toContain("padding-top:0.3125em"); + expect(tokens.optionStyleAttr).toContain("padding-bottom:0.3125em"); expect(tokens.optionStyleAttr).toContain("border-radius:2px"); + + // Export 체크박스 옵션 스타일에는 border/border-radius 를 제외하고 px 단위가 포함되지 않아야 한다. + expectNoPxInStyleString(tokens.optionStyleAttr, { + allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/], + }); }); it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => { @@ -162,11 +178,16 @@ describe("formHelpers.computeFormRadioExportTokens", () => { expect(tokens.optionStyleAttr).toContain("border-color:#facc15"); expect(tokens.optionStyleAttr).toContain("border-width:1px"); expect(tokens.optionStyleAttr).toContain("border-style:solid"); - expect(tokens.optionStyleAttr).toContain("padding-left:4px"); - expect(tokens.optionStyleAttr).toContain("padding-right:4px"); - expect(tokens.optionStyleAttr).toContain("padding-top:2px"); - expect(tokens.optionStyleAttr).toContain("padding-bottom:2px"); + expect(tokens.optionStyleAttr).toContain("padding-left:0.25em"); + expect(tokens.optionStyleAttr).toContain("padding-right:0.25em"); + expect(tokens.optionStyleAttr).toContain("padding-top:0.125em"); + expect(tokens.optionStyleAttr).toContain("padding-bottom:0.125em"); expect(tokens.optionStyleAttr).toContain("border-radius:4px"); + + // Export 라디오 옵션 스타일에는 border/border-radius 를 제외하고 px 단위가 포함되지 않아야 한다. + expectNoPxInStyleString(tokens.optionStyleAttr, { + allowPatterns: [/border-radius:\d+px/, /border-width:\d+px/], + }); }); }); @@ -253,6 +274,10 @@ describe("formHelpers - public tokens", () => { expect(tokens.inputStyle.backgroundColor).toBe("#123456"); expect(tokens.inputStyle.borderColor).toBe("#ff0000"); expect(tokens.inputStyle.borderRadius).toBe("6px"); + + // 퍼블릭 인풋 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다. + expectNoPxInStyleObject(tokens.wrapperStyle); + expectNoPxInStyleObject(tokens.inputStyle, { allowKeys: ["borderRadius", "borderWidth"] }); }); it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => { @@ -276,6 +301,10 @@ describe("formHelpers - public tokens", () => { expect(tokens.selectStyle.backgroundColor).toBe("#123456"); expect(tokens.selectStyle.borderColor).toBe("#ff0000"); expect(tokens.selectStyle.borderRadius).toBe("6px"); + + // 퍼블릭 셀렉트 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다. + expectNoPxInStyleObject(tokens.wrapperStyle); + expectNoPxInStyleObject(tokens.selectStyle, { allowKeys: ["borderRadius", "borderWidth"] }); }); it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => { @@ -304,6 +333,13 @@ describe("formHelpers - public tokens", () => { expect(tokens.optionContainerStyle.borderRadius).toBe("6px"); expect(tokens.groupTextStyle.color).toBe("#ffffff"); expect(tokens.optionTextStyle.color).toBe("#ffffff"); + + // 퍼블릭 체크박스 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다. + expectNoPxInStyleObject(tokens.groupStyle); + expectNoPxInStyleObject(tokens.optionsStyle); + expectNoPxInStyleObject(tokens.optionContainerStyle, { allowKeys: ["borderRadius", "borderWidth"] }); + expectNoPxInStyleObject(tokens.groupTextStyle); + expectNoPxInStyleObject(tokens.optionTextStyle); }); it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => { diff --git a/tests/unit/imageHelpers.spec.ts b/tests/unit/imageHelpers.spec.ts index d6d5433..f3e16a2 100644 --- a/tests/unit/imageHelpers.spec.ts +++ b/tests/unit/imageHelpers.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { expectNoPxInStyleParts } from "./styleInvariants"; import { computeImageExportStyles, computeImageEditorTokens, @@ -28,7 +29,7 @@ describe("imageHelpers.computeImageExportStyles", () => { widthPx: 320, }); - expect(imgStyleParts).toContain("width:320px"); + expect(imgStyleParts).toContain("width:20em"); }); it("borderRadius 토큰이 full 이면 border-radius 9999px 를 사용해야 한다", () => { @@ -47,6 +48,18 @@ describe("imageHelpers.computeImageExportStyles", () => { expect(imgStyleParts).toContain("border-radius:20px"); }); + + it("Export 이미지 스타일에는 border-radius 를 제외하고 px 단위 값이 포함되지 않아야 한다", () => { + const { wrapperStyleParts, imgStyleParts } = computeImageExportStyles({ + backgroundColorCustom: "#123456", + widthMode: "fixed", + widthPx: 320, + borderRadius: "lg", + }); + + expectNoPxInStyleParts(wrapperStyleParts); + expectNoPxInStyleParts(imgStyleParts, { allowPatterns: [/border-radius:\d+px/] }); + }); }); describe("computeImageEditorTokens", () => { diff --git a/tests/unit/listHelpers.spec.ts b/tests/unit/listHelpers.spec.ts index 883d6ce..fac714e 100644 --- a/tests/unit/listHelpers.spec.ts +++ b/tests/unit/listHelpers.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants"; import { computeListExportTokens, computeListEditorTokens, @@ -81,6 +82,17 @@ describe("listHelpers.computeListExportTokens", () => { const tokens = computeListExportTokens({ ...baseProps }); expect(tokens.items).toEqual([]); }); + + it("Export 리스트 listStyleParts 에는 px 단위 값이 포함되지 않아야 한다", () => { + const tokens = computeListExportTokens({ + ...baseProps, + align: "center", + items: ["a"], + backgroundColorCustom: "#123456", + }); + + expectNoPxInStyleParts(tokens.listStyleParts); + }); }); describe("listHelpers.computeListEditorTokens", () => { @@ -158,17 +170,28 @@ describe("listHelpers.computeListPublicTokens", () => { expect(byTokenLg.gapEm).toBe(16 / 16); }); - it("fontSizeCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => { + it("fontSizeCustom / lineHeightCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => { const tokens = computeListPublicTokens({ ...baseProps, fontSizeCustom: "32px", + lineHeightCustom: "24px", backgroundColorCustom: " #123456 ", }); expect(tokens.listStyle.fontSize).toBe("2em"); + expect(tokens.listStyle.lineHeight).toBe("1.5em"); expect(tokens.listStyle.backgroundColor).toBe("#123456"); }); + it("lineHeightCustom 이 px 인 경우 em 단위로 변환되어야 한다", () => { + const tokens = computeListPublicTokens({ + ...baseProps, + lineHeightCustom: "24px", + }); + + expect(tokens.listStyle.lineHeight).toBe("1.5em"); + }); + it("bulletStyle 은 bulletStyle 지정값 또는 ordered 여부에 따라 결정되어야 한다", () => { const defaultDisc = computeListPublicTokens({ ...baseProps, ordered: false, bulletStyle: undefined }); const defaultDecimal = computeListPublicTokens({ ...baseProps, ordered: true, bulletStyle: undefined }); @@ -178,4 +201,16 @@ describe("listHelpers.computeListPublicTokens", () => { expect(defaultDecimal.bulletStyle).toBe("decimal"); expect(none.bulletStyle).toBe("none"); }); + + it("퍼블릭 리스트 style 에 px 단위 값이 포함되지 않아야 한다", () => { + const tokens = computeListPublicTokens({ + ...baseProps, + fontSizeCustom: "32px", + lineHeightCustom: "24px", + textColorCustom: "#ff0000", + backgroundColorCustom: "#123456", + }); + + expectNoPxInStyleObject(tokens.listStyle); + }); }); diff --git a/tests/unit/previewExportParity.spec.ts b/tests/unit/previewExportParity.spec.ts index e6a6e0c..7cea8f2 100644 --- a/tests/unit/previewExportParity.spec.ts +++ b/tests/unit/previewExportParity.spec.ts @@ -192,4 +192,74 @@ describe("프리뷰와 정적 내보내기 일치 (고수준)", () => { expect(staticHtml).toContain(text); } }); + + it( + "리스트+폼+구분선 조합에서 리스트 컨테이너와 구분선이 프리뷰/정적 HTML 모두에 존재하고, 리스트는 pb-list 클래스를 사용하며 li 에 margin-bottom 인라인 스타일을 사용하지 않아야 한다", + () => { + const blocks: Block[] = [ + { + id: "divider_combo", + type: "divider", + props: { + align: "center", + thickness: "medium", + widthMode: "full", + }, + } as any, + { + id: "list_combo", + type: "list", + props: { + items: ["리스트 아이템 1", "리스트 아이템 2"], + ordered: false, + align: "left", + }, + } as any, + { + id: "form_combo", + type: "form", + props: { + kind: "contact", + submitTarget: "internal", + // v1 fallback 필드를 사용해 양쪽 렌더러가 동일한 폼 필드를 가지도록 한다. + fields: [ + { id: "f_name", name: "name", label: "이름", type: "text", required: true }, + ], + fieldIds: [], + submitButtonId: null, + formWidthMode: "full", + }, + } as any, + ]; + + const projectConfig: ProjectConfig = { + title: "리스트+폼+구분선 레이아웃 테스트", + slug: "list-form-divider-layout", + canvasPreset: "full", + }; + + const previewHtml = ReactDOMServer.renderToString( + React.createElement(PublicPageRenderer, { blocks }), + ); + + const staticHtml = buildStaticHtml(blocks, projectConfig); + + // 리스트 텍스트는 양쪽에 존재해야 한다. + expect(previewHtml).toContain("리스트 아이템 1"); + expect(staticHtml).toContain("리스트 아이템 1"); + + // 구분선은 프리뷰/Export 모두에 존재해야 한다. + expect(previewHtml).toContain('data-testid="preview-divider"'); + expect(staticHtml).toContain('
    ]*style=\"[^\"]*margin-bottom:/); + expect(staticHtml).not.toMatch(/]*style=\"[^\"]*margin-bottom:/); + }, + ); }); diff --git a/tests/unit/sectionHelpers.spec.ts b/tests/unit/sectionHelpers.spec.ts index 2b75069..df43ea4 100644 --- a/tests/unit/sectionHelpers.spec.ts +++ b/tests/unit/sectionHelpers.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants"; import { computeSectionExportTokens, computeSectionPublicTokens, @@ -73,6 +74,21 @@ describe("sectionHelpers.computeSectionExportTokens", () => { expect(tokens.sectionStyleParts).toContain("position:relative"); expect(tokens.sectionStyleParts).toContain("overflow:hidden"); }); + + it("Export 섹션 sectionStyleParts 에는 px 단위 값이 포함되지 않아야 한다", () => { + const tokens = computeSectionExportTokens({ + ...baseProps, + backgroundColorCustom: "#123456", + backgroundImageSrc: "https://example.com/bg.png", + backgroundImageSize: "cover", + backgroundImagePositionMode: "preset", + backgroundImagePosition: "top", + backgroundImageRepeat: "repeat-x", + backgroundVideoSrc: "https://example.com/bg.mp4", + } as SectionBlockProps); + + expectNoPxInStyleParts(tokens.sectionStyleParts); + }); }); describe("sectionHelpers.computeSectionPublicTokens", () => { @@ -90,6 +106,11 @@ describe("sectionHelpers.computeSectionPublicTokens", () => { expect(tokens.sectionStyle.paddingBottom).toBe("2em"); expect(tokens.innerWrapperStyle.maxWidth).toBe("50em"); expect(tokens.columnsContainerStyle.columnGap).toBe("1.5em"); + + // 퍼블릭 섹션 토큰 스타일에는 px 단위 값이 포함되지 않아야 한다. + expectNoPxInStyleObject(tokens.sectionStyle); + expectNoPxInStyleObject(tokens.innerWrapperStyle); + expectNoPxInStyleObject(tokens.columnsContainerStyle); }); it("backgroundImage* 와 backgroundVideoSrc 를 올바르게 반영해야 한다", () => { diff --git a/tests/unit/sectionLayout.spec.ts b/tests/unit/sectionLayout.spec.ts index 73f416a..acfeba1 100644 --- a/tests/unit/sectionLayout.spec.ts +++ b/tests/unit/sectionLayout.spec.ts @@ -16,7 +16,7 @@ describe("getSectionLayoutConfig", () => { const cfg = getSectionLayoutConfig(baseProps); expect(cfg.backgroundClass).toBe("bg-slate-950"); - expect(cfg.paddingYClass).toBe("py-12"); + expect(cfg.paddingYClass).toBe("py-6"); expect(cfg.maxWidthClass).toBe("max-w-5xl"); expect(cfg.gapXClass).toBe("gap-8"); expect(cfg.alignItemsClass).toBe("items-start"); @@ -49,7 +49,7 @@ describe("getSectionLayoutConfig", () => { expect(narrowCfg.maxWidthClass).toBe("max-w-3xl"); expect(narrowCfg.gapXClass).toBe("gap-4"); expect(narrowCfg.alignItemsClass).toBe("items-end"); - expect(narrowCfg.paddingYClass).toBe("py-8"); + expect(narrowCfg.paddingYClass).toBe("py-4"); }); it("primary 배경과 큰 패딩에 대해 올바른 클래스를 반환해야 한다", () => { @@ -61,6 +61,6 @@ describe("getSectionLayoutConfig", () => { const cfg = getSectionLayoutConfig(primaryLarge); expect(cfg.backgroundClass).toBe("bg-sky-900"); - expect(cfg.paddingYClass).toBe("py-20"); + expect(cfg.paddingYClass).toBe("py-10"); }); }); diff --git a/tests/unit/styleInvariants.ts b/tests/unit/styleInvariants.ts new file mode 100644 index 0000000..fdfbb9f --- /dev/null +++ b/tests/unit/styleInvariants.ts @@ -0,0 +1,34 @@ +import { expect } from "vitest"; + +// 스타일 관련 인변언트(전역 규칙)를 검증하기 위한 공통 테스트 유틸리티. +// - 퍼블릭/익스포트 토큰에서 px 단위 값 사용을 금지하거나, +// - border-width 등 꼭 필요한 px 사용만 예외로 허용할 때 사용한다. +export type StyleObject = Record; + +export function expectNoPxInStyleObject(style: unknown, options?: { allowKeys?: string[] }) { + if (!style || typeof style !== "object") return; + const allow = new Set(options?.allowKeys ?? []); + + for (const [key, value] of Object.entries(style as StyleObject)) { + if (value == null) continue; + if (allow.has(key)) continue; + if (typeof value === "object") continue; + + const str = String(value); + expect(str).not.toContain("px"); + } +} + +export function expectNoPxInStyleString(style: string | undefined | null, options?: { allowPatterns?: RegExp[] }) { + if (!style) return; + const allowPatterns = options?.allowPatterns ?? []; + if (allowPatterns.some((re) => re.test(style))) return; + + expect(style).not.toContain("px"); +} + +export function expectNoPxInStyleParts(parts: string[] | undefined | null, options?: { allowPatterns?: RegExp[] }) { + if (!parts || parts.length === 0) return; + const combined = parts.join(";"); + expectNoPxInStyleString(combined, options); +} diff --git a/tests/unit/textHelpers.spec.ts b/tests/unit/textHelpers.spec.ts index 851b4d4..7ddbf92 100644 --- a/tests/unit/textHelpers.spec.ts +++ b/tests/unit/textHelpers.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants"; import { computeTextPbTokens, computeTextEditorTokens, @@ -68,6 +69,17 @@ describe("textHelpers.computeTextPbTokens", () => { expect(prose.maxWidthClass).toBe("pb-text-maxw-prose"); expect(narrow.maxWidthClass).toBe("pb-text-maxw-narrow"); }); + + it("텍스트 Pb inlineStyles 에는 px 단위 값이 포함되지 않아야 한다", () => { + const tokens = computeTextPbTokens({ + ...baseProps, + colorMode: "custom", + colorCustom: "#ff0000", + backgroundColorCustom: "#123456", + }); + + expectNoPxInStyleParts(tokens.inlineStyles); + }); }); describe("textHelpers.computeTextEditorTokens", () => { @@ -128,7 +140,7 @@ describe("textHelpers.computeTextPublicTokens", () => { } as TextBlockProps); expect(tokens.sizeClass).toBe(""); - expect(tokens.styleOverrides.fontSize).toBe("24px"); + expect(tokens.styleOverrides.fontSize).toBe("1.5em"); }); it("colorCustom / backgroundColorCustom 이 styleOverrides 에 반영되어야 한다", () => { @@ -141,4 +153,29 @@ describe("textHelpers.computeTextPublicTokens", () => { expect(tokens.styleOverrides.color).toBe("#ff0000"); expect(tokens.styleOverrides.backgroundColor).toBe("#123456"); }); + + it("lineHeightCustom / letterSpacingCustom \uc774 px \uc778 \uacbd\uc6b0 em \ub2e8\uc704 \uc2a4\ud0c0\uc77c\ub85c \ubcc0\ud658\ub418\uc5b4\uc57c \ud55c\ub2e4", () => { + const tokens = computeTextPublicTokens({ + ...baseBlockProps, + lineHeightCustom: "24px", + letterSpacingCustom: "2px", + } as TextBlockProps); + + expect(tokens.styleOverrides.lineHeight).toBe("1.5em"); + expect(tokens.styleOverrides.letterSpacing).toBe("0.125em"); + }); + + it("퍼블릭 텍스트 styleOverrides 에 px 단위 값이 포함되지 않아야 한다", () => { + const tokens = computeTextPublicTokens({ + ...baseBlockProps, + fontSizeMode: "custom", + fontSizeCustom: "24px", + lineHeightCustom: "24px", + letterSpacingCustom: "2px", + colorCustom: "#ff0000", + backgroundColorCustom: "#123456", + } as TextBlockProps); + + expectNoPxInStyleObject(tokens.styleOverrides); + }); }); diff --git a/tests/unit/videoHelpers.spec.ts b/tests/unit/videoHelpers.spec.ts index cb68e0e..20c85f4 100644 --- a/tests/unit/videoHelpers.spec.ts +++ b/tests/unit/videoHelpers.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { expectNoPxInStyleParts } from "./styleInvariants"; import { normalizeVideoSourceUrl, resolveVideoPlatform, @@ -124,7 +125,7 @@ describe("videoHelpers.computeVideoExportTokens", () => { const escapers = { escapeAttr: (value: string) => value }; - it("fixed width / 카드 스타일을 px 단위 스타일 파트로 계산해야 한다", () => { + it("fixed width / 카드 스타일을 em 단위 스타일 파트로 계산해야 한다", () => { const tokens = computeVideoExportTokens( { ...baseProps, @@ -139,15 +140,33 @@ describe("videoHelpers.computeVideoExportTokens", () => { ); expect(tokens.aspectClass).toBe(""); - expect(tokens.wrapperStyleParts).toContain("width:640px"); - expect(tokens.videoStyleParts).toContain("width:640px"); + expect(tokens.wrapperStyleParts).toContain("width:40em"); + expect(tokens.videoStyleParts).toContain("width:40em"); expect(tokens.wrapperStyleParts).toContain("background-color:#123456"); - expect(tokens.wrapperStyleParts).toContain("padding:24px"); - expect(tokens.wrapperStyleParts).toContain("border-radius:16px"); + expect(tokens.wrapperStyleParts).toContain("padding:1.5em"); + expect(tokens.wrapperStyleParts).toContain("border-radius:1em"); expect(tokens.outerStyleParts).toContain("display:flex"); expect(tokens.outerStyleParts).toContain("justify-content:flex-start"); }); + it("Export 비디오 wrapper/video 스타일 파트에는 px 단위 값이 포함되지 않아야 한다", () => { + const tokens = computeVideoExportTokens( + { + ...baseProps, + align: "left", + widthMode: "fixed", + widthPx: 640, + backgroundColorCustom: "#123456", + cardPaddingPx: 24, + borderRadiusPx: 16, + } as any, + escapers, + ); + + expectNoPxInStyleParts(tokens.wrapperStyleParts); + expectNoPxInStyleParts(tokens.videoStyleParts); + }); + it("widthMode 가 full 이면 width:100% 로 설정하고 align 에 따라 justify-content 를 계산해야 한다", () => { const right = computeVideoExportTokens( { diff --git a/uploads/2ba60346-1d25-4051-b159-7bcd204e5d4a b/uploads/2ba60346-1d25-4051-b159-7bcd204e5d4a new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/2ba60346-1d25-4051-b159-7bcd204e5d4a @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/413f7d95-0201-4661-8220-60d74ff7c002 b/uploads/413f7d95-0201-4661-8220-60d74ff7c002 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/413f7d95-0201-4661-8220-60d74ff7c002 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/4808a9fd-47dd-4c6e-b5e5-f7c182d90b57 b/uploads/4808a9fd-47dd-4c6e-b5e5-f7c182d90b57 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/4808a9fd-47dd-4c6e-b5e5-f7c182d90b57 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/483802a9-61df-4f8e-b8bc-ce7cad880844 b/uploads/483802a9-61df-4f8e-b8bc-ce7cad880844 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/483802a9-61df-4f8e-b8bc-ce7cad880844 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/8f4b8166-2e27-47bc-91f2-13c7032b38de b/uploads/8f4b8166-2e27-47bc-91f2-13c7032b38de new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/8f4b8166-2e27-47bc-91f2-13c7032b38de @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/a38f84bc-ceb3-4e84-b833-4428c46ab30d b/uploads/a38f84bc-ceb3-4e84-b833-4428c46ab30d new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/a38f84bc-ceb3-4e84-b833-4428c46ab30d @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-image-1764313031781 b/uploads/test-image-1764313031781 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764313031781 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764318607137 b/uploads/test-image-1764318607137 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764318607137 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764318728257 b/uploads/test-image-1764318728257 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764318728257 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764318797840 b/uploads/test-image-1764318797840 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764318797840 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764318911884 b/uploads/test-image-1764318911884 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764318911884 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764318995004 b/uploads/test-image-1764318995004 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764318995004 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-image-1764319063267 b/uploads/test-image-1764319063267 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-image-1764319063267 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764313031785 b/uploads/test-section-bg-1764313031785 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764313031785 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764318607161 b/uploads/test-section-bg-1764318607161 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764318607161 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764318728281 b/uploads/test-section-bg-1764318728281 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764318728281 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764318797888 b/uploads/test-section-bg-1764318797888 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764318797888 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764318911897 b/uploads/test-section-bg-1764318911897 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764318911897 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764318995031 b/uploads/test-section-bg-1764318995031 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764318995031 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-1764319063282 b/uploads/test-section-bg-1764319063282 new file mode 100644 index 0000000..0cc5db2 --- /dev/null +++ b/uploads/test-section-bg-1764319063282 @@ -0,0 +1 @@ +dummy-image \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764313031800 b/uploads/test-section-bg-video-1764313031800 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764313031800 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764318607206 b/uploads/test-section-bg-video-1764318607206 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764318607206 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764318728320 b/uploads/test-section-bg-video-1764318728320 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764318728320 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764318797958 b/uploads/test-section-bg-video-1764318797958 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764318797958 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764318911928 b/uploads/test-section-bg-video-1764318911928 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764318911928 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764318995073 b/uploads/test-section-bg-video-1764318995073 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764318995073 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-section-bg-video-1764319063329 b/uploads/test-section-bg-video-1764319063329 new file mode 100644 index 0000000..badc796 --- /dev/null +++ b/uploads/test-section-bg-video-1764319063329 @@ -0,0 +1 @@ +dummy-section-video \ No newline at end of file diff --git a/uploads/test-video-1764313031793 b/uploads/test-video-1764313031793 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764313031793 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764318607186 b/uploads/test-video-1764318607186 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764318607186 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764318728303 b/uploads/test-video-1764318728303 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764318728303 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764318797944 b/uploads/test-video-1764318797944 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764318797944 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764318911920 b/uploads/test-video-1764318911920 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764318911920 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764318995060 b/uploads/test-video-1764318995060 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764318995060 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-1764319063318 b/uploads/test-video-1764319063318 new file mode 100644 index 0000000..87ccacf --- /dev/null +++ b/uploads/test-video-1764319063318 @@ -0,0 +1 @@ +dummy-video \ No newline at end of file diff --git a/uploads/test-video-poster-1764313031783 b/uploads/test-video-poster-1764313031783 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764313031783 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764318607147 b/uploads/test-video-poster-1764318607147 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764318607147 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764318728267 b/uploads/test-video-poster-1764318728267 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764318728267 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764318797857 b/uploads/test-video-poster-1764318797857 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764318797857 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764318911889 b/uploads/test-video-poster-1764318911889 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764318911889 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764318995011 b/uploads/test-video-poster-1764318995011 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764318995011 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file diff --git a/uploads/test-video-poster-1764319063271 b/uploads/test-video-poster-1764319063271 new file mode 100644 index 0000000..7285c92 --- /dev/null +++ b/uploads/test-video-poster-1764319063271 @@ -0,0 +1 @@ +dummy-poster \ No newline at end of file