일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
930 lines
33 KiB
PHP
930 lines
33 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use App\Seo\ComponentHtmlMapper;
|
|
use App\Seo\ExpressionEvaluator;
|
|
use App\Seo\SeoRenderer;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 실제 상품 상세 페이지 렌더링 시나리오를 검증합니다.
|
|
*
|
|
* 사용자 피드백 기반: 실제 SEO 페이지에서 발생한 문제들을 재현하여 검증합니다.
|
|
* - Icon이 <div> 대신 <i>로 렌더링되는지
|
|
* - Img가 <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 컴포넌트가 <i> 태그로 렌더링됩니다 (재현 시 발생한 버그: <div>로 렌더링됨).
|
|
*/
|
|
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('<i', $html);
|
|
$this->assertStringNotContainsString('<div', $html);
|
|
}
|
|
|
|
/**
|
|
* Img 컴포넌트가 <img> 셀프 클로징 태그로 렌더링됩니다.
|
|
*/
|
|
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('<img', $html);
|
|
$this->assertStringNotContainsString('</img>', $html);
|
|
$this->assertStringContainsString('src=', $html);
|
|
$this->assertStringContainsString('alt="상품 이미지"', $html);
|
|
}
|
|
|
|
/**
|
|
* Button 컴포넌트가 <button> 태그로 렌더링됩니다.
|
|
*/
|
|
public function test_button_renders_as_button_tag(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Button',
|
|
'props' => ['className' => 'px-4 py-2', 'text' => '구매하기'],
|
|
],
|
|
];
|
|
|
|
$html = $this->render($components);
|
|
|
|
$this->assertStringContainsString('<button', $html);
|
|
$this->assertStringContainsString('</button>', $html);
|
|
$this->assertStringContainsString('구매하기', $html);
|
|
}
|
|
|
|
/**
|
|
* Nav 컴포넌트가 <nav> 태그로 렌더링됩니다.
|
|
*/
|
|
public function test_nav_renders_as_nav_tag(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Nav',
|
|
'props' => ['className' => 'flex items-center'],
|
|
'children' => [
|
|
['name' => 'A', 'props' => ['href' => '/', 'text' => 'Home']],
|
|
],
|
|
],
|
|
];
|
|
|
|
$html = $this->render($components);
|
|
|
|
$this->assertStringContainsString('<nav', $html);
|
|
$this->assertStringContainsString('</nav>', $html);
|
|
$this->assertStringContainsString('<a', $html);
|
|
$this->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 컴포넌트가 <input> 셀프 클로징 태그로 렌더링됩니다.
|
|
*/
|
|
public function test_input_renders_as_input_self_closing(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Input',
|
|
'props' => ['type' => 'text', 'placeholder' => '검색어 입력'],
|
|
],
|
|
];
|
|
|
|
$html = $this->render($components);
|
|
|
|
$this->assertStringContainsString('<input', $html);
|
|
$this->assertStringNotContainsString('</input>', $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('<h1', $html);
|
|
$this->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('<img', $html);
|
|
// 2개 이미지의 src 포함
|
|
$this->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('<img', $html);
|
|
$this->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('<img', $html);
|
|
$this->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' => '<p>고급 무선 마우스 세트입니다.</p><ul><li>블루투스 5.0</li></ul>',
|
|
'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('<i', $html, 'Icon이 <i> 태그여야 합니다');
|
|
$this->assertStringContainsString('<h1', $html, 'H1이 <h1> 태그여야 합니다');
|
|
$this->assertStringContainsString('<img', $html, '이미지가 <img> 태그여야 합니다');
|
|
$this->assertStringContainsString('<button', $html, 'Button이 <button> 태그여야 합니다');
|
|
$this->assertStringContainsString('<span', $html, 'Span이 <span> 태그여야 합니다');
|
|
|
|
// 상품 데이터 검증
|
|
$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('<ul>', $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('<header', $html);
|
|
$this->assertStringContainsString('<nav', $html);
|
|
$this->assertStringContainsString('<ul', $html);
|
|
$this->assertStringContainsString('<li', $html);
|
|
$this->assertStringContainsString('<a', $html);
|
|
$this->assertStringContainsString('<i', $html, 'Icon이 <i> 태그여야 합니다');
|
|
$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 컴포넌트는 <select> 태그로 렌더링됩니다 (skip 아님).
|
|
*/
|
|
public function test_select_renders_as_select_tag(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Select',
|
|
'props' => ['className' => 'w-full'],
|
|
],
|
|
];
|
|
|
|
$html = $this->render($components);
|
|
|
|
$this->assertStringContainsString('<select', $html);
|
|
}
|
|
}
|