표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이 갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상 대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다. 판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은 회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다. - 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 / _computed stale 전파 / 원본 타입 소실) - 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설 - 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인 - BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환 - iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬 - 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임 - 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지 못하던 결함 수정 - SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다 - audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소, 신규 룰 5종 등재
276 lines
9.0 KiB
PHP
276 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use App\Seo\ComponentHtmlMapper;
|
|
use App\Seo\ExpressionEvaluator;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 반복 렌더 노드의 조건 평가 순서와 자동 변수 주입이 React 렌더러와 같은지 감시합니다.
|
|
*
|
|
* 배경:
|
|
* 같은 레이아웃 JSON 을 React `DynamicRenderer` 와 SEO PHP 렌더러가 각각 그린다.
|
|
* `iteration` 과 `if` 를 함께 쓴 노드에서, 조건이 항목 변수(`{{user.is_active}}` 등)를
|
|
* 참조하면 부모 시점 컨텍스트에는 그 변수가 없다. 조건을 iteration 보다 먼저 평가하면
|
|
* 항상 거짓이 되어 **목록 전체가 사라진다** — 예외도 경고도 남지 않는다.
|
|
*
|
|
* React 는 engine-v1.56.0 에서 "iteration 이 있으면 부모 시점 조건으로 끊지 않는다" 로
|
|
* 고쳤다. 한쪽만 고치면 브라우저에서는 목록이 보이는데 봇 화면에서만 비므로, 이 테스트가
|
|
* PHP 쪽 순서를 고정한다.
|
|
*
|
|
* `{item_var}_index` 자동 변수도 같은 이유로 양쪽에 있어야 한다.
|
|
*
|
|
* @see \App\Seo\ComponentHtmlMapper::renderComponent()
|
|
* @see \App\Seo\ComponentHtmlMapper::renderIteration()
|
|
*/
|
|
class SeoIterationConditionParityTest 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'],
|
|
'Span' => ['tag' => 'span'],
|
|
]);
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* 항목 변수를 참조하는 if 가 iteration 과 함께 있어도 목록이 통째로 사라지지 않습니다.
|
|
*/
|
|
public function test_iteration_with_item_referencing_if_renders_matching_items(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Li',
|
|
'if' => '{{user.is_active}}',
|
|
'iteration' => [
|
|
'data' => '{{users}}',
|
|
'item_var' => 'user',
|
|
],
|
|
'props' => ['text' => '{{user.name}}'],
|
|
],
|
|
];
|
|
|
|
$context = [
|
|
'users' => [
|
|
['name' => '가', 'is_active' => true],
|
|
['name' => '나', 'is_active' => false],
|
|
['name' => '다', 'is_active' => true],
|
|
],
|
|
];
|
|
|
|
$html = $this->render($components, $context);
|
|
|
|
// 수정 전: 부모 시점에 `user` 가 없어 조건이 거짓 → 빈 문자열(목록 전체 소실)
|
|
$this->assertStringContainsString('<li>가</li>', $html);
|
|
$this->assertStringContainsString('<li>다</li>', $html);
|
|
$this->assertStringNotContainsString('<li>나</li>', $html);
|
|
}
|
|
|
|
/**
|
|
* condition 키로 작성해도 같은 순서가 적용됩니다.
|
|
*/
|
|
public function test_iteration_with_item_referencing_condition_key(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Li',
|
|
'condition' => '{{row.visible}}',
|
|
'iteration' => [
|
|
'source' => '{{rows}}',
|
|
'item_var' => 'row',
|
|
],
|
|
'props' => ['text' => '{{row.label}}'],
|
|
],
|
|
];
|
|
|
|
$context = [
|
|
'rows' => [
|
|
['label' => 'A', 'visible' => true],
|
|
['label' => 'B', 'visible' => false],
|
|
],
|
|
];
|
|
|
|
$html = $this->render($components, $context);
|
|
|
|
$this->assertStringContainsString('<li>A</li>', $html);
|
|
$this->assertStringNotContainsString('<li>B</li>', $html);
|
|
}
|
|
|
|
/**
|
|
* 외곽 변수만 참조하는 if 는 종전과 같은 결과를 냅니다 (거짓이면 목록 미렌더).
|
|
*/
|
|
public function test_iteration_with_outer_scope_if_false_renders_nothing(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Li',
|
|
'if' => '{{showList}}',
|
|
'iteration' => [
|
|
'data' => '{{items}}',
|
|
'item_var' => 'item',
|
|
],
|
|
'props' => ['text' => '{{item.name}}'],
|
|
],
|
|
];
|
|
|
|
$context = [
|
|
'showList' => false,
|
|
'items' => [['name' => '가'], ['name' => '나']],
|
|
];
|
|
|
|
$this->assertSame('', $this->render($components, $context));
|
|
}
|
|
|
|
/**
|
|
* 외곽 변수만 참조하는 if 가 참이면 전 항목이 렌더됩니다.
|
|
*/
|
|
public function test_iteration_with_outer_scope_if_true_renders_all_items(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Li',
|
|
'if' => '{{showList}}',
|
|
'iteration' => [
|
|
'data' => '{{items}}',
|
|
'item_var' => 'item',
|
|
],
|
|
'props' => ['text' => '{{item.name}}'],
|
|
],
|
|
];
|
|
|
|
$context = [
|
|
'showList' => true,
|
|
'items' => [['name' => '가'], ['name' => '나']],
|
|
];
|
|
|
|
$html = $this->render($components, $context);
|
|
|
|
$this->assertStringContainsString('<li>가</li>', $html);
|
|
$this->assertStringContainsString('<li>나</li>', $html);
|
|
}
|
|
|
|
/**
|
|
* iteration 이 없는 노드의 if 는 종전처럼 즉시 차단합니다 (회귀 방지).
|
|
*/
|
|
public function test_if_without_iteration_still_blocks_immediately(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Div',
|
|
'if' => '{{visible}}',
|
|
'props' => ['text' => '숨김 대상'],
|
|
],
|
|
];
|
|
|
|
$this->assertSame('', $this->render($components, ['visible' => false]));
|
|
}
|
|
|
|
/**
|
|
* `{item_var}_index` 자동 변수가 항목 컨텍스트에 주입됩니다.
|
|
*/
|
|
public function test_iteration_injects_item_var_index_automatic_variable(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Li',
|
|
'iteration' => [
|
|
'data' => '{{items}}',
|
|
'item_var' => 'row',
|
|
],
|
|
'props' => ['text' => '{{row_index}}'],
|
|
],
|
|
];
|
|
|
|
$context = ['items' => [['name' => '가'], ['name' => '나'], ['name' => '다']]];
|
|
|
|
$html = $this->render($components, $context);
|
|
|
|
$this->assertStringContainsString('<li>0</li>', $html);
|
|
$this->assertStringContainsString('<li>1</li>', $html);
|
|
$this->assertStringContainsString('<li>2</li>', $html);
|
|
}
|
|
|
|
/**
|
|
* `{item_var}_index` 주입이 명시적 index_var 를 덮어쓰지 않습니다.
|
|
*/
|
|
public function test_item_var_index_coexists_with_explicit_index_var(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Li',
|
|
'iteration' => [
|
|
'data' => '{{items}}',
|
|
'item_var' => 'row',
|
|
'index_var' => 'i',
|
|
],
|
|
'children' => [
|
|
['name' => 'Span', 'props' => ['text' => '{{i}}']],
|
|
['name' => 'Span', 'props' => ['text' => '{{row_index}}']],
|
|
],
|
|
],
|
|
];
|
|
|
|
$context = ['items' => [['name' => '가'], ['name' => '나']]];
|
|
|
|
$html = $this->render($components, $context);
|
|
|
|
$this->assertStringContainsString('<li><span>0</span><span>0</span></li>', $html);
|
|
$this->assertStringContainsString('<li><span>1</span><span>1</span></li>', $html);
|
|
}
|
|
|
|
/**
|
|
* 항목 조건이 자동 변수(`{item_var}_index`)를 참조해도 항목별로 평가됩니다.
|
|
*/
|
|
public function test_item_condition_can_reference_injected_index_variable(): void
|
|
{
|
|
$components = [
|
|
[
|
|
'name' => 'Li',
|
|
'if' => '{{row_index < 2}}',
|
|
'iteration' => [
|
|
'data' => '{{items}}',
|
|
'item_var' => 'row',
|
|
],
|
|
'props' => ['text' => '{{row.name}}'],
|
|
],
|
|
];
|
|
|
|
$context = ['items' => [['name' => '가'], ['name' => '나'], ['name' => '다']]];
|
|
|
|
$html = $this->render($components, $context);
|
|
|
|
$this->assertStringContainsString('<li>가</li>', $html);
|
|
$this->assertStringContainsString('<li>나</li>', $html);
|
|
$this->assertStringNotContainsString('<li>다</li>', $html);
|
|
}
|
|
}
|