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'], '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', ], '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(' 'Hr'], ['name' => 'Br'], ]; $html = $this->render($components); $this->assertStringContainsString('
    ', $html); $this->assertStringContainsString('
    ', $html); $this->assertStringNotContainsString('', $html); $this->assertStringNotContainsString('
    ', $html); } /** * 컴포넌트 레벨 text 속성이 렌더링됩니다. */ public function test_component_level_text_attribute(): void { $components = [ [ 'name' => 'Span', 'text' => '$t:shop.back', ], ]; app('translator')->addLines(['shop.back' => '뒤로가기'], 'ko'); app()->setLocale('ko'); $html = $this->render($components); $this->assertStringContainsString('뒤로가기', $html); } /** * 컴포넌트 레벨 text가 props.text보다 우선합니다. */ public function test_component_text_takes_priority_over_props_text(): void { $components = [ [ 'name' => 'Span', 'text' => '우선 텍스트', 'props' => ['text' => '차선 텍스트'], ], ]; $html = $this->render($components); $this->assertStringContainsString('우선 텍스트', $html); $this->assertStringNotContainsString('차선 텍스트', $html); } /** * Composite 컴포넌트가 div 태그로 렌더링되고 children이 포함됩니다. */ public function test_composite_component_renders_with_children(): void { $components = [ [ 'type' => 'composite', 'name' => 'FormField', 'props' => ['className' => 'mb-4'], 'children' => [ [ 'name' => 'Label', 'props' => ['text' => '이름'], ], [ 'name' => 'Input', 'props' => ['name' => 'username', 'type' => 'text'], ], ], ], ]; $html = $this->render($components); $this->assertStringContainsString('
    ', $html); $this->assertStringContainsString('', $html); $this->assertStringContainsString(' 'composite', 'name' => 'ProductImageViewer', 'props' => [ 'images' => '{{product.data.images}}', ], ], ]; $context = [ 'product' => [ 'data' => [ 'images' => [ ['url' => 'https://example.com/img1.jpg', 'alt' => '메인 이미지'], ['url' => 'https://example.com/img2.jpg', 'alt' => '서브 이미지'], ], ], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('assertStringContainsString('alt="메인 이미지"', $html); $this->assertStringContainsString('addLines([ 'shop.tabs.detail_info' => '상세정보', 'shop.tabs.reviews' => '리뷰', ], 'ko'); app()->setLocale('ko'); $components = [ [ 'type' => 'composite', 'name' => 'TabNavigation', 'props' => [ 'tabs' => [ ['id' => 'info', 'label' => '$t:shop.tabs.detail_info'], ['id' => 'reviews', 'label' => '$t:shop.tabs.reviews', 'badge' => '{{reviewCount}}'], ], ], ], ]; $context = ['reviewCount' => 15]; $html = $this->render($components, $context); $this->assertStringContainsString('상세정보', $html); $this->assertStringContainsString('리뷰', $html); $this->assertStringContainsString('15', $html); } /** * dangerouslySetInnerHTML prop이 HTML 콘텐츠를 렌더링합니다. */ public function test_dangerously_set_inner_html_renders_content(): void { $components = [ [ 'name' => 'Div', 'props' => [ 'dangerouslySetInnerHTML' => '{{product.data.description}}', ], ], ]; $context = [ 'product' => [ 'data' => [ 'description' => '

    상품 설명 내용

    ', ], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('

    상품 설명 내용

    ', $html); } /** * Fragment 컴포넌트는 래퍼 태그 없이 children만 렌더링합니다. */ public function test_fragment_renders_children_without_wrapper(): void { $components = [ [ 'name' => 'Fragment', 'children' => [ ['name' => 'Span', 'props' => ['text' => '첫째']], ['name' => 'Span', 'props' => ['text' => '둘째']], ], ], ]; $html = $this->render($components); $this->assertSame('첫째둘째', $html); } // ========================================================================= // 6-B. config/fallback 검증 (신규 8개) // ========================================================================= /** * componentMap 미설정 시 모든 컴포넌트가 div로 렌더링됩니다. * textProps/allowedAttrs도 미설정이므로 텍스트/속성 미출력 (안전한 저하). */ public function test_no_config_all_components_fallback_to_div(): void { $mapper = new ComponentHtmlMapper; $html = $mapper->render([ ['name' => 'Div', 'props' => ['text' => '내용']], ['name' => 'CustomWidget', 'props' => ['text' => '커스텀']], ], [], $this->evaluator); // componentMap 없이도 기본 textProps로 텍스트 추출 $this->assertSame('
    내용
    커스텀
    ', $html); } /** * renderModes 미설정 + component_map에 render 키 → 빈 문자열 반환 */ public function test_no_render_modes_render_key_ignored(): void { $mapper = new ComponentHtmlMapper; $mapper->setComponentMap([ 'Widget' => ['tag' => 'div', 'render' => 'custom_mode'], ]); $html = $mapper->render([ ['name' => 'Widget', 'props' => ['text' => '내용']], ], [], $this->evaluator); $this->assertSame('', $html); } /** * selfClosing 미설정 시 img, hr도 닫힘 태그 생성 */ public function test_no_self_closing_all_tags_have_closing(): void { $mapper = new ComponentHtmlMapper; $mapper->setComponentMap([ 'Img' => ['tag' => 'img'], 'Hr' => ['tag' => 'hr'], ]); $html = $mapper->render([ ['name' => 'Img', 'props' => ['src' => 'test.jpg']], ['name' => 'Hr'], ], [], $this->evaluator); $this->assertStringContainsString('', $html); $this->assertStringContainsString('', $html); } /** * selfClosing에 커스텀 태그 추가 시 해당 태그 셀프 클로징 */ public function test_custom_self_closing_tags(): void { $mapper = new ComponentHtmlMapper; $mapper->setComponentMap(['CustomTag' => ['tag' => 'custom']]); $mapper->setSelfClosing(['custom']); $mapper->setAllowedAttrs(['id']); $html = $mapper->render([ ['name' => 'CustomTag', 'props' => ['id' => 'test']], ], [], $this->evaluator); $this->assertStringContainsString('', $html); $this->assertStringNotContainsString('', $html); } /** * skip:true 컴포넌트는 렌더링되지 않습니다. */ public function test_skip_true_component_renders_nothing(): void { $components = [['name' => 'Modal', 'props' => ['text' => '모달 내용']]]; $html = $this->render($components); $this->assertSame('', $html); } /** * skip:true 컴포넌트는 children이 있어도 렌더링되지 않습니다. */ public function test_skip_true_ignores_children(): void { $components = [ [ 'name' => 'Modal', 'children' => [ ['name' => 'Div', 'props' => ['text' => '자식']], ], ], ]; $html = $this->render($components); $this->assertSame('', $html); } /** * tag:"" (Fragment) → 래퍼 없이 children만 렌더링 */ public function test_empty_tag_renders_children_only(): void { $components = [ [ 'name' => 'Fragment', 'children' => [ ['name' => 'P', 'props' => ['text' => '단락 1']], ['name' => 'P', 'props' => ['text' => '단락 2']], ], ], ]; $html = $this->render($components); $this->assertSame('

    단락 1

    단락 2

    ', $html); } /** * 같은 이름으로 다른 tag 지정 시 config 태그 사용 */ public function test_component_map_overrides_default_behavior(): void { $mapper = new ComponentHtmlMapper; $mapper->setComponentMap([ 'CustomDiv' => ['tag' => 'section'], ]); $html = $mapper->render([ ['name' => 'CustomDiv', 'props' => ['text' => '내용']], ], [], $this->evaluator); $this->assertStringContainsString('
    ', $html); $this->assertStringNotContainsString('
    ', $html); } // ========================================================================= // 6-C. 선언적 렌더 모드 — iterate 타입 (신규 8개) // ========================================================================= /** * iterate 모드: image_gallery → img 태그 목록 생성 */ public function test_iterate_mode_renders_image_gallery(): void { $components = [ [ 'name' => 'ProductImageViewer', 'props' => [ 'images' => '{{product.images}}', ], ], ]; $context = [ 'product' => [ 'images' => [ ['url' => 'https://cdn.test/a.jpg', 'alt' => 'A'], ['url' => 'https://cdn.test/b.jpg', 'alt' => 'B'], ], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('A', $html); $this->assertStringContainsString('B', $html); } /** * iterate 모드: 빈 배열 → 빈 래퍼 태그만 */ public function test_iterate_mode_with_empty_array(): void { $components = [ [ 'name' => 'ProductImageViewer', 'props' => ['images' => '{{product.images}}'], ], ]; $context = ['product' => ['images' => []]]; $html = $this->render($components, $context); $this->assertStringContainsString('
    ', $html); $this->assertStringContainsString('
    ', $html); } /** * iterate 모드: source가 null → 빈 래퍼 태그만 */ public function test_iterate_mode_with_null_source(): void { $components = [ [ 'name' => 'ProductImageViewer', 'props' => ['images' => '{{product.images}}'], ], ]; $context = ['product' => ['images' => null]]; $html = $this->render($components, $context); $this->assertStringContainsString('
    ', $html); } /** * iterate 모드: 1개 아이템 → 정확히 1개 아이템 태그 */ public function test_iterate_mode_with_single_item(): void { $components = [ [ 'name' => 'ProductImageViewer', 'props' => ['images' => '{{images}}'], ], ]; $context = [ 'images' => [ ['url' => 'https://cdn.test/single.jpg', 'alt' => '단일'], ], ]; $html = $this->render($components, $context); $this->assertSame(1, substr_count($html, ' "https://cdn.test/img{$i}.jpg", 'alt' => "이미지{$i}"]; } $components = [ ['name' => 'ProductImageViewer', 'props' => ['images' => '{{images}}']], ]; $html = $this->render($components, ['images' => $images]); $this->assertSame(10, substr_count($html, ' 'TabNavigation', 'props' => [ 'tabs' => [ ['id' => 'info', 'label' => '상세정보'], ['id' => 'reviews', 'label' => '리뷰'], ], ], ], ]; $html = $this->render($components); $this->assertStringContainsString('상세정보', $html); $this->assertStringContainsString('리뷰', $html); } /** * iterate 모드: tab_list + badge → badge span 포함 */ public function test_iterate_mode_tab_list_with_badge(): void { $components = [ [ 'name' => 'TabNavigation', 'props' => [ 'tabs' => [ ['id' => 'reviews', 'label' => '리뷰', 'badge' => '{{reviewCount}}'], ], ], ], ]; $html = $this->render($components, ['reviewCount' => 42]); $this->assertStringContainsString('리뷰', $html); $this->assertStringContainsString('42', $html); } /** * iterate 모드: item_attrs {url|src} → url 없으면 src 사용 */ public function test_iterate_mode_item_attrs_fallback_field(): void { $components = [ ['name' => 'ProductImageViewer', 'props' => ['images' => '{{images}}']], ]; $context = [ 'images' => [ ['src' => 'https://cdn.test/fallback.jpg', 'alt' => '폴백'], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('src="https://cdn.test/fallback.jpg"', $html); } // ========================================================================= // 6-D. 선언적 렌더 모드 — format 타입 (신규 5개) // ========================================================================= /** * format 모드: "{value} / {max}" → "4.5 / 5" */ public function test_format_mode_star_rating(): void { $components = [ [ 'name' => 'StarRating', 'props' => ['value' => '4.5', 'max' => '5'], ], ]; $html = $this->render($components); $this->assertStringContainsString('4.5 / 5', $html); $this->assertStringContainsString(' 'StarRating', 'props' => ['value' => '3.2'], ], ]; $html = $this->render($components); $this->assertStringContainsString('3.2 / 5', $html); } /** * format 모드: props 없음 → 기본값만 표시 */ public function test_format_mode_all_placeholders_missing(): void { $components = [ [ 'name' => 'StarRating', 'props' => [], ], ]; $html = $this->render($components); $this->assertStringContainsString(' / 5', $html); } /** * format 모드: 일부 prop만 있음 → 있는 것만 치환 */ public function test_format_mode_partial_placeholders(): void { $components = [ [ 'name' => 'StarRating', 'props' => ['value' => '4.0'], ], ]; $html = $this->render($components); $this->assertStringContainsString('4.0 / 5', $html); } /** * format 모드: format 키 없음 → 빈 문자열 */ public function test_format_mode_no_format_string(): void { $mapper = new ComponentHtmlMapper; $mapper->setComponentMap([ 'NoFormat' => ['tag' => 'span', 'render' => 'text_format'], ]); $mapper->setRenderModes(['text_format' => ['type' => 'format']]); $html = $mapper->render([ ['name' => 'NoFormat', 'props' => ['value' => '4.5']], ], [], $this->evaluator); $this->assertStringContainsString('', $html); } // ========================================================================= // 6-E. 선언적 렌더 모드 — raw 타입 (신규 4개) // ========================================================================= /** * raw 모드: HTML 콘텐츠 그대로 출력 (이스케이프 없음) */ public function test_raw_mode_html_content(): void { $components = [ [ 'name' => 'HtmlContent', 'props' => ['content' => '{{page.content}}'], ], ]; $context = ['page' => ['content' => '

    본문 내용

    ']]; $html = $this->render($components, $context); $this->assertStringContainsString('

    본문 내용

    ', $html); } /** * raw 모드: {{binding}} → context에서 해석 후 출력 */ public function test_raw_mode_with_binding(): void { $components = [ [ 'name' => 'HtmlContent', 'props' => ['content' => '{{product.description}}'], ], ]; $context = ['product' => ['description' => '

    상세 설명

    내용

    ']]; $html = $this->render($components, $context); $this->assertStringContainsString('

    상세 설명

    내용

    ', $html); } /** * raw 모드: 콘텐츠 없음 → 빈 래퍼 태그 */ public function test_raw_mode_empty_content(): void { $components = [ [ 'name' => 'HtmlContent', 'props' => ['content' => '{{page.content}}'], ], ]; $context = ['page' => ['content' => '']]; $html = $this->render($components, $context); $this->assertStringContainsString('
    ', $html); $this->assertStringContainsString('
    ', $html); } /** * raw 모드: 여러 줄 HTML → 전부 보존 */ public function test_raw_mode_multiline_html(): void { $multilineHtml = "

    제목

    \n

    첫 번째 단락

    \n

    두 번째 단락

    "; $components = [ [ 'name' => 'HtmlContent', 'props' => ['content' => '{{content}}'], ], ]; $html = $this->render($components, ['content' => $multilineHtml]); $this->assertStringContainsString('

    제목

    ', $html); $this->assertStringContainsString('

    첫 번째 단락

    ', $html); $this->assertStringContainsString('

    두 번째 단락

    ', $html); } // ========================================================================= // 6-F. 실제 페이지 구조 렌더링 검증 (신규 12개) // ========================================================================= /** * 상품 상세 페이지: Header+Main(이미지갤러리+상품정보)+Footer */ public function test_product_detail_page_structure(): void { $components = [ [ 'name' => 'Header', 'children' => [['name' => 'Nav', 'children' => [['name' => 'A', 'props' => ['href' => '/', 'text' => '홈']]]]], ], [ 'name' => 'Main', 'children' => [ ['name' => 'ProductImageViewer', 'props' => ['images' => '{{product.images}}']], ['name' => 'H1', 'props' => ['text' => '{{product.name}}']], ['name' => 'Span', 'props' => ['text' => '{{product.price}}']], ], ], ['name' => 'Footer', 'children' => [['name' => 'P', 'props' => ['text' => '© 2026']]]], ]; $context = [ 'product' => [ 'name' => '테스트 상품', 'price' => '29,000원', 'images' => [['url' => 'https://cdn.test/p1.jpg', 'alt' => '상품 이미지']], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('
    ', $html); $this->assertStringContainsString('
    ', $html); $this->assertStringContainsString('