스타일 수정
CI / test (push) Failing after 44m2s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-29 15:11:49 +09:00
parent c9a62d479c
commit 1f085bd64c
64 changed files with 568 additions and 126 deletions
@@ -26,7 +26,7 @@ import {
buildVideoEmbedUrl, buildVideoEmbedUrl,
computeVideoPublicTokens, computeVideoPublicTokens,
} from "@/features/editor/utils/videoHelpers"; } 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 { computeTextPublicTokens } from "@/features/editor/utils/textHelpers";
import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers"; import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers";
import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers"; import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers";
@@ -50,8 +50,12 @@ export function getSectionLayoutConfig(props: SectionBlockProps) {
? "bg-sky-900" ? "bg-sky-900"
: "bg-slate-950"; : "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 = 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 = const maxWidthClass =
props.maxWidthMode === "narrow" props.maxWidthMode === "narrow"
@@ -302,16 +306,40 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
if (block.type === "button") { if (block.type === "button") {
const props = block.props as ButtonBlockProps; 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 ( return (
<div key={block.id} className={tokens.alignClass}> <div key={block.id} className={wrapperClass}>
<a <a href={props.href} className={buttonClassName} style={buttonStyle}>
href={props.href}
className={`inline-flex items-center justify-center text-sm font-medium hover:bg-sky-500 transition-colors ${tokens.widthClass}`}
style={buttonStyle}
>
<span className="whitespace-pre-wrap">{props.label}</span> <span className="whitespace-pre-wrap">{props.label}</span>
</a> </a>
</div> </div>
@@ -326,13 +354,13 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
<div <div
key={block.id} key={block.id}
data-testid="preview-divider" data-testid="preview-divider"
className={`flex ${tokens.alignClass}`} className={`flex w-full ${tokens.alignClass}`}
style={{ marginTop: tokens.wrapperMarginEm, marginBottom: tokens.wrapperMarginEm }} style={{ marginTop: tokens.wrapperMarginEm, marginBottom: tokens.wrapperMarginEm }}
> >
<div style={tokens.innerStyle}> <div className={tokens.innerWidthClass} style={tokens.innerStyle}>
<div <div
data-testid="preview-divider-line" data-testid="preview-divider-line"
className={`${tokens.thicknessClass} ${tokens.innerWidthClass}`} className={tokens.thicknessClass}
style={tokens.lineStyle} style={tokens.lineStyle}
/> />
</div> </div>
@@ -367,27 +395,17 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
return ( return (
<ListTag <ListTag
className={tokens.alignClass} className={`pb-list ${tokens.alignClass}`}
style={{ style={{
...tokens.listStyle, ...tokens.listStyle,
listStyleType: listStyleType:
tokens.bulletStyle === "none" tokens.bulletStyle === "none"
? "none" ? "none"
: tokens.bulletStyle, : tokens.bulletStyle,
paddingLeft: 12 + level * 8,
}} }}
> >
{nodes.map((node, index) => ( {nodes.map((node) => (
<li <li key={node.id}>
key={node.id}
style={
index < nodes.length - 1
? {
marginBottom: `${tokens.gapEm}em`,
}
: undefined
}
>
{node.text} {node.text}
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)} {node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
</li> </li>
+7 -5
View File
@@ -64,17 +64,19 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto"); const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto");
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) { 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") { if (typeof input.paddingX === "number") {
inlineStyles.push(`padding-left:${input.paddingX}px`); const paddingInline = pxToEm(input.paddingX);
inlineStyles.push(`padding-right:${input.paddingX}px`); inlineStyles.push(`padding-left:${paddingInline}`);
inlineStyles.push(`padding-right:${paddingInline}`);
} }
if (typeof input.paddingY === "number") { if (typeof input.paddingY === "number") {
inlineStyles.push(`padding-top:${input.paddingY}px`); const paddingBlock = pxToEm(input.paddingY);
inlineStyles.push(`padding-bottom:${input.paddingY}px`); inlineStyles.push(`padding-top:${paddingBlock}`);
inlineStyles.push(`padding-bottom:${paddingBlock}`);
} }
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") { if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
+2 -1
View File
@@ -19,8 +19,9 @@ export const computeDividerExportTokens = (
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : ""; const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
const color = colorRaw !== "" ? colorRaw : "#475569"; const color = colorRaw !== "" ? colorRaw : "#475569";
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16; 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 }; return { style };
}; };
+22 -20
View File
@@ -84,20 +84,21 @@ export const computeFormInputExportTokens = (
} }
if (typeof props.paddingX === "number" && props.paddingX >= 0) { if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX; const em = props.paddingX / 16;
inputStyleParts.push(`padding-left:${px}px`); inputStyleParts.push(`padding-left:${em}em`);
inputStyleParts.push(`padding-right:${px}px`); inputStyleParts.push(`padding-right:${em}em`);
} }
if (typeof props.paddingY === "number" && props.paddingY >= 0) { if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY; const em = props.paddingY / 16;
inputStyleParts.push(`padding-top:${py}px`); inputStyleParts.push(`padding-top:${em}em`);
inputStyleParts.push(`padding-bottom:${py}px`); inputStyleParts.push(`padding-bottom:${em}em`);
} }
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth }); const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { 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); const radiusPx = computeRadiusPx(props.borderRadius);
@@ -127,19 +128,20 @@ export const computeFormSelectExportTokens = (
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth }); const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { 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) { if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX; const em = props.paddingX / 16;
selectStyleParts.push(`padding-left:${px}px`); selectStyleParts.push(`padding-left:${em}em`);
selectStyleParts.push(`padding-right:${px}px`); selectStyleParts.push(`padding-right:${em}em`);
} }
if (typeof props.paddingY === "number" && props.paddingY >= 0) { if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY; const em = props.paddingY / 16;
selectStyleParts.push(`padding-top:${py}px`); selectStyleParts.push(`padding-top:${em}em`);
selectStyleParts.push(`padding-bottom:${py}px`); selectStyleParts.push(`padding-bottom:${em}em`);
} }
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
@@ -176,15 +178,15 @@ const computeOptionGroupExportTokensBase = (
} }
if (typeof props.paddingX === "number" && props.paddingX >= 0) { if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX; const em = props.paddingX / 16;
optionStyleParts.push(`padding-left:${px}px`); optionStyleParts.push(`padding-left:${em}em`);
optionStyleParts.push(`padding-right:${px}px`); optionStyleParts.push(`padding-right:${em}em`);
} }
if (typeof props.paddingY === "number" && props.paddingY >= 0) { if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY; const em = props.paddingY / 16;
optionStyleParts.push(`padding-top:${py}px`); optionStyleParts.push(`padding-top:${em}em`);
optionStyleParts.push(`padding-bottom:${py}px`); optionStyleParts.push(`padding-bottom:${em}em`);
} }
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
+2 -1
View File
@@ -27,7 +27,8 @@ export function computeImageExportStyles(input: ImageExportStyleInput): ImageExp
const widthMode: ImageWidthMode = input.widthMode ?? "auto"; const widthMode: ImageWidthMode = input.widthMode ?? "auto";
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) { 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"; const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
+9 -1
View File
@@ -134,7 +134,15 @@ export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens
} }
} }
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { 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() !== "") { if (props.textColorCustom && props.textColorCustom.trim() !== "") {
listStyle.color = props.textColorCustom; listStyle.color = props.textColorCustom;
+44 -1
View File
@@ -276,6 +276,17 @@ export interface TextPublicTokens {
styleOverrides: CSSProperties; 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 { export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens {
const alignClass = const alignClass =
props.align === "center" props.align === "center"
@@ -308,7 +319,15 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens
props.fontSizeCustom && props.fontSizeCustom &&
props.fontSizeCustom.trim() !== "" 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() !== "") { if (props.colorCustom && props.colorCustom.trim() !== "") {
@@ -319,6 +338,30 @@ export function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens
styleOverrides.backgroundColor = props.backgroundColorCustom.trim(); 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 { return {
alignClass, alignClass,
sizeClass, sizeClass,
+5 -4
View File
@@ -101,8 +101,9 @@ export function computeVideoExportTokens(
const widthMode = props.widthMode ?? "auto"; const widthMode = props.widthMode ?? "auto";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
wrapperStyleParts.push(`width:${props.widthPx}px`); const widthEm = pxToEm(props.widthPx);
videoStyleParts.push(`width:${props.widthPx}px`); wrapperStyleParts.push(`width:${widthEm}`);
videoStyleParts.push(`width:${widthEm}`);
} else if (widthMode === "full") { } else if (widthMode === "full") {
wrapperStyleParts.push("width:100%"); wrapperStyleParts.push("width:100%");
videoStyleParts.push("width:100%"); videoStyleParts.push("width:100%");
@@ -113,11 +114,11 @@ export function computeVideoExportTokens(
} }
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) { 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) { if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
wrapperStyleParts.push(`border-radius:${props.borderRadiusPx}px`); wrapperStyleParts.push(`border-radius:${pxToEm(props.borderRadiusPx)}`);
} }
const align = const align =
+1
View File
@@ -263,6 +263,7 @@ body {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
border-width: 1px; border-width: 1px;
text-decoration: none;
} }
.pb-btn-size-xs { .pb-btn-size-xs {
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
+32 -27
View File
@@ -70,6 +70,11 @@ describe("/api/export", () => {
expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")'); expect(mainJs).toContain('querySelectorAll("form.pb-form-controller")');
expect(mainJs).toContain("fetch("); 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"); const html = await indexEntry!.async("string");
expect(html).toContain("<title>내보내기 테스트 페이지</title>"); expect(html).toContain("<title>내보내기 테스트 페이지</title>");
expect(html).toContain("ZIP 내보내기 테스트"); expect(html).toContain("ZIP 내보내기 테스트");
@@ -1043,8 +1048,8 @@ describe("/api/export", () => {
expect(html).not.toContain(`/api/video/${videoId}`); expect(html).not.toContain(`/api/video/${videoId}`);
// 비디오 카드 스타일이 wrapper style 에 반영되어야 한다. // 비디오 카드 스타일이 wrapper style 에 반영되어야 한다.
expect(html).toContain("background-color:#123456"); expect(html).toContain("background-color:#123456");
expect(html).toContain("padding:24px"); expect(html).toContain("padding:1.5em");
expect(html).toContain("border-radius:16px"); expect(html).toContain("border-radius:1em");
}); });
it("섹션 backgroundVideoSrc 가 /api/video/:id 인 경우 ZIP videos/ 및 상대 경로로 치환되고 섹션 안에 배경 비디오 video 태그로 렌더되어야 한다", async () => { it("섹션 backgroundVideoSrc 가 /api/video/:id 인 경우 ZIP videos/ 및 상대 경로로 치환되고 섹션 안에 배경 비디오 video 태그로 렌더되어야 한다", async () => {
@@ -1157,7 +1162,7 @@ describe("/api/export", () => {
expect(html).toContain("pb-divider"); expect(html).toContain("pb-divider");
// 스타일 검증: medium 두께, 지정 색상, marginYPx 기반 여백이 style 속성에 포함되어야 한다. // 스타일 검증: medium 두께, 지정 색상, marginYPx 기반 여백이 style 속성에 포함되어야 한다.
expect(html).toContain("border-bottom:2px solid #123456"); expect(html).toContain("border-bottom:2px solid #123456");
expect(html).toContain("margin:32px 0;"); expect(html).toContain("margin:2em 0;");
}); });
it("image 블록은 정적 HTML에서 카드 스타일(배경/너비/둥글기)이 반영되어야 한다", async () => { it("image 블록은 정적 HTML에서 카드 스타일(배경/너비/둥글기)이 반영되어야 한다", async () => {
@@ -1209,7 +1214,7 @@ describe("/api/export", () => {
// 카드 배경색이 wrapper div style 에 반영되어야 한다. // 카드 배경색이 wrapper div style 에 반영되어야 한다.
expect(html).toContain("background-color:#123456"); expect(html).toContain("background-color:#123456");
// 고정 너비/둥글기 스타일이 img style 에 반영되어야 한다. // 고정 너비/둥글기 스타일이 img style 에 반영되어야 한다.
expect(html).toContain("width:480px"); expect(html).toContain("width:30em");
expect(html).toContain("border-radius:32px"); expect(html).toContain("border-radius:32px");
}); });
@@ -2230,11 +2235,11 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string"); const html = await indexEntry!.async("string");
expect(html).toContain("name=\"email\""); expect(html).toContain("name=\"email\"");
expect(html).toContain("width:320px"); expect(html).toContain("width:20em");
expect(html).toContain("padding-left:16px"); expect(html).toContain("padding-left:1em");
expect(html).toContain("padding-right:16px"); expect(html).toContain("padding-right:1em");
expect(html).toContain("padding-top:8px"); expect(html).toContain("padding-top:0.5em");
expect(html).toContain("padding-bottom:8px"); expect(html).toContain("padding-bottom:0.5em");
expect(html).toContain("background-color:#123456"); expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px"); expect(html).toContain("border-radius:6px");
@@ -2292,11 +2297,11 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string"); const html = await indexEntry!.async("string");
expect(html).toContain("name=\"category\""); expect(html).toContain("name=\"category\"");
expect(html).toContain("width:240px"); expect(html).toContain("width:15em");
expect(html).toContain("padding-left:16px"); expect(html).toContain("padding-left:1em");
expect(html).toContain("padding-right:16px"); expect(html).toContain("padding-right:1em");
expect(html).toContain("padding-top:8px"); expect(html).toContain("padding-top:0.5em");
expect(html).toContain("padding-bottom:8px"); expect(html).toContain("padding-bottom:0.5em");
expect(html).toContain("background-color:#123456"); expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px"); expect(html).toContain("border-radius:6px");
@@ -2349,10 +2354,10 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string"); const html = await indexEntry!.async("string");
expect(html).toContain("name=\"features\""); expect(html).toContain("name=\"features\"");
expect(html).toContain("padding-left:8px"); expect(html).toContain("padding-left:0.5em");
expect(html).toContain("padding-right:8px"); expect(html).toContain("padding-right:0.5em");
expect(html).toContain("padding-top:4px"); expect(html).toContain("padding-top:0.25em");
expect(html).toContain("padding-bottom:4px"); expect(html).toContain("padding-bottom:0.25em");
expect(html).toContain("background-color:#123456"); expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px"); expect(html).toContain("border-radius:6px");
@@ -2405,10 +2410,10 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string"); const html = await indexEntry!.async("string");
expect(html).toContain("name=\"plan\""); expect(html).toContain("name=\"plan\"");
expect(html).toContain("padding-left:8px"); expect(html).toContain("padding-left:0.5em");
expect(html).toContain("padding-right:8px"); expect(html).toContain("padding-right:0.5em");
expect(html).toContain("padding-top:4px"); expect(html).toContain("padding-top:0.25em");
expect(html).toContain("padding-bottom:4px"); expect(html).toContain("padding-bottom:0.25em");
expect(html).toContain("background-color:#123456"); expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px"); expect(html).toContain("border-radius:6px");
@@ -2512,11 +2517,11 @@ describe("/api/export", () => {
const html = await indexEntry!.async("string"); const html = await indexEntry!.async("string");
// widthMode=fixed, widthPx, paddingX/paddingY, fillColorCustom, strokeColorCustom, textColorCustom 이 style 속성에 포함되어야 한다. // widthMode=fixed, widthPx, paddingX/paddingY, fillColorCustom, strokeColorCustom, textColorCustom 이 style 속성에 포함되어야 한다.
expect(html).toContain("width:240px"); expect(html).toContain("width:15em");
expect(html).toContain("padding-left:16px"); expect(html).toContain("padding-left:1em");
expect(html).toContain("padding-right:16px"); expect(html).toContain("padding-right:1em");
expect(html).toContain("padding-top:8px"); expect(html).toContain("padding-top:0.5em");
expect(html).toContain("padding-bottom:8px"); expect(html).toContain("padding-bottom:0.5em");
expect(html).toContain("background-color:#123456"); expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000"); expect(html).toContain("border-color:#ff0000");
expect(html).toContain("color:#00ff00"); expect(html).toContain("color:#00ff00");
@@ -33,6 +33,15 @@ describe("PublicPageRenderer - 버튼 블록 스타일", () => {
const link = container.querySelector("a") as HTMLAnchorElement | null; const link = container.querySelector("a") as HTMLAnchorElement | null;
expect(link).not.toBeNull(); 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) // 배경색: #123456 → rgb(18, 52, 86)
expect(link!.style.backgroundColor).toBe("rgb(18, 52, 86)"); expect(link!.style.backgroundColor).toBe("rgb(18, 52, 86)");
// 외곽선 색상: #ff0000 → rgb(255, 0, 0) // 외곽선 색상: #ff0000 → rgb(255, 0, 0)
@@ -56,7 +56,7 @@ describe("PublicPageRenderer - 텍스트 블록 스타일", () => {
// 정렬 클래스 // 정렬 클래스
expect(el.className).toContain("text-center"); expect(el.className).toContain("text-center");
// 커스텀 폰트 크기: sizeClass 는 비워지고, 인라인 스타일로 설정된다. // 커스텀 폰트 크기: 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.color).toBe("rgb(255, 0, 0)");
expect(el.style.backgroundColor).toBe("rgb(18, 52, 86)"); expect(el.style.backgroundColor).toBe("rgb(18, 52, 86)");
+22 -5
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { expectNoPxInStyleParts } from "./styleInvariants";
import { import {
computeButtonPbTokens, computeButtonPbTokens,
computeButtonPublicTokens, computeButtonPublicTokens,
@@ -53,15 +54,31 @@ describe("buttonHelpers.computeButtonPbTokens", () => {
}); });
expect(tokens.alignClass).toBe("pb-text-right"); expect(tokens.alignClass).toBe("pb-text-right");
expect(tokens.inlineStyles).toContain("width:200px"); expect(tokens.inlineStyles).toContain("width:12.5em");
expect(tokens.inlineStyles).toContain("padding-left:16px"); expect(tokens.inlineStyles).toContain("padding-left:1em");
expect(tokens.inlineStyles).toContain("padding-right:16px"); expect(tokens.inlineStyles).toContain("padding-right:1em");
expect(tokens.inlineStyles).toContain("padding-top:8px"); expect(tokens.inlineStyles).toContain("padding-top:0.5em");
expect(tokens.inlineStyles).toContain("padding-bottom:8px"); expect(tokens.inlineStyles).toContain("padding-bottom:0.5em");
expect(tokens.inlineStyles).toContain("background-color:#111111"); expect(tokens.inlineStyles).toContain("background-color:#111111");
expect(tokens.inlineStyles).toContain("border-color:#222222"); expect(tokens.inlineStyles).toContain("border-color:#222222");
expect(tokens.inlineStyles).toContain("color:#ffffff"); 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", () => { describe("buttonHelpers.computeButtonPublicTokens", () => {
+33 -5
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants";
import { import {
computeDividerExportTokens, computeDividerExportTokens,
computeDividerEditorTokens, computeDividerEditorTokens,
@@ -16,9 +17,9 @@ const baseProps: DividerBlockProps = {
describe("dividerHelpers.computeDividerExportTokens", () => { describe("dividerHelpers.computeDividerExportTokens", () => {
const escapers = { escapeAttr: (v: string) => v }; const escapers = { escapeAttr: (v: string) => v };
it("기본 값은 1px 두께, 기본 색상(#475569), marginY=16px 이어야 한다", () => { it("기본 값은 1px 두께, 기본 색상(#475569), marginY=1em 이어야 한다", () => {
const tokens = computeDividerExportTokens(baseProps, escapers); 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 두께로 렌더링되어야 한다", () => { it("thickness=\"medium\" 인 경우 2px 두께로 렌더링되어야 한다", () => {
@@ -33,12 +34,25 @@ describe("dividerHelpers.computeDividerExportTokens", () => {
it("marginYPx > 0 이면 해당 값을 margin Y 로 사용해야 한다", () => { it("marginYPx > 0 이면 해당 값을 margin Y 로 사용해야 한다", () => {
const tokens = computeDividerExportTokens({ ...baseProps, marginYPx: 24 }, escapers); 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); 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.innerStyle.width).toBe("auto");
expect(tokens.lineStyle.width).toBe("30em"); 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);
});
}); });
+54 -18
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleString } from "./styleInvariants";
import { import {
computeFormInputExportTokens, computeFormInputExportTokens,
computeFormSelectExportTokens, computeFormSelectExportTokens,
@@ -64,12 +65,17 @@ describe("formHelpers.computeFormInputExportTokens", () => {
expect(tokens.inputStyleAttr).toContain("color:#ff0000"); expect(tokens.inputStyleAttr).toContain("color:#ff0000");
expect(tokens.inputStyleAttr).toContain("background-color:#111827"); expect(tokens.inputStyleAttr).toContain("background-color:#111827");
expect(tokens.inputStyleAttr).toContain("border-color:#4b5563"); expect(tokens.inputStyleAttr).toContain("border-color:#4b5563");
expect(tokens.inputStyleAttr).toContain("padding-left:8px"); expect(tokens.inputStyleAttr).toContain("padding-left:0.5em");
expect(tokens.inputStyleAttr).toContain("padding-right:8px"); expect(tokens.inputStyleAttr).toContain("padding-right:0.5em");
expect(tokens.inputStyleAttr).toContain("padding-top:4px"); expect(tokens.inputStyleAttr).toContain("padding-top:0.25em");
expect(tokens.inputStyleAttr).toContain("padding-bottom:4px"); expect(tokens.inputStyleAttr).toContain("padding-bottom:0.25em");
expect(tokens.inputStyleAttr).toContain("width:300px"); expect(tokens.inputStyleAttr).toContain("width:18.75em");
expect(tokens.inputStyleAttr).toContain("border-radius:6px"); 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 속성이 비어 있어야 한다", () => { it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
@@ -97,12 +103,17 @@ describe("formHelpers.computeFormSelectExportTokens", () => {
expect(tokens.selectStyleAttr).toContain("color:#00ff00"); expect(tokens.selectStyleAttr).toContain("color:#00ff00");
expect(tokens.selectStyleAttr).toContain("background-color:#020617"); expect(tokens.selectStyleAttr).toContain("background-color:#020617");
expect(tokens.selectStyleAttr).toContain("border-color:#64748b"); expect(tokens.selectStyleAttr).toContain("border-color:#64748b");
expect(tokens.selectStyleAttr).toContain("padding-left:12px"); expect(tokens.selectStyleAttr).toContain("padding-left:0.75em");
expect(tokens.selectStyleAttr).toContain("padding-right:12px"); expect(tokens.selectStyleAttr).toContain("padding-right:0.75em");
expect(tokens.selectStyleAttr).toContain("padding-top:6px"); expect(tokens.selectStyleAttr).toContain("padding-top:0.375em");
expect(tokens.selectStyleAttr).toContain("padding-bottom:6px"); expect(tokens.selectStyleAttr).toContain("padding-bottom:0.375em");
expect(tokens.selectStyleAttr).toContain("width:240px"); expect(tokens.selectStyleAttr).toContain("width:15em");
expect(tokens.selectStyleAttr).toContain("border-radius:9999px"); 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 속성이 비어 있어야 한다", () => { it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
@@ -130,11 +141,16 @@ describe("formHelpers.computeFormCheckboxExportTokens", () => {
expect(tokens.optionStyleAttr).toContain("border-color:#e5e7eb"); expect(tokens.optionStyleAttr).toContain("border-color:#e5e7eb");
expect(tokens.optionStyleAttr).toContain("border-width:1px"); expect(tokens.optionStyleAttr).toContain("border-width:1px");
expect(tokens.optionStyleAttr).toContain("border-style:solid"); expect(tokens.optionStyleAttr).toContain("border-style:solid");
expect(tokens.optionStyleAttr).toContain("padding-left:10px"); expect(tokens.optionStyleAttr).toContain("padding-left:0.625em");
expect(tokens.optionStyleAttr).toContain("padding-right:10px"); expect(tokens.optionStyleAttr).toContain("padding-right:0.625em");
expect(tokens.optionStyleAttr).toContain("padding-top:5px"); expect(tokens.optionStyleAttr).toContain("padding-top:0.3125em");
expect(tokens.optionStyleAttr).toContain("padding-bottom:5px"); expect(tokens.optionStyleAttr).toContain("padding-bottom:0.3125em");
expect(tokens.optionStyleAttr).toContain("border-radius:2px"); 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 속성이 비어 있어야 한다", () => { it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
@@ -162,11 +178,16 @@ describe("formHelpers.computeFormRadioExportTokens", () => {
expect(tokens.optionStyleAttr).toContain("border-color:#facc15"); expect(tokens.optionStyleAttr).toContain("border-color:#facc15");
expect(tokens.optionStyleAttr).toContain("border-width:1px"); expect(tokens.optionStyleAttr).toContain("border-width:1px");
expect(tokens.optionStyleAttr).toContain("border-style:solid"); expect(tokens.optionStyleAttr).toContain("border-style:solid");
expect(tokens.optionStyleAttr).toContain("padding-left:4px"); expect(tokens.optionStyleAttr).toContain("padding-left:0.25em");
expect(tokens.optionStyleAttr).toContain("padding-right:4px"); expect(tokens.optionStyleAttr).toContain("padding-right:0.25em");
expect(tokens.optionStyleAttr).toContain("padding-top:2px"); expect(tokens.optionStyleAttr).toContain("padding-top:0.125em");
expect(tokens.optionStyleAttr).toContain("padding-bottom:2px"); expect(tokens.optionStyleAttr).toContain("padding-bottom:0.125em");
expect(tokens.optionStyleAttr).toContain("border-radius:4px"); 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.backgroundColor).toBe("#123456");
expect(tokens.inputStyle.borderColor).toBe("#ff0000"); expect(tokens.inputStyle.borderColor).toBe("#ff0000");
expect(tokens.inputStyle.borderRadius).toBe("6px"); expect(tokens.inputStyle.borderRadius).toBe("6px");
// 퍼블릭 인풋 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
expectNoPxInStyleObject(tokens.wrapperStyle);
expectNoPxInStyleObject(tokens.inputStyle, { allowKeys: ["borderRadius", "borderWidth"] });
}); });
it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => { it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => {
@@ -276,6 +301,10 @@ describe("formHelpers - public tokens", () => {
expect(tokens.selectStyle.backgroundColor).toBe("#123456"); expect(tokens.selectStyle.backgroundColor).toBe("#123456");
expect(tokens.selectStyle.borderColor).toBe("#ff0000"); expect(tokens.selectStyle.borderColor).toBe("#ff0000");
expect(tokens.selectStyle.borderRadius).toBe("6px"); expect(tokens.selectStyle.borderRadius).toBe("6px");
// 퍼블릭 셀렉트 토큰 스타일에는 borderRadius/borderWidth 를 제외하고 px 단위가 없어야 한다.
expectNoPxInStyleObject(tokens.wrapperStyle);
expectNoPxInStyleObject(tokens.selectStyle, { allowKeys: ["borderRadius", "borderWidth"] });
}); });
it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => { it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => {
@@ -304,6 +333,13 @@ describe("formHelpers - public tokens", () => {
expect(tokens.optionContainerStyle.borderRadius).toBe("6px"); expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
expect(tokens.groupTextStyle.color).toBe("#ffffff"); expect(tokens.groupTextStyle.color).toBe("#ffffff");
expect(tokens.optionTextStyle.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: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => { it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => {
+14 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { expectNoPxInStyleParts } from "./styleInvariants";
import { import {
computeImageExportStyles, computeImageExportStyles,
computeImageEditorTokens, computeImageEditorTokens,
@@ -28,7 +29,7 @@ describe("imageHelpers.computeImageExportStyles", () => {
widthPx: 320, widthPx: 320,
}); });
expect(imgStyleParts).toContain("width:320px"); expect(imgStyleParts).toContain("width:20em");
}); });
it("borderRadius 토큰이 full 이면 border-radius 9999px 를 사용해야 한다", () => { it("borderRadius 토큰이 full 이면 border-radius 9999px 를 사용해야 한다", () => {
@@ -47,6 +48,18 @@ describe("imageHelpers.computeImageExportStyles", () => {
expect(imgStyleParts).toContain("border-radius:20px"); 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", () => { describe("computeImageEditorTokens", () => {
+36 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
import { import {
computeListExportTokens, computeListExportTokens,
computeListEditorTokens, computeListEditorTokens,
@@ -81,6 +82,17 @@ describe("listHelpers.computeListExportTokens", () => {
const tokens = computeListExportTokens({ ...baseProps }); const tokens = computeListExportTokens({ ...baseProps });
expect(tokens.items).toEqual([]); expect(tokens.items).toEqual([]);
}); });
it("Export 리스트 listStyleParts 에는 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeListExportTokens({
...baseProps,
align: "center",
items: ["a"],
backgroundColorCustom: "#123456",
});
expectNoPxInStyleParts(tokens.listStyleParts);
});
}); });
describe("listHelpers.computeListEditorTokens", () => { describe("listHelpers.computeListEditorTokens", () => {
@@ -158,17 +170,28 @@ describe("listHelpers.computeListPublicTokens", () => {
expect(byTokenLg.gapEm).toBe(16 / 16); expect(byTokenLg.gapEm).toBe(16 / 16);
}); });
it("fontSizeCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => { it("fontSizeCustom / lineHeightCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => {
const tokens = computeListPublicTokens({ const tokens = computeListPublicTokens({
...baseProps, ...baseProps,
fontSizeCustom: "32px", fontSizeCustom: "32px",
lineHeightCustom: "24px",
backgroundColorCustom: " #123456 ", backgroundColorCustom: " #123456 ",
}); });
expect(tokens.listStyle.fontSize).toBe("2em"); expect(tokens.listStyle.fontSize).toBe("2em");
expect(tokens.listStyle.lineHeight).toBe("1.5em");
expect(tokens.listStyle.backgroundColor).toBe("#123456"); 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 여부에 따라 결정되어야 한다", () => { it("bulletStyle 은 bulletStyle 지정값 또는 ordered 여부에 따라 결정되어야 한다", () => {
const defaultDisc = computeListPublicTokens({ ...baseProps, ordered: false, bulletStyle: undefined }); const defaultDisc = computeListPublicTokens({ ...baseProps, ordered: false, bulletStyle: undefined });
const defaultDecimal = computeListPublicTokens({ ...baseProps, ordered: true, bulletStyle: undefined }); const defaultDecimal = computeListPublicTokens({ ...baseProps, ordered: true, bulletStyle: undefined });
@@ -178,4 +201,16 @@ describe("listHelpers.computeListPublicTokens", () => {
expect(defaultDecimal.bulletStyle).toBe("decimal"); expect(defaultDecimal.bulletStyle).toBe("decimal");
expect(none.bulletStyle).toBe("none"); expect(none.bulletStyle).toBe("none");
}); });
it("퍼블릭 리스트 style 에 px 단위 값이 포함되지 않아야 한다", () => {
const tokens = computeListPublicTokens({
...baseProps,
fontSizeCustom: "32px",
lineHeightCustom: "24px",
textColorCustom: "#ff0000",
backgroundColorCustom: "#123456",
});
expectNoPxInStyleObject(tokens.listStyle);
});
}); });
+70
View File
@@ -192,4 +192,74 @@ describe("프리뷰와 정적 내보내기 일치 (고수준)", () => {
expect(staticHtml).toContain(text); 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('<hr class="pb-divider"');
// 리스트 컨테이너는 pb-list 클래스를 사용해야 한다.
expect(previewHtml).toContain('class="pb-list');
expect(staticHtml).toContain('class="pb-list"');
// 리스트 아이템(li)은 margin-bottom 인라인 스타일을 사용하지 않아야 한다.
// divider 등 다른 요소에서 margin-bottom 을 사용하는 것은 허용한다.
expect(previewHtml).not.toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/);
expect(staticHtml).not.toMatch(/<li[^>]*style=\"[^\"]*margin-bottom:/);
},
);
}); });
+21
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
import { import {
computeSectionExportTokens, computeSectionExportTokens,
computeSectionPublicTokens, computeSectionPublicTokens,
@@ -73,6 +74,21 @@ describe("sectionHelpers.computeSectionExportTokens", () => {
expect(tokens.sectionStyleParts).toContain("position:relative"); expect(tokens.sectionStyleParts).toContain("position:relative");
expect(tokens.sectionStyleParts).toContain("overflow:hidden"); 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", () => { describe("sectionHelpers.computeSectionPublicTokens", () => {
@@ -90,6 +106,11 @@ describe("sectionHelpers.computeSectionPublicTokens", () => {
expect(tokens.sectionStyle.paddingBottom).toBe("2em"); expect(tokens.sectionStyle.paddingBottom).toBe("2em");
expect(tokens.innerWrapperStyle.maxWidth).toBe("50em"); expect(tokens.innerWrapperStyle.maxWidth).toBe("50em");
expect(tokens.columnsContainerStyle.columnGap).toBe("1.5em"); expect(tokens.columnsContainerStyle.columnGap).toBe("1.5em");
// 퍼블릭 섹션 토큰 스타일에는 px 단위 값이 포함되지 않아야 한다.
expectNoPxInStyleObject(tokens.sectionStyle);
expectNoPxInStyleObject(tokens.innerWrapperStyle);
expectNoPxInStyleObject(tokens.columnsContainerStyle);
}); });
it("backgroundImage* 와 backgroundVideoSrc 를 올바르게 반영해야 한다", () => { it("backgroundImage* 와 backgroundVideoSrc 를 올바르게 반영해야 한다", () => {
+3 -3
View File
@@ -16,7 +16,7 @@ describe("getSectionLayoutConfig", () => {
const cfg = getSectionLayoutConfig(baseProps); const cfg = getSectionLayoutConfig(baseProps);
expect(cfg.backgroundClass).toBe("bg-slate-950"); 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.maxWidthClass).toBe("max-w-5xl");
expect(cfg.gapXClass).toBe("gap-8"); expect(cfg.gapXClass).toBe("gap-8");
expect(cfg.alignItemsClass).toBe("items-start"); expect(cfg.alignItemsClass).toBe("items-start");
@@ -49,7 +49,7 @@ describe("getSectionLayoutConfig", () => {
expect(narrowCfg.maxWidthClass).toBe("max-w-3xl"); expect(narrowCfg.maxWidthClass).toBe("max-w-3xl");
expect(narrowCfg.gapXClass).toBe("gap-4"); expect(narrowCfg.gapXClass).toBe("gap-4");
expect(narrowCfg.alignItemsClass).toBe("items-end"); expect(narrowCfg.alignItemsClass).toBe("items-end");
expect(narrowCfg.paddingYClass).toBe("py-8"); expect(narrowCfg.paddingYClass).toBe("py-4");
}); });
it("primary 배경과 큰 패딩에 대해 올바른 클래스를 반환해야 한다", () => { it("primary 배경과 큰 패딩에 대해 올바른 클래스를 반환해야 한다", () => {
@@ -61,6 +61,6 @@ describe("getSectionLayoutConfig", () => {
const cfg = getSectionLayoutConfig(primaryLarge); const cfg = getSectionLayoutConfig(primaryLarge);
expect(cfg.backgroundClass).toBe("bg-sky-900"); expect(cfg.backgroundClass).toBe("bg-sky-900");
expect(cfg.paddingYClass).toBe("py-20"); expect(cfg.paddingYClass).toBe("py-10");
}); });
}); });
+34
View File
@@ -0,0 +1,34 @@
import { expect } from "vitest";
// 스타일 관련 인변언트(전역 규칙)를 검증하기 위한 공통 테스트 유틸리티.
// - 퍼블릭/익스포트 토큰에서 px 단위 값 사용을 금지하거나,
// - border-width 등 꼭 필요한 px 사용만 예외로 허용할 때 사용한다.
export type StyleObject = Record<string, unknown>;
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);
}
+38 -1
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { expectNoPxInStyleObject, expectNoPxInStyleParts } from "./styleInvariants";
import { import {
computeTextPbTokens, computeTextPbTokens,
computeTextEditorTokens, computeTextEditorTokens,
@@ -68,6 +69,17 @@ describe("textHelpers.computeTextPbTokens", () => {
expect(prose.maxWidthClass).toBe("pb-text-maxw-prose"); expect(prose.maxWidthClass).toBe("pb-text-maxw-prose");
expect(narrow.maxWidthClass).toBe("pb-text-maxw-narrow"); 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", () => { describe("textHelpers.computeTextEditorTokens", () => {
@@ -128,7 +140,7 @@ describe("textHelpers.computeTextPublicTokens", () => {
} as TextBlockProps); } as TextBlockProps);
expect(tokens.sizeClass).toBe(""); expect(tokens.sizeClass).toBe("");
expect(tokens.styleOverrides.fontSize).toBe("24px"); expect(tokens.styleOverrides.fontSize).toBe("1.5em");
}); });
it("colorCustom / backgroundColorCustom 이 styleOverrides 에 반영되어야 한다", () => { it("colorCustom / backgroundColorCustom 이 styleOverrides 에 반영되어야 한다", () => {
@@ -141,4 +153,29 @@ describe("textHelpers.computeTextPublicTokens", () => {
expect(tokens.styleOverrides.color).toBe("#ff0000"); expect(tokens.styleOverrides.color).toBe("#ff0000");
expect(tokens.styleOverrides.backgroundColor).toBe("#123456"); 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);
});
}); });
+24 -5
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { expectNoPxInStyleParts } from "./styleInvariants";
import { import {
normalizeVideoSourceUrl, normalizeVideoSourceUrl,
resolveVideoPlatform, resolveVideoPlatform,
@@ -124,7 +125,7 @@ describe("videoHelpers.computeVideoExportTokens", () => {
const escapers = { escapeAttr: (value: string) => value }; const escapers = { escapeAttr: (value: string) => value };
it("fixed width / 카드 스타일을 px 단위 스타일 파트로 계산해야 한다", () => { it("fixed width / 카드 스타일을 em 단위 스타일 파트로 계산해야 한다", () => {
const tokens = computeVideoExportTokens( const tokens = computeVideoExportTokens(
{ {
...baseProps, ...baseProps,
@@ -139,15 +140,33 @@ describe("videoHelpers.computeVideoExportTokens", () => {
); );
expect(tokens.aspectClass).toBe(""); expect(tokens.aspectClass).toBe("");
expect(tokens.wrapperStyleParts).toContain("width:640px"); expect(tokens.wrapperStyleParts).toContain("width:40em");
expect(tokens.videoStyleParts).toContain("width:640px"); expect(tokens.videoStyleParts).toContain("width:40em");
expect(tokens.wrapperStyleParts).toContain("background-color:#123456"); expect(tokens.wrapperStyleParts).toContain("background-color:#123456");
expect(tokens.wrapperStyleParts).toContain("padding:24px"); expect(tokens.wrapperStyleParts).toContain("padding:1.5em");
expect(tokens.wrapperStyleParts).toContain("border-radius:16px"); expect(tokens.wrapperStyleParts).toContain("border-radius:1em");
expect(tokens.outerStyleParts).toContain("display:flex"); expect(tokens.outerStyleParts).toContain("display:flex");
expect(tokens.outerStyleParts).toContain("justify-content:flex-start"); 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 를 계산해야 한다", () => { it("widthMode 가 full 이면 width:100% 로 설정하고 align 에 따라 justify-content 를 계산해야 한다", () => {
const right = computeVideoExportTokens( const right = computeVideoExportTokens(
{ {
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster