mapper = new ComponentHtmlMapper;
$this->evaluator = new ExpressionEvaluator;
// 기본 config 설정 (seo-config.json 시뮬레이션)
$this->mapper->setComponentMap($this->getDefaultComponentMap());
$this->mapper->setRenderModes($this->getDefaultRenderModes());
$this->mapper->setSelfClosing($this->getDefaultSelfClosing());
$this->mapper->setTextProps($this->getDefaultTextProps());
$this->mapper->setAttrMap($this->getDefaultAttrMap());
$this->mapper->setAllowedAttrs($this->getDefaultAllowedAttrs());
}
/**
* 기본 컴포넌트 매핑을 반환합니다.
*
* @return array 컴포넌트 매핑
*/
private function getDefaultComponentMap(): array
{
return [
'Div' => ['tag' => 'div'],
'Span' => ['tag' => 'span'],
'P' => ['tag' => 'p'],
'H1' => ['tag' => 'h1'],
'H2' => ['tag' => 'h2'],
'H3' => ['tag' => 'h3'],
'H4' => ['tag' => 'h4'],
'H5' => ['tag' => 'h5'],
'H6' => ['tag' => 'h6'],
'A' => ['tag' => 'a'],
'Button' => ['tag' => 'button'],
'Img' => ['tag' => 'img'],
'Input' => ['tag' => 'input'],
'Form' => ['tag' => 'form'],
'Ul' => ['tag' => 'ul'],
'Ol' => ['tag' => 'ol'],
'Li' => ['tag' => 'li'],
'Nav' => ['tag' => 'nav'],
'Header' => ['tag' => 'header', 'render' => 'text_format', 'format' => '{site_name}', 'defaults' => ['site_name' => 'G7']],
'Footer' => ['tag' => 'footer', 'render' => 'text_format', 'format' => '© {site_name}', 'defaults' => ['site_name' => 'G7']],
'Main' => ['tag' => 'main'],
'Section' => ['tag' => 'section'],
'Article' => ['tag' => 'article'],
'Icon' => ['tag' => 'i', 'name_to_class' => 'fas fa-{name}'],
'Table' => ['tag' => 'table'],
'Thead' => ['tag' => 'thead'],
'Tbody' => ['tag' => 'tbody'],
'Tr' => ['tag' => 'tr'],
'Th' => ['tag' => 'th'],
'Td' => ['tag' => 'td'],
'Label' => ['tag' => 'label'],
'Select' => ['tag' => 'select', 'render' => 'select_options', 'props_source' => 'options'],
'Option' => ['tag' => 'option'],
'Textarea' => ['tag' => 'textarea'],
'Strong' => ['tag' => 'strong'],
'Em' => ['tag' => 'em'],
'Small' => ['tag' => 'small'],
'Hr' => ['tag' => 'hr'],
'Br' => ['tag' => 'br'],
'Fragment' => ['tag' => ''],
// Composite 컴포넌트
'Container' => ['tag' => 'div'],
'Flex' => ['tag' => 'div'],
'Grid' => ['tag' => 'div'],
'SectionLayout' => ['tag' => 'section'],
'Card' => ['tag' => 'article'],
'FormField' => ['tag' => 'div'],
'Badge' => ['tag' => 'span'],
'Breadcrumb' => ['tag' => 'nav'],
'Pagination' => ['tag' => 'nav', 'render' => 'pagination_links'],
'SearchBar' => ['tag' => 'div'],
'Avatar' => ['tag' => 'span', 'render' => 'text_format', 'format' => '{author.nickname}'],
'UserInfo' => ['tag' => 'span', 'render' => 'text_format', 'format' => '{author.nickname} · {subText}'],
'ProductCard' => ['tag' => 'article', 'render' => 'product_card_view', 'props_source' => 'product'],
'PageHeader' => ['tag' => 'header'],
'DataGrid' => ['tag' => 'table'],
'ExpandableContent' => ['tag' => 'div'],
// skip 컴포넌트
'Modal' => ['tag' => 'div', 'skip' => true],
'Toast' => ['tag' => 'div', 'skip' => true],
'Toggle' => ['tag' => 'div', 'skip' => true],
'FileUploader' => ['tag' => 'div', 'skip' => true],
// render 모드 컴포넌트
'ProductImageViewer' => ['tag' => 'div', 'render' => 'image_gallery', 'props_source' => 'images'],
'ImageGallery' => ['tag' => 'div', 'render' => 'image_gallery', 'props_source' => 'images'],
'TabNavigation' => ['tag' => 'nav', 'render' => 'tab_list', 'props_source' => 'tabs'],
'StarRating' => ['tag' => 'span', 'render' => 'text_format', 'format' => '{value} / {max}', 'defaults' => ['max' => '5']],
'HtmlContent' => ['tag' => 'div', 'render' => 'html_content', 'props_source' => 'content'],
];
}
/**
* 기본 렌더 모드를 반환합니다.
*
* @return array 렌더 모드 정의
*/
private function getDefaultRenderModes(): array
{
return [
'image_gallery' => [
'type' => 'iterate',
'source' => '$props_source',
'item_tag' => 'img',
'item_attrs' => ['src' => '{url|download_url|src|image_url}', 'alt' => '{alt_text_current|alt_text|alt}'],
],
'tab_list' => [
'type' => 'iterate',
'source' => '$props_source',
'item_tag' => 'span',
'item_content' => '{label}',
'badge_field' => 'badge',
],
'select_options' => [
'type' => 'iterate',
'source' => '$props_source',
'item_tag' => 'option',
'item_attrs' => ['value' => '{value}'],
'item_content' => '{label|label_key|text|name}',
],
'text_format' => [
'type' => 'format',
],
'html_content' => [
'type' => 'raw',
'source' => '$props_source',
],
'product_card_view' => [
'type' => 'fields',
'source' => '$props_source',
'link' => [
'href' => '/products/{id}',
'base_url' => '$var:shopBase',
],
'fields' => [
['tag' => 'img', 'attrs' => ['src' => '{thumbnail_url}', 'alt' => '{name_localized|name}']],
['tag' => 'h3', 'content' => '{name_localized|name}'],
[
'tag' => 'p',
'children' => [
['tag' => 'span', 'content' => '{primary_category}', 'if' => '{primary_category}'],
['tag' => 'span', 'content' => '{brand_name}', 'if' => '{brand_name}'],
],
],
[
'tag' => 'p',
'children' => [
['tag' => 'span', 'content' => '{selling_price_formatted}'],
['tag' => 'del', 'content' => '{list_price_formatted}', 'if' => '{discount_rate}'],
['tag' => 'span', 'content' => '{discount_rate}%', 'if' => '{discount_rate}'],
],
],
['tag' => 'p', 'iterate' => 'labels', 'item_tag' => 'span', 'item_content' => '{name}'],
['tag' => 'span', 'content' => '{sales_status_label}', 'if' => '{sales_status}'],
],
],
'pagination_links' => [
'type' => 'pagination',
'max_links' => 10,
],
];
}
/**
* 기본 셀프 클로징 태그 목록을 반환합니다.
*
* @return array 셀프 클로징 태그
*/
private function getDefaultSelfClosing(): array
{
return ['img', 'input', 'hr', 'br'];
}
/**
* 컴포넌트 배열을 HTML로 렌더링하는 헬퍼 메서드입니다.
*
* @param array $components 컴포넌트 배열
* @param array $context 데이터 컨텍스트
* @return string 렌더링된 HTML
*/
private function render(array $components, array $context = []): string
{
return $this->mapper->render($components, $context, $this->evaluator);
}
// =========================================================================
// 기존 테스트 마이그레이션 (17개) — config 기반
// =========================================================================
/**
* Div + children 텍스트를 올바르게 렌더링합니다.
*/
public function test_div_with_children_text(): void
{
$components = [
[
'name' => 'Div',
'children' => [
[
'name' => 'Span',
'props' => ['text' => '내용'],
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('
', $html);
$this->assertStringContainsString('내용', $html);
$this->assertStringContainsString('
', $html);
}
/**
* H1 + text prop을 올바르게 렌더링합니다.
*/
public function test_h1_with_text_prop(): void
{
$components = [
[
'name' => 'H1',
'props' => ['text' => '제목'],
],
];
$html = $this->render($components);
$this->assertSame('제목
', $html);
}
/**
* Img + src/alt를 셀프 클로징 태그로 렌더링합니다.
*/
public function test_img_with_src_and_alt(): void
{
$components = [
[
'name' => 'Img',
'props' => [
'src' => 'https://example.com/image.jpg',
'alt' => '설명',
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('
assertStringContainsString('src="https://example.com/image.jpg"', $html);
$this->assertStringContainsString('alt="설명"', $html);
$this->assertStringNotContainsString('', $html);
}
/**
* A + href + children을 올바르게 렌더링합니다.
*/
public function test_a_with_href_and_children(): void
{
$components = [
[
'name' => 'A',
'props' => ['href' => 'https://example.com'],
'children' => [
[
'name' => 'Span',
'props' => ['text' => '텍스트'],
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('', $html);
$this->assertStringContainsString('텍스트', $html);
$this->assertStringContainsString('', $html);
}
/**
* Container 컴포넌트는 div 태그로 렌더링됩니다.
*/
public function test_container_renders_as_div(): void
{
$components = [
[
'name' => 'Container',
'props' => ['className' => 'container'],
'children' => [
[
'name' => 'Span',
'props' => ['text' => '내용'],
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('', $html);
$this->assertStringContainsString('
내용', $html);
}
/**
* 중첩 컴포넌트를 올바르게 렌더링합니다.
*/
public function test_nested_components(): void
{
$components = [
[
'name' => 'Div',
'children' => [
[
'name' => 'Section',
'children' => [
[
'name' => 'Article',
'children' => [
[
'name' => 'P',
'props' => ['text' => '깊은 중첩'],
],
],
],
],
],
],
],
];
$html = $this->render($components);
$this->assertSame('
', $html);
}
/**
* if=true 조건의 컴포넌트는 렌더링에 포함됩니다.
*/
public function test_if_true_included(): void
{
$components = [
[
'name' => 'Div',
'if' => '{{visible}}',
'props' => ['text' => '보이는 요소'],
],
];
$html = $this->render($components, ['visible' => true]);
$this->assertStringContainsString('
보이는 요소
', $html);
}
/**
* if=false 조건의 컴포넌트는 렌더링에서 제외됩니다.
*/
public function test_if_false_excluded(): void
{
$components = [
[
'name' => 'Div',
'if' => '{{visible}}',
'props' => ['text' => '숨겨진 요소'],
],
];
$html = $this->render($components, ['visible' => false]);
$this->assertSame('', $html);
}
/**
* iteration으로 반복 렌더링을 올바르게 수행합니다.
*/
public function test_iteration_renders_repeated_output(): void
{
$components = [
[
'name' => 'Li',
'iteration' => [
'data' => '{{items}}',
'item_var' => 'item',
'index_var' => 'idx',
],
'props' => ['text' => '{{item.name}}'],
],
];
$context = [
'items' => [
['name' => '사과'],
['name' => '바나나'],
['name' => '체리'],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('
사과', $html);
$this->assertStringContainsString('
바나나', $html);
$this->assertStringContainsString('
체리', $html);
}
/**
* iteration의 source 키로 반복 렌더링을 수행합니다 (레이아웃 JSON 스키마 호환).
*/
public function test_iteration_with_source_key(): void
{
$components = [
[
'name' => 'Div',
'iteration' => [
'source' => '{{product.data.notice.values}}',
'item_var' => 'noticeItem',
],
'props' => ['className' => 'flex'],
'children' => [
['name' => 'Div', 'props' => ['text' => '{{noticeItem.key}}']],
['name' => 'Div', 'props' => ['text' => '{{noticeItem.value}}']],
],
],
];
$context = [
'product' => [
'data' => [
'notice' => [
'values' => [
['key' => '색상', 'value' => '빨강'],
['key' => '사이즈', 'value' => 'M'],
],
],
],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('색상', $html);
$this->assertStringContainsString('빨강', $html);
$this->assertStringContainsString('사이즈', $html);
$this->assertStringContainsString('M', $html);
}
/**
* 알 수 없는 컴포넌트명은 div 태그로 폴백됩니다.
*/
public function test_unknown_component_fallback_to_div(): void
{
$components = [
[
'name' => 'UnknownWidget',
'props' => ['text' => '폴백 내용'],
],
];
$html = $this->render($components);
$this->assertSame('
폴백 내용
', $html);
}
/**
* className prop이 class 속성으로 변환됩니다.
*/
public function test_classname_prop_to_class_attribute(): void
{
$components = [
[
'name' => 'Div',
'props' => ['className' => 'text-lg font-bold'],
'children' => [
[
'name' => 'Span',
'props' => ['text' => '스타일'],
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('class="text-lg font-bold"', $html);
}
/**
* children이 빈 배열이면 빈 태그를 렌더링합니다.
*/
public function test_empty_children_renders_empty_tag(): void
{
$components = [
[
'name' => 'Div',
'children' => [],
],
];
$html = $this->render($components);
$this->assertSame('
', $html);
}
/**
* 데이터 바인딩이 포함된 prop을 올바르게 평가합니다.
*/
public function test_data_binding_in_props(): void
{
$components = [
[
'name' => 'A',
'props' => ['href' => '{{link.url}}'],
'children' => [
[
'name' => 'Span',
'props' => ['text' => '{{link.label}}'],
],
],
],
];
$context = [
'link' => [
'url' => '/products/1',
'label' => '상품 상세',
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('href="/products/1"', $html);
$this->assertStringContainsString('
상품 상세', $html);
}
/**
* 허용되지 않은 속성(이벤트 핸들러 등)은 HTML에 포함되지 않습니다.
*/
public function test_disallowed_attributes_excluded(): void
{
$components = [
[
'name' => 'Button',
'props' => [
'text' => '클릭',
'onClick' => 'handleClick',
'type' => 'button',
],
],
];
$html = $this->render($components);
$this->assertStringNotContainsString('onClick', $html);
$this->assertStringContainsString('type="button"', $html);
$this->assertStringContainsString('