TDD,E2E 개선, 미적용 스타일 개선
CI / test (push) Failing after 2m54s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-07 09:52:42 +09:00
parent e726f43f7c
commit a5b432fb7b
167 changed files with 7397 additions and 663 deletions
+520
View File
@@ -531,6 +531,520 @@ describe("/api/export", () => {
expect(formHtml).toContain('name="__config"');
});
it("formInput 플로팅 라벨은 Export 에서 placeholder 텍스트와 라벨이 겹치지 않아야 한다", async () => {
const blocks: Block[] = [
{
id: "form_floating_1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
fieldIds: ["field_name"],
submitButtonId: null,
formWidthMode: "full",
},
} as any,
{
id: "field_name",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
labelDisplay: "floating",
// placeholder 가 라벨과 같을 때도 Export 에서는 placeholder 텍스트가 인풋 안에 보이지 않아야 한다.
placeholder: "이름",
required: true,
} as any,
} as any,
];
const projectConfig: ProjectConfig = {
title: "플로팅 라벨 Export 테스트",
slug: "form-floating-export-test",
canvasPreset: "full",
} as ProjectConfig;
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// 플로팅 라벨의 컨테이너는 pb-form-field pb-form-field--floating 클래스를 포함해야 한다.
expect(html).toContain("pb-form-field pb-form-field--floating");
// name="name" 인 input 태그를 찾아 placeholder 를 검사한다.
const inputMatch = html.match(/<input[^>]*name=\"name\"[^>]*>/);
expect(inputMatch).not.toBeNull();
const inputTag = inputMatch![0];
// placeholder 안에 "이름" 텍스트가 그대로 노출되면 안 된다.
expect(inputTag).not.toContain('placeholder="이름"');
// 대신 플로팅 라벨 전용으로 공백 placeholder 를 사용한다.
expect(inputTag).toContain('placeholder=" "');
// 플로팅 라벨 컨테이너 내부에서 input 이 label 보다 먼저 나와야 CSS sibling 기반 플로팅이 동작한다.
const fieldMatch = html.match(
/<div class=\"pb-form-field pb-form-field--floating\">([\s\S]*?)<\/div>/,
);
expect(fieldMatch).not.toBeNull();
const fieldInner = fieldMatch![1];
const inputIndex = fieldInner.indexOf("<input");
const labelIndex = fieldInner.indexOf("<label");
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(labelIndex).toBeGreaterThanOrEqual(0);
expect(inputIndex).toBeLessThan(labelIndex);
});
it("formSelect 라벨 레이아웃(inline)이 Export HTML 에도 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_select_export_1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
fieldIds: ["select_plan"],
submitButtonId: null,
formWidthMode: "full",
},
} as any,
{
id: "select_plan",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
labelDisplay: "visible",
labelLayout: "inline",
labelGapPx: 16,
} as any,
} as any,
];
const projectConfig: ProjectConfig = {
title: "셀렉트 라벨/레이아웃 Export 테스트",
slug: "form-select-layout-export-test",
canvasPreset: "full",
} as ProjectConfig;
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// 라벨 표시 방식 visible + inline 레이아웃: pb-form-label 이 정상적으로 노출되어야 한다.
expect(html).toMatch(/<label class=\"pb-form-label\"[^>]*>플랜<\/label>/);
// inline 레이아웃: pb-form-field 컨테이너에 flex-direction:row / align-items:center / column-gap 이 style 로 반영되어야 한다.
// labelGapPx: 16px -> 1em
const divMatch = html.match(/<div class=\"pb-form-field\"[^>]*style=\"([^\"]*)\"[^>]*>/);
expect(divMatch).not.toBeNull();
const styleAttr = divMatch![1];
expect(styleAttr).toContain("flex-direction:row");
expect(styleAttr).toContain("align-items:center");
expect(styleAttr).toContain("column-gap:1em");
});
it("formInput 라벨 표시 방식 hidden/floating 이 Export HTML 에도 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_hidden_export",
type: "formInput",
props: {
label: "숨김 라벨",
formFieldName: "hidden_label",
labelDisplay: "hidden",
},
} as any,
{
id: "form_input_floating_export",
type: "formInput",
props: {
label: "플로팅 라벨",
formFieldName: "floating_label",
labelDisplay: "floating",
placeholder: "플로팅 플레이스홀더",
paddingY: 16,
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formInput 라벨 표시 방식 Export 테스트",
slug: "form-input-label-display-export-test",
canvasPreset: "full",
} as ProjectConfig;
const html = buildStaticHtml(blocks, projectConfig);
// hidden: pb-form-label sr-only 로 렌더되어야 한다.
expect(html).toMatch(
/<div class="pb-form-field">\s*<input class="pb-input"[^>]*name="hidden_label"[^>]*>\s*<label class="pb-form-label sr-only"[^>]*>숨김 라벨<\/label>\s*<\/div>/,
);
// floating: pb-form-field pb-form-field--floating + placeholder=" " + pb-form-label 구조를 사용해야 한다.
expect(html).toMatch(
/<div class="pb-form-field pb-form-field--floating"[^>]*>\s*<input class="pb-input"[^>]*name="floating_label"[^>]*placeholder=" "[^>]*>\s*<label class="pb-form-label"[^>]*>플로팅 라벨<\/label>\s*<\/div>/,
);
const floatingDivMatch = html.match(
/<div class="pb-form-field pb-form-field--floating"([^>]*)>/,
);
expect(floatingDivMatch).not.toBeNull();
expect(floatingDivMatch![1]).toContain('style="--pb-input-padding-y:1rem"');
});
it("formInput Export HTML 은 label 과 input 을 for/id 로 연결해야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_hidden_export_for_id",
type: "formInput",
props: {
label: "숨김 라벨",
formFieldName: "hidden_label_for_id",
labelDisplay: "hidden",
},
} as any,
{
id: "form_input_floating_export_for_id",
type: "formInput",
props: {
label: "플로팅 라벨",
formFieldName: "floating_label_for_id",
labelDisplay: "floating",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "formInput for/id Export 테스트",
slug: "form-input-for-id-export-test",
canvasPreset: "full",
} as ProjectConfig;
const html = buildStaticHtml(blocks, projectConfig);
expect(html).toMatch(
/<input[^>]*name="hidden_label_for_id"[^>]*id="hidden_label_for_id"[^>]*>/,
);
expect(html).toMatch(
/<label[^>]*for="hidden_label_for_id"[^>]*>숨김 라벨<\/label>/,
);
expect(html).toMatch(
/<input[^>]*name="floating_label_for_id"[^>]*id="floating_label_for_id"[^>]*>/,
);
expect(html).toMatch(
/<label[^>]*for="floating_label_for_id"[^>]*>플로팅 라벨<\/label>/,
);
});
it("formSelect Export HTML 은 name 과 option value 를 정확히 반영해야 한다", () => {
const blocks: Block[] = [
{
id: "select_export_attrs",
type: "formSelect",
props: {
label: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
required: true,
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "formSelect Export 속성 테스트",
slug: "form-select-attrs-export-test",
canvasPreset: "full",
} as ProjectConfig;
const html = buildStaticHtml(blocks, projectConfig);
expect(html).toMatch(/<select[^>]*class="pb-select"[^>]*name="plan"[^>]*required[^>]*>/);
expect(html).toContain('<option value="basic">Basic<\/option>'.replace("\\/", "/"));
expect(html).toContain('<option value="pro">Pro<\/option>'.replace("\\/", "/"));
});
it("formCheckbox/formRadio Export HTML 의 input 은 type/name/value 를 정확히 반영해야 한다", () => {
const blocks: Block[] = [
{
id: "features_export_attrs",
type: "formCheckbox",
props: {
groupLabel: "기능",
formFieldName: "features",
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
} as any,
},
{
id: "choices_export_attrs",
type: "formRadio",
props: {
groupLabel: "선택",
formFieldName: "choice",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "formCheckbox/formRadio Export 속성 테스트",
slug: "form-option-attrs-export-test",
canvasPreset: "full",
} as ProjectConfig;
const html = buildStaticHtml(blocks, projectConfig);
// 체크박스 옵션 input: type="checkbox" name="features" value="opt1"/"opt2"
expect(html).toMatch(/<input type="checkbox"[^>]*name="features"[^>]*value="opt1"[^>]*>/);
expect(html).toMatch(/<input type="checkbox"[^>]*name="features"[^>]*value="opt2"[^>]*>/);
// 라디오 옵션 input: type="radio" name="choice" value="a"/"b"
expect(html).toMatch(/<input type="radio"[^>]*name="choice"[^>]*value="a"[^>]*>/);
expect(html).toMatch(/<input type="radio"[^>]*name="choice"[^>]*value="b"[^>]*>/);
});
it("FormBlock.requiredFieldIds 는 Export HTML input/select 의 required 속성에도 반영되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_required_export",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fieldIds: ["field_name", "field_plan"],
requiredFieldIds: ["field_name", "field_plan"],
} as any,
},
{
id: "field_name",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
required: false,
} as any,
},
{
id: "field_plan",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [
{ label: "Basic", value: "basic" },
{ label: "Pro", value: "pro" },
],
} as any,
},
];
const projectConfig: ProjectConfig = {
title: "FormBlock required Export 테스트",
slug: "form-required-export-test",
canvasPreset: "full",
} as ProjectConfig;
const html = buildStaticHtml(blocks, projectConfig);
// field_name 은 FormBlock.requiredFieldIds 로 인해 required 이어야 한다.
expect(html).toMatch(/<input[^>]*name="name"[^>]*required[^>]*>/);
// 라디오 그룹에서는 첫 번째 옵션만 required 여야 한다.
const firstRadioMatch = html.match(/<input type="radio"[^>]*name="plan"[^>]*value="basic"[^>]*>/);
const secondRadioMatch = html.match(/<input type="radio"[^>]*name="plan"[^>]*value="pro"[^>]*>/);
expect(firstRadioMatch).not.toBeNull();
expect(firstRadioMatch![0]).toContain("required");
expect(secondRadioMatch).not.toBeNull();
expect(secondRadioMatch![0]).not.toContain("required");
});
it("formCheckbox/formRadio 의 optionLayout 값이 Export HTML 의 pb-form-options 컨테이너 클래스로 반영되어야 한다", () => {
const checkboxBlocks: Block[] = [
{
id: "features_export_options",
type: "formCheckbox",
props: {
groupLabel: "기능",
formFieldName: "features",
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
optionLayout: "stacked",
},
} as any,
];
const radioBlocks: Block[] = [
{
id: "choices_export_options",
type: "formRadio",
props: {
groupLabel: "선택",
formFieldName: "choice",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
optionLayout: "inline",
},
} as any,
];
const projectConfig: ProjectConfig = {
title: "옵션 레이아웃 Export 테스트",
slug: "form-option-layout-export-test",
canvasPreset: "full",
} as ProjectConfig;
const checkboxHtml = buildStaticHtml(checkboxBlocks, projectConfig);
expect(checkboxHtml).toContain('class="pb-form-options pb-form-options--stacked"');
const radioHtml = buildStaticHtml(radioBlocks, projectConfig);
expect(radioHtml).toContain('class="pb-form-options pb-form-options--inline"');
// 각 옵션 라벨은 pb-form-option 클래스를 사용해야 한다.
expect(radioHtml).toContain('class="pb-form-option"');
});
it("formCheckbox/formRadio 그룹 타이틀 표시 방식과 레이아웃이 Export HTML 에도 반영되어야 한다", async () => {
const blocks: Block[] = [
{
id: "form_group_export_1",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
fields: [],
fieldIds: ["features", "choices"],
submitButtonId: null,
formWidthMode: "full",
},
} as any,
{
id: "features",
type: "formCheckbox",
props: {
groupLabel: "기능",
formFieldName: "features",
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
groupLabelDisplay: "hidden",
labelLayout: "inline",
labelGapPx: 8,
} as any,
} as any,
{
id: "choices",
type: "formRadio",
props: {
groupLabel: "선택",
formFieldName: "choice",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
groupLabelDisplay: "visible",
labelLayout: "inline",
labelGapPx: 24,
} as any,
} as any,
];
const projectConfig: ProjectConfig = {
title: "체크박스/라디오 그룹 라벨 Export 테스트",
slug: "form-group-layout-export-test",
canvasPreset: "full",
} as ProjectConfig;
const payload = { blocks, projectConfig };
const { POST: handleExport } = await import("@/app/api/export/route");
const res = await handleExport(
new Request(`${BASE_URL}/api/export`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
expect(res.status).toBe(200);
const arrayBuffer = await res.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const indexEntry = zip.file("index.html");
expect(indexEntry).toBeTruthy();
const html = await indexEntry!.async("string");
// 체크박스 그룹: groupLabelDisplay hidden -> pb-form-label sr-only 여야 한다.
expect(html).toContain('<label class="pb-form-label sr-only"');
// 라디오 그룹: inline 레이아웃 + labelGapPx 24px -> column-gap:1.5em, 세로 중앙 정렬이 pb-form-field style 에 반영되어야 한다.
const radioDivMatch = html.match(/<div class=\"pb-form-field\"[^>]*style=\"([^\"]*)\"[^>]*>[^]*선택[^]*<label class=\"pb-form-option/);
expect(radioDivMatch).not.toBeNull();
const radioStyle = radioDivMatch![1];
expect(radioStyle).toContain("flex-direction:row");
expect(radioStyle).toContain("align-items:center");
expect(radioStyle).toContain("column-gap:1.5em");
});
it("FormBlock 에 fieldIds/fields 가 모두 없으면 Export 에서 아무 <form>/필드도 생성하지 않아야 한다", async () => {
const blocks: Block[] = [
@@ -2231,6 +2745,9 @@ describe("/api/export", () => {
fillColorCustom: "#123456",
strokeColorCustom: "#ff0000",
borderRadius: "lg",
fontSizeCustom: "24px",
lineHeightCustom: "30px",
letterSpacingCustom: "2px",
},
} as any,
];
@@ -2271,6 +2788,9 @@ describe("/api/export", () => {
expect(html).toContain("background-color:#123456");
expect(html).toContain("border-color:#ff0000");
expect(html).toContain("border-radius:6px");
expect(html).toContain("font-size:1.5em");
expect(html).toContain("line-height:1.875em");
expect(html).toContain("letter-spacing:0.125em");
});
it("formSelect 블록은 스타일 속성으로 셀렉트 배경/보더/패딩/너비/둥글기를 반영해야 한다", async () => {