Files
Gnuboard7/tests/Unit/Seo/ComponentHtmlMapperTest.php
HeuJung 057e002ad1 feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인
StorageInterface::url 의 public 하드코딩을 완화하고 core.storage.filter_url 훅을
신설해, 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)의 직접 URL 서빙을 옵트인으로
열었다. 미설정 시 기존 스트리밍 100% 보존.

디스크 결정은 확장 개별 설정 > 코어 전역 > 미설정 순이며, 서빙·삭제·이동은 행에 기록된
disk 를 따라 전환 전후 자산이 혼재해도 각자 실제 저장 위치를 향한다. 고아 disk(공급
플러그인 비활성화)는 스트리밍으로 폴백해 500 이 되지 않는다.

공개 이슈 (@lyg-kaban 건의) 대응.
2026-08-14 08:05:54 +09:00

5018 lines
167 KiB
PHP

<?php
namespace Tests\Unit\Seo;
use App\Seo\ComponentHtmlMapper;
use App\Seo\ExpressionEvaluator;
use Tests\TestCase;
class ComponentHtmlMapperTest extends TestCase
{
private ComponentHtmlMapper $mapper;
private ExpressionEvaluator $evaluator;
/**
* 테스트 초기화 - ComponentHtmlMapper 및 ExpressionEvaluator 인스턴스를 준비합니다.
*/
protected function setUp(): void
{
parent::setUp();
$this->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('<div>', $html);
$this->assertStringContainsString('<span>내용</span>', $html);
$this->assertStringContainsString('</div>', $html);
}
/**
* H1 + text prop을 올바르게 렌더링합니다.
*/
public function test_h1_with_text_prop(): void
{
$components = [
[
'name' => 'H1',
'props' => ['text' => '제목'],
],
];
$html = $this->render($components);
$this->assertSame('<h1>제목</h1>', $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('<img', $html);
$this->assertStringContainsString('src="https://example.com/image.jpg"', $html);
$this->assertStringContainsString('alt="설명"', $html);
$this->assertStringNotContainsString('</img>', $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('<a href="https://example.com">', $html);
$this->assertStringContainsString('<span>텍스트</span>', $html);
$this->assertStringContainsString('</a>', $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('<div class="container">', $html);
$this->assertStringContainsString('<span>내용</span>', $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('<div><section><article><p>깊은 중첩</p></article></section></div>', $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('<div>보이는 요소</div>', $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('<li>사과</li>', $html);
$this->assertStringContainsString('<li>바나나</li>', $html);
$this->assertStringContainsString('<li>체리</li>', $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('<div>폴백 내용</div>', $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('<div></div>', $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('<span>상품 상세</span>', $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('<button', $html);
}
/**
* 셀프 클로징 태그(hr, br)가 올바르게 렌더링됩니다.
*/
public function test_self_closing_tags(): void
{
$components = [
['name' => 'Hr'],
['name' => 'Br'],
];
$html = $this->render($components);
$this->assertStringContainsString('<hr>', $html);
$this->assertStringContainsString('<br>', $html);
$this->assertStringNotContainsString('</hr>', $html);
$this->assertStringNotContainsString('</br>', $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('<span>뒤로가기</span>', $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('<div class="mb-4">', $html);
$this->assertStringContainsString('<label>이름</label>', $html);
$this->assertStringContainsString('<input', $html);
}
/**
* ProductImageViewer가 이미지 태그를 렌더링합니다.
*/
public function test_product_image_viewer_renders_images(): void
{
$components = [
[
'type' => '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('<img src="https://example.com/img1.jpg"', $html);
$this->assertStringContainsString('alt="메인 이미지"', $html);
$this->assertStringContainsString('<img src="https://example.com/img2.jpg"', $html);
}
/**
* TabNavigation이 탭 버튼을 렌더링합니다.
*/
public function test_tab_navigation_renders_tabs(): void
{
app('translator')->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' => '<p>상품 설명 <strong>내용</strong></p>',
],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<p>상품 설명 <strong>내용</strong></p>', $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('<span>첫째</span><span>둘째</span>', $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('<div>내용</div><div>커스텀</div>', $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('</img>', $html);
$this->assertStringContainsString('</hr>', $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('<custom id="test">', $html);
$this->assertStringNotContainsString('</custom>', $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('<p>단락 1</p><p>단락 2</p>', $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('<section>', $html);
$this->assertStringNotContainsString('<div>', $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('<img src="https://cdn.test/a.jpg" alt="A">', $html);
$this->assertStringContainsString('<img src="https://cdn.test/b.jpg" alt="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('<div>', $html);
$this->assertStringContainsString('</div>', $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('<div>', $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, '<img'));
}
/**
* iterate 모드: 10개 아이템 → 10개 아이템 태그
*/
public function test_iterate_mode_with_many_items(): void
{
$images = [];
for ($i = 0; $i < 10; $i++) {
$images[] = ['url' => "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, '<img'));
}
/**
* iterate 모드: tab_list → span 태그에 label 텍스트
*/
public function test_iterate_mode_tab_list_with_labels(): void
{
$components = [
[
'name' => 'TabNavigation',
'props' => [
'tabs' => [
['id' => 'info', 'label' => '상세정보'],
['id' => 'reviews', 'label' => '리뷰'],
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<span>상세정보</span>', $html);
$this->assertStringContainsString('<span>리뷰</span>', $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('<span', $html);
}
/**
* format 모드: max 미전달 → defaults의 "5" 사용
*/
public function test_format_mode_with_defaults(): void
{
$components = [
[
'name' => '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('<span>', $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' => '<p>본문 <strong>내용</strong></p>']];
$html = $this->render($components, $context);
$this->assertStringContainsString('<p>본문 <strong>내용</strong></p>', $html);
}
/**
* raw 모드: {{binding}} → context에서 해석 후 출력
*/
public function test_raw_mode_with_binding(): void
{
$components = [
[
'name' => 'HtmlContent',
'props' => ['content' => '{{product.description}}'],
],
];
$context = ['product' => ['description' => '<h2>상세 설명</h2><p>내용</p>']];
$html = $this->render($components, $context);
$this->assertStringContainsString('<h2>상세 설명</h2><p>내용</p>', $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('<div>', $html);
$this->assertStringContainsString('</div>', $html);
}
/**
* raw 모드: 여러 줄 HTML → 전부 보존
*/
public function test_raw_mode_multiline_html(): void
{
$multilineHtml = "<h1>제목</h1>\n<p>첫 번째 단락</p>\n<p>두 번째 단락</p>";
$components = [
[
'name' => 'HtmlContent',
'props' => ['content' => '{{content}}'],
],
];
$html = $this->render($components, ['content' => $multilineHtml]);
$this->assertStringContainsString('<h1>제목</h1>', $html);
$this->assertStringContainsString('<p>첫 번째 단락</p>', $html);
$this->assertStringContainsString('<p>두 번째 단락</p>', $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('<header>', $html);
$this->assertStringContainsString('<main>', $html);
$this->assertStringContainsString('<footer>', $html);
$this->assertStringContainsString('<h1>테스트 상품</h1>', $html);
$this->assertStringContainsString('29,000원', $html);
$this->assertStringContainsString('<img src="https://cdn.test/p1.jpg"', $html);
}
/**
* 상품 목록 페이지: 카테고리명+ProductCard iteration
*/
public function test_product_list_page_with_iteration(): void
{
$components = [
['name' => 'H1', 'props' => ['text' => '{{category.name}}']],
[
'name' => 'ProductCard',
'iteration' => ['data' => '{{products}}', 'item_var' => 'product'],
'children' => [
['name' => 'H3', 'props' => ['text' => '{{product.name}}']],
['name' => 'Span', 'props' => ['text' => '{{product.price}}']],
],
],
];
$context = [
'category' => ['name' => '전자제품'],
'products' => [
['name' => '노트북', 'price' => '1,500,000원'],
['name' => '태블릿', 'price' => '800,000원'],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<h1>전자제품</h1>', $html);
$this->assertSame(2, substr_count($html, '<article'));
$this->assertStringContainsString('노트북', $html);
$this->assertStringContainsString('태블릿', $html);
}
/**
* 카테고리 페이지: Breadcrumb+H1+Grid+Pagination
*/
public function test_category_page_with_nested_components(): void
{
$components = [
['name' => 'Breadcrumb', 'children' => [['name' => 'A', 'props' => ['href' => '/', 'text' => '홈']]]],
['name' => 'H1', 'props' => ['text' => '카테고리']],
[
'name' => 'Grid',
'children' => [
['name' => 'Article', 'props' => ['text' => '상품 A']],
['name' => 'Article', 'props' => ['text' => '상품 B']],
],
],
['name' => 'Pagination'],
];
$html = $this->render($components);
$this->assertStringContainsString('<nav>', $html);
$this->assertStringContainsString('<h1>카테고리</h1>', $html);
$this->assertSame(2, substr_count($html, '<article>'));
}
/**
* 검색 결과 페이지: SearchBar+결과목록+조건부 빈결과
*/
public function test_search_results_page(): void
{
$components = [
['name' => 'SearchBar'],
['name' => 'H2', 'props' => ['text' => '검색 결과: {{query}}']],
[
'name' => 'Div',
'if' => '{{hasResults}}',
'children' => [
[
'name' => 'Article',
'iteration' => ['data' => '{{results}}', 'item_var' => 'result'],
'props' => ['text' => '{{result.title}}'],
],
],
],
[
'name' => 'P',
'if' => '{{noResults}}',
'props' => ['text' => '검색 결과가 없습니다.'],
],
];
// 결과 있는 경우
$context = [
'query' => '노트북',
'hasResults' => true,
'noResults' => false,
'results' => [['title' => '노트북 A'], ['title' => '노트북 B']],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('검색 결과: 노트북', $html);
$this->assertStringContainsString('노트북 A', $html);
$this->assertStringNotContainsString('검색 결과가 없습니다', $html);
}
/**
* 로그인 폼: Form+FormField(Label+Input)+Button
*/
public function test_login_page_form_structure(): void
{
$components = [
[
'name' => 'Form',
'children' => [
[
'name' => 'FormField',
'children' => [
['name' => 'Label', 'props' => ['text' => '이메일']],
['name' => 'Input', 'props' => ['name' => 'email', 'type' => 'text']],
],
],
[
'name' => 'FormField',
'children' => [
['name' => 'Label', 'props' => ['text' => '비밀번호']],
['name' => 'Input', 'props' => ['name' => 'password', 'type' => 'password']],
],
],
['name' => 'Button', 'props' => ['text' => '로그인', 'type' => 'submit']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<form>', $html);
$this->assertStringContainsString('<label>이메일</label>', $html);
$this->assertStringContainsString('name="email"', $html);
$this->assertStringContainsString('<button type="submit">로그인</button>', $html);
}
/**
* 게시글 페이지: 제목+작성자+본문(raw HTML)
*/
public function test_article_page_with_html_content(): void
{
$components = [
['name' => 'H1', 'props' => ['text' => '{{post.title}}']],
['name' => 'Span', 'props' => ['text' => '{{post.author}}']],
['name' => 'HtmlContent', 'props' => ['content' => '{{post.body}}']],
];
$context = [
'post' => [
'title' => '첫 번째 글',
'author' => '홍길동',
'body' => '<p>본문 내용입니다.</p><ul><li>항목 1</li></ul>',
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<h1>첫 번째 글</h1>', $html);
$this->assertStringContainsString('홍길동', $html);
$this->assertStringContainsString('<p>본문 내용입니다.</p>', $html);
$this->assertStringContainsString('<ul><li>항목 1</li></ul>', $html);
}
/**
* 헤더: Logo(Img)+Nav(Ul>Li>A 반복)
*/
public function test_header_navigation_structure(): void
{
$components = [
[
'name' => 'Header',
'children' => [
['name' => 'Img', 'props' => ['src' => 'https://cdn.test/logo.png', 'alt' => '로고']],
[
'name' => 'Nav',
'children' => [
[
'name' => 'Ul',
'children' => [
[
'name' => 'Li',
'iteration' => ['data' => '{{menuItems}}', 'item_var' => 'menu'],
'children' => [['name' => 'A', 'props' => ['href' => '{{menu.url}}', 'text' => '{{menu.label}}']]],
],
],
],
],
],
],
],
];
$context = [
'menuItems' => [
['url' => '/', 'label' => '홈'],
['url' => '/shop', 'label' => '쇼핑'],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<header>', $html);
$this->assertStringContainsString('<img src="https://cdn.test/logo.png"', $html);
$this->assertStringContainsString('<nav>', $html);
$this->assertStringContainsString('홈', $html);
$this->assertStringContainsString('쇼핑', $html);
}
/**
* 푸터: 링크+저작권 텍스트
*/
public function test_footer_with_links_and_copyright(): void
{
$components = [
[
'name' => 'Footer',
'children' => [
['name' => 'A', 'props' => ['href' => '/terms', 'text' => '이용약관']],
['name' => 'A', 'props' => ['href' => '/privacy', 'text' => '개인정보']],
['name' => 'P', 'props' => ['text' => '© 2026 G7']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<footer>', $html);
$this->assertStringContainsString('이용약관', $html);
$this->assertStringContainsString('개인정보', $html);
$this->assertStringContainsString('© 2026 G7', $html);
}
/**
* 조건부 섹션: 로그인 시 마이페이지, 비로그인 시 로그인 버튼
*/
public function test_page_with_conditional_sections(): void
{
$components = [
['name' => 'A', 'if' => '{{isLoggedIn}}', 'props' => ['href' => '/mypage', 'text' => '마이페이지']],
['name' => 'A', 'if' => '{{isGuest}}', 'props' => ['href' => '/login', 'text' => '로그인']],
];
// 로그인 상태
$html = $this->render($components, ['isLoggedIn' => true, 'isGuest' => false]);
$this->assertStringContainsString('마이페이지', $html);
$this->assertStringNotContainsString('로그인', $html);
// 비로그인 상태
$html2 = $this->render($components, ['isLoggedIn' => false, 'isGuest' => true]);
$this->assertStringNotContainsString('마이페이지', $html2);
$this->assertStringContainsString('로그인', $html2);
}
/**
* 전체 페이지에 걸친 데이터 바인딩
*/
public function test_page_with_data_binding_throughout(): void
{
$components = [
['name' => 'H1', 'props' => ['text' => '{{product.name}}']],
['name' => 'Span', 'props' => ['text' => '{{product.price}}']],
['name' => 'P', 'props' => ['text' => '{{product.summary}}']],
['name' => 'Img', 'props' => ['src' => '{{product.thumbnail}}', 'alt' => '{{product.name}}']],
];
$context = [
'product' => [
'name' => '프리미엄 노트북',
'price' => '2,500,000원',
'summary' => '최신 프로세서 탑재',
'thumbnail' => 'https://cdn.test/notebook.jpg',
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<h1>프리미엄 노트북</h1>', $html);
$this->assertStringContainsString('2,500,000원', $html);
$this->assertStringContainsString('최신 프로세서 탑재', $html);
$this->assertStringContainsString('src="https://cdn.test/notebook.jpg"', $html);
}
/**
* 5단계 이상 중첩: Div>Section>Article>Div>P
*/
public function test_deeply_nested_layout(): void
{
$components = [
[
'name' => 'Div',
'children' => [[
'name' => 'Section',
'children' => [[
'name' => 'Article',
'children' => [[
'name' => 'Div',
'children' => [[
'name' => 'P',
'children' => [[
'name' => 'Strong',
'props' => ['text' => '최하위'],
]],
]],
]],
]],
]],
],
];
$html = $this->render($components);
$this->assertSame('<div><section><article><div><p><strong>최하위</strong></p></div></article></section></div>', $html);
}
/**
* 상품 카드 iteration 내부에 이미지+제목+가격+Badge
*/
public function test_complex_iteration_with_nested_children(): void
{
$components = [
[
'name' => 'Div',
'iteration' => ['data' => '{{products}}', 'item_var' => 'p'],
'children' => [
['name' => 'Img', 'props' => ['src' => '{{p.image}}', 'alt' => '{{p.name}}']],
['name' => 'H3', 'props' => ['text' => '{{p.name}}']],
['name' => 'Span', 'props' => ['text' => '{{p.price}}']],
['name' => 'Badge', 'props' => ['text' => '{{p.badge}}']],
],
],
];
$context = [
'products' => [
['name' => '상품 A', 'price' => '10,000원', 'image' => 'https://cdn.test/a.jpg', 'badge' => 'NEW'],
['name' => '상품 B', 'price' => '20,000원', 'image' => 'https://cdn.test/b.jpg', 'badge' => 'SALE'],
],
];
$html = $this->render($components, $context);
$this->assertSame(2, substr_count($html, '<h3>'));
$this->assertSame(2, substr_count($html, '<img'));
$this->assertStringContainsString('NEW', $html);
$this->assertStringContainsString('SALE', $html);
}
// =========================================================================
// 6-G. 엣지 케이스 검증 (신규 10개)
// =========================================================================
/**
* name 키 없음 → div fallback
*/
public function test_component_with_no_name(): void
{
$components = [['props' => ['text' => '이름없음']]];
$html = $this->render($components);
$this->assertStringContainsString('<div>이름없음</div>', $html);
}
/**
* name:"" → div fallback
*/
public function test_component_with_empty_name(): void
{
$components = [['name' => '', 'props' => ['text' => '빈이름']]];
$html = $this->render($components);
$this->assertStringContainsString('<div>빈이름</div>', $html);
}
/**
* text에 <script> → 이스케이프 처리
*/
public function test_xss_prevention_in_text(): void
{
$components = [['name' => 'Div', 'props' => ['text' => '<script>alert("xss")</script>']]];
$html = $this->render($components);
$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('&lt;script&gt;', $html);
}
/**
* className에 xss 시도 → 이스케이프 처리
*/
public function test_xss_prevention_in_attributes(): void
{
$components = [['name' => 'Div', 'props' => ['className' => '" onload="alert(1)']]];
$html = $this->render($components);
// 큰따옴표가 &quot;로 이스케이프되어 XSS 방지
$this->assertStringContainsString('&quot;', $html);
$this->assertStringNotContainsString('onload="alert', $html);
}
/**
* context에서 null 값 → 빈 문자열
*/
public function test_null_context_values(): void
{
$components = [['name' => 'Span', 'props' => ['text' => '{{nullValue}}']]];
$html = $this->render($components, ['nullValue' => null]);
$this->assertSame('<span></span>', $html);
}
/**
* 빈 components 배열 → 빈 문자열
*/
public function test_empty_components_array(): void
{
$html = $this->render([]);
$this->assertSame('', $html);
}
/**
* iteration source가 문자열 → 빈 결과 (에러 없음)
*/
public function test_iteration_with_non_array_data(): void
{
$components = [
[
'name' => 'Li',
'iteration' => ['data' => '{{items}}', 'item_var' => 'item'],
'props' => ['text' => '{{item}}'],
],
];
$html = $this->render($components, ['items' => '문자열']);
$this->assertSame('', $html);
}
/**
* {{a.b.c.d.e}} 5단계 중첩 경로 → 정상 해석
*/
public function test_binding_with_nested_dot_path(): void
{
$components = [['name' => 'Span', 'props' => ['text' => '{{a.b.c.d.e}}']]];
$context = ['a' => ['b' => ['c' => ['d' => ['e' => '깊은 값']]]]];
$html = $this->render($components, $context);
$this->assertStringContainsString('깊은 값', $html);
}
/**
* {{first}} {{last}} 여러 바인딩 → 모두 치환
*/
public function test_multiple_bindings_in_single_text(): void
{
$components = [['name' => 'Span', 'props' => ['text' => '{{first}} {{last}}']]];
$context = ['first' => '홍', 'last' => '길동'];
$html = $this->render($components, $context);
$this->assertStringContainsString('홍 길동', $html);
}
/**
* 한국어, 특수 문자 → 정상 렌더링
*/
public function test_special_characters_in_content(): void
{
$components = [
['name' => 'P', 'props' => ['text' => '한국어 テスト &amp; 특수문자']],
];
$html = $this->render($components);
$this->assertStringContainsString('한국어 テスト', $html);
}
// =========================================================================
// 6-H. self_closing 태그 검증 (신규 4개)
// =========================================================================
/**
* Img → <img ...> (닫힘 태그 없음)
*/
public function test_img_self_closing_no_end_tag(): void
{
$components = [['name' => 'Img', 'props' => ['src' => 'test.jpg']]];
$html = $this->render($components);
$this->assertStringContainsString('<img', $html);
$this->assertStringNotContainsString('</img>', $html);
}
/**
* Input → <input ...> (닫힘 태그 없음)
*/
public function test_input_self_closing_no_end_tag(): void
{
$components = [['name' => 'Input', 'props' => ['type' => 'text']]];
$html = $this->render($components);
$this->assertStringContainsString('<input', $html);
$this->assertStringNotContainsString('</input>', $html);
}
/**
* Hr, Br → <hr>, <br> (닫힘 태그 없음)
*/
public function test_hr_br_self_closing(): void
{
$components = [['name' => 'Hr'], ['name' => 'Br']];
$html = $this->render($components);
$this->assertSame('<hr><br>', $html);
}
/**
* Div, Span → 반드시 닫힘 태그 있음
*/
public function test_non_self_closing_has_end_tag(): void
{
$components = [
['name' => 'Div', 'children' => []],
['name' => 'Span', 'children' => []],
];
$html = $this->render($components);
$this->assertStringContainsString('</div>', $html);
$this->assertStringContainsString('</span>', $html);
}
// =========================================================================
// 6-I. $t: 번역 + 데이터 바인딩 통합 검증 (신규 4개)
// =========================================================================
/**
* $t:shop.title → 번역된 한국어 텍스트
*/
public function test_translation_key_in_text(): void
{
app('translator')->addLines(['shop.title' => '쇼핑몰'], 'ko');
app()->setLocale('ko');
$components = [['name' => 'H1', 'text' => '$t:shop.title']];
$html = $this->render($components);
$this->assertStringContainsString('<h1>쇼핑몰</h1>', $html);
}
/**
* $t:shop.greeting|name=홍길동 → 파라미터 치환
*/
public function test_translation_with_params(): void
{
app('translator')->addLines(['shop.greeting' => ':name님 환영합니다'], 'ko');
app()->setLocale('ko');
$components = [['name' => 'Span', 'text' => '$t:shop.greeting|name=홍길동']];
$html = $this->render($components);
$this->assertStringContainsString('홍길동님 환영합니다', $html);
}
/**
* 같은 페이지에 $t: 텍스트 + {{data}} 바인딩 공존
*/
public function test_binding_and_translation_mixed(): void
{
app('translator')->addLines(['shop.title' => '상품 목록'], 'ko');
app()->setLocale('ko');
$components = [
['name' => 'H1', 'text' => '$t:shop.title'],
['name' => 'Span', 'props' => ['text' => '{{count}}개']],
];
$html = $this->render($components, ['count' => 42]);
$this->assertStringContainsString('상품 목록', $html);
$this->assertStringContainsString('42개', $html);
}
/**
* 존재하지 않는 번역 키 → 빈 문자열 (raw key 노출 방지)
*/
public function test_missing_translation_key_empty_string(): void
{
$components = [['name' => 'Span', 'text' => '$t:nonexistent.key']];
$html = $this->render($components);
$this->assertStringNotContainsString('nonexistent.key', $html);
$this->assertStringContainsString('<span>', $html);
}
// =========================================================================
// responsive.desktop props 병합 테스트
// =========================================================================
/**
* responsive.desktop의 props가 기본 props에 병합됩니다.
*/
public function test_responsive_desktop_props_merged(): void
{
$components = [
[
'name' => 'Div',
'props' => ['className' => 'hidden'],
'responsive' => [
'desktop' => [
'props' => ['className' => 'block max-w-7xl'],
],
],
'children' => [
['name' => 'Span', 'props' => ['text' => '내용']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('class="block max-w-7xl"', $html);
$this->assertStringNotContainsString('class="hidden"', $html);
}
/**
* responsive.desktop의 if 조건이 적용됩니다.
*/
public function test_responsive_desktop_if_override(): void
{
$components = [
[
'name' => 'Div',
'props' => ['text' => '데스크톱 전용'],
'responsive' => [
'desktop' => [
'if' => '{{showDesktop}}',
],
],
],
];
// showDesktop=true → 렌더링됨
$html = $this->render($components, ['showDesktop' => true]);
$this->assertStringContainsString('데스크톱 전용', $html);
// showDesktop=false → 렌더링 안됨
$html = $this->render($components, ['showDesktop' => false]);
$this->assertSame('', $html);
}
/**
* responsive.desktop의 text 속성이 적용됩니다.
*/
public function test_responsive_desktop_text_override(): void
{
$components = [
[
'name' => 'Span',
'text' => '모바일 텍스트',
'responsive' => [
'desktop' => [
'text' => '데스크톱 텍스트',
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('데스크톱 텍스트', $html);
$this->assertStringNotContainsString('모바일 텍스트', $html);
}
/**
* responsive에 desktop이 없으면 기본 속성이 유지됩니다.
*/
public function test_responsive_without_desktop_keeps_defaults(): void
{
$components = [
[
'name' => 'Div',
'props' => ['className' => 'mobile-only'],
'responsive' => [
'portable' => [
'props' => ['className' => 'portable-class'],
],
],
'children' => [
['name' => 'Span', 'props' => ['text' => '내용']],
],
],
];
$html = $this->render($components);
// portable은 무시, 기본 className 유지
$this->assertStringContainsString('class="mobile-only"', $html);
}
// =========================================================================
// Icon name_to_class 변환 테스트
// =========================================================================
/**
* Icon의 name prop이 Font Awesome class로 변환됩니다.
*/
public function test_icon_name_to_font_awesome_class(): void
{
$components = [
[
'name' => 'Icon',
'props' => ['name' => 'shopping-cart'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('class="fas fa-shopping-cart"', $html);
$this->assertStringNotContainsString('name=', $html);
}
/**
* Icon의 name이 바인딩 표현식인 경우 평가 후 변환합니다.
*/
public function test_icon_name_binding_evaluated(): void
{
$components = [
[
'name' => 'Icon',
'props' => ['name' => '{{iconName}}'],
],
];
$html = $this->render($components, ['iconName' => 'heart']);
$this->assertStringContainsString('class="fas fa-heart"', $html);
}
/**
* Icon의 fa- 접두사가 중복 방지됩니다.
*/
public function test_icon_fa_prefix_dedup(): void
{
$components = [
[
'name' => 'Icon',
'props' => ['name' => 'fa-star'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('class="fas fa-star"', $html);
$this->assertStringNotContainsString('fas fa-fa-star', $html);
}
/**
* Icon의 기존 className이 name_to_class 결과와 병합됩니다.
*/
public function test_icon_classname_merged(): void
{
$components = [
[
'name' => 'Icon',
'props' => ['name' => 'check', 'className' => 'text-green-500 w-4 h-4'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('class="fas fa-check text-green-500 w-4 h-4"', $html);
}
/**
* Icon에 aria-label과 role이 자동 생성됩니다.
*/
public function test_icon_accessibility_attrs(): void
{
$components = [
[
'name' => 'Icon',
'props' => ['name' => 'shopping-cart'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('aria-label="shopping cart"', $html);
$this->assertStringContainsString('role="img"', $html);
}
/**
* Icon name이 빈 문자열이면 변환하지 않습니다.
*/
public function test_icon_empty_name_no_transform(): void
{
$components = [
[
'name' => 'Icon',
'props' => ['name' => ''],
],
];
$html = $this->render($components);
$this->assertStringNotContainsString('fas fa-', $html);
}
// =========================================================================
// Header/Footer text_format 렌더 모드 테스트
// =========================================================================
/**
* Header가 text_format으로 site_name을 렌더링합니다.
*/
public function test_header_renders_site_name(): void
{
$components = [
[
'name' => 'Header',
'props' => ['site_name' => 'My Site'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<header', $html);
$this->assertStringContainsString('My Site', $html);
$this->assertStringContainsString('</header>', $html);
}
/**
* Header에 site_name 미전달 시 기본값 G7이 사용됩니다.
*/
public function test_header_default_site_name(): void
{
$components = [
[
'name' => 'Header',
'props' => [],
],
];
$html = $this->render($components);
$this->assertStringContainsString('G7', $html);
}
/**
* Footer가 copyright 포맷으로 렌더링됩니다.
*/
public function test_footer_renders_copyright(): void
{
$components = [
[
'name' => 'Footer',
'props' => ['site_name' => 'My Site'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<footer', $html);
$this->assertStringContainsString('©', $html);
$this->assertStringContainsString('My Site', $html);
$this->assertStringContainsString('</footer>', $html);
}
/**
* Footer에 site_name 미전달 시 기본값 G7이 사용됩니다.
*/
public function test_footer_default_site_name(): void
{
$components = [
[
'name' => 'Footer',
'props' => [],
],
];
$html = $this->render($components);
$this->assertStringContainsString('© G7', $html);
}
// =========================================================================
// 이미지 src download_url fallback 테스트
// =========================================================================
/**
* url 필드가 비어있을 때 download_url로 fallback합니다.
*
* @effects download_url_falls_back_to_api_path_when_direct_unavailable
*/
public function test_image_gallery_download_url_fallback(): void
{
$components = [
[
'name' => 'ProductImageViewer',
'props' => [
'images' => '{{product.images}}',
],
],
];
$context = [
'product' => [
'images' => [
[
'url' => '',
'download_url' => '/api/modules/sirsoft-ecommerce/product-image/abc123',
'alt_text_current' => '상품 이미지 1',
],
[
'url' => null,
'download_url' => '/api/modules/sirsoft-ecommerce/product-image/def456',
'alt_text_current' => '상품 이미지 2',
],
],
],
];
$html = $this->render($components, $context);
// download_url로 fallback (url()로 절대 경로 변환)
$this->assertStringContainsString('/api/modules/sirsoft-ecommerce/product-image/abc123', $html);
$this->assertStringContainsString('/api/modules/sirsoft-ecommerce/product-image/def456', $html);
$this->assertStringContainsString('alt="상품 이미지 1"', $html);
$this->assertStringContainsString('alt="상품 이미지 2"', $html);
}
/**
* url 필드가 존재하면 download_url보다 우선합니다.
* (공개 자산 디스크 설정 시 download_url 이 직접 CDN URL 이어도 동일 경로로 소비)
*
* @effects download_url_falls_back_to_api_path_when_direct_unavailable
*/
public function test_image_gallery_url_takes_priority(): void
{
$components = [
[
'name' => 'ProductImageViewer',
'props' => [
'images' => '{{product.images}}',
],
],
];
$context = [
'product' => [
'images' => [
[
'url' => 'https://cdn.example.com/image.jpg',
'download_url' => '/api/modules/sirsoft-ecommerce/product-image/abc123',
'alt_text_current' => '상품 이미지',
],
],
],
];
$html = $this->render($components, $context);
// url이 있으면 url 사용
$this->assertStringContainsString('src="https://cdn.example.com/image.jpg"', $html);
$this->assertStringNotContainsString('product-image/abc123', $html);
}
/**
* download_url 이 절대 CDN URL 일 때(공개 자산 디스크 설정) iterate 가드가
* url() 이중 접두 없이 그대로 통과시킵니다.
*
* 공개#100 이후 download_url 은 상대 API 경로만이 아니라 절대 CDN URL 일 수
* 있다 — 가드가 http 프리픽스를 오판하면 `http://origin/https://cdn...` 처럼
* 깨진 src 가 봇 화면에 나간다.
*
* @effects seo_guard_passes_absolute_cdn_url_unmodified
*/
public function test_image_gallery_absolute_cdn_download_url_passes_unmodified(): void
{
$components = [
[
'name' => 'ProductImageViewer',
'props' => [
'images' => '{{product.images}}',
],
],
];
$context = [
'product' => [
'images' => [
[
'url' => '',
'download_url' => 'https://cdn.test/assets/sirsoft-ecommerce/images/products/1/a.jpg',
'alt_text_current' => 'CDN 이미지',
],
],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString(
'src="https://cdn.test/assets/sirsoft-ecommerce/images/products/1/a.jpg"',
$html,
);
// url() 이중 접두 회귀 방어 — 절대 URL 앞에 오리진이 덧붙으면 안 된다
$this->assertStringNotContainsString('/https://cdn.test/', $html);
}
/**
* ProductCard(fields attrs 가드)의 thumbnail_url 이 절대 CDN URL 일 때
* 그대로 통과하고, 상대 경로일 때만 절대화됩니다.
*
* @effects seo_guard_passes_absolute_cdn_url_unmodified
*/
public function test_product_card_thumbnail_absolute_cdn_url_passes_unmodified(): void
{
$components = [
[
'type' => 'composite',
'name' => 'ProductCard',
'props' => ['product' => '{{product}}'],
],
];
$context = [
'product' => [
'id' => 7,
'name' => 'CDN Thumb',
'name_localized' => 'CDN 썸네일 상품',
'thumbnail_url' => 'https://cdn.test/assets/sirsoft-ecommerce/images/products/7/t.jpg',
'selling_price_formatted' => '1,000원',
'list_price_formatted' => '1,000원',
'discount_rate' => 0,
'primary_category' => '',
'brand_name' => '',
'sales_status' => '',
'sales_status_label' => '',
'labels' => [],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString(
'src="https://cdn.test/assets/sirsoft-ecommerce/images/products/7/t.jpg"',
$html,
);
$this->assertStringNotContainsString('/https://cdn.test/', $html);
// 대조군 — 상대 경로는 여전히 절대화된다 (가드의 양방향 동작 고정)
$context['product']['thumbnail_url'] = '/storage/products/rel.jpg';
$relativeHtml = $this->render($components, $context);
$this->assertStringContainsString('src="'.url('/storage/products/rel.jpg').'"', $relativeHtml);
}
/**
* alt_text_current 필드로 alt 속성이 렌더링됩니다.
*/
public function test_image_gallery_alt_text_current_field(): void
{
$components = [
[
'name' => 'ImageGallery',
'props' => [
'images' => '{{images}}',
],
],
];
$context = [
'images' => [
[
'url' => 'https://example.com/img.jpg',
'alt_text_current' => '현재 로케일 대체 텍스트',
],
[
'url' => 'https://example.com/img2.jpg',
'alt_text_current' => '',
'alt_text' => '기본 대체 텍스트',
],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('alt="현재 로케일 대체 텍스트"', $html);
$this->assertStringContainsString('alt="기본 대체 텍스트"', $html);
}
// =========================================================================
// React 렌더러 패리티 — 노드 문법 (condition/conditions/iterator/text/responsive/if)
// =========================================================================
/**
* condition 속성이 if의 별칭으로 동작합니다.
*/
public function test_condition_property_acts_as_if_alias(): void
{
$shown = $this->render([
['type' => 'basic', 'name' => 'Div', 'condition' => '{{flag}}', 'text' => '보임'],
], ['flag' => true]);
$hidden = $this->render([
['type' => 'basic', 'name' => 'Div', 'condition' => '{{flag}}', 'text' => '숨김'],
], ['flag' => false]);
$this->assertStringContainsString('보임', $shown);
$this->assertSame('', $hidden);
}
/**
* conditions AND 그룹은 모든 조건이 참일 때만 렌더링됩니다.
*/
public function test_conditions_and_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['and' => ['{{a}}', '{{b}}']],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => true, 'b' => true]));
$this->assertSame('', $this->render([$component], ['a' => true, 'b' => false]));
}
/**
* conditions OR 그룹은 하나라도 참이면 렌더링됩니다.
*/
public function test_conditions_or_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['or' => ['{{a}}', '{{b}}']],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => false, 'b' => true]));
$this->assertSame('', $this->render([$component], ['a' => false, 'b' => false]));
}
/**
* conditions 중첩 그룹(AND 안의 OR)이 평가됩니다.
*/
public function test_conditions_nested_group(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => ['and' => ['{{a}}', ['or' => ['{{b}}', '{{c}}']]]],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['a' => true, 'b' => false, 'c' => true]));
$this->assertSame('', $this->render([$component], ['a' => true, 'b' => false, 'c' => false]));
}
/**
* conditions if/else 체인은 하나라도 매칭되면 렌더링됩니다.
*/
public function test_conditions_branch_chain(): void
{
$component = [
'type' => 'basic',
'name' => 'Div',
'conditions' => [
['if' => "{{role === 'admin'}}"],
['if' => "{{role === 'manager'}}"],
],
'text' => '표시',
];
$this->assertStringContainsString('표시', $this->render([$component], ['role' => 'manager']));
$this->assertSame('', $this->render([$component], ['role' => 'guest']));
}
/**
* conditions if/else 체인의 else 브랜치(if 없음)는 항상 매칭됩니다.
*/
public function test_conditions_else_branch_always_matches(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'conditions' => [['if' => '{{never}}'], []],
'text' => '표시',
],
], ['never' => false]);
$this->assertStringContainsString('표시', $html);
}
/**
* 빈 AND 그룹은 true, 빈 OR 그룹은 false 로 평가됩니다 (React와 동일).
*/
public function test_conditions_empty_groups(): void
{
$and = $this->render([
['type' => 'basic', 'name' => 'Div', 'conditions' => ['and' => []], 'text' => 'AND'],
]);
$or = $this->render([
['type' => 'basic', 'name' => 'Div', 'conditions' => ['or' => []], 'text' => 'OR'],
]);
$this->assertStringContainsString('AND', $and);
$this->assertSame('', $or);
}
/**
* type: "iterator" 노드가 iteration 으로 변환되어 반복 렌더링됩니다.
*/
public function test_iterator_type_is_converted_to_iteration(): void
{
$html = $this->render([
[
'type' => 'iterator',
'name' => 'Div',
'data' => '{{items}}',
'itemName' => 'entry',
'children' => [
['type' => 'basic', 'name' => 'Span', 'text' => '{{entry.label}}'],
],
],
], ['items' => [['label' => '하나'], ['label' => '둘']]]);
$this->assertStringContainsString('하나', $html);
$this->assertStringContainsString('둘', $html);
}
/**
* type: "iterator" 의 itemName 을 생략하면 기본 별칭 item 이 사용됩니다.
*/
public function test_iterator_type_defaults_item_var(): void
{
$html = $this->render([
[
'type' => 'iterator',
'name' => 'Div',
'data' => '{{items}}',
'children' => [
['type' => 'basic', 'name' => 'Span', 'text' => '{{item.label}}'],
],
],
], ['items' => [['label' => '기본별칭']]]);
$this->assertStringContainsString('기본별칭', $html);
}
/**
* children 배열 내 문자열은 텍스트 노드로 출력됩니다.
*/
public function test_string_child_is_rendered_as_text_node(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'children' => ['문자열 조각', ['type' => 'basic', 'name' => 'Span', 'text' => '요소']],
],
]);
$this->assertStringContainsString('문자열 조각', $html);
$this->assertStringContainsString('<span>요소</span>', $html);
}
/**
* children 배열 내 문자열은 HTML 이스케이프되어 출력됩니다.
*/
public function test_string_child_is_escaped(): void
{
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'children' => ['<b>굵게</b>']],
]);
$this->assertStringContainsString('&lt;b&gt;', $html);
$this->assertStringNotContainsString('<b>', $html);
}
/**
* children 배열 내 문자열의 표현식은 해석하지 않고 리터럴로 둡니다.
*
* React DynamicRenderer 가 문자열 자식을 그대로 반환하므로(표현식 미해석),
* SEO 가 해석하면 반대 방향의 패리티 격차가 생깁니다.
*/
public function test_string_child_expression_is_not_resolved(): void
{
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'children' => ['{{value}}']],
], ['value' => '해석되면 안 됨']);
$this->assertStringContainsString('{{value}}', $html);
$this->assertStringNotContainsString('해석되면 안 됨', $html);
}
/**
* text 와 children 이 함께 있으면 text 가 우선합니다 (React와 동일 우선순위).
*/
public function test_node_text_takes_precedence_over_children(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '텍스트 우선',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '자식']],
],
]);
$this->assertStringContainsString('텍스트 우선', $html);
$this->assertStringNotContainsString('자식', $html);
}
/**
* text 키가 있으면 해석 결과가 비어도 children 으로 폴백하지 않습니다.
*
* React 는 `text !== undefined` 인 순간 children 경로로 내려가지 않는다
* (DynamicRenderer.tsx:2591,2639). 폴백하면 봇 화면에만 없는 내용이 나타난다.
*/
public function test_empty_node_text_does_not_fall_back_to_children(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '{{missing.path}}',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '자식폴백']],
],
], ['other' => 'x']);
$this->assertStringNotContainsString('자식폴백', $html);
}
/**
* `?? '$t:key'` 폴백으로 나온 번역 토큰이 text 에서 해석됩니다.
*
* bool/null 판정 때문에 text 해석 경로를 바꾸더라도, 파이프·인라인 `$t:` 후처리를
* 건너뛰면 안 된다 — 건너뛰면 봇 화면에 `$t:home.welcome_description` 이 그대로 남는다.
*/
public function test_translation_token_in_fallback_literal_is_resolved_in_node_text(): void
{
$this->evaluator->setTranslations(['home.welcome_description' => '환영합니다']);
$html = $this->render([
[
'type' => 'basic',
'name' => 'P',
'text' => "{{settings?.site_description ?? '\$t:home.welcome_description'}}",
],
], ['settings' => []]);
$this->assertStringContainsString('환영합니다', $html);
$this->assertStringNotContainsString('$t:', $html);
}
/**
* bool / null 로 평가되는 text 는 렌더되지 않습니다 (JSX 시맨틱).
*
* React 는 단일 바인딩 text 를 raw 값으로 받아 그대로 자식에 넘기므로 {false} 가
* 화면에 나오지 않는다. 문자열화하면 봇 화면에만 'false' 라는 글자가 노출된다.
*/
public function test_boolean_and_null_node_text_are_not_rendered(): void
{
$context = ['flags' => ['on' => true, 'off' => false, 'nothing' => null]];
foreach (['{{flags.off}}' => 'false', '{{flags.on}}' => 'true'] as $expression => $forbidden) {
$html = $this->render([
['type' => 'basic', 'name' => 'Span', 'text' => $expression],
], $context);
$this->assertStringNotContainsString($forbidden, $html, "text={$expression} 가 문자열로 노출되었습니다.");
}
$html = $this->render([
['type' => 'basic', 'name' => 'Span', 'text' => '{{flags.nothing}}'],
], $context);
$this->assertStringNotContainsString('null', $html);
}
/**
* 알 수 없는 conditions 형식은 렌더링됩니다 (React evaluateRenderCondition 동형).
*
* 여기서 숨기면 봇 화면에서만 노드가 사라진다 — #86 과 동일한 실패 모드.
*/
public function test_unknown_conditions_format_still_renders(): void
{
$cases = [
'빈 배열' => [],
'문자열 리스트' => ['{{flag}}'],
'if/then 없는 객체 리스트' => [['foo' => 1]],
'and/or 없는 객체' => ['foo' => 1],
];
foreach ($cases as $label => $conditions) {
$html = $this->render([
[
'type' => 'basic',
'name' => 'Span',
'conditions' => $conditions,
'text' => '렌더됨',
],
], ['flag' => true]);
$this->assertStringContainsString('렌더됨', $html, "conditions={$label} 이 숨겨졌습니다.");
}
}
/**
* 데스크톱에 매칭되는 responsive 키가 여러 개면 커스텀 범위가 프리셋을 이깁니다.
*
* React ResponsiveManager::getMatchingKey() 와 동일한 우선순위. 매칭 키를 전부
* 순차 병합하면 JSON 키 선언 순서에 따라 봇 화면 출력이 달라진다.
*/
public function test_responsive_custom_range_wins_over_preset(): void
{
$node = [
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'desktop' => ['text' => '프리셋'],
'1024-' => ['text' => '커스텀'],
],
];
$this->assertStringContainsString('커스텀', $this->render([$node]));
// 선언 순서를 뒤집어도 결과가 같아야 한다
$node['responsive'] = [
'1024-' => ['text' => '커스텀'],
'desktop' => ['text' => '프리셋'],
];
$html = $this->render([$node]);
$this->assertStringContainsString('커스텀', $html);
$this->assertStringNotContainsString('프리셋', $html);
}
/**
* 커스텀 범위끼리는 좁은 범위가 넓은 범위를 이깁니다.
*/
public function test_responsive_narrower_range_wins(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'1024-' => ['text' => '넓은범위'],
'1024-1279' => ['text' => '좁은범위'],
],
],
]);
$this->assertStringContainsString('좁은범위', $html);
$this->assertStringNotContainsString('넓은범위', $html);
}
/**
* min > max 인 잘못된 범위 키는 무시됩니다 (React parseRange 와 동일).
*/
public function test_responsive_invalid_range_key_is_ignored(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'1500-1000' => ['text' => '잘못된범위'],
],
],
]);
$this->assertStringContainsString('기본', $html);
$this->assertStringNotContainsString('잘못된범위', $html);
}
/**
* responsive.desktop 의 children 오버라이드가 적용됩니다.
*/
public function test_responsive_desktop_children_override(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '기본자식']],
'responsive' => [
'desktop' => [
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '데스크톱자식']],
],
],
],
]);
$this->assertStringContainsString('데스크톱자식', $html);
$this->assertStringNotContainsString('기본자식', $html);
}
/**
* responsive.desktop 의 iteration 오버라이드가 적용됩니다.
*/
public function test_responsive_desktop_iteration_override(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'responsive' => [
'desktop' => [
'iteration' => ['source' => '{{items}}', 'item_var' => 'row'],
],
],
'children' => [['type' => 'basic', 'name' => 'Span', 'text' => '{{row.label}}']],
],
], ['items' => [['label' => 'A'], ['label' => 'B']]]);
$this->assertSame(2, substr_count($html, '<span>'));
}
/**
* 데스크톱 폭을 포함하는 숫자 범위 responsive 키가 적용됩니다.
*/
public function test_responsive_numeric_range_matching_desktop_is_applied(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => ['1024-' => ['text' => '넓은화면']],
],
]);
$this->assertStringContainsString('넓은화면', $html);
}
/**
* 데스크톱 폭 밖의 responsive 키(모바일/좁은 범위)는 무시됩니다.
*/
public function test_responsive_non_desktop_breakpoints_are_ignored(): void
{
$html = $this->render([
[
'type' => 'basic',
'name' => 'Div',
'text' => '기본',
'responsive' => [
'mobile' => ['text' => '모바일'],
'portable' => ['text' => '포터블'],
'0-639' => ['text' => '좁은화면'],
],
],
]);
$this->assertStringContainsString('기본', $html);
$this->assertStringNotContainsString('모바일', $html);
$this->assertStringNotContainsString('포터블', $html);
$this->assertStringNotContainsString('좁은화면', $html);
}
/**
* if 결과 문자열 "null" / "undefined" 는 거짓으로 판정됩니다 (React와 동일).
*/
public function test_if_treats_null_and_undefined_strings_as_falsy(): void
{
foreach (['null', 'undefined', 'NULL', ' false ', '0'] as $value) {
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'if' => '{{flag}}', 'text' => '표시'],
], ['flag' => $value]);
$this->assertSame('', $html, "if 값 '{$value}' 는 거짓이어야 합니다.");
}
}
/**
* if 결과가 참이면 정상 렌더링됩니다 (거짓 목록 확장의 회귀 방지).
*/
public function test_if_truthy_values_still_render(): void
{
foreach ([true, 'yes', '1', 'nullable'] as $value) {
$html = $this->render([
['type' => 'basic', 'name' => 'Div', 'if' => '{{flag}}', 'text' => '표시'],
], ['flag' => $value]);
$this->assertStringContainsString('표시', $html);
}
}
// =========================================================================
// 헬퍼 메서드 — config 기반 설정 반환
// =========================================================================
/**
* 기본 텍스트 추출 속성 목록을 반환합니다.
*
* @return array 텍스트 속성 목록
*/
private function getDefaultTextProps(): array
{
return ['text', 'label', 'value', 'title'];
}
/**
* 기본 속성명 매핑을 반환합니다.
*
* @return array 속성명 매핑 (JSX → HTML)
*/
private function getDefaultAttrMap(): array
{
return [
'className' => 'class',
'htmlFor' => 'for',
];
}
/**
* 기본 허용 속성 목록을 반환합니다.
*
* @return array 허용 HTML 속성 목록
*/
private function getDefaultAllowedAttrs(): array
{
return [
'class', 'id', 'href', 'src', 'alt', 'title', 'name', 'type',
'placeholder', 'for', 'target', 'rel', 'width', 'height',
'role', 'aria-label', 'aria-describedby', 'data-testid', 'style',
];
}
// =========================================================================
// config 기반 text_props / attr_map / allowed_attrs 테스트 (8개)
// =========================================================================
/**
* text_props 설정에 따라 텍스트를 추출합니다.
*/
public function test_text_props_from_config(): void
{
$components = [
[
'name' => 'Span',
'props' => ['text' => '텍스트 내용'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('텍스트 내용', $html);
}
/**
* text_props가 빈 배열이면 텍스트를 추출하지 않습니다.
*/
public function test_empty_text_props_no_text_output(): void
{
$this->mapper->setTextProps([]);
$components = [
[
'name' => 'Span',
'props' => ['text' => '보이면 안됨'],
],
];
$html = $this->render($components);
$this->assertStringNotContainsString('보이면 안됨', $html);
}
/**
* text_props 순서가 텍스트 추출 우선순위를 결정합니다.
*/
public function test_custom_text_props_order(): void
{
// label을 text보다 우선하도록 순서 변경
$this->mapper->setTextProps(['label', 'text']);
$components = [
[
'name' => 'Span',
'props' => [
'text' => '텍스트',
'label' => '레이블',
],
],
];
$html = $this->render($components);
// label이 우선이므로 label 값이 출력됨
$this->assertStringContainsString('레이블', $html);
}
/**
* attr_map에 따라 className이 class로 변환됩니다.
*/
public function test_attr_map_from_config(): void
{
$components = [
[
'name' => 'Div',
'props' => ['className' => 'test-class'],
'children' => [
['name' => 'Span', 'props' => ['text' => '내용']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('class="test-class"', $html);
$this->assertStringNotContainsString('className', $html);
}
/**
* 커스텀 attr_map으로 추가 매핑이 가능합니다.
*/
public function test_custom_attr_map(): void
{
$this->mapper->setAttrMap([
'className' => 'class',
'htmlFor' => 'for',
'dataValue' => 'data-value',
]);
// data-value를 허용 목록에 추가
$this->mapper->setAllowedAttrs(array_merge($this->getDefaultAllowedAttrs(), ['data-value']));
$components = [
[
'name' => 'Div',
'props' => ['dataValue' => 'custom'],
'children' => [
['name' => 'Span', 'props' => ['text' => '내용']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('data-value="custom"', $html);
}
/**
* allowed_attrs에 포함된 속성만 출력됩니다.
*/
public function test_allowed_attrs_from_config(): void
{
$components = [
[
'name' => 'A',
'props' => [
'href' => 'https://example.com',
'className' => 'link',
'onclick' => 'alert(1)', // 허용 목록에 없음
],
'children' => [
['name' => 'Span', 'props' => ['text' => '링크']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('href="https://example.com"', $html);
$this->assertStringContainsString('class="link"', $html);
$this->assertStringNotContainsString('onclick', $html);
}
/**
* allowed_attrs가 빈 배열이면 속성을 출력하지 않습니다.
*/
public function test_empty_allowed_attrs_no_attributes(): void
{
$this->mapper->setAllowedAttrs([]);
$components = [
[
'name' => 'Div',
'props' => [
'className' => 'test',
'id' => 'main',
],
'children' => [
['name' => 'Span', 'props' => ['text' => '내용']],
],
],
];
$html = $this->render($components);
// 태그는 있지만 속성은 없어야 함
$this->assertStringContainsString('<div>', $html);
$this->assertStringNotContainsString('class=', $html);
$this->assertStringNotContainsString('id=', $html);
}
/**
* 커스텀 속성(data-* 등)을 허용 목록에 추가할 수 있습니다.
*/
public function test_extra_allowed_attrs(): void
{
$this->mapper->setAllowedAttrs(array_merge(
$this->getDefaultAllowedAttrs(),
['data-custom', 'data-analytics']
));
$components = [
[
'name' => 'Div',
'props' => [
'data-custom' => 'value1',
'data-analytics' => 'value2',
],
'children' => [
['name' => 'Span', 'props' => ['text' => '내용']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('data-custom="value1"', $html);
$this->assertStringContainsString('data-analytics="value2"', $html);
}
// ========================
// navigate/openWindow 링크 자동 생성 테스트
// ========================
/**
* Button + 정적 path navigate → <a href="/login"> 변환을 검증합니다.
*/
public function test_navigate_button_static_path(): void
{
$components = [
[
'name' => 'Button',
'text' => '로그인',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/login']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="/login"', $html);
$this->assertStringContainsString('로그인</a>', $html);
$this->assertStringNotContainsString('<button', $html);
}
/**
* Div + navigate → <a>로 래핑을 검증합니다.
*/
public function test_navigate_div_wrapping(): void
{
$components = [
[
'name' => 'Div',
'props' => ['className' => 'card'],
'children' => [
['name' => 'Span', 'text' => '상품명'],
],
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/shop']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="/shop"><div class="card">', $html);
$this->assertStringContainsString('</div></a>', $html);
}
/**
* 동적 path (데이터소스 컨텍스트) 해석을 검증합니다.
*/
public function test_navigate_dynamic_path_from_context(): void
{
$components = [
[
'name' => 'Button',
'text' => '상세보기',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/posts/{{post.slug}}']],
],
],
];
$context = ['post' => ['slug' => 'hello-world']];
$html = $this->render($components, $context);
$this->assertStringContainsString('<a href="/posts/hello-world"', $html);
}
/**
* navigate + query params → 쿼리스트링 빌드를 검증합니다.
*/
public function test_navigate_with_query_params(): void
{
$components = [
[
'name' => 'Button',
'text' => '검색',
'actions' => [
[
'handler' => 'navigate',
'params' => [
'path' => '/search',
'query' => ['q' => 'test', 'page' => '1'],
],
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="/search?q=test&amp;page=1"', $html);
}
/**
* replace: true인 navigate → 링크 미생성을 검증합니다.
*/
public function test_navigate_replace_true_skipped(): void
{
$components = [
[
'name' => 'Button',
'text' => '필터',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/shop', 'replace' => true]],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<button', $html);
$this->assertStringNotContainsString('<a ', $html);
}
/**
* sequence 내 navigate 추출을 검증합니다.
*/
public function test_navigate_in_sequence(): void
{
$components = [
[
'name' => 'Button',
'text' => '이동',
'actions' => [
[
'handler' => 'sequence',
'actions' => [
['handler' => 'setState', 'params' => ['target' => 'local', 'key' => 'loading', 'value' => true]],
['handler' => 'navigate', 'params' => ['path' => '/dashboard']],
],
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="/dashboard"', $html);
$this->assertStringNotContainsString('<button', $html);
}
/**
* 미해석 _global 표현식 → 링크 생성 skip을 검증합니다.
*/
public function test_navigate_unresolved_global_skipped(): void
{
$components = [
[
'name' => 'Button',
'text' => '이동',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '{{_global.unknownBase}}/page']],
],
],
];
$html = $this->render($components);
// globalResolver 미설정 → _global 미해석 → 원본 태그 유지
$this->assertStringContainsString('<button', $html);
$this->assertStringNotContainsString('<a ', $html);
}
/**
* globalResolver로 _global.shopBase 해석을 검증합니다.
*/
public function test_navigate_global_resolver(): void
{
$this->mapper->setGlobalResolver(function (string $expr): ?string {
if (str_contains($expr, 'shopBase')) {
return '/shop';
}
return null;
});
$components = [
[
'name' => 'Button',
'text' => '상품 보기',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '{{_global.shopBase}}/products/123']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="/shop/products/123"', $html);
}
/**
* A 컴포넌트 + 기존 href 있음 → navigate 무시(스킵)를 검증합니다.
*/
public function test_navigate_a_tag_existing_href_skipped(): void
{
$components = [
[
'name' => 'A',
'props' => ['href' => '/existing-link'],
'text' => '기존 링크',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/new-link']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('href="/existing-link"', $html);
$this->assertStringNotContainsString('/new-link', $html);
}
/**
* A 컴포넌트 + href 없음 + navigate → href 주입을 검증합니다.
*/
public function test_navigate_a_tag_no_href_inject(): void
{
$components = [
[
'name' => 'A',
'props' => ['className' => 'link'],
'text' => '링크',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/about']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="/about"', $html);
$this->assertStringContainsString('class="link"', $html);
}
/**
* click 외 이벤트(keydown) → navigate 무시를 검증합니다.
*/
public function test_navigate_non_click_event_ignored(): void
{
$components = [
[
'name' => 'Button',
'text' => '버튼',
'actions' => [
['type' => 'keydown', 'handler' => 'navigate', 'params' => ['path' => '/page']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<button', $html);
$this->assertStringNotContainsString('<a ', $html);
}
/**
* iteration 내 navigate → 각 아이템별 다른 href 생성을 검증합니다.
*/
public function test_navigate_in_iteration(): void
{
$components = [
[
'name' => 'Button',
'iteration' => [
'source' => 'categories',
'item_var' => 'cat',
],
'text' => '{{cat.name}}',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/category/{{cat.slug}}']],
],
],
];
$context = [
'categories' => [
['name' => '전자기기', 'slug' => 'electronics'],
['name' => '의류', 'slug' => 'clothing'],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<a href="/category/electronics"', $html);
$this->assertStringContainsString('<a href="/category/clothing"', $html);
$this->assertStringContainsString('전자기기</a>', $html);
$this->assertStringContainsString('의류</a>', $html);
}
/**
* Button 변환 시 class 보존을 검증합니다.
*/
public function test_navigate_button_preserves_class(): void
{
$components = [
[
'name' => 'Button',
'props' => ['className' => 'btn btn-primary'],
'text' => '이동',
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/page']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="/page"', $html);
$this->assertStringContainsString('class="btn btn-primary"', $html);
$this->assertStringNotContainsString('<button', $html);
}
/**
* self-closing 태그(Img) + navigate → <a>로 래핑을 검증합니다.
*/
public function test_navigate_self_closing_wrapping(): void
{
$components = [
[
'name' => 'Img',
'props' => ['src' => '/banner.jpg', 'alt' => '배너'],
'actions' => [
['handler' => 'navigate', 'params' => ['path' => '/promotions']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="/promotions"><img', $html);
$this->assertStringContainsString('</a>', $html);
}
/**
* openWindow → target="_blank" 자동 부여를 검증합니다.
*/
public function test_open_window_target_blank(): void
{
$components = [
[
'name' => 'Button',
'text' => '외부 링크',
'actions' => [
['handler' => 'openWindow', 'params' => ['path' => 'https://example.com']],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="https://example.com"', $html);
$this->assertStringContainsString('target="_blank"', $html);
$this->assertStringNotContainsString('<button', $html);
}
/**
* sequence 내 openWindow 추출을 검증합니다.
*/
public function test_open_window_in_sequence(): void
{
$components = [
[
'name' => 'Button',
'text' => '새 창',
'actions' => [
[
'handler' => 'sequence',
'actions' => [
['handler' => 'setState', 'params' => ['target' => 'local', 'key' => 'clicked', 'value' => true]],
['handler' => 'openWindow', 'params' => ['path' => 'https://docs.example.com']],
],
],
],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<a href="https://docs.example.com"', $html);
$this->assertStringContainsString('target="_blank"', $html);
}
// ========================================
// fields 렌더 모드 테스트
// ========================================
/**
* fields 모드: ProductCard에서 상품 객체의 주요 필드가 HTML로 렌더링되는지 확인합니다.
*/
public function test_fields_mode_renders_product_card_basic_fields(): void
{
// seoVars 설정 (shopBase 해석용)
$this->mapper->setSeoVars(['shopBase' => 'shop']);
$components = [
[
'name' => 'Div',
'iteration' => [
'source' => '{{products.data}}',
'item_var' => 'product',
],
'children' => [
[
'type' => 'composite',
'name' => 'ProductCard',
'props' => [
'product' => '{{product}}',
],
],
],
],
];
$context = [
'products' => [
'data' => [
[
'id' => 1,
'name' => 'Test Product',
'name_localized' => '테스트 상품',
'thumbnail_url' => '/storage/products/thumb1.jpg',
'selling_price_formatted' => '10,000원',
'list_price_formatted' => '15,000원',
'discount_rate' => 33,
'primary_category' => '의류',
'brand_name' => '나이키',
'sales_status' => 'on_sale',
'sales_status_label' => '판매중',
'labels' => [
['name' => '베스트', 'color' => '#ff0000'],
['name' => '무료배송', 'color' => '#00ff00'],
],
],
],
],
];
$html = $this->render($components, $context);
// link 래핑
$this->assertStringContainsString('<a href="/shop/products/1">', $html);
// 썸네일 이미지
$this->assertStringContainsString('<img', $html);
$this->assertStringContainsString('thumb1.jpg', $html);
$this->assertStringContainsString('alt="테스트 상품"', $html);
// 상품명
$this->assertStringContainsString('<h3>테스트 상품</h3>', $html);
// 카테고리 & 브랜드 (children 그룹 내)
$this->assertStringContainsString('<p><span>의류</span><span>나이키</span></p>', $html);
// 가격 (children 그룹 내)
$this->assertStringContainsString('<span>10,000원</span>', $html);
$this->assertStringContainsString('<del>15,000원</del>', $html);
$this->assertStringContainsString('<span>33%</span>', $html);
// 라벨
$this->assertStringContainsString('<span>베스트</span>', $html);
$this->assertStringContainsString('<span>무료배송</span>', $html);
// 판매 상태
$this->assertStringContainsString('판매중', $html);
}
/**
* fields 모드: 할인율 없는 상품에서 원가/할인율이 렌더링되지 않는지 확인합니다.
*/
public function test_fields_mode_conditional_fields_hidden_when_empty(): void
{
// seoVars 설정 (shopBase 해석용)
$this->mapper->setSeoVars(['shopBase' => 'shop']);
$components = [
[
'type' => 'composite',
'name' => 'ProductCard',
'props' => [
'product' => '{{product}}',
],
],
];
$context = [
'product' => [
'id' => 2,
'name' => 'No Discount',
'name_localized' => '정가 상품',
'thumbnail_url' => '/storage/products/thumb2.jpg',
'selling_price_formatted' => '20,000원',
'list_price_formatted' => '20,000원',
'discount_rate' => 0,
'primary_category' => '',
'brand_name' => '',
'sales_status' => '',
'sales_status_label' => '',
'labels' => [],
],
];
$html = $this->render($components, $context);
// link 래핑
$this->assertStringContainsString('<a href="/shop/products/2">', $html);
// 상품명/가격은 렌더링
$this->assertStringContainsString('<h3>정가 상품</h3>', $html);
$this->assertStringContainsString('20,000원', $html);
// discount_rate=0 → if 조건 falsy → del/할인율 미렌더링
$this->assertStringNotContainsString('<del>', $html);
$this->assertStringNotContainsString('%', $html);
// 빈 카테고리/브랜드 → children 모두 빈값 → <p> 태그 자체 미출력
$this->assertStringNotContainsString('<p><span></span>', $html);
// labels 빈 배열 → <p> 미렌더링
}
/**
* fields 모드: name_localized가 없으면 name으로 fallback되는지 확인합니다.
*/
public function test_fields_mode_field_pattern_fallback(): void
{
$components = [
[
'type' => 'composite',
'name' => 'ProductCard',
'props' => [
'product' => '{{product}}',
],
],
];
$context = [
'product' => [
'name' => 'English Name',
'name_localized' => '',
'thumbnail_url' => '/thumb.jpg',
'selling_price_formatted' => '5,000원',
'discount_rate' => 0,
'labels' => [],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<h3>English Name</h3>', $html);
$this->assertStringContainsString('alt="English Name"', $html);
}
/**
* fields 모드: 소스 객체가 빈 경우 빈 article 태그만 렌더링되는지 확인합니다.
*/
public function test_fields_mode_empty_source_renders_empty(): void
{
$components = [
[
'type' => 'composite',
'name' => 'ProductCard',
'props' => [
'product' => '{{nonexistent}}',
],
],
];
$html = $this->render($components);
// fields 모드에서 source가 null → innerHtml 빈 문자열
$this->assertStringContainsString('<article>', $html);
}
/**
* fields 모드: 여러 상품이 iteration으로 반복 렌더링되는지 확인합니다.
*/
public function test_fields_mode_multiple_products_via_iteration(): void
{
// seoVars 설정 (shopBase 해석용)
$this->mapper->setSeoVars(['shopBase' => 'shop']);
$components = [
[
'name' => 'Div',
'iteration' => [
'source' => '{{products.data}}',
'item_var' => 'product',
],
'children' => [
[
'type' => 'composite',
'name' => 'ProductCard',
'props' => [
'product' => '{{product}}',
],
],
],
],
];
$context = [
'products' => [
'data' => [
[
'id' => 1,
'name_localized' => '상품A',
'thumbnail_url' => '/a.jpg',
'selling_price_formatted' => '1,000원',
'discount_rate' => 0,
'labels' => [],
],
[
'id' => 2,
'name_localized' => '상품B',
'thumbnail_url' => '/b.jpg',
'selling_price_formatted' => '2,000원',
'discount_rate' => 10,
'list_price_formatted' => '2,200원',
'labels' => [['name' => '신상']],
],
],
],
];
$html = $this->render($components, $context);
// 각 상품별 다른 링크 URL
$this->assertStringContainsString('<a href="/shop/products/1">', $html);
$this->assertStringContainsString('<a href="/shop/products/2">', $html);
$this->assertStringContainsString('상품A', $html);
$this->assertStringContainsString('상품B', $html);
$this->assertStringContainsString('1,000원', $html);
$this->assertStringContainsString('2,000원', $html);
$this->assertStringContainsString('<span>신상</span>', $html);
$this->assertEquals(2, substr_count($html, '<article'));
}
/**
* fields 모드에서 각 필드 항목 사이에 줄바꿈이 삽입됩니다.
*/
public function test_fields_mode_entries_separated_by_newlines(): void
{
$components = [
[
'type' => 'composite',
'name' => 'ProductCard',
'props' => [
'product' => '{{product}}',
],
],
];
$context = [
'product' => [
'name_localized' => '테스트 상품',
'thumbnail_url' => '/test.jpg',
'selling_price_formatted' => '5,000원',
'discount_rate' => 0,
'labels' => [],
],
];
$html = $this->render($components, $context);
// 줄바꿈으로 분리된 필드 엔트리 확인
$lines = array_filter(explode("\n", $html), fn ($line) => trim($line) !== '');
$this->assertGreaterThan(1, count($lines), 'fields 모드 출력에 여러 줄이 있어야 합니다');
// 주요 필드가 별도 줄에 있는지 확인
$this->assertStringContainsString('테스트 상품', $html);
$this->assertStringContainsString('5,000원', $html);
}
// ========================================
// fields 모드 — children 그룹핑 + link 래핑 테스트
// ========================================
/**
* fields 모드: children 그룹핑 — <p> 래퍼 내 <span> 2개 정상 렌더링
*/
public function test_fields_mode_children_grouping(): void
{
$this->mapper->setRenderModes([
'test_children' => [
'type' => 'fields',
'source' => '$props_source',
'fields' => [
[
'tag' => 'p',
'children' => [
['tag' => 'span', 'content' => '{category}'],
['tag' => 'span', 'content' => '{brand}'],
],
],
],
],
]);
$this->mapper->setComponentMap([
'TestComp' => ['tag' => 'div', 'render' => 'test_children', 'props_source' => 'item'],
]);
$components = [
['type' => 'composite', 'name' => 'TestComp', 'props' => ['item' => '{{item}}']],
];
$context = ['item' => ['category' => '의류', 'brand' => '나이키']];
$html = $this->render($components, $context);
$this->assertStringContainsString('<p><span>의류</span><span>나이키</span></p>', $html);
}
/**
* fields 모드: children 그룹핑 — 모든 자식이 조건부+빈값이면 <p> 태그 미출력
*/
public function test_fields_mode_children_all_empty(): void
{
$this->mapper->setRenderModes([
'test_children_empty' => [
'type' => 'fields',
'source' => '$props_source',
'fields' => [
['tag' => 'h3', 'content' => '{name}'],
[
'tag' => 'p',
'children' => [
['tag' => 'span', 'content' => '{category}', 'if' => '{category}'],
['tag' => 'span', 'content' => '{brand}', 'if' => '{brand}'],
],
],
],
],
]);
$this->mapper->setComponentMap([
'TestComp' => ['tag' => 'div', 'render' => 'test_children_empty', 'props_source' => 'item'],
]);
$components = [
['type' => 'composite', 'name' => 'TestComp', 'props' => ['item' => '{{item}}']],
];
$context = ['item' => ['name' => '상품', 'category' => '', 'brand' => '']];
$html = $this->render($components, $context);
$this->assertStringContainsString('<h3>상품</h3>', $html);
// 빈 children → <p> 미출력
$this->assertStringNotContainsString('<p>', $html);
}
/**
* fields 모드: link 래핑 — <a href="/products/42"> 전체 필드 래핑
*/
public function test_fields_mode_link_wrapping(): void
{
$this->mapper->setRenderModes([
'test_link' => [
'type' => 'fields',
'source' => '$props_source',
'link' => ['href' => '/products/{id}'],
'fields' => [
['tag' => 'h3', 'content' => '{name}'],
],
],
]);
$this->mapper->setComponentMap([
'TestComp' => ['tag' => 'div', 'render' => 'test_link', 'props_source' => 'item'],
]);
$components = [
['type' => 'composite', 'name' => 'TestComp', 'props' => ['item' => '{{item}}']],
];
$context = ['item' => ['id' => 42, 'name' => '테스트']];
$html = $this->render($components, $context);
$this->assertStringContainsString('<a href="/products/42">', $html);
$this->assertStringContainsString('<h3>테스트</h3>', $html);
$this->assertStringContainsString('</a>', $html);
}
/**
* fields 모드: link + $var:shopBase → /shop/products/1
*/
public function test_fields_mode_link_with_var_base_url(): void
{
$this->mapper->setSeoVars(['shopBase' => 'shop']);
$this->mapper->setRenderModes([
'test_link_var' => [
'type' => 'fields',
'source' => '$props_source',
'link' => ['href' => '/products/{id}', 'base_url' => '$var:shopBase'],
'fields' => [
['tag' => 'h3', 'content' => '{name}'],
],
],
]);
$this->mapper->setComponentMap([
'TestComp' => ['tag' => 'div', 'render' => 'test_link_var', 'props_source' => 'item'],
]);
$components = [
['type' => 'composite', 'name' => 'TestComp', 'props' => ['item' => '{{item}}']],
];
$context = ['item' => ['id' => 1, 'name' => '테스트']];
$html = $this->render($components, $context);
// $var:shopBase → 'shop' → '/shop' (/ 자동 접두사) → /shop/products/1
$this->assertStringContainsString('<a href="/shop/products/1">', $html);
}
/**
* fields 모드: seoVars 미설정 시 $var: base_url은 빈 문자열, href만 사용
*/
public function test_fields_mode_link_no_var_fallback(): void
{
// seoVars 미설정 → $var:shopBase 해석 불가 → base_url 빈 문자열
$this->mapper->setRenderModes([
'test_link_no_var' => [
'type' => 'fields',
'source' => '$props_source',
'link' => ['href' => '/products/{id}', 'base_url' => '$var:shopBase'],
'fields' => [
['tag' => 'h3', 'content' => '{name}'],
],
],
]);
$this->mapper->setComponentMap([
'TestComp' => ['tag' => 'div', 'render' => 'test_link_no_var', 'props_source' => 'item'],
]);
$components = [
['type' => 'composite', 'name' => 'TestComp', 'props' => ['item' => '{{item}}']],
];
$context = ['item' => ['id' => 5, 'name' => '상품']];
$html = $this->render($components, $context);
// base_url 해석 불가 → href만 사용
$this->assertStringContainsString('<a href="/products/5">', $html);
}
/**
* fields 모드: base_url="/" → 이중 슬래시 방지 → /products/1
*/
public function test_fields_mode_link_base_url_slash(): void
{
$this->mapper->setRenderModes([
'test_link_slash' => [
'type' => 'fields',
'source' => '$props_source',
'link' => ['href' => '/products/{id}', 'base_url' => '/'],
'fields' => [
['tag' => 'h3', 'content' => '{name}'],
],
],
]);
$this->mapper->setComponentMap([
'TestComp' => ['tag' => 'div', 'render' => 'test_link_slash', 'props_source' => 'item'],
]);
$components = [
['type' => 'composite', 'name' => 'TestComp', 'props' => ['item' => '{{item}}']],
];
$context = ['item' => ['id' => 1, 'name' => '상품']];
$html = $this->render($components, $context);
// base_url="/" → 빈 문자열 변환 → 이중 슬래시 방지
$this->assertStringContainsString('<a href="/products/1">', $html);
$this->assertStringNotContainsString('//products', $html);
}
/**
* fields 모드: id 필드 없으면 link 미생성, 필드만 출력
*/
public function test_fields_mode_link_missing_id(): void
{
$this->mapper->setRenderModes([
'test_link_no_id' => [
'type' => 'fields',
'source' => '$props_source',
'link' => ['href' => '/products/{id}'],
'fields' => [
['tag' => 'h3', 'content' => '{name}'],
],
],
]);
$this->mapper->setComponentMap([
'TestComp' => ['tag' => 'div', 'render' => 'test_link_no_id', 'props_source' => 'item'],
]);
$components = [
['type' => 'composite', 'name' => 'TestComp', 'props' => ['item' => '{{item}}']],
];
// id 키 미포함
$context = ['item' => ['name' => '상품']];
$html = $this->render($components, $context);
// <a> 태그 미생성
$this->assertStringNotContainsString('<a ', $html);
// 필드는 정상 렌더링
$this->assertStringContainsString('<h3>상품</h3>', $html);
}
/**
* fields 모드: link/children 없는 기존 flat 설정 → 동일 동작 (하위 호환)
*/
public function test_fields_mode_backward_compatible_flat(): void
{
$this->mapper->setRenderModes([
'test_flat' => [
'type' => 'fields',
'source' => '$props_source',
'fields' => [
['tag' => 'h3', 'content' => '{name}'],
['tag' => 'span', 'content' => '{price}'],
['tag' => 'span', 'content' => '{category}', 'if' => '{category}'],
],
],
]);
$this->mapper->setComponentMap([
'TestComp' => ['tag' => 'div', 'render' => 'test_flat', 'props_source' => 'item'],
]);
$components = [
['type' => 'composite', 'name' => 'TestComp', 'props' => ['item' => '{{item}}']],
];
$context = ['item' => ['name' => '상품', 'price' => '1,000원', 'category' => '의류']];
$html = $this->render($components, $context);
// link/children 없음 → <a> 미생성, flat 렌더링
$this->assertStringNotContainsString('<a ', $html);
$this->assertStringContainsString('<h3>상품</h3>', $html);
$this->assertStringContainsString('<span>1,000원</span>', $html);
$this->assertStringContainsString('<span>의류</span>', $html);
}
// =========================================================================
// seoVars (meta.seo.vars → format 모드 변수 주입) 테스트
// =========================================================================
/**
* seoVars 설정 시 Header의 {site_name}이 seoVars 값으로 치환됩니다.
*/
public function test_format_mode_seo_vars_override_defaults(): void
{
$this->mapper->setSeoVars(['site_name' => 'My Store']);
$components = [
[
'name' => 'Header',
'props' => [],
],
];
$html = $this->render($components);
$this->assertStringContainsString('<header', $html);
$this->assertStringContainsString('My Store', $html);
$this->assertStringNotContainsString('G7', $html);
}
/**
* seoVars 설정 시 Footer의 {site_name}도 seoVars 값으로 치환됩니다.
*/
public function test_format_mode_seo_vars_footer(): void
{
$this->mapper->setSeoVars(['site_name' => '테스트 사이트']);
$components = [
[
'name' => 'Footer',
'props' => [],
],
];
$html = $this->render($components);
$this->assertStringContainsString('© 테스트 사이트', $html);
$this->assertStringNotContainsString('G7', $html);
}
/**
* props가 seoVars보다 우선합니다.
*/
public function test_format_mode_props_override_seo_vars(): void
{
$this->mapper->setSeoVars(['site_name' => 'Vars Site']);
$components = [
[
'name' => 'Header',
'props' => ['site_name' => 'Props Site'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('Props Site', $html);
$this->assertStringNotContainsString('Vars Site', $html);
}
/**
* seoVars 미설정 시 기존대로 defaults가 사용됩니다.
*/
public function test_format_mode_no_seo_vars_uses_defaults(): void
{
// setSeoVars 호출하지 않음
$components = [
[
'name' => 'Header',
'props' => [],
],
];
$html = $this->render($components);
$this->assertStringContainsString('G7', $html);
}
/**
* seoVars에 빈 문자열이면 defaults로 폴백합니다.
*/
public function test_format_mode_empty_seo_vars_falls_back_to_defaults(): void
{
$this->mapper->setSeoVars(['site_name' => '']);
$components = [
[
'name' => 'Header',
'props' => [],
],
];
$html = $this->render($components);
$this->assertStringContainsString('G7', $html);
}
/**
* seoVars는 format 모드에서만 작동하며 일반 태그 렌더링에는 영향 없습니다.
*/
public function test_seo_vars_do_not_affect_normal_tag_rendering(): void
{
$this->mapper->setSeoVars(['site_name' => 'Should Not Appear']);
$components = [
[
'name' => 'Div',
'props' => ['className' => 'test'],
'text' => 'Normal Content',
],
];
$html = $this->render($components);
$this->assertStringContainsString('Normal Content', $html);
$this->assertStringNotContainsString('Should Not Appear', $html);
}
/**
* StarRating의 props가 seoVars보다 우선하여 렌더링됩니다.
*/
public function test_format_mode_star_rating_props_over_seo_vars(): void
{
$this->mapper->setSeoVars(['max' => '10']);
$components = [
[
'name' => 'StarRating',
'props' => ['value' => '4.5', 'max' => '5'],
],
];
$html = $this->render($components);
$this->assertStringContainsString('4.5 / 5', $html);
$this->assertStringNotContainsString('4.5 / 10', $html);
}
// ==========================================
// text_format dot notation (객체 prop에서 중첩 필드 추출)
// ==========================================
/**
* Avatar 컴포넌트: author 객체에서 nickname을 추출합니다.
*/
public function test_avatar_renders_author_nickname(): void
{
$context = [
'comment' => [
'author' => ['id' => 1, 'nickname' => '홍길동', 'profile_photo_url' => '/avatar.jpg'],
],
];
$components = [
[
'name' => 'Avatar',
'props' => ['author' => '{{comment?.author}}'],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<span>', $html);
$this->assertStringContainsString('홍길동', $html);
}
/**
* UserInfo 컴포넌트: author nickname + subText(날짜)를 렌더링합니다.
*/
public function test_userinfo_renders_author_and_date(): void
{
$context = [
'comment' => [
'author' => ['id' => 1, 'nickname' => '김철수'],
'created_at' => '2026-03-19 10:00:00',
],
];
$components = [
[
'name' => 'UserInfo',
'props' => [
'author' => '{{comment?.author}}',
'subText' => '{{comment?.created_at}}',
],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('김철수', $html);
$this->assertStringContainsString('2026-03-19 10:00:00', $html);
$this->assertStringContainsString('·', $html);
}
/**
* text_format dot notation: author가 null이면 빈 문자열을 출력합니다.
*/
public function test_text_format_dot_notation_null_object(): void
{
$context = [];
$components = [
[
'name' => 'Avatar',
'props' => ['author' => '{{missing?.author}}'],
],
];
$html = $this->render($components, $context);
// nickname 없으면 빈 span
$this->assertStringNotContainsString('null', $html);
}
/**
* text_format dot notation: 깊은 경로도 해석합니다.
*/
public function test_text_format_deep_dot_notation(): void
{
// 커스텀 컴포넌트맵으로 깊은 dot notation 테스트
$componentMap = $this->getDefaultComponentMap();
$componentMap['DeepTest'] = [
'tag' => 'span',
'render' => 'text_format',
'format' => '{data.nested.name}',
];
$this->mapper->setComponentMap($componentMap);
$context = [
'item' => [
'nested' => ['name' => '깊은값'],
],
];
$components = [
[
'name' => 'DeepTest',
'props' => ['data' => '{{item}}'],
],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('깊은값', $html);
}
// ===================================================================
// pagination 렌더 모드 테스트
// ===================================================================
/**
* Pagination — 여러 페이지가 있으면 페이지 링크 생성
*/
public function test_pagination_renders_page_links(): void
{
$components = [
[
'type' => 'composite',
'name' => 'Pagination',
'props' => [
'currentPage' => '{{posts?.data?.pagination?.current_page ?? 1}}',
'totalPages' => '{{posts?.data?.pagination?.last_page ?? 1}}',
],
],
];
$context = [
'posts' => ['data' => ['pagination' => ['current_page' => 1, 'last_page' => 5]]],
'route' => ['path' => '/board/free'],
];
$html = $this->render($components, $context);
$this->assertStringContainsString('<nav', $html);
$this->assertStringContainsString('<span>1</span>', $html);
$this->assertStringContainsString('<a href="/board/free?page=2">2</a>', $html);
$this->assertStringContainsString('<a href="/board/free?page=5">5</a>', $html);
}
/**
* Pagination — 1 페이지만 있으면 빈 nav
*/
public function test_pagination_single_page_renders_empty(): void
{
$components = [
[
'type' => 'composite',
'name' => 'Pagination',
'props' => [
'currentPage' => '1',
'totalPages' => '1',
],
],
];
$context = ['route' => ['path' => '/board/free']];
$html = $this->render($components, $context);
$this->assertStringNotContainsString('<a href', $html);
}
/**
* Pagination — 현재 페이지는 span, 나머지는 a 태그
*/
public function test_pagination_current_page_is_span(): void
{
$components = [
[
'type' => 'composite',
'name' => 'Pagination',
'props' => [
'currentPage' => '3',
'totalPages' => '5',
],
],
];
$context = ['route' => ['path' => '/board/gallery']];
$html = $this->render($components, $context);
$this->assertStringContainsString('<span>3</span>', $html);
$this->assertStringContainsString('<a href="/board/gallery?page=1">1</a>', $html);
$this->assertStringContainsString('<a href="/board/gallery?page=4">4</a>', $html);
}
// =========================================================================
// $all_props source + item_attrs 테스트
// =========================================================================
/**
* fields 모드에서 source: $all_props 사용 시 모든 props를 해석하여 데이터로 사용합니다.
*/
public function test_fields_mode_all_props_renders_site_name(): void
{
$this->mapper->setRenderModes(array_merge($this->getDefaultRenderModes(), [
'header_nav' => [
'type' => 'fields',
'source' => '$all_props',
'fields' => [
['tag' => 'a', 'attrs' => ['href' => '/'], 'content' => '{siteName}'],
],
],
]));
$this->mapper->setComponentMap(array_merge($this->getDefaultComponentMap(), [
'Header' => ['tag' => 'header', 'render' => 'header_nav'],
]));
$html = $this->render([
[
'name' => 'Header',
'props' => [
'siteName' => '{{_global.settings.general.site_name}}',
],
],
], [
'_global' => ['settings' => ['general' => ['site_name' => '테스트사이트']]],
]);
$this->assertStringContainsString('<header>', $html);
$this->assertStringContainsString('<a href="/">테스트사이트</a>', $html);
$this->assertStringContainsString('</header>', $html);
}
/**
* fields 모드에서 source: $all_props + iterate로 boards 네비게이션을 렌더링합니다.
*/
public function test_fields_mode_all_props_renders_boards_navigation(): void
{
$this->mapper->setRenderModes(array_merge($this->getDefaultRenderModes(), [
'header_nav' => [
'type' => 'fields',
'source' => '$all_props',
'fields' => [
['tag' => 'a', 'attrs' => ['href' => '/'], 'content' => '{siteName}'],
[
'tag' => 'nav',
'iterate' => 'boards',
'item_tag' => 'a',
'item_content' => '{name}',
'item_attrs' => ['href' => '/board/{slug}'],
],
],
],
]));
$this->mapper->setComponentMap(array_merge($this->getDefaultComponentMap(), [
'Header' => ['tag' => 'header', 'render' => 'header_nav'],
]));
$html = $this->render([
[
'name' => 'Header',
'props' => [
'siteName' => '{{_global.settings.general.site_name}}',
'boards' => '{{boards.data}}',
],
],
], [
'_global' => ['settings' => ['general' => ['site_name' => 'G7']]],
'boards' => [
'data' => [
['name' => '공지사항', 'slug' => 'notice'],
['name' => '자유게시판', 'slug' => 'free'],
],
],
]);
$this->assertStringContainsString('<a href="/">G7</a>', $html);
$this->assertStringContainsString('<a href="/board/notice">공지사항</a>', $html);
$this->assertStringContainsString('<a href="/board/free">자유게시판</a>', $html);
}
/**
* fields 모드에서 source: $all_props + 중첩 객체 (socialLinks) dot notation if 조건을 지원합니다.
*/
public function test_fields_mode_all_props_renders_footer_with_social_links(): void
{
$this->mapper->setRenderModes(array_merge($this->getDefaultRenderModes(), [
'footer_nav' => [
'type' => 'fields',
'source' => '$all_props',
'fields' => [
['tag' => 'p', 'content' => '{siteDescription}', 'if' => '{siteDescription}'],
[
'tag' => 'nav',
'children' => [
['tag' => 'a', 'attrs' => ['href' => '{socialLinks.github}'], 'content' => 'GitHub', 'if' => '{socialLinks.github}'],
['tag' => 'a', 'attrs' => ['href' => '{socialLinks.twitter}'], 'content' => 'Twitter', 'if' => '{socialLinks.twitter}'],
['tag' => 'a', 'attrs' => ['href' => '{socialLinks.discord}'], 'content' => 'Discord', 'if' => '{socialLinks.discord}'],
],
],
['tag' => 'p', 'content' => '© {siteName}'],
],
],
]));
$this->mapper->setComponentMap(array_merge($this->getDefaultComponentMap(), [
'Footer' => ['tag' => 'footer', 'render' => 'footer_nav'],
]));
$html = $this->render([
[
'name' => 'Footer',
'props' => [
'siteName' => '{{_global.settings.general.site_name}}',
'siteDescription' => '{{_global.settings.general.site_description}}',
'socialLinks' => [
'github' => '{{_global.settings.social.github}}',
'twitter' => '{{_global.settings.social.twitter}}',
'discord' => '{{_global.settings.social.discord}}',
],
],
],
], [
'_global' => [
'settings' => [
'general' => ['site_name' => 'G7', 'site_description' => '오픈소스 CMS'],
'social' => ['github' => 'https://github.com/g7', 'twitter' => '', 'discord' => 'https://discord.gg/g7'],
],
],
]);
$this->assertStringContainsString('<footer>', $html);
$this->assertStringContainsString('<p>오픈소스 CMS</p>', $html);
$this->assertStringContainsString('<a href="https://github.com/g7">GitHub</a>', $html);
$this->assertStringNotContainsString('Twitter', $html); // twitter가 빈 문자열이므로 미렌더링
$this->assertStringContainsString('<a href="https://discord.gg/g7">Discord</a>', $html);
$this->assertStringContainsString('<p>© G7</p>', $html);
$this->assertStringContainsString('</footer>', $html);
}
/**
* iterate에서 item_attrs를 지원하여 아이템별 동적 속성을 렌더링합니다.
*/
public function test_field_iterate_with_item_attrs(): void
{
$this->mapper->setRenderModes(array_merge($this->getDefaultRenderModes(), [
'nav_links' => [
'type' => 'fields',
'source' => '$props_source',
'fields' => [
[
'tag' => 'nav',
'iterate' => 'items',
'item_tag' => 'a',
'item_content' => '{title}',
'item_attrs' => ['href' => '{url}'],
],
],
],
]));
$this->mapper->setComponentMap(array_merge($this->getDefaultComponentMap(), [
'NavLinks' => ['tag' => 'div', 'render' => 'nav_links', 'props_source' => 'data'],
]));
$html = $this->render([
[
'name' => 'NavLinks',
'props' => [
'data' => '{{links}}',
],
],
], [
'links' => [
'items' => [
['title' => '홈', 'url' => '/'],
['title' => '소개', 'url' => '/about'],
],
],
]);
$this->assertStringContainsString('<a href="/">홈</a>', $html);
$this->assertStringContainsString('<a href="/about">소개</a>', $html);
}
/**
* fields 모드에서 $t: 번역 키를 content에서 해석합니다.
*/
public function test_fields_mode_translates_t_keys_in_content(): void
{
// 번역 사전 설정
$this->evaluator->setTranslations([
'nav' => ['home' => '홈', 'popular' => '인기', 'shop' => '쇼핑'],
'footer' => ['community' => '커뮤니티', 'info' => '정보'],
]);
$this->mapper->setRenderModes(array_merge($this->getDefaultRenderModes(), [
'header_nav' => [
'type' => 'fields',
'source' => '$all_props',
'fields' => [
['tag' => 'a', 'attrs' => ['href' => '/'], 'content' => '{siteName}'],
[
'tag' => 'nav',
'children' => [
['tag' => 'a', 'attrs' => ['href' => '/'], 'content' => '$t:nav.home'],
['tag' => 'a', 'attrs' => ['href' => '/boards/popular'], 'content' => '$t:nav.popular'],
['tag' => 'a', 'attrs' => ['href' => '/shop/products'], 'content' => '$t:nav.shop'],
],
],
],
],
]));
$this->mapper->setComponentMap(array_merge($this->getDefaultComponentMap(), [
'Header' => ['tag' => 'header', 'render' => 'header_nav'],
]));
$html = $this->render([
[
'name' => 'Header',
'props' => [
'siteName' => '{{_global.settings.general.site_name}}',
'boards' => '{{boards.data}}',
],
],
], [
'_global' => ['settings' => ['general' => ['site_name' => 'G7']]],
'boards' => ['data' => []],
]);
$this->assertStringContainsString('<a href="/">G7</a>', $html);
$this->assertStringContainsString('<a href="/">홈</a>', $html);
$this->assertStringContainsString('<a href="/boards/popular">인기</a>', $html);
$this->assertStringContainsString('<a href="/shop/products">쇼핑</a>', $html);
}
/**
* fields 모드에서 $t: 번역 키를 children 내 중첩 구조에서 해석합니다.
*/
public function test_fields_mode_translates_t_keys_in_footer_link_groups(): void
{
$this->evaluator->setTranslations([
'footer' => [
'community' => '커뮤니티',
'info' => '정보',
'policy' => '정책',
'all_boards' => '전체 게시판',
'about' => '회사소개',
'terms' => '이용약관',
],
'nav' => ['home' => '홈', 'popular' => '인기'],
]);
$this->mapper->setRenderModes(array_merge($this->getDefaultRenderModes(), [
'footer_nav' => [
'type' => 'fields',
'source' => '$all_props',
'fields' => [
['tag' => 'p', 'content' => '{siteName}'],
[
'tag' => 'div',
'children' => [
[
'tag' => 'div',
'children' => [
['tag' => 'h4', 'content' => '$t:footer.community'],
['tag' => 'a', 'attrs' => ['href' => '/'], 'content' => '$t:nav.home'],
['tag' => 'a', 'attrs' => ['href' => '/boards/popular'], 'content' => '$t:nav.popular'],
['tag' => 'a', 'attrs' => ['href' => '/boards'], 'content' => '$t:footer.all_boards'],
],
],
[
'tag' => 'div',
'children' => [
['tag' => 'h4', 'content' => '$t:footer.info'],
['tag' => 'a', 'attrs' => ['href' => '/page/about'], 'content' => '$t:footer.about'],
],
],
[
'tag' => 'div',
'children' => [
['tag' => 'h4', 'content' => '$t:footer.policy'],
['tag' => 'a', 'attrs' => ['href' => '/page/terms'], 'content' => '$t:footer.terms'],
],
],
],
],
['tag' => 'p', 'content' => '© {siteName}. All rights reserved.'],
['tag' => 'p', 'content' => 'Powered by G7'],
],
],
]));
$this->mapper->setComponentMap(array_merge($this->getDefaultComponentMap(), [
'Footer' => ['tag' => 'footer', 'render' => 'footer_nav'],
]));
$html = $this->render([
[
'name' => 'Footer',
'props' => [
'siteName' => '{{_global.settings.general.site_name}}',
],
],
], [
'_global' => ['settings' => ['general' => ['site_name' => 'G7']]],
]);
$this->assertStringContainsString('<footer>', $html);
$this->assertStringContainsString('<p>G7</p>', $html);
$this->assertStringContainsString('<h4>커뮤니티</h4>', $html);
$this->assertStringContainsString('<a href="/">홈</a>', $html);
$this->assertStringContainsString('<a href="/boards/popular">인기</a>', $html);
$this->assertStringContainsString('<a href="/boards">전체 게시판</a>', $html);
$this->assertStringContainsString('<h4>정보</h4>', $html);
$this->assertStringContainsString('<a href="/page/about">회사소개</a>', $html);
$this->assertStringContainsString('<h4>정책</h4>', $html);
$this->assertStringContainsString('<a href="/page/terms">이용약관</a>', $html);
$this->assertStringContainsString('<p>© G7. All rights reserved.</p>', $html);
$this->assertStringContainsString('<p>Powered by G7</p>', $html);
$this->assertStringContainsString('</footer>', $html);
}
// =========================================================================
// classMap (조건부 CSS 클래스) 테스트
// =========================================================================
/**
* classMap의 key 표현식이 variants와 매칭되면 해당 클래스가 적용됩니다.
*/
public function test_classmap_resolves_matching_variant(): void
{
$components = [[
'type' => 'basic',
'name' => 'Span',
'classMap' => [
'base' => 'px-2 py-1 rounded-full text-xs',
'variants' => [
'active' => 'bg-green-100 text-green-800',
'inactive' => 'bg-gray-100 text-gray-600',
],
'key' => '{{product.status}}',
'default' => 'bg-gray-100',
],
'text' => '활성',
]];
$context = ['product' => ['status' => 'active']];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('px-2 py-1 rounded-full text-xs bg-green-100 text-green-800', $html);
}
/**
* classMap의 key가 variants에 없으면 default 클래스가 적용됩니다.
*/
public function test_classmap_falls_back_to_default(): void
{
$components = [[
'type' => 'basic',
'name' => 'Span',
'classMap' => [
'base' => 'px-2 py-1',
'variants' => [
'active' => 'bg-green-100',
],
'key' => '{{product.status}}',
'default' => 'bg-gray-100',
],
'text' => '상태',
]];
$context = ['product' => ['status' => 'unknown']];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('px-2 py-1 bg-gray-100', $html);
$this->assertStringNotContainsString('bg-green-100', $html);
}
/**
* classMap과 기존 className이 함께 사용되면 클래스가 병합됩니다.
*/
public function test_classmap_merges_with_existing_classname(): void
{
$components = [[
'type' => 'basic',
'name' => 'Span',
'props' => [
'className' => 'cursor-pointer hover:opacity-80',
],
'classMap' => [
'base' => 'px-2',
'variants' => [
'active' => 'bg-blue-500',
],
'key' => '{{status}}',
],
'text' => 'test',
]];
$context = ['status' => 'active'];
$html = $this->mapper->render($components, $context, $this->evaluator);
// className + classMap base + variant 순서로 병합
$this->assertStringContainsString('cursor-pointer hover:opacity-80 px-2 bg-blue-500', $html);
}
/**
* classMap에 base가 없어도 variant 클래스만 적용됩니다.
*/
public function test_classmap_without_base(): void
{
$components = [[
'type' => 'basic',
'name' => 'Span',
'classMap' => [
'variants' => [
'success' => 'text-green-500',
'error' => 'text-red-500',
],
'key' => '{{result}}',
],
'text' => 'ok',
]];
$context = ['result' => 'success'];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('text-green-500', $html);
$this->assertStringNotContainsString('text-red-500', $html);
}
/**
* classMap의 key가 빈 문자열이면 default가 적용됩니다.
*/
public function test_classmap_empty_key_uses_default(): void
{
$components = [[
'type' => 'basic',
'name' => 'Span',
'classMap' => [
'base' => 'base-class',
'variants' => [
'active' => 'active-class',
],
'key' => '{{missing_field}}',
'default' => 'default-class',
],
'text' => 'test',
]];
$context = [];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('base-class default-class', $html);
}
/**
* Select 의 options 는 option 목록으로 렌더링됩니다.
*
* 일반 화면은 option 라벨을 그리는데 봇 화면에는 선택값(내부 식별자)이 글자로 나가고
* 있었다 (`<select>created_at_desc</select>`).
*/
public function test_select_renders_option_list_from_options_prop(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => [
'className' => 'w-40',
'value' => 'created_at_desc',
'options' => '{{sortOptions ?? []}}',
],
]];
$context = ['sortOptions' => [
['value' => 'created_at_desc', 'label' => '최신순'],
['value' => 'rating_desc', 'label' => '평점 높은순'],
]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<option value="created_at_desc">최신순</option>', $html);
$this->assertStringContainsString('<option value="rating_desc">평점 높은순</option>', $html);
$this->assertStringNotContainsString('>created_at_desc<', $html);
}
/**
* options 가 없는 Select 는 선택값을 글자로 노출하지 않습니다.
*/
public function test_select_without_options_does_not_leak_value_as_text(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['value' => 'created_at_desc'],
]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertSame('<select></select>', $html);
}
/**
* children 으로 정의된 Option 은 option 태그로 보존됩니다.
*/
public function test_select_children_options_are_preserved(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['value' => 'a'],
'children' => [
['type' => 'basic', 'name' => 'Option', 'props' => ['value' => 'a'], 'text' => '가나다'],
['type' => 'basic', 'name' => 'Option', 'props' => ['value' => 'b'], 'text' => '라마바'],
],
]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringContainsString('<option>가나다</option>', $html);
$this->assertStringContainsString('<option>라마바</option>', $html);
}
/**
* Input/Textarea 도 선택값을 글자로 노출하지 않습니다.
*/
public function test_form_controls_do_not_render_value_prop_as_text(): void
{
$components = [
['type' => 'basic', 'name' => 'Input', 'props' => ['value' => 'internal_code']],
['type' => 'basic', 'name' => 'Textarea', 'props' => ['value' => 'internal_code']],
];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringNotContainsString('internal_code', $html);
}
/**
* 폼 제어가 아닌 태그는 종전대로 value prop 을 텍스트로 승계합니다 (기존 동작 보존).
*/
public function test_non_form_tag_still_uses_value_prop_as_text(): void
{
$components = [['type' => 'basic', 'name' => 'Span', 'props' => ['value' => '표시할 값']]];
$html = $this->mapper->render($components, [], $this->evaluator);
$this->assertStringContainsString('>표시할 값<', $html);
}
/**
* iterate 모드에서 item_attrs 와 item_content 는 함께 적용됩니다.
*/
public function test_iterate_mode_applies_attrs_and_content_together(): void
{
$components = [[
'type' => 'basic',
'name' => 'Select',
'props' => ['options' => '{{items ?? []}}'],
]];
$context = ['items' => [['value' => 'v1', 'label' => '라벨1']]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<option value="v1">라벨1</option>', $html);
}
/**
* 속성만 선언된 iterate 모드(image_gallery)는 종전 동작을 유지합니다.
*/
public function test_iterate_mode_attrs_only_keeps_previous_behaviour(): void
{
$components = [[
'type' => 'composite',
'name' => 'ProductImageViewer',
'props' => ['images' => '{{images ?? []}}'],
]];
$context = ['images' => [['url' => 'https://cdn.test/a.png', 'alt' => '이미지']]];
$html = $this->mapper->render($components, $context, $this->evaluator);
$this->assertStringContainsString('<img src="https://cdn.test/a.png" alt="이미지">', $html);
}
}