Files
Gnuboard7/tests/Unit/Seo/SeoSwitchValueParityTest.php
T
HeuJung 5ba7a83597 feat(core,engine): 부트스트랩 리소스 정적 게시(bake) 및 폴백 체계 도입
공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩
리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전
디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접
서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms),
미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다.

- 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC
 (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거
 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄,
 sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외
- 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거),
 fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트,
 ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환
- 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control,
 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess
 mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지)
- SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch
 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설,
 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류
- 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3,
 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋
 이력 전수 재조사 반영
- 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
2026-08-25 17:02:53 +09:00

179 lines
5.8 KiB
PHP

<?php
namespace Tests\Unit\Seo;
use App\Seo\ComponentHtmlMapper;
use App\Seo\ExpressionEvaluator;
use Tests\TestCase;
/**
* props 값의 `$switch`/`$cases` 객체 해석이 React 렌더러와 같은지 감시합니다.
*
* 배경 (engine-v1.56.0 패리티):
* React `DataBindingEngine.resolveObject` 는 `{ "$switch": "{{expr}}", "$cases": {...},
* "$default": ... }` 형태의 props 값을 선언적 분기로 해석한다 (일반 경로 + 반복 경로).
* 봇(SEO) 렌더러가 이를 모르면 그 prop 은 배열이라는 이유로 **속성 자체가 조용히
* 사라진다** — 예외도 경고도 없다. 레이아웃 최상위 `computed` 의 `$switch` 는
* `SeoRenderer::resolveComputedSwitch` 가 별도로 처리하며, 이 테스트는 **노드 props
* 값** 축을 고정한다.
*
* 범위: props(속성) 축. 노드 `text` 키는 React 도 문자열만 해석하므로 대상이 아니다.
*
* @see ComponentHtmlMapper::buildAttributes()
*/
class SeoSwitchValueParityTest extends TestCase
{
private ComponentHtmlMapper $mapper;
private ExpressionEvaluator $evaluator;
protected function setUp(): void
{
parent::setUp();
$this->mapper = new ComponentHtmlMapper;
$this->evaluator = new ExpressionEvaluator;
$this->mapper->setComponentMap([
'Div' => ['tag' => 'div'],
'Li' => ['tag' => 'li'],
]);
$this->mapper->setTextProps(['text']);
$this->mapper->setAllowedAttrs(['class', 'id']);
$this->mapper->setAttrMap(['className' => 'class']);
}
/**
* 컴포넌트 배열을 렌더링합니다.
*
* @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);
}
/**
* 일반 경로 — props 의 $switch 객체가 case 값으로 해석됩니다.
*
* @effects switch_prop_values_resolved_in_bot_render
*/
public function test_general_path_switch_prop_resolves_to_case_value(): void
{
$html = $this->render([
[
'name' => 'Div',
'props' => [
'className' => [
'$switch' => '{{tab}}',
'$cases' => ['products' => 'text-red', 'contents' => 'text-blue'],
'$default' => 'text-gray',
],
],
'text' => 'x',
],
], ['tab' => 'products']);
$this->assertStringContainsString('class="text-red"', $html);
}
/**
* 반복 경로 — 항목 변수를 참조하는 $switch 키가 항목마다 해석됩니다.
*
* @effects switch_prop_values_resolved_in_bot_render
*/
public function test_iteration_path_switch_key_resolves_per_item(): void
{
$html = $this->render([
[
'name' => 'Li',
'iteration' => [
'data' => '{{rows}}',
'item_var' => 'row',
],
'props' => [
'className' => [
'$switch' => '{{row.kind}}',
'$cases' => ['a' => 'kind-a', 'b' => 'kind-b'],
],
],
'text' => '{{row.kind}}',
],
], ['rows' => [['kind' => 'a'], ['kind' => 'b']]]);
$this->assertStringContainsString('class="kind-a"', $html);
$this->assertStringContainsString('class="kind-b"', $html);
}
/**
* 매칭 실패 시 $default 로 폴백합니다.
*
* @effects switch_prop_values_resolved_in_bot_render
*/
public function test_switch_falls_back_to_default_when_no_case_matches(): void
{
$html = $this->render([
[
'name' => 'Div',
'props' => [
'className' => [
'$switch' => '{{tab}}',
'$cases' => ['products' => 'text-red'],
'$default' => 'text-gray',
],
],
'text' => 'x',
],
], ['tab' => 'unknown']);
$this->assertStringContainsString('class="text-gray"', $html);
}
/**
* 매칭 실패 + $default 부재 → 속성이 방출되지 않습니다 (React undefined 와 동일).
*/
public function test_switch_without_match_or_default_emits_no_attribute(): void
{
$html = $this->render([
[
'name' => 'Div',
'props' => [
'className' => [
'$switch' => '{{tab}}',
'$cases' => ['products' => 'text-red'],
],
],
'text' => 'x',
],
], ['tab' => 'unknown']);
$this->assertStringNotContainsString('class=', $html);
$this->assertStringContainsString('x', $html);
}
/**
* case 값의 `{{}}` 바인딩도 해석됩니다 (React 중첩 표현식 지원과 동일).
*
* @effects switch_prop_values_resolved_in_bot_render
*/
public function test_switch_case_value_bindings_are_resolved(): void
{
$html = $this->render([
[
'name' => 'Div',
'props' => [
'className' => [
'$switch' => '{{tab}}',
'$cases' => ['products' => 'badge-{{level}}'],
],
],
'text' => 'x',
],
], ['tab' => 'products', 'level' => 'gold']);
$this->assertStringContainsString('class="badge-gold"', $html);
}
}