대신 로 렌더링되는지 * - Img가 셀프 클로징 태그로 렌더링되는지 * - 상품 데이터 바인딩 (이름, 가격, 이미지) 적용 여부 * - $t: 번역 키 해석 여부 * - 표현식 평가 여부 * - 템플릿 CSS 경로 포함 여부 */ class RealWorldRenderingTest extends TestCase { private ComponentHtmlMapper $mapper; private ExpressionEvaluator $evaluator; /** * sirsoft-basic 템플릿의 실제 seo-config.json을 로드하여 테스트합니다. */ protected function setUp(): void { parent::setUp(); $this->mapper = new ComponentHtmlMapper; $this->evaluator = new ExpressionEvaluator; // 실제 seo-config.json 로드 $configPath = base_path('templates/_bundled/sirsoft-basic/seo-config.json'); $this->assertTrue(file_exists($configPath), 'seo-config.json must exist'); $config = json_decode(file_get_contents($configPath), true); $this->assertIsArray($config); if (! empty($config['component_map'])) { $this->mapper->setComponentMap($config['component_map']); } if (! empty($config['render_modes'])) { $this->mapper->setRenderModes($config['render_modes']); } if (! empty($config['self_closing'])) { $this->mapper->setSelfClosing($config['self_closing']); } if (! empty($config['text_props'])) { $this->mapper->setTextProps($config['text_props']); } if (! empty($config['attr_map'])) { $this->mapper->setAttrMap($config['attr_map']); } if (! empty($config['allowed_attrs'])) { $this->mapper->setAllowedAttrs($config['allowed_attrs']); } } /** * 테스트 헬퍼: 컴포넌트를 렌더링합니다. * * @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); } // ======================================================== // 0. $all_props 해석 회귀 — 단일/다중 바인딩 판정 변경의 영향 // ======================================================== /** * $all_props 모드에서 다중 바인딩 prop 이 보간된 문자열로 해석됩니다. * * 회귀: 단일 바인딩 판정이 `{{a}} - {{b}}` 를 단일로 오인하면 내부가 * `a}} - {{b` 로 해석되어 값이 통째로 비워졌습니다. */ public function test_all_props_mode_interpolates_multi_binding_prop(): void { $html = $this->render([ [ 'type' => 'layout', 'name' => 'Header', 'props' => [ 'siteName' => '{{site.name}} - {{site.tagline}}', ], ], ], ['site' => ['name' => '그누보드7', 'tagline' => '오픈소스 CMS']]); $this->assertStringContainsString('그누보드7 - 오픈소스 CMS', $html); } /** * $all_props 모드에서 단일 바인딩 prop 은 원본 타입(배열)을 유지합니다. */ public function test_all_props_mode_preserves_single_binding_array(): void { $html = $this->render([ [ 'type' => 'layout', 'name' => 'Header', 'props' => [ 'siteName' => '{{site.name}}', 'menuItems' => '{{menu}}', ], ], ], [ 'site' => ['name' => '그누보드7'], 'menu' => [ ['label' => '공지', 'url' => '/board/notice'], ], ]); $this->assertStringContainsString('그누보드7', $html); } // ======================================================== // 1. 태그 매핑 검증 — Icon, Img 등이 올바른 HTML 태그를 사용하는지 // ======================================================== /** * Icon 컴포넌트가 태그로 렌더링됩니다 (재현 시 발생한 버그:
로 렌더링됨). */ public function test_icon_renders_as_i_tag(): void { $components = [ [ 'name' => 'Icon', 'props' => ['name' => 'shopping-cart', 'className' => 'w-5 h-5'], ], ]; $html = $this->render($components); $this->assertStringContainsString('assertStringNotContainsString(' 셀프 클로징 태그로 렌더링됩니다. */ public function test_img_renders_as_img_tag_self_closing(): void { $components = [ [ 'name' => 'Img', 'props' => [ 'src' => '/images/product.jpg', 'alt' => '상품 이미지', 'className' => 'w-full', ], ], ]; $html = $this->render($components); $this->assertStringContainsString('assertStringNotContainsString('', $html); $this->assertStringContainsString('src=', $html); $this->assertStringContainsString('alt="상품 이미지"', $html); } /** * Button 컴포넌트가 ', $html); $this->assertStringContainsString('구매하기', $html); } /** * Nav 컴포넌트가 ', $html); $this->assertStringContainsString('assertStringContainsString('Home', $html); } /** * H1~H6 컴포넌트가 올바른 헤딩 태그로 렌더링됩니다. */ public function test_headings_render_correct_tags(): void { $headings = ['H1' => 'h1', 'H2' => 'h2', 'H3' => 'h3', 'H4' => 'h4', 'H5' => 'h5', 'H6' => 'h6']; foreach ($headings as $component => $tag) { $components = [['name' => $component, 'props' => ['text' => 'Heading']]]; $html = $this->render($components); $this->assertStringContainsString("<{$tag}", $html, "{$component}가 <{$tag}>로 렌더링되어야 합니다"); } } /** * Input 컴포넌트가 셀프 클로징 태그로 렌더링됩니다. */ public function test_input_renders_as_input_self_closing(): void { $components = [ [ 'name' => 'Input', 'props' => ['type' => 'text', 'placeholder' => '검색어 입력'], ], ]; $html = $this->render($components); $this->assertStringContainsString('assertStringNotContainsString('', $html); } // ======================================================== // 2. 데이터 바인딩 검증 — 상품 데이터가 렌더링에 반영되는지 // ======================================================== /** * 상품명이 바인딩으로 올바르게 표시됩니다. */ public function test_product_name_binding(): void { $components = [ [ 'name' => 'H1', 'props' => [ 'className' => 'text-xl font-bold', 'text' => '{{product.data.name_localized}}', ], ], ]; $context = [ 'product' => [ 'data' => [ 'name_localized' => '무선 마우스 세트 #99', ], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('assertStringContainsString('무선 마우스 세트 #99', $html); } /** * 상품 가격이 바인딩으로 올바르게 표시됩니다. */ public function test_product_price_binding(): void { $components = [ [ 'name' => 'Span', 'props' => [ 'className' => 'text-2xl font-bold', 'text' => '{{product.data.selling_price_formatted}}', ], ], ]; $context = [ 'product' => [ 'data' => [ 'selling_price_formatted' => '129,000원', ], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('129,000원', $html); } /** * 상품 카테고리명이 바인딩으로 표시됩니다. */ public function test_product_category_binding(): void { $components = [ [ 'name' => 'Span', 'props' => [ 'className' => 'text-sm text-gray-500', 'text' => '{{product.data.category_name}}', ], ], ]; $context = [ 'product' => ['data' => ['category_name' => '소파']], ]; $html = $this->render($components, $context); $this->assertStringContainsString('소파', $html); } /** * 브랜드명이 바인딩으로 표시됩니다. */ public function test_brand_name_binding(): void { $components = [ [ 'name' => 'Div', 'children' => [ [ 'name' => 'Span', 'props' => ['className' => 'text-sm text-gray-500', 'text' => '브랜드'], ], [ 'name' => 'Span', 'props' => ['text' => '{{product.data.brand_name}}'], ], ], ], ]; $context = [ 'product' => ['data' => ['brand_name' => 'LG Electronics']], ]; $html = $this->render($components, $context); $this->assertStringContainsString('LG Electronics', $html); $this->assertStringContainsString('브랜드', $html); } // ======================================================== // 3. 이미지 렌더링 검증 — ProductImageViewer + 이미지 바인딩 // ======================================================== /** * ProductImageViewer가 이미지 갤러리를 올바르게 렌더링합니다. */ public function test_product_image_viewer_renders_images(): void { $components = [ [ 'name' => 'ProductImageViewer', 'props' => [ 'images' => '{{product.data.images}}', ], ], ]; $context = [ 'product' => [ 'data' => [ 'images' => [ ['url' => '/storage/products/mouse-1.jpg', 'alt' => '마우스 정면'], ['url' => '/storage/products/mouse-2.jpg', 'alt' => '마우스 측면'], ], ], ], ]; $html = $this->render($components, $context); // img 태그가 생성되어야 함 $this->assertStringContainsString('assertStringContainsString('mouse-1.jpg', $html); $this->assertStringContainsString('mouse-2.jpg', $html); // alt 속성 포함 $this->assertStringContainsString('마우스 정면', $html); } /** * ProductImageViewer에 이미지가 배열로 직접 전달될 때도 렌더링됩니다. */ public function test_product_image_viewer_with_direct_array(): void { $components = [ [ 'name' => 'ProductImageViewer', 'props' => [ 'images' => [ ['src' => 'https://example.com/img1.jpg', 'alt' => '이미지1'], ['src' => 'https://example.com/img2.jpg', 'alt' => '이미지2'], ], ], ], ]; $html = $this->render($components); $this->assertStringContainsString('assertStringContainsString('https://example.com/img1.jpg', $html); } /** * 일반 Img 컴포넌트로 상품 이미지를 렌더링할 수 있습니다. */ public function test_img_with_bound_src(): void { $components = [ [ 'name' => 'Img', 'props' => [ 'src' => '{{product.data.thumbnail}}', 'alt' => '{{product.data.name_localized}}', 'className' => 'w-full rounded-lg', ], ], ]; $context = [ 'product' => [ 'data' => [ 'thumbnail' => '/storage/products/thumb.jpg', 'name_localized' => '무선 마우스', ], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('assertStringContainsString('thumb.jpg', $html); $this->assertStringContainsString('무선 마우스', $html); } // ======================================================== // 4. 번역 ($t:) 검증 // ======================================================== /** * $t: 번역 키가 해석됩니다 (재현 시 발생한 버그: $t:shop.product.reviews_count 그대로 노출). */ public function test_translation_key_resolved(): void { $this->evaluator->setTranslations([ 'shop' => [ 'product' => [ 'reviews_count' => '개의 리뷰', ], ], ]); $components = [ [ 'name' => 'Span', 'props' => ['text' => '$t:shop.product.reviews_count'], ], ]; $html = $this->render($components); $this->assertStringContainsString('개의 리뷰', $html); $this->assertStringNotContainsString('$t:shop', $html); } /** * $t: 번역 키와 데이터 바인딩이 혼합된 텍스트가 올바르게 렌더링됩니다. */ public function test_translation_mixed_with_binding(): void { $this->evaluator->setTranslations([ 'shop' => [ 'product' => [ 'write_question' => '질문 작성', ], 'back' => '뒤로가기', 'tabs' => [ 'detail_info' => '상세정보', 'reviews' => '리뷰', 'qna' => 'Q&A', ], ], ]); $components = [ [ 'name' => 'Div', 'children' => [ ['name' => 'Span', 'props' => ['text' => '$t:shop.back']], ['name' => 'Button', 'props' => ['text' => '$t:shop.product.write_question']], ], ], ]; $html = $this->render($components); $this->assertStringContainsString('뒤로가기', $html); $this->assertStringContainsString('질문 작성', $html); } // ======================================================== // 5. 실제 상품 상세 페이지 구조 통합 테스트 // ======================================================== /** * 상품 상세 페이지의 핵심 구조가 올바르게 렌더링됩니다. * Header + 이미지 + 상품 정보 + 설명 + 리뷰 */ public function test_product_detail_page_core_structure(): void { $this->evaluator->setTranslations([ 'shop' => [ 'back' => '뒤로가기', 'product' => [ 'brand' => '브랜드', 'shipping_info' => '배송 정보', 'reviews_count' => '개의 리뷰', 'write_question' => '질문 작성', ], 'write_review' => '리뷰 작성', ], ]); $components = [ // 전체 래퍼 [ 'name' => 'Div', 'props' => ['className' => 'min-h-screen flex flex-col'], 'children' => [ // 뒤로가기 버튼 [ 'name' => 'Div', 'props' => ['className' => 'flex items-center'], 'children' => [ ['name' => 'Icon', 'props' => ['name' => 'chevron-left']], ['name' => 'Span', 'props' => ['text' => '$t:shop.back']], ], ], // 이미지 갤러리 [ 'name' => 'ProductImageViewer', 'props' => ['images' => '{{product.data.images}}'], ], // 상품 정보 [ 'name' => 'Div', 'props' => ['className' => 'space-y-4'], 'children' => [ // 카테고리 [ 'name' => 'Span', 'props' => [ 'className' => 'text-sm text-gray-500', 'text' => '{{product.data.category_name}}', ], ], // 상품명 [ 'name' => 'H1', 'props' => [ 'className' => 'text-xl font-bold', 'text' => '{{product.data.name_localized}}', ], ], // 가격 [ 'name' => 'Span', 'props' => [ 'className' => 'text-2xl font-bold', 'text' => '{{product.data.selling_price_formatted}}', ], ], // 브랜드 [ 'name' => 'Div', 'children' => [ ['name' => 'Span', 'props' => ['text' => '$t:shop.product.brand']], ['name' => 'Span', 'props' => ['text' => '{{product.data.brand_name}}']], ], ], ], ], // 상품 설명 (HtmlContent) [ 'name' => 'HtmlContent', 'props' => [ 'content' => '{{product.data.description_localized}}', ], ], // 리뷰 섹션 [ 'name' => 'Div', 'children' => [ [ 'name' => 'Span', 'props' => ['text' => '{{product.data.rating_avg}}'], ], [ 'name' => 'Span', 'props' => ['text' => ' / 5'], ], [ 'name' => 'Button', 'props' => ['text' => '$t:shop.write_review'], ], ], ], ], ], ]; $context = [ 'product' => [ 'data' => [ 'name_localized' => '무선 마우스 세트 #99', 'category_name' => '소파', 'selling_price_formatted' => '129,000원', 'brand_name' => 'LG Electronics', 'description_localized' => '

고급 무선 마우스 세트입니다.

  • 블루투스 5.0
', 'rating_avg' => '4.5', 'images' => [ ['url' => '/storage/products/mouse-1.jpg', 'alt' => '마우스 정면'], ['url' => '/storage/products/mouse-2.jpg', 'alt' => '마우스 측면'], ], ], ], ]; $html = $this->render($components, $context); // 태그 검증 $this->assertStringContainsString(' 태그여야 합니다'); $this->assertStringContainsString(' 태그여야 합니다'); $this->assertStringContainsString(' 태그여야 합니다'); $this->assertStringContainsString(' 태그여야 합니다'); $this->assertStringContainsString(' 태그여야 합니다'); // 상품 데이터 검증 $this->assertStringContainsString('무선 마우스 세트 #99', $html, '상품명이 표시되어야 합니다'); $this->assertStringContainsString('129,000원', $html, '가격이 표시되어야 합니다'); $this->assertStringContainsString('소파', $html, '카테고리가 표시되어야 합니다'); $this->assertStringContainsString('LG Electronics', $html, '브랜드가 표시되어야 합니다'); $this->assertStringContainsString('4.5', $html, '평점이 표시되어야 합니다'); // 이미지 검증 $this->assertStringContainsString('mouse-1.jpg', $html, '상품 이미지가 표시되어야 합니다'); $this->assertStringContainsString('마우스 정면', $html, '이미지 alt가 표시되어야 합니다'); // 상품 설명 (HtmlContent → raw 렌더링) $this->assertStringContainsString('고급 무선 마우스 세트입니다', $html, '상품 설명이 표시되어야 합니다'); $this->assertStringContainsString('
    ', $html, 'HTML 설명이 이스케이프 없이 표시되어야 합니다'); // 번역 검증 $this->assertStringContainsString('뒤로가기', $html, '$t: 번역이 적용되어야 합니다'); $this->assertStringContainsString('브랜드', $html, '$t: 번역이 적용되어야 합니다'); $this->assertStringContainsString('리뷰 작성', $html, '$t: 번역이 적용되어야 합니다'); $this->assertStringNotContainsString('$t:shop', $html, '$t: 원본 키가 노출되면 안됩니다'); } // ======================================================== // 6. 헤더/네비게이션 구조 검증 // ======================================================== /** * 헤더 네비게이션이 올바른 태그 구조로 렌더링됩니다. */ public function test_header_navigation_structure(): void { $components = [ [ 'name' => 'Header', 'props' => ['className' => 'sticky top-0'], 'children' => [ [ 'name' => 'Nav', 'props' => ['className' => 'flex items-center'], 'children' => [ [ 'name' => 'Ul', 'children' => [ [ 'name' => 'Li', 'children' => [ ['name' => 'A', 'props' => ['href' => '/', 'text' => 'Home']], ], ], [ 'name' => 'Li', 'children' => [ ['name' => 'A', 'props' => ['href' => '/shop', 'text' => 'Shop']], ], ], ], ], ], ], // 장바구니 아이콘 [ 'name' => 'Icon', 'props' => ['name' => 'shopping-cart', 'className' => 'w-5 h-5'], ], ], ], ]; $html = $this->render($components); $this->assertStringContainsString('assertStringContainsString('assertStringContainsString('assertStringContainsString('assertStringContainsString('assertStringContainsString(' 태그여야 합니다'); $this->assertStringContainsString('Home', $html); $this->assertStringContainsString('Shop', $html); } // ======================================================== // 7. StarRating format 모드 검증 // ======================================================== /** * StarRating이 format 모드로 올바르게 렌더링됩니다. */ public function test_star_rating_format_mode(): void { $components = [ [ 'name' => 'StarRating', 'props' => ['value' => '4.5'], ], ]; $html = $this->render($components); $this->assertStringContainsString('4.5', $html); $this->assertStringContainsString('/ 5', $html); } /** * StarRating에 바인딩된 값이 올바르게 표시됩니다. */ public function test_star_rating_with_binding(): void { $components = [ [ 'name' => 'StarRating', 'props' => ['value' => '{{product.data.rating_avg}}'], ], ]; $context = [ 'product' => ['data' => ['rating_avg' => '3.8']], ]; $html = $this->render($components, $context); $this->assertStringContainsString('3.8', $html); $this->assertStringContainsString('/ 5', $html); } // ======================================================== // 8. CSS 경로 검증 — 템플릿 CSS가 포함되어야 함 // ======================================================== /** * SeoRenderer가 template.json의 CSS 에셋을 자동으로 서빙 URL로 변환합니다. */ public function test_seo_renderer_auto_resolves_template_css(): void { // seo-config.json에 Font Awesome CDN이 포함되어 있는지 확인 $configPath = base_path('templates/_bundled/sirsoft-basic/seo-config.json'); $config = json_decode(file_get_contents($configPath), true); $stylesheets = $config['stylesheets'] ?? []; $hasFontAwesome = false; foreach ($stylesheets as $url) { if (str_contains($url, 'font-awesome')) { $hasFontAwesome = true; } } $this->assertTrue($hasFontAwesome, 'Font Awesome CSS가 seo-config.json에 포함되어야 합니다'); // SeoRenderer.getTemplateCssUrls()가 템플릿 CSS를 자동 해석하는지 확인 $renderer = app(SeoRenderer::class); $method = new \ReflectionMethod($renderer, 'getTemplateCssUrls'); $method->setAccessible(true); // template.json에 CSS 에셋이 정의되어 있어야 함 $templateJsonPath = base_path('templates/_bundled/sirsoft-basic/template.json'); $templateJson = json_decode(file_get_contents($templateJsonPath), true); $cssPaths = $templateJson['assets']['css'] ?? []; $this->assertNotEmpty($cssPaths, 'template.json에 CSS 에셋이 정의되어야 합니다'); // dist/css/components.css → /api/templates/assets/{id}/css/components.css 변환 검증 foreach ($cssPaths as $cssPath) { $servePath = preg_replace('#^dist/#', '', $cssPath); $expectedUrl = '/api/templates/assets/sirsoft-basic/'.$servePath; $this->assertStringContainsString('components.css', $expectedUrl, '템플릿 CSS URL이 올바르게 변환되어야 합니다'); } } // ======================================================== // 9. 복잡한 표현식 + 조건부 렌더링 검증 // ======================================================== /** * 조건부 렌더링 (if)이 올바르게 동작합니다. */ public function test_conditional_rendering_with_data(): void { $components = [ [ 'name' => 'Div', 'if' => '{{product.data.has_options}}', 'props' => ['text' => '옵션 있음'], ], [ 'name' => 'Div', 'if' => '{{!product.data.has_options}}', 'props' => ['text' => '옵션 없음'], ], ]; $context = ['product' => ['data' => ['has_options' => true]]]; $html = $this->render($components, $context); $this->assertStringContainsString('옵션 있음', $html); $this->assertStringNotContainsString('옵션 없음', $html); } /** * 배송 정보가 조건부로 표시됩니다. */ public function test_shipping_info_conditional(): void { $components = [ [ 'name' => 'Div', 'if' => '{{product.data.shipping_policy}}', 'children' => [ [ 'name' => 'Span', 'props' => ['text' => '{{product.data.shipping_policy.name}}'], ], [ 'name' => 'Span', 'props' => ['text' => '{{product.data.shipping_policy.fee_summary}}'], ], ], ], ]; $context = [ 'product' => [ 'data' => [ 'shipping_policy' => [ 'name' => '국내 무료배송', 'fee_summary' => 'KR: Free Shipping', ], ], ], ]; $html = $this->render($components, $context); $this->assertStringContainsString('국내 무료배송', $html); $this->assertStringContainsString('KR: Free Shipping', $html); } // ======================================================== // 10. skip된 컴포넌트 검증 (인터랙티브 요소) // ======================================================== /** * skip:true 컴포넌트는 렌더링되지 않습니다 (인터랙티브 UI 제외). */ public function test_skipped_components_not_rendered(): void { $components = [ [ 'name' => 'QuantitySelector', 'props' => ['value' => '1'], ], ]; $html = $this->render($components); // QuantitySelector는 seo-config.json에서 skip:true $this->assertEmpty(trim($html), 'QuantitySelector(skip:true)는 렌더링되지 않아야 합니다'); } /** * Select 컴포넌트는