일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
3485 lines
107 KiB
PHP
3485 lines
107 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Seo;
|
|
|
|
use App\Seo\ExpressionEvaluator;
|
|
use Tests\TestCase;
|
|
|
|
class ExpressionEvaluatorTest extends TestCase
|
|
{
|
|
private ExpressionEvaluator $evaluator;
|
|
|
|
/**
|
|
* 테스트 초기화 - ExpressionEvaluator 인스턴스를 준비합니다.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->evaluator = new ExpressionEvaluator;
|
|
}
|
|
|
|
/**
|
|
* dot notation 으로 중첩 객체의 값을 해석합니다.
|
|
*/
|
|
public function test_dot_notation_resolves_nested_value(): void
|
|
{
|
|
$context = ['user' => ['name' => '홍길동']];
|
|
|
|
$result = $this->evaluator->evaluate('{{user.name}}', $context);
|
|
|
|
$this->assertSame('홍길동', $result);
|
|
}
|
|
|
|
/**
|
|
* 깊은 dot notation 경로를 해석합니다.
|
|
*/
|
|
public function test_deep_dot_notation(): void
|
|
{
|
|
$context = ['product' => ['data' => ['name' => '에어맥스']]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data.name}}', $context);
|
|
|
|
$this->assertSame('에어맥스', $result);
|
|
}
|
|
|
|
/**
|
|
* optional chaining(?.)으로 존재하는 값을 해석합니다.
|
|
*/
|
|
public function test_optional_chaining_value_exists(): void
|
|
{
|
|
$context = ['user' => ['profile' => ['image' => 'https://example.com/avatar.jpg']]];
|
|
|
|
$result = $this->evaluator->evaluate('{{user?.profile?.image}}', $context);
|
|
|
|
$this->assertSame('https://example.com/avatar.jpg', $result);
|
|
}
|
|
|
|
/**
|
|
* optional chaining(?.)으로 존재하지 않는 값은 빈 문자열을 반환합니다.
|
|
*/
|
|
public function test_optional_chaining_missing_value(): void
|
|
{
|
|
$context = ['user' => ['name' => '홍길동']];
|
|
|
|
$result = $this->evaluator->evaluate('{{user?.profile?.image}}', $context);
|
|
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing(??)으로 null 값에 기본값을 사용합니다.
|
|
*/
|
|
public function test_null_coalescing_with_null_value(): void
|
|
{
|
|
$context = ['value' => null];
|
|
|
|
$result = $this->evaluator->evaluate("{{value ?? 'default'}}", $context);
|
|
|
|
$this->assertSame('default', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing(??)으로 존재하지 않는 키에 기본값을 사용합니다.
|
|
*/
|
|
public function test_null_coalescing_with_nonexistent_key(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate("{{missing ?? 'fallback'}}", $context);
|
|
|
|
$this->assertSame('fallback', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing(??)으로 값이 존재하면 실제 값을 반환합니다.
|
|
*/
|
|
public function test_null_coalescing_with_existing_value(): void
|
|
{
|
|
$context = ['value' => '실제값'];
|
|
|
|
$result = $this->evaluator->evaluate("{{value ?? 'default'}}", $context);
|
|
|
|
$this->assertSame('실제값', $result);
|
|
}
|
|
|
|
/**
|
|
* $t: 접두사로 번역 키를 처리합니다.
|
|
*/
|
|
public function test_translation_key(): void
|
|
{
|
|
// __() 함수가 번역 키를 반환하도록 app translator 설정
|
|
app('translator')->addLines(['seo.title' => 'SEO 제목'], 'ko');
|
|
app()->setLocale('ko');
|
|
|
|
$result = $this->evaluator->evaluate('$t:seo.title', []);
|
|
|
|
$this->assertSame('SEO 제목', $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 인덱스([N])를 올바르게 해석합니다.
|
|
*/
|
|
public function test_array_index_access(): void
|
|
{
|
|
$context = [
|
|
'images' => [
|
|
['url' => '첫번째URL'],
|
|
['url' => '두번째URL'],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{images[0]?.url}}', $context);
|
|
|
|
$this->assertSame('첫번째URL', $result);
|
|
}
|
|
|
|
/**
|
|
* 범위를 벗어난 배열 인덱스는 빈 문자열을 반환합니다.
|
|
*/
|
|
public function test_array_index_out_of_range(): void
|
|
{
|
|
$context = [
|
|
'images' => [
|
|
['url' => '첫번째URL'],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{images[99]?.url}}', $context);
|
|
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: truthy 조건에서 true 분기 값을 반환합니다.
|
|
*/
|
|
public function test_ternary_evaluates_true_branch(): void
|
|
{
|
|
$context = ['a' => true, 'b' => 'yes', 'c' => 'no'];
|
|
|
|
$result = $this->evaluator->evaluate('{{a ? b : c}}', $context);
|
|
|
|
$this->assertSame('yes', $result);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 키는 빈 문자열을 반환합니다.
|
|
*/
|
|
public function test_nonexistent_key_returns_empty(): void
|
|
{
|
|
$context = ['user' => ['name' => '홍길동']];
|
|
|
|
$result = $this->evaluator->evaluate('{{nonexistent.key}}', $context);
|
|
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
/**
|
|
* dot notation과 null coalescing을 혼합하여 사용합니다.
|
|
*/
|
|
public function test_mixed_dot_notation_with_null_coalescing(): void
|
|
{
|
|
$context = ['product' => ['data' => ['name' => '에어맥스']]];
|
|
|
|
$result = $this->evaluator->evaluate("{{product.data.name ?? ''}}", $context);
|
|
|
|
$this->assertSame('에어맥스', $result);
|
|
}
|
|
|
|
/**
|
|
* 숫자 값을 문자열로 변환합니다.
|
|
*/
|
|
public function test_numeric_value_converted_to_string(): void
|
|
{
|
|
$context = ['price' => 15000];
|
|
|
|
$result = $this->evaluator->evaluate('{{price}}', $context);
|
|
|
|
$this->assertSame('15000', $result);
|
|
}
|
|
|
|
/**
|
|
* boolean 값을 문자열로 변환합니다.
|
|
*/
|
|
public function test_boolean_value_converted_to_string(): void
|
|
{
|
|
$context = ['active' => true];
|
|
|
|
$result = $this->evaluator->evaluate('{{active}}', $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 표현식 없는 일반 문자열은 그대로 반환합니다.
|
|
*/
|
|
public function test_plain_string_without_expression(): void
|
|
{
|
|
$result = $this->evaluator->evaluate('Hello World', []);
|
|
|
|
$this->assertSame('Hello World', $result);
|
|
}
|
|
|
|
/**
|
|
* 다국어 객체에서 현재 로케일 값을 자동 추출합니다.
|
|
*/
|
|
public function test_localized_object_resolves_to_current_locale(): void
|
|
{
|
|
app()->setLocale('ko');
|
|
|
|
$context = ['product' => ['data' => ['name' => ['ko' => '무선 마우스', 'en' => 'Wireless Mouse']]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data.name}}', $context);
|
|
|
|
$this->assertSame('무선 마우스', $result);
|
|
}
|
|
|
|
/**
|
|
* 다국어 객체에서 현재 로케일이 없으면 fallback 로케일 값을 반환합니다.
|
|
*/
|
|
public function test_localized_object_falls_back_to_fallback_locale(): void
|
|
{
|
|
app()->setLocale('ja');
|
|
config(['app.fallback_locale' => 'en']);
|
|
|
|
$context = ['product' => ['data' => ['name' => ['ko' => '무선 마우스', 'en' => 'Wireless Mouse']]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data.name}}', $context);
|
|
|
|
$this->assertSame('Wireless Mouse', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 비교 연산자 (Comparison Operators)
|
|
// ==========================================
|
|
|
|
/**
|
|
* > 연산자: 좌측이 우측보다 클 때 true를 반환합니다.
|
|
*/
|
|
public function test_comparison_greater_than_true(): void
|
|
{
|
|
$context = ['product' => ['data' => ['discount_rate' => 25.6]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data.discount_rate > 0}}', $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* > 연산자: 좌측이 우측보다 작을 때 false를 반환합니다.
|
|
*/
|
|
public function test_comparison_greater_than_false(): void
|
|
{
|
|
$context = ['product' => ['data' => ['discount_rate' => 0]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data.discount_rate > 0}}', $context);
|
|
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* optional chaining이 포함된 비교 표현식을 처리합니다.
|
|
*/
|
|
public function test_comparison_with_optional_chaining(): void
|
|
{
|
|
$context = ['product' => ['data' => ['discount_rate' => 15]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data?.discount_rate > 0}}', $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* === 연산자: 문자열 동등 비교입니다.
|
|
*/
|
|
public function test_comparison_strict_equals_string(): void
|
|
{
|
|
$context = ['product' => ['data' => ['sales_status' => 'on_sale']]];
|
|
|
|
$result = $this->evaluator->evaluate("{{product.data?.sales_status === 'on_sale'}}", $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* !== 연산자: 문자열 불일치 비교입니다.
|
|
*/
|
|
public function test_comparison_strict_not_equals(): void
|
|
{
|
|
$context = ['product' => ['data' => ['sales_status' => 'on_sale']]];
|
|
|
|
$result = $this->evaluator->evaluate("{{product.data?.sales_status !== 'sold_out'}}", $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* <= 연산자: 숫자 비교입니다.
|
|
*/
|
|
public function test_comparison_less_than_or_equal(): void
|
|
{
|
|
$context = ['product' => ['data' => ['discount_rate' => 0]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data?.discount_rate <= 0}}', $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 좌측 경로가 미존재하면 빈 문자열을 반환합니다.
|
|
*/
|
|
public function test_comparison_with_null_left_returns_empty(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{missing.path > 0}}', $context);
|
|
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 논리 연산자 (Logical Operators)
|
|
// ==========================================
|
|
|
|
/**
|
|
* || 연산자: 좌측이 truthy이면 true를 반환합니다.
|
|
*/
|
|
public function test_logical_or_left_truthy(): void
|
|
{
|
|
$context = ['a' => 1, 'b' => 0];
|
|
|
|
$result = $this->evaluator->evaluate('{{a || b}}', $context);
|
|
|
|
$this->assertSame('1', $result);
|
|
}
|
|
|
|
/**
|
|
* || 연산자: 좌측이 falsy이면 우측 피연산자 값을 반환합니다.
|
|
*/
|
|
public function test_logical_or_right_truthy(): void
|
|
{
|
|
$context = ['a' => 0, 'b' => 1];
|
|
|
|
$result = $this->evaluator->evaluate('{{a || b}}', $context);
|
|
|
|
$this->assertSame('1', $result);
|
|
}
|
|
|
|
/**
|
|
* || 연산자: 양쪽 모두 falsy이면 우측 피연산자 값을 반환합니다.
|
|
*/
|
|
public function test_logical_or_both_falsy(): void
|
|
{
|
|
$context = ['a' => 0, 'b' => 0];
|
|
|
|
$result = $this->evaluator->evaluate('{{a || b}}', $context);
|
|
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* || 연산자: 값 폴백 관용구가 값을 그대로 반환합니다.
|
|
*
|
|
* 레이아웃이 `{{query?.q || ''}}` 를 값 폴백으로 사용하므로 boolean이 아니라
|
|
* 피연산자 값이어야 합니다 (프론트엔드 JS 평가와 동일).
|
|
*/
|
|
public function test_logical_or_value_fallback_idiom(): void
|
|
{
|
|
$this->assertSame('test', $this->evaluator->evaluate("{{query?.q || ''}}", ['query' => ['q' => 'test']]));
|
|
$this->assertSame('기본값', $this->evaluator->evaluate("{{query?.q || '기본값'}}", ['query' => []]));
|
|
}
|
|
|
|
/**
|
|
* && 연산자: 좌측이 truthy이면 우측 피연산자 값을 반환합니다.
|
|
*/
|
|
public function test_logical_and_both_truthy(): void
|
|
{
|
|
$context = ['a' => 1, 'b' => 1];
|
|
|
|
$result = $this->evaluator->evaluate('{{a && b}}', $context);
|
|
|
|
$this->assertSame('1', $result);
|
|
}
|
|
|
|
/**
|
|
* && 연산자: 한쪽이 falsy이면 falsy 피연산자 값을 반환합니다.
|
|
*/
|
|
public function test_logical_and_one_falsy(): void
|
|
{
|
|
$context = ['a' => 1, 'b' => 0];
|
|
|
|
$result = $this->evaluator->evaluate('{{a && b}}', $context);
|
|
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* ! 연산자: truthy 값을 false로 변환합니다.
|
|
*/
|
|
public function test_negation_truthy_becomes_false(): void
|
|
{
|
|
$context = ['value' => 25.6];
|
|
|
|
$result = $this->evaluator->evaluate('{{!value}}', $context);
|
|
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* ! 연산자: falsy 값을 true로 변환합니다.
|
|
*/
|
|
public function test_negation_falsy_becomes_true(): void
|
|
{
|
|
$context = ['value' => 0];
|
|
|
|
$result = $this->evaluator->evaluate('{{!value}}', $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 복합 논리 표현식: !a || a <= 0 패턴을 처리합니다.
|
|
*/
|
|
public function test_complex_logical_with_negation_and_comparison(): void
|
|
{
|
|
// discount_rate가 있고 > 0이면 "할인 없음" 조건은 false
|
|
$context = ['product' => ['data' => ['discount_rate' => 25.6]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{!product.data?.discount_rate || product.data?.discount_rate <= 0}}', $context);
|
|
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* 복합 논리 표현식: discount_rate가 0이면 "할인 없음" 조건은 true입니다.
|
|
*/
|
|
public function test_complex_logical_no_discount(): void
|
|
{
|
|
$context = ['product' => ['data' => ['discount_rate' => 0]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{!product.data?.discount_rate || product.data?.discount_rate <= 0}}', $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 브래킷 접근자 (Bracket Accessor)
|
|
// ==========================================
|
|
|
|
/**
|
|
* 문자열 리터럴 브래킷 접근자를 해석합니다.
|
|
*/
|
|
public function test_bracket_accessor_string_literal(): void
|
|
{
|
|
$context = ['prices' => ['KRW' => ['formatted' => '64,000원']]];
|
|
|
|
$result = $this->evaluator->evaluate("{{prices['KRW'].formatted}}", $context);
|
|
|
|
$this->assertSame('64,000원', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing이 포함된 브래킷 접근자를 해석합니다.
|
|
*/
|
|
public function test_bracket_accessor_with_null_coalescing(): void
|
|
{
|
|
$context = [
|
|
'product' => [
|
|
'data' => [
|
|
'multi_currency_list_price' => [
|
|
'KRW' => ['formatted' => '86,000원'],
|
|
'USD' => ['formatted' => '$66.00'],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
// _global.preferredCurrency가 없으므로 'KRW' fallback 사용
|
|
$result = $this->evaluator->evaluate("{{product.data?.multi_currency_list_price?.[_global.preferredCurrency ?? 'KRW']?.formatted}}", $context);
|
|
|
|
$this->assertSame('86,000원', $result);
|
|
}
|
|
|
|
/**
|
|
* 브래킷 접근자 + 최상위 ?? fallback 경로입니다.
|
|
*/
|
|
public function test_bracket_accessor_with_outer_null_coalescing(): void
|
|
{
|
|
$context = [
|
|
'product' => [
|
|
'data' => [
|
|
'list_price_formatted' => '86,000원',
|
|
// multi_currency_list_price가 없음
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate("{{product.data?.multi_currency_list_price?.[_global.preferredCurrency ?? 'KRW']?.formatted ?? product.data?.list_price_formatted}}", $context);
|
|
|
|
$this->assertSame('86,000원', $result);
|
|
}
|
|
|
|
/**
|
|
* 브래킷 접근자: 동적 키가 context에 있으면 그 값을 사용합니다.
|
|
*/
|
|
public function test_bracket_accessor_with_dynamic_key_from_context(): void
|
|
{
|
|
$context = [
|
|
'_global' => ['preferredCurrency' => 'USD'],
|
|
'prices' => [
|
|
'KRW' => ['formatted' => '86,000원'],
|
|
'USD' => ['formatted' => '$66.00'],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate("{{prices.[_global.preferredCurrency ?? 'KRW'].formatted}}", $context);
|
|
|
|
$this->assertSame('$66.00', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 인라인 $t: 토큰 (Inline Translation Tokens)
|
|
// ==========================================
|
|
|
|
/**
|
|
* 텍스트 중간에 위치한 $t: 토큰을 해석합니다.
|
|
*/
|
|
public function test_inline_translation_token(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'shop' => ['product' => ['reviews_count' => '개 리뷰']],
|
|
]);
|
|
|
|
$result = $this->evaluator->evaluate('(0 $t:shop.product.reviews_count)', []);
|
|
|
|
$this->assertSame('(0 개 리뷰)', $result);
|
|
}
|
|
|
|
/**
|
|
* {{binding}}과 $t: 토큰이 혼합된 표현식을 처리합니다.
|
|
*/
|
|
public function test_mixed_binding_and_translation(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'shop' => ['product' => ['reviews_count' => '개 리뷰']],
|
|
]);
|
|
$context = ['review_count' => 5];
|
|
|
|
$result = $this->evaluator->evaluate('({{review_count}} $t:shop.product.reviews_count)', $context);
|
|
|
|
$this->assertSame('(5 개 리뷰)', $result);
|
|
}
|
|
|
|
/**
|
|
* 미등록 인라인 $t: 토큰은 빈 문자열로 치환됩니다.
|
|
*/
|
|
public function test_inline_unknown_translation_returns_empty(): void
|
|
{
|
|
$result = $this->evaluator->evaluate('(0 $t:unknown.key)', []);
|
|
|
|
$this->assertSame('(0 )', $result);
|
|
}
|
|
|
|
/**
|
|
* $t:defer: 접두사는 $t: 와 동일한 키로 해석됩니다.
|
|
*
|
|
* 프론트엔드의 지연 해석 표기를 SEO에서도 인식해야 합니다 — 미인식 시 키가
|
|
* `defer:xxx` 가 되어 사전 조회에 실패하고 빈 문자열이 출력됩니다.
|
|
*/
|
|
public function test_defer_translation_prefix_resolves_same_key(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'shop' => ['product' => ['reviews_count' => '개 리뷰']],
|
|
]);
|
|
|
|
$result = $this->evaluator->evaluate('$t:defer:shop.product.reviews_count', []);
|
|
|
|
$this->assertSame('개 리뷰', $result);
|
|
}
|
|
|
|
/**
|
|
* $t:defer: 접두사가 파라미터와 함께 사용되어도 해석됩니다.
|
|
*/
|
|
public function test_defer_translation_prefix_with_params(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'shop' => ['order' => ['total' => '총 {{amount}}원']],
|
|
]);
|
|
|
|
$result = $this->evaluator->evaluate('$t:defer:shop.order.total|amount=1000', []);
|
|
|
|
$this->assertSame('총 1000원', $result);
|
|
}
|
|
|
|
/**
|
|
* 텍스트 중간의 $t:defer: 토큰도 해석되며 raw 문자열이 남지 않습니다.
|
|
*/
|
|
public function test_inline_defer_translation_token(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'shop' => ['product' => ['reviews_count' => '개 리뷰']],
|
|
]);
|
|
|
|
$result = $this->evaluator->evaluate('(0 $t:defer:shop.product.reviews_count)', []);
|
|
|
|
$this->assertSame('(0 개 리뷰)', $result);
|
|
$this->assertStringNotContainsString('defer', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 후처리: 고립 연산자 정리
|
|
// ==========================================
|
|
|
|
/**
|
|
* 빈 {{}} 치환으로 남은 고립 연산자(+)를 정리합니다.
|
|
*/
|
|
public function test_orphaned_operator_cleaned_up(): void
|
|
{
|
|
// 복잡한 표현식이 빈 문자열로 평가된 후 + 만 남는 경우
|
|
$result = $this->evaluator->evaluate('+{{someFunc()}}', []);
|
|
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 통합 시나리오: 실제 레이아웃 표현식
|
|
// ==========================================
|
|
|
|
/**
|
|
* 실제 상품 상세 가격 표현식: 할인가 표시 조건입니다.
|
|
*/
|
|
public function test_real_world_discount_price_condition(): void
|
|
{
|
|
$context = [
|
|
'product' => [
|
|
'data' => [
|
|
'discount_rate' => 25.6,
|
|
'selling_price_formatted' => '64,000원',
|
|
'list_price_formatted' => '86,000원',
|
|
'multi_currency_selling_price' => [
|
|
'KRW' => ['formatted' => '64,000원'],
|
|
],
|
|
'multi_currency_list_price' => [
|
|
'KRW' => ['formatted' => '86,000원'],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
// if 조건: 할인 여부
|
|
$condition = $this->evaluator->evaluate('{{product.data?.discount_rate > 0}}', $context);
|
|
$this->assertSame('true', $condition);
|
|
|
|
// 할인율
|
|
$rate = $this->evaluator->evaluate('{{product.data?.discount_rate}}%', $context);
|
|
$this->assertSame('25.6%', $rate);
|
|
|
|
// 원가 (multi_currency → formatted)
|
|
$listPrice = $this->evaluator->evaluate("{{product.data?.multi_currency_list_price?.[_global.preferredCurrency ?? 'KRW']?.formatted ?? product.data?.list_price_formatted}}", $context);
|
|
$this->assertSame('86,000원', $listPrice);
|
|
|
|
// 할인가
|
|
$sellingPrice = $this->evaluator->evaluate("{{product.data?.multi_currency_selling_price?.[_global.preferredCurrency ?? 'KRW']?.formatted ?? product.data?.selling_price_formatted}}", $context);
|
|
$this->assertSame('64,000원', $sellingPrice);
|
|
}
|
|
|
|
/**
|
|
* 실제 상품 상세: 할인 없는 상품의 가격 표시 조건입니다.
|
|
*/
|
|
public function test_real_world_no_discount_price_condition(): void
|
|
{
|
|
$context = [
|
|
'product' => [
|
|
'data' => [
|
|
'discount_rate' => 0,
|
|
'selling_price_formatted' => '30,000원',
|
|
],
|
|
],
|
|
];
|
|
|
|
// 할인 있음 조건 → false
|
|
$hasDiscount = $this->evaluator->evaluate('{{product.data?.discount_rate > 0}}', $context);
|
|
$this->assertSame('false', $hasDiscount);
|
|
|
|
// 할인 없음 조건 → true
|
|
$noDiscount = $this->evaluator->evaluate('{{!product.data?.discount_rate || product.data?.discount_rate <= 0}}', $context);
|
|
$this->assertSame('true', $noDiscount);
|
|
}
|
|
|
|
/**
|
|
* 문자열 연결 연산자(+)가 포함된 복합 표현식은 빈 문자열을 반환합니다.
|
|
*/
|
|
public function test_string_concat_operator_returns_empty(): void
|
|
{
|
|
$context = ['product' => ['data' => ['selling_price' => 64000]]];
|
|
|
|
// + 연산자(문자열 연결)는 미지원 → 빈 문자열
|
|
$result = $this->evaluator->evaluate("{{(product.data?.selling_price).toLocaleString() + '원'}}", $context);
|
|
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
/**
|
|
* setTranslations로 설정된 번역은 인라인 $t: 토큰에서도 사용됩니다.
|
|
*/
|
|
public function test_set_translations_used_in_inline_token(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'common' => ['back' => '뒤로가기'],
|
|
]);
|
|
|
|
$result = $this->evaluator->evaluate('$t:common.back', []);
|
|
|
|
$this->assertSame('뒤로가기', $result);
|
|
}
|
|
|
|
/**
|
|
* 괄호로 감싼 null coalescing + 비교 연산자 테스트
|
|
* 실제 사용 예: 탭 패널 조건 `{{(_local.activeTab ?? 'info') === 'info'}}`
|
|
*/
|
|
public function test_parenthesized_null_coalescing_comparison(): void
|
|
{
|
|
$context = [];
|
|
|
|
// _local.activeTab 미존재 → 'info' fallback → 'info' === 'info' → true
|
|
$result = $this->evaluator->evaluate("{{(_local.activeTab ?? 'info') === 'info'}}", $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 괄호 내 null coalescing으로 fallback된 값이 비교 우측과 다를 때
|
|
*/
|
|
public function test_parenthesized_null_coalescing_comparison_false(): void
|
|
{
|
|
$context = [];
|
|
|
|
// _local.activeTab 미존재 → 'info' fallback → 'info' === 'reviews' → false
|
|
$result = $this->evaluator->evaluate("{{(_local.activeTab ?? 'info') === 'reviews'}}", $context);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* 괄호 내 null coalescing에서 실제 값이 있는 경우
|
|
*/
|
|
public function test_parenthesized_null_coalescing_with_existing_value(): void
|
|
{
|
|
$context = ['_local' => ['activeTab' => 'reviews']];
|
|
|
|
// _local.activeTab = 'reviews' → 'reviews' === 'reviews' → true
|
|
$result = $this->evaluator->evaluate("{{(_local.activeTab ?? 'info') === 'reviews'}}", $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
// === 가상 프로퍼티 해석 테스트 ===
|
|
|
|
/**
|
|
* 배열의 .length 가상 프로퍼티 해석
|
|
*/
|
|
public function test_array_length_virtual_property(): void
|
|
{
|
|
$context = [
|
|
'products' => ['data' => [['id' => 1], ['id' => 2], ['id' => 3]]],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{products.data.length}}', $context);
|
|
$this->assertSame('3', $result);
|
|
}
|
|
|
|
/**
|
|
* 빈 배열의 .length 가상 프로퍼티
|
|
*/
|
|
public function test_empty_array_length_virtual_property(): void
|
|
{
|
|
$context = ['items' => []];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.length}}', $context);
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* .length를 비교 연산자와 함께 사용
|
|
*/
|
|
public function test_array_length_in_comparison(): void
|
|
{
|
|
$context = [
|
|
'popularProducts' => ['data' => [['id' => 1], ['id' => 2]]],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{popularProducts.data.length > 0}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* .length를 && 조건과 함께 사용 (인기상품 패턴)
|
|
*/
|
|
public function test_array_length_with_and_condition(): void
|
|
{
|
|
$context = [
|
|
'popularProducts' => ['data' => [['id' => 1]]],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{popularProducts.data && popularProducts.data.length > 0}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 빈 배열 .length와 && 조건 (false 반환)
|
|
*/
|
|
public function test_empty_array_length_with_and_condition(): void
|
|
{
|
|
$context = [
|
|
'popularProducts' => ['data' => []],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{popularProducts.data && popularProducts.data.length > 0}}', $context);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* 문자열의 .length 가상 프로퍼티
|
|
*/
|
|
public function test_string_length_virtual_property(): void
|
|
{
|
|
$context = ['name' => '홍길동'];
|
|
|
|
$result = $this->evaluator->evaluate('{{name.length}}', $context);
|
|
$this->assertSame('3', $result);
|
|
}
|
|
|
|
/**
|
|
* optional chaining과 .length 조합
|
|
*/
|
|
public function test_optional_chaining_with_length(): void
|
|
{
|
|
$context = [
|
|
'product' => ['data' => ['notice' => ['values' => [['key' => 'a', 'value' => 'b']]]]],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data?.notice?.values.length > 0}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 경로의 .length → 빈 문자열
|
|
*/
|
|
public function test_nonexistent_path_length_returns_empty(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{missing.data.length}}', $context);
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
/**
|
|
* 중첩 깊은 경로의 .length
|
|
*/
|
|
public function test_deeply_nested_array_length(): void
|
|
{
|
|
$context = [
|
|
'product' => [
|
|
'data' => [
|
|
'notice' => [
|
|
'values' => [
|
|
['key' => 'color', 'value' => 'red'],
|
|
['key' => 'size', 'value' => 'M'],
|
|
['key' => 'weight', 'value' => '100g'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data.notice.values.length}}', $context);
|
|
$this->assertSame('3', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 배열 인스턴스 메서드 (Array Instance Methods)
|
|
// ==========================================
|
|
|
|
/**
|
|
* Array.join() — 배열을 구분자로 결합합니다.
|
|
*/
|
|
public function test_array_join(): void
|
|
{
|
|
$context = ['colors' => ['빨강', '파랑', '초록']];
|
|
|
|
$result = $this->evaluator->evaluate("{{colors.join(', ')}}", $context);
|
|
$this->assertSame('빨강, 파랑, 초록', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.join() — 구분자 없이 기본 쉼표 사용
|
|
*/
|
|
public function test_array_join_default_separator(): void
|
|
{
|
|
$context = ['items' => ['a', 'b', 'c']];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.join()}}', $context);
|
|
$this->assertSame('a,b,c', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.slice() — 부분 배열 추출
|
|
*/
|
|
public function test_array_slice(): void
|
|
{
|
|
$context = ['items' => ['a', 'b', 'c', 'd', 'e']];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.slice(1, 3).join()}}', $context);
|
|
$this->assertSame('b,c', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.includes() — 요소 포함 여부
|
|
*/
|
|
public function test_array_includes_true(): void
|
|
{
|
|
$context = ['tags' => ['sale', 'new', 'hot']];
|
|
|
|
$result = $this->evaluator->evaluate("{{tags.includes('sale')}}", $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.includes() — 미포함
|
|
*/
|
|
public function test_array_includes_false(): void
|
|
{
|
|
$context = ['tags' => ['sale', 'new']];
|
|
|
|
$result = $this->evaluator->evaluate("{{tags.includes('hot')}}", $context);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.indexOf() — 요소 인덱스 검색
|
|
*/
|
|
public function test_array_index_of(): void
|
|
{
|
|
$context = ['items' => ['a', 'b', 'c']];
|
|
|
|
$result = $this->evaluator->evaluate("{{items.indexOf('b')}}", $context);
|
|
$this->assertSame('1', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.indexOf() — 없는 요소는 -1 반환
|
|
*/
|
|
public function test_array_index_of_not_found(): void
|
|
{
|
|
$context = ['items' => ['a', 'b']];
|
|
|
|
$result = $this->evaluator->evaluate("{{items.indexOf('z')}}", $context);
|
|
$this->assertSame('-1', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.reverse() — 배열 역순
|
|
*/
|
|
public function test_array_reverse(): void
|
|
{
|
|
$context = ['items' => [1, 2, 3]];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.reverse().join()}}', $context);
|
|
$this->assertSame('3,2,1', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.flat() — 중첩 배열 평탄화
|
|
*/
|
|
public function test_array_flat(): void
|
|
{
|
|
$context = ['nested' => [[1, 2], [3, 4], [5]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{nested.flat().join()}}', $context);
|
|
$this->assertSame('1,2,3,4,5', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.at() — 음수 인덱스 지원
|
|
*/
|
|
public function test_array_at_negative_index(): void
|
|
{
|
|
$context = ['items' => ['a', 'b', 'c']];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.at(-1)}}', $context);
|
|
$this->assertSame('c', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.concat() — 배열 병합
|
|
*/
|
|
public function test_array_concat(): void
|
|
{
|
|
$context = ['a' => [1, 2], 'b' => [3, 4]];
|
|
|
|
$result = $this->evaluator->evaluate('{{a.concat(b).join()}}', $context);
|
|
$this->assertSame('1,2,3,4', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.map() — 객체 배열에서 특정 프로퍼티 추출
|
|
*/
|
|
public function test_array_map_property(): void
|
|
{
|
|
$context = [
|
|
'products' => [
|
|
['name' => '마우스', 'price' => 15000],
|
|
['name' => '키보드', 'price' => 25000],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate("{{products.map(item => item.name).join(', ')}}", $context);
|
|
$this->assertSame('마우스, 키보드', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.filter() — 조건에 맞는 요소만 추출
|
|
*/
|
|
public function test_array_filter(): void
|
|
{
|
|
$context = [
|
|
'items' => [
|
|
['name' => 'A', 'active' => true],
|
|
['name' => 'B', 'active' => false],
|
|
['name' => 'C', 'active' => true],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate("{{items.filter(item => item.active).map(item => item.name).join(', ')}}", $context);
|
|
$this->assertSame('A, C', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.find() — 조건에 맞는 첫 요소
|
|
*/
|
|
public function test_array_find(): void
|
|
{
|
|
$context = [
|
|
'users' => [
|
|
['id' => 1, 'name' => '홍길동'],
|
|
['id' => 2, 'name' => '김철수'],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{users.find(u => u.id).name}}', $context);
|
|
$this->assertSame('홍길동', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.some() — 하나라도 조건 충족
|
|
*/
|
|
public function test_array_some(): void
|
|
{
|
|
$context = ['items' => [['stock' => 0], ['stock' => 5], ['stock' => 0]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.some(item => item.stock)}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.every() — 모두 조건 충족
|
|
*/
|
|
public function test_array_every_true(): void
|
|
{
|
|
$context = ['items' => [['active' => true], ['active' => true]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.every(item => item.active)}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.every() — 일부 미충족
|
|
*/
|
|
public function test_array_every_false(): void
|
|
{
|
|
$context = ['items' => [['active' => true], ['active' => false]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.every(item => item.active)}}', $context);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.findIndex() — 조건에 맞는 첫 인덱스
|
|
*/
|
|
public function test_array_find_index(): void
|
|
{
|
|
$context = ['items' => [['id' => 10], ['id' => 20], ['id' => 30]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.findIndex(item => item.id)}}', $context);
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.sort() — 콜백 없이 기본 정렬
|
|
*/
|
|
public function test_array_sort_default(): void
|
|
{
|
|
$context = ['items' => [3, 1, 2]];
|
|
|
|
$result = $this->evaluator->evaluate('{{items.sort().join()}}', $context);
|
|
$this->assertSame('1,2,3', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 문자열 인스턴스 메서드 (String Instance Methods)
|
|
// ==========================================
|
|
|
|
/**
|
|
* String.split() — 문자열 분할
|
|
*/
|
|
public function test_string_split(): void
|
|
{
|
|
$context = ['csv' => 'a,b,c'];
|
|
|
|
$result = $this->evaluator->evaluate("{{csv.split(',').length}}", $context);
|
|
$this->assertSame('3', $result);
|
|
}
|
|
|
|
/**
|
|
* String.trim()
|
|
*/
|
|
public function test_string_trim(): void
|
|
{
|
|
$context = ['text' => ' hello '];
|
|
|
|
$result = $this->evaluator->evaluate('{{text.trim()}}', $context);
|
|
$this->assertSame('hello', $result);
|
|
}
|
|
|
|
/**
|
|
* String.toLowerCase()
|
|
*/
|
|
public function test_string_to_lower_case(): void
|
|
{
|
|
$context = ['text' => 'HELLO WORLD'];
|
|
|
|
$result = $this->evaluator->evaluate('{{text.toLowerCase()}}', $context);
|
|
$this->assertSame('hello world', $result);
|
|
}
|
|
|
|
/**
|
|
* String.toUpperCase()
|
|
*/
|
|
public function test_string_to_upper_case(): void
|
|
{
|
|
$context = ['text' => 'hello'];
|
|
|
|
$result = $this->evaluator->evaluate('{{text.toUpperCase()}}', $context);
|
|
$this->assertSame('HELLO', $result);
|
|
}
|
|
|
|
/**
|
|
* String.includes()
|
|
*/
|
|
public function test_string_includes(): void
|
|
{
|
|
$context = ['text' => 'Hello World'];
|
|
|
|
$result = $this->evaluator->evaluate("{{text.includes('World')}}", $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* String.startsWith()
|
|
*/
|
|
public function test_string_starts_with(): void
|
|
{
|
|
$context = ['url' => 'https://example.com'];
|
|
|
|
$result = $this->evaluator->evaluate("{{url.startsWith('https')}}", $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* String.endsWith()
|
|
*/
|
|
public function test_string_ends_with(): void
|
|
{
|
|
$context = ['file' => 'image.png'];
|
|
|
|
$result = $this->evaluator->evaluate("{{file.endsWith('.png')}}", $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* String.replace() — 최초 1회만 치환
|
|
*/
|
|
public function test_string_replace(): void
|
|
{
|
|
$context = ['text' => 'foo bar foo'];
|
|
|
|
$result = $this->evaluator->evaluate("{{text.replace('foo', 'baz')}}", $context);
|
|
$this->assertSame('baz bar foo', $result);
|
|
}
|
|
|
|
/**
|
|
* String.replaceAll() — 전체 치환
|
|
*/
|
|
public function test_string_replace_all(): void
|
|
{
|
|
$context = ['text' => 'foo bar foo'];
|
|
|
|
$result = $this->evaluator->evaluate("{{text.replaceAll('foo', 'baz')}}", $context);
|
|
$this->assertSame('baz bar baz', $result);
|
|
}
|
|
|
|
/**
|
|
* String.substring()
|
|
*/
|
|
public function test_string_substring(): void
|
|
{
|
|
$context = ['text' => 'Hello World'];
|
|
|
|
$result = $this->evaluator->evaluate('{{text.substring(0, 5)}}', $context);
|
|
$this->assertSame('Hello', $result);
|
|
}
|
|
|
|
/**
|
|
* String.slice() — 음수 인덱스 지원
|
|
*/
|
|
public function test_string_slice_negative(): void
|
|
{
|
|
$context = ['text' => 'Hello World'];
|
|
|
|
$result = $this->evaluator->evaluate('{{text.slice(-5)}}', $context);
|
|
$this->assertSame('World', $result);
|
|
}
|
|
|
|
/**
|
|
* String.indexOf()
|
|
*/
|
|
public function test_string_index_of(): void
|
|
{
|
|
$context = ['text' => 'Hello World'];
|
|
|
|
$result = $this->evaluator->evaluate("{{text.indexOf('World')}}", $context);
|
|
$this->assertSame('6', $result);
|
|
}
|
|
|
|
/**
|
|
* String.repeat()
|
|
*/
|
|
public function test_string_repeat(): void
|
|
{
|
|
$context = ['star' => '★'];
|
|
|
|
$result = $this->evaluator->evaluate('{{star.repeat(3)}}', $context);
|
|
$this->assertSame('★★★', $result);
|
|
}
|
|
|
|
/**
|
|
* String.padStart()
|
|
*/
|
|
public function test_string_pad_start(): void
|
|
{
|
|
$context = ['num' => '5'];
|
|
|
|
$result = $this->evaluator->evaluate("{{num.padStart(3, '0')}}", $context);
|
|
$this->assertSame('005', $result);
|
|
}
|
|
|
|
/**
|
|
* String.charAt()
|
|
*/
|
|
public function test_string_char_at(): void
|
|
{
|
|
$context = ['text' => 'ABC'];
|
|
|
|
$result = $this->evaluator->evaluate('{{text.charAt(1)}}', $context);
|
|
$this->assertSame('B', $result);
|
|
}
|
|
|
|
/**
|
|
* String.at() — 음수 인덱스
|
|
*/
|
|
public function test_string_at_negative(): void
|
|
{
|
|
$context = ['text' => 'ABCDE'];
|
|
|
|
$result = $this->evaluator->evaluate('{{text.at(-1)}}', $context);
|
|
$this->assertSame('E', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 숫자 인스턴스 메서드 (Number Instance Methods)
|
|
// ==========================================
|
|
|
|
/**
|
|
* Number.toLocaleString() — 천 단위 구분
|
|
*/
|
|
public function test_number_to_locale_string(): void
|
|
{
|
|
$context = ['price' => 64000];
|
|
|
|
$result = $this->evaluator->evaluate('{{price.toLocaleString()}}', $context);
|
|
$this->assertSame('64,000', $result);
|
|
}
|
|
|
|
/**
|
|
* Number.toFixed() — 소수점 고정
|
|
*/
|
|
public function test_number_to_fixed(): void
|
|
{
|
|
$context = ['rate' => 3.14159];
|
|
|
|
$result = $this->evaluator->evaluate('{{rate.toFixed(2)}}', $context);
|
|
$this->assertSame('3.14', $result);
|
|
}
|
|
|
|
/**
|
|
* Number.toString() — 진수 변환
|
|
*/
|
|
public function test_number_to_string_base(): void
|
|
{
|
|
$context = ['num' => 255];
|
|
|
|
$result = $this->evaluator->evaluate('{{num.toString(16)}}', $context);
|
|
$this->assertSame('ff', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 정적 메서드 (Static Methods)
|
|
// ==========================================
|
|
|
|
/**
|
|
* Object.keys() — 객체 키 추출
|
|
*/
|
|
public function test_object_keys(): void
|
|
{
|
|
$context = ['obj' => ['a' => 1, 'b' => 2, 'c' => 3]];
|
|
|
|
$result = $this->evaluator->evaluate("{{Object.keys(obj).join(', ')}}", $context);
|
|
$this->assertSame('a, b, c', $result);
|
|
}
|
|
|
|
/**
|
|
* Object.values() — 객체 값 추출
|
|
*/
|
|
public function test_object_values(): void
|
|
{
|
|
$context = ['obj' => ['x' => 10, 'y' => 20]];
|
|
|
|
$result = $this->evaluator->evaluate("{{Object.values(obj).join(', ')}}", $context);
|
|
$this->assertSame('10, 20', $result);
|
|
}
|
|
|
|
/**
|
|
* Object.keys().length — 키 수 (메서드 + 프로퍼티 체이닝)
|
|
*/
|
|
public function test_object_keys_length(): void
|
|
{
|
|
$context = ['obj' => ['a' => 1, 'b' => 2, 'c' => 3]];
|
|
|
|
$result = $this->evaluator->evaluate('{{Object.keys(obj).length}}', $context);
|
|
$this->assertSame('3', $result);
|
|
}
|
|
|
|
/**
|
|
* Math.min()
|
|
*/
|
|
public function test_math_min(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{Math.min(10, 5, 20)}}', $context);
|
|
$this->assertSame('5', $result);
|
|
}
|
|
|
|
/**
|
|
* Math.max()
|
|
*/
|
|
public function test_math_max(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{Math.max(10, 5, 20)}}', $context);
|
|
$this->assertSame('20', $result);
|
|
}
|
|
|
|
/**
|
|
* Math.floor()
|
|
*/
|
|
public function test_math_floor(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{Math.floor(3.7)}}', $context);
|
|
$this->assertSame('3', $result);
|
|
}
|
|
|
|
/**
|
|
* Math.ceil()
|
|
*/
|
|
public function test_math_ceil(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{Math.ceil(3.2)}}', $context);
|
|
$this->assertSame('4', $result);
|
|
}
|
|
|
|
/**
|
|
* Math.round()
|
|
*/
|
|
public function test_math_round(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{Math.round(3.5)}}', $context);
|
|
$this->assertSame('4', $result);
|
|
}
|
|
|
|
/**
|
|
* Math.abs()
|
|
*/
|
|
public function test_math_abs(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{Math.abs(-42)}}', $context);
|
|
$this->assertSame('42', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.isArray() — 배열 판별
|
|
*/
|
|
public function test_array_is_array_true(): void
|
|
{
|
|
$context = ['items' => [1, 2, 3]];
|
|
|
|
$result = $this->evaluator->evaluate('{{Array.isArray(items)}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* Array.isArray() — 비배열
|
|
*/
|
|
public function test_array_is_array_false(): void
|
|
{
|
|
$context = ['text' => 'hello'];
|
|
|
|
$result = $this->evaluator->evaluate('{{Array.isArray(text)}}', $context);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* JSON.stringify()
|
|
*/
|
|
public function test_json_stringify(): void
|
|
{
|
|
$context = ['obj' => ['name' => '테스트']];
|
|
|
|
$result = $this->evaluator->evaluate('{{JSON.stringify(obj)}}', $context);
|
|
$this->assertSame('{"name":"테스트"}', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 전역 함수 (Global Functions)
|
|
// ==========================================
|
|
|
|
/**
|
|
* Number() — 형 변환
|
|
*/
|
|
public function test_global_number(): void
|
|
{
|
|
$context = ['str' => '42'];
|
|
|
|
$result = $this->evaluator->evaluate('{{Number(str)}}', $context);
|
|
$this->assertSame('42', $result);
|
|
}
|
|
|
|
/**
|
|
* String() — 형 변환
|
|
*/
|
|
public function test_global_string(): void
|
|
{
|
|
$context = ['num' => 123];
|
|
|
|
$result = $this->evaluator->evaluate('{{String(num)}}', $context);
|
|
$this->assertSame('123', $result);
|
|
}
|
|
|
|
/**
|
|
* Boolean() — truthy 판별
|
|
*/
|
|
public function test_global_boolean_truthy(): void
|
|
{
|
|
$context = ['val' => 'hello'];
|
|
|
|
$result = $this->evaluator->evaluate('{{Boolean(val)}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* parseInt()
|
|
*/
|
|
public function test_global_parse_int(): void
|
|
{
|
|
$context = ['str' => '42.7'];
|
|
|
|
$result = $this->evaluator->evaluate('{{parseInt(str)}}', $context);
|
|
$this->assertSame('42', $result);
|
|
}
|
|
|
|
/**
|
|
* parseFloat()
|
|
*/
|
|
public function test_global_parse_float(): void
|
|
{
|
|
$context = ['str' => '3.14'];
|
|
|
|
$result = $this->evaluator->evaluate('{{parseFloat(str)}}', $context);
|
|
$this->assertSame('3.14', $result);
|
|
}
|
|
|
|
/**
|
|
* encodeURIComponent()
|
|
*/
|
|
public function test_global_encode_uri_component(): void
|
|
{
|
|
$context = ['q' => '한글 검색'];
|
|
|
|
$result = $this->evaluator->evaluate('{{encodeURIComponent(q)}}', $context);
|
|
$this->assertSame(rawurlencode('한글 검색'), $result);
|
|
}
|
|
|
|
/**
|
|
* isNaN() — 비숫자 판별
|
|
*/
|
|
public function test_global_is_na_n(): void
|
|
{
|
|
$context = ['val' => 'abc'];
|
|
|
|
$result = $this->evaluator->evaluate('{{isNaN(val)}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 메서드 체이닝 (Method Chaining)
|
|
// ==========================================
|
|
|
|
/**
|
|
* Object.values().join() — 정적 메서드 → 인스턴스 메서드 체이닝
|
|
*/
|
|
public function test_method_chaining_static_to_instance(): void
|
|
{
|
|
$context = ['option' => ['color' => '빨강', 'size' => 'M']];
|
|
|
|
$result = $this->evaluator->evaluate("{{Object.values(option).join(', ')}}", $context);
|
|
$this->assertSame('빨강, M', $result);
|
|
}
|
|
|
|
/**
|
|
* split → join 체이닝
|
|
*/
|
|
public function test_method_chaining_split_join(): void
|
|
{
|
|
$context = ['text' => 'a-b-c'];
|
|
|
|
$result = $this->evaluator->evaluate("{{text.split('-').join(', ')}}", $context);
|
|
$this->assertSame('a, b, c', $result);
|
|
}
|
|
|
|
/**
|
|
* filter → map → join 체이닝
|
|
*/
|
|
public function test_method_chaining_filter_map_join(): void
|
|
{
|
|
$context = [
|
|
'items' => [
|
|
['name' => '마우스', 'stock' => 5],
|
|
['name' => '키보드', 'stock' => 0],
|
|
['name' => '모니터', 'stock' => 3],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate("{{items.filter(item => item.stock).map(item => item.name).join(', ')}}", $context);
|
|
$this->assertSame('마우스, 모니터', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// 실제 레이아웃 표현식 시나리오
|
|
// ==========================================
|
|
|
|
/**
|
|
* 실제 레이아웃: 옵션 그룹 값 표시 (Object.values.join 패턴)
|
|
*/
|
|
public function test_real_world_option_values_display(): void
|
|
{
|
|
$context = [
|
|
'selectedOptions' => ['컬러' => '블랙', '사이즈' => 'L'],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate("{{Object.values(selectedOptions).join(' / ')}}", $context);
|
|
$this->assertSame('블랙 / L', $result);
|
|
}
|
|
|
|
/**
|
|
* 실제 레이아웃: 쿠폰 목록에서 이름만 추출
|
|
*/
|
|
public function test_real_world_coupon_names(): void
|
|
{
|
|
$context = [
|
|
'coupons' => [
|
|
['name' => '10% 할인', 'type' => 'percent'],
|
|
['name' => '무료배송', 'type' => 'shipping'],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate("{{coupons.map(c => c.name).join(', ')}}", $context);
|
|
$this->assertSame('10% 할인, 무료배송', $result);
|
|
}
|
|
|
|
/**
|
|
* 실제 레이아웃: 가격 포맷 (toLocaleString)
|
|
*/
|
|
public function test_real_world_price_format(): void
|
|
{
|
|
$context = ['product' => ['data' => ['selling_price' => 64000]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.data.selling_price.toLocaleString()}}', $context);
|
|
$this->assertSame('64,000', $result);
|
|
}
|
|
|
|
/**
|
|
* 실제 레이아웃: 상품정보제공고시 항목 키 매핑
|
|
*/
|
|
public function test_real_world_notice_item_mapping(): void
|
|
{
|
|
$context = [
|
|
'notice' => [
|
|
'values' => [
|
|
['key' => 'material', 'value' => '면 100%'],
|
|
['key' => 'origin', 'value' => '대한민국'],
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate('{{notice.values.map(item => item.key).join()}}', $context);
|
|
$this->assertSame('material,origin', $result);
|
|
}
|
|
|
|
/**
|
|
* 실제 레이아웃: 색상 옵션 목록 (join 표현식)
|
|
*/
|
|
public function test_real_world_color_options_join(): void
|
|
{
|
|
$context = [
|
|
'group' => [
|
|
'values' => [
|
|
['name' => '블랙'],
|
|
['name' => '화이트'],
|
|
['name' => '네이비'],
|
|
],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate("{{group.values.map(v => v.name).join(', ')}}", $context);
|
|
$this->assertSame('블랙, 화이트, 네이비', $result);
|
|
}
|
|
|
|
/**
|
|
* Number.isNaN — 정적 메서드
|
|
*/
|
|
public function test_number_is_na_n_static(): void
|
|
{
|
|
$context = ['val' => 42];
|
|
|
|
$result = $this->evaluator->evaluate('{{Number.isNaN(val)}}', $context);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* Number.isFinite — 정적 메서드
|
|
*/
|
|
public function test_number_is_finite_static(): void
|
|
{
|
|
$context = ['val' => 42];
|
|
|
|
$result = $this->evaluator->evaluate('{{Number.isFinite(val)}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 숫자 리터럴 처리 테스트
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 숫자 리터럴 0은 경로가 아닌 리터럴로 해석됩니다.
|
|
*/
|
|
public function test_numeric_literal_zero(): void
|
|
{
|
|
$result = $this->evaluator->evaluate('{{0}}', []);
|
|
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* 양의 정수 리터럴이 올바르게 해석됩니다.
|
|
*/
|
|
public function test_numeric_literal_positive_integer(): void
|
|
{
|
|
$result = $this->evaluator->evaluate('{{42}}', []);
|
|
|
|
$this->assertSame('42', $result);
|
|
}
|
|
|
|
/**
|
|
* 음의 정수 리터럴이 올바르게 해석됩니다.
|
|
*/
|
|
public function test_numeric_literal_negative_integer(): void
|
|
{
|
|
$result = $this->evaluator->evaluate('{{-1}}', []);
|
|
|
|
$this->assertSame('-1', $result);
|
|
}
|
|
|
|
/**
|
|
* 소수점 리터럴이 올바르게 해석됩니다.
|
|
*/
|
|
public function test_numeric_literal_decimal(): void
|
|
{
|
|
$result = $this->evaluator->evaluate('{{3.14}}', []);
|
|
|
|
$this->assertSame('3.14', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing 폴백으로 숫자 리터럴 0이 올바르게 반환됩니다.
|
|
*/
|
|
public function test_null_coalescing_fallback_to_numeric_zero(): void
|
|
{
|
|
$context = ['products' => ['data' => [['id' => 1]]]];
|
|
|
|
// products.data.pagination은 존재하지 않으므로 ?? 0 으로 폴백
|
|
$result = $this->evaluator->evaluate('{{products?.data?.pagination?.total ?? 0}}', $context);
|
|
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing 폴백으로 숫자 리터럴 1이 올바르게 반환됩니다.
|
|
*/
|
|
public function test_null_coalescing_fallback_to_numeric_one(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{missing?.path ?? 1}}', $context);
|
|
|
|
$this->assertSame('1', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing에서 좌측 값이 존재하면 폴백하지 않습니다.
|
|
*/
|
|
public function test_null_coalescing_no_fallback_when_value_exists(): void
|
|
{
|
|
$context = ['products' => ['meta' => ['total' => 97]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{products?.meta?.total ?? 0}}', $context);
|
|
|
|
$this->assertSame('97', $result);
|
|
}
|
|
|
|
// =========================================================================
|
|
// $t: 파라미터 {{}} 표현식 해석 테스트
|
|
// =========================================================================
|
|
|
|
/**
|
|
* $t: 파라미터 값에 {{}} 표현식이 포함되면 컨텍스트에서 해석됩니다.
|
|
*/
|
|
public function test_translation_param_with_expression(): void
|
|
{
|
|
$this->evaluator->setTranslations(['shop' => ['total_count' => '총 {{count}}개']]);
|
|
$context = ['products' => ['meta' => ['total' => 97]]];
|
|
|
|
$result = $this->evaluator->evaluate('$t:shop.total_count|count={{products?.meta?.total ?? 0}}', $context);
|
|
|
|
$this->assertSame('총 97개', $result);
|
|
}
|
|
|
|
/**
|
|
* $t: 파라미터의 {{}} 표현식이 폴백을 사용할 때 올바르게 해석됩니다.
|
|
*/
|
|
public function test_translation_param_expression_with_fallback(): void
|
|
{
|
|
$this->evaluator->setTranslations(['shop' => ['total_count' => '총 {{count}}개']]);
|
|
$context = ['products' => ['data' => []]];
|
|
|
|
// products.data.pagination.total은 존재하지 않으므로 ?? 0 폴백
|
|
$result = $this->evaluator->evaluate('$t:shop.total_count|count={{products?.data?.pagination?.total ?? 0}}', $context);
|
|
|
|
$this->assertSame('총 0개', $result);
|
|
}
|
|
|
|
/**
|
|
* $t: 파라미터에 리터럴 값이 올바르게 처리됩니다.
|
|
*/
|
|
public function test_translation_param_literal_value(): void
|
|
{
|
|
$this->evaluator->setTranslations(['shop' => ['total_count' => '총 {{count}}개']]);
|
|
|
|
$result = $this->evaluator->evaluate('$t:shop.total_count|count=42', []);
|
|
|
|
$this->assertSame('총 42개', $result);
|
|
}
|
|
|
|
/**
|
|
* 템플릿 번역의 {{param}} 형식과 Laravel의 :param 형식 모두 치환됩니다.
|
|
*/
|
|
public function test_translation_both_param_formats(): void
|
|
{
|
|
// {{param}} 형식 (템플릿 번역)
|
|
$this->evaluator->setTranslations(['test' => ['msg' => '{{name}}님 환영']]);
|
|
|
|
$result = $this->evaluator->evaluate('$t:test.msg|name=홍길동', []);
|
|
$this->assertSame('홍길동님 환영', $result);
|
|
|
|
// :param 형식 (Laravel 번역) — Laravel __() fallback
|
|
app('translator')->addLines(['test.laravel_msg' => ':name님 환영합니다'], 'ko');
|
|
app()->setLocale('ko');
|
|
|
|
$evaluator2 = new ExpressionEvaluator;
|
|
$result2 = $evaluator2->evaluate('$t:test.laravel_msg|name=홍길동', []);
|
|
$this->assertSame('홍길동님 환영합니다', $result2);
|
|
}
|
|
|
|
/**
|
|
* $t: 파라미터 없이도 번역이 올바르게 동작합니다.
|
|
*/
|
|
public function test_translation_without_params(): void
|
|
{
|
|
$this->evaluator->setTranslations(['shop' => ['title' => '쇼핑몰']]);
|
|
|
|
$result = $this->evaluator->evaluate('$t:shop.title', []);
|
|
|
|
$this->assertSame('쇼핑몰', $result);
|
|
}
|
|
|
|
/**
|
|
* $t: 파라미터에 여러 {{}} 표현식이 포함될 때 모두 해석됩니다.
|
|
*/
|
|
public function test_translation_multiple_expression_params(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'shop' => ['page_info' => '{{current}} / {{total}} 페이지'],
|
|
]);
|
|
$context = ['meta' => ['current_page' => 3, 'last_page' => 10]];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
'$t:shop.page_info|current={{meta?.current_page ?? 1}}|total={{meta?.last_page ?? 1}}',
|
|
$context
|
|
);
|
|
|
|
$this->assertSame('3 / 10 페이지', $result);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 산술 연산 (Arithmetic Operations)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 덧셈: 경로 값 + 숫자 리터럴이 올바르게 계산됩니다.
|
|
*/
|
|
public function test_arithmetic_addition(): void
|
|
{
|
|
$context = ['page' => ['current' => 3]];
|
|
|
|
$result = $this->evaluator->evaluate('{{page.current + 1}}', $context);
|
|
|
|
$this->assertSame('4', $result);
|
|
}
|
|
|
|
/**
|
|
* 뺄셈: 경로 값 - 숫자 리터럴이 올바르게 계산됩니다.
|
|
*/
|
|
public function test_arithmetic_subtraction(): void
|
|
{
|
|
$context = ['page' => ['current' => 3]];
|
|
|
|
$result = $this->evaluator->evaluate('{{page.current - 1}}', $context);
|
|
|
|
$this->assertSame('2', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing + 산술 연산이 조합됩니다.
|
|
*/
|
|
public function test_arithmetic_with_null_coalescing(): void
|
|
{
|
|
$context = ['products' => ['data' => ['pagination' => ['current_page' => 5]]]];
|
|
|
|
$result = $this->evaluator->evaluate('{{(products?.data?.pagination?.current_page ?? 1) + 1}}', $context);
|
|
$this->assertSame('6', $result);
|
|
|
|
$result = $this->evaluator->evaluate('{{(products?.data?.pagination?.current_page ?? 1) - 1}}', $context);
|
|
$this->assertSame('4', $result);
|
|
}
|
|
|
|
/**
|
|
* null coalescing fallback + 산술 연산이 올바르게 동작합니다.
|
|
*/
|
|
public function test_arithmetic_with_null_coalescing_fallback(): void
|
|
{
|
|
$context = [];
|
|
|
|
// missing path → fallback to 1, then 1 + 1 = 2
|
|
$result = $this->evaluator->evaluate('{{(missing?.path ?? 1) + 1}}', $context);
|
|
$this->assertSame('2', $result);
|
|
|
|
// missing path → fallback to 1, then 1 - 1 = 0
|
|
$result = $this->evaluator->evaluate('{{(missing?.path ?? 1) - 1}}', $context);
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* 산술 연산에서 좌측이 비숫자일 때 산술이 무시됩니다.
|
|
*/
|
|
public function test_arithmetic_non_numeric_left_side(): void
|
|
{
|
|
$context = ['name' => '텍스트'];
|
|
|
|
$result = $this->evaluator->evaluate('{{name + 1}}', $context);
|
|
|
|
// 비숫자이므로 산술 연산 무시, 빈 문자열 반환
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
/**
|
|
* 소수점 산술 연산이 올바르게 동작합니다.
|
|
*/
|
|
public function test_arithmetic_with_decimal(): void
|
|
{
|
|
$context = ['price' => 100];
|
|
|
|
$result = $this->evaluator->evaluate('{{price + 0.5}}', $context);
|
|
$this->assertSame('100.5', $result);
|
|
}
|
|
|
|
/**
|
|
* 페이지네이션 실제 패턴: 이전/다음 페이지 계산
|
|
*/
|
|
public function test_arithmetic_real_world_pagination(): void
|
|
{
|
|
$context = [
|
|
'products' => [
|
|
'data' => [
|
|
'pagination' => [
|
|
'current_page' => 3,
|
|
'last_page' => 10,
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
// 이전 페이지
|
|
$prev = $this->evaluator->evaluate('{{(products?.data?.pagination?.current_page ?? 1) - 1}}', $context);
|
|
$this->assertSame('2', $prev);
|
|
|
|
// 다음 페이지
|
|
$next = $this->evaluator->evaluate('{{(products?.data?.pagination?.current_page ?? 1) + 1}}', $context);
|
|
$this->assertSame('4', $next);
|
|
}
|
|
|
|
// ==========================================
|
|
// null 비교 JavaScript 시맨틱 (SEO 컨텍스트)
|
|
// ==========================================
|
|
|
|
/**
|
|
* null !== 값 → true (JavaScript null 시맨틱)
|
|
*/
|
|
public function test_null_strict_not_equals_value_returns_true(): void
|
|
{
|
|
$context = ['_global' => []];
|
|
|
|
// _global.commentEdit?.editingCommentId가 null (미존재)
|
|
// null !== comment?.id → true
|
|
$result = $this->evaluator->evaluate('{{_global.commentEdit?.editingCommentId !== 123}}', $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* null === 값 → false (JavaScript null 시맨틱)
|
|
*/
|
|
public function test_null_strict_equals_value_returns_false(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{missing.path === "test"}}', $context);
|
|
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* null !== null → false
|
|
*/
|
|
public function test_null_strict_not_equals_null_returns_false(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{missing.a !== missing.b}}', $context);
|
|
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* null === null → true
|
|
*/
|
|
public function test_null_strict_equals_null_returns_true(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluate('{{missing.a === missing.b}}', $context);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* SEO 댓글 if 조건: _global 미존재 시에도 && 체인이 정상 작동합니다.
|
|
*/
|
|
public function test_seo_comment_if_condition_with_empty_global(): void
|
|
{
|
|
$context = [
|
|
'_global' => [],
|
|
'comment' => [
|
|
'deleted_at' => null,
|
|
'status' => 'active',
|
|
'id' => 42,
|
|
],
|
|
];
|
|
|
|
// 댓글 컨텐츠 표시 조건: !deleted_at && status !== 'blinded' && editingId !== commentId
|
|
$result = $this->evaluator->evaluate(
|
|
'{{!comment?.deleted_at && comment?.status !== "blinded" && _global.commentEdit?.editingCommentId !== comment?.id}}',
|
|
$context
|
|
);
|
|
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
// ==========================================
|
|
// evaluateRaw (원본 값 반환)
|
|
// ==========================================
|
|
|
|
/**
|
|
* evaluateRaw: 단일 {{expr}} 패턴으로 배열을 원본 반환합니다.
|
|
*/
|
|
public function test_evaluate_raw_returns_array(): void
|
|
{
|
|
$context = [
|
|
'comment' => [
|
|
'author' => ['id' => 1, 'nickname' => '홍길동', 'profile_photo_url' => '/avatar.jpg'],
|
|
],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluateRaw('{{comment?.author}}', $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertSame('홍길동', $result['nickname']);
|
|
}
|
|
|
|
/**
|
|
* evaluateRaw: 문자열 값은 문자열로 반환합니다.
|
|
*/
|
|
public function test_evaluate_raw_returns_string(): void
|
|
{
|
|
$context = ['comment' => ['created_at' => '2026-03-19 10:00:00']];
|
|
|
|
$result = $this->evaluator->evaluateRaw('{{comment?.created_at}}', $context);
|
|
|
|
$this->assertSame('2026-03-19 10:00:00', $result);
|
|
}
|
|
|
|
/**
|
|
* evaluateRaw: 복합 표현식은 문자열로 반환합니다.
|
|
*/
|
|
public function test_evaluate_raw_complex_expression_returns_string(): void
|
|
{
|
|
$context = ['a' => 'hello'];
|
|
|
|
$result = $this->evaluator->evaluateRaw('prefix {{a}} suffix', $context);
|
|
|
|
$this->assertSame('prefix hello suffix', $result);
|
|
}
|
|
|
|
/**
|
|
* evaluateRaw: 바인딩이 둘 이상이면 보간된 문자열을 반환합니다.
|
|
*
|
|
* 회귀: 양끝 앵커 정규식이 `{{a}} - {{b}}` 도 단일 바인딩으로 오인해
|
|
* 내부를 `a}} - {{b` 로 해석하던 문제.
|
|
*/
|
|
public function test_evaluate_raw_multiple_bindings_returns_interpolated_string(): void
|
|
{
|
|
$context = ['a' => '제목', 'b' => '작성자'];
|
|
|
|
$result = $this->evaluator->evaluateRaw('{{a}} - {{b}}', $context);
|
|
|
|
$this->assertSame('제목 - 작성자', $result);
|
|
}
|
|
|
|
/**
|
|
* evaluateRaw: 중첩 객체 리터럴은 내부에 `}}` 가 있어도 단일 바인딩으로 유지됩니다.
|
|
*/
|
|
public function test_evaluate_raw_nested_object_literal_stays_single_binding(): void
|
|
{
|
|
$result = $this->evaluator->evaluateRaw("{{ {outer: {inner: 'value'}} }}", []);
|
|
|
|
$this->assertSame(['outer' => ['inner' => 'value']], $result);
|
|
}
|
|
|
|
/**
|
|
* evaluateRaw: 미존재 경로는 null을 반환합니다.
|
|
*/
|
|
public function test_evaluate_raw_missing_path_returns_null(): void
|
|
{
|
|
$context = [];
|
|
|
|
$result = $this->evaluator->evaluateRaw('{{missing.path}}', $context);
|
|
|
|
$this->assertNull($result);
|
|
}
|
|
|
|
// ===================================================================
|
|
// 배열 리터럴 파싱 테스트
|
|
// ===================================================================
|
|
|
|
/**
|
|
* 배열 리터럴 + includes() — 포함
|
|
*/
|
|
public function test_array_literal_includes_true(): void
|
|
{
|
|
$context = ['board' => ['type' => 'gallery']];
|
|
|
|
$result = $this->evaluator->evaluate("{{['gallery','card'].includes(board?.type)}}", $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 리터럴 + includes() — 미포함
|
|
*/
|
|
public function test_array_literal_includes_false(): void
|
|
{
|
|
$context = ['board' => ['type' => 'basic']];
|
|
|
|
$result = $this->evaluator->evaluate("{{['gallery','card'].includes(board?.type)}}", $context);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 리터럴 + 부정 연산자 + includes() — 게시판 타입 분기 실제 패턴
|
|
*/
|
|
public function test_array_literal_negated_includes_board_type(): void
|
|
{
|
|
$context = ['posts' => ['data' => ['board' => ['type' => 'gallery']]]];
|
|
|
|
// gallery 타입 → includes=true → !includes=false → basic 블록 미렌더링
|
|
$result = $this->evaluator->evaluate(
|
|
"{{posts?.data?.board?.type === 'basic' || !['gallery','card'].includes(posts?.data?.board?.type ?? 'basic')}}",
|
|
$context
|
|
);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 리터럴 + 부정 연산자 — basic 타입은 fallback으로 렌더링
|
|
*/
|
|
public function test_array_literal_negated_includes_basic_type(): void
|
|
{
|
|
$context = ['posts' => ['data' => ['board' => ['type' => 'basic']]]];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
"{{posts?.data?.board?.type === 'basic' || !['gallery','card'].includes(posts?.data?.board?.type ?? 'basic')}}",
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 리터럴 + 부정 연산자 — 알 수 없는 타입은 basic으로 fallback
|
|
*/
|
|
public function test_array_literal_negated_includes_unknown_type(): void
|
|
{
|
|
$context = ['posts' => ['data' => ['board' => ['type' => 'unknown']]]];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
"{{posts?.data?.board?.type === 'basic' || !['gallery','card'].includes(posts?.data?.board?.type ?? 'basic')}}",
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 빈 배열 리터럴 includes — 항상 false
|
|
*/
|
|
public function test_empty_array_literal_includes(): void
|
|
{
|
|
$context = ['val' => 'test'];
|
|
|
|
$result = $this->evaluator->evaluate('{{[].includes(val)}}', $context);
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 리터럴 — 숫자 요소 포함
|
|
*/
|
|
public function test_array_literal_with_numbers(): void
|
|
{
|
|
$context = ['status' => ['code' => 200]];
|
|
|
|
$result = $this->evaluator->evaluate('{{[200, 201, 204].includes(status?.code)}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 리터럴 — 표현식 요소가 context로 해석되어 배열로 파싱됨
|
|
*/
|
|
public function test_array_literal_with_expression_elements_resolves(): void
|
|
{
|
|
$context = ['items' => ['a', 'b'], 'val' => 'a'];
|
|
|
|
// val은 'a'로 해석되므로 [['a','b'], 'a'].includes('a') → true
|
|
$result = $this->evaluator->evaluate("{{[items, val].includes('a')}}", $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
// ====================================================================
|
|
// SEO Overrides (seo_overrides 와일드카드 매칭)
|
|
// ====================================================================
|
|
|
|
public function test_seo_override_wildcard_returns_value_for_dynamic_bracket_access(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'collapsedReplies' => ['*' => false],
|
|
],
|
|
]);
|
|
|
|
$context = [
|
|
'_local' => [],
|
|
'comment' => ['id' => 5, 'depth' => 1],
|
|
];
|
|
|
|
// _local.collapsedReplies?.[$computed.xxx] — $computed 미존재 → 브래킷 키 null
|
|
// seo_overrides 와일드카드가 false 반환 → false === false → true
|
|
$result = $this->evaluator->evaluate(
|
|
'{{_local.collapsedReplies?.[$computed.commentRootMap?.[comment?.id]] === false}}',
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
public function test_seo_override_wildcard_returns_value_for_resolved_bracket_key(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'collapsedReplies' => ['*' => false],
|
|
],
|
|
]);
|
|
|
|
$context = [
|
|
'_local' => ['collapsedReplies' => []],
|
|
'rootId' => '3',
|
|
];
|
|
|
|
// 브래킷 키가 정상 해석되지만, _local.collapsedReplies.3이 없음 → 와일드카드 매칭
|
|
$result = $this->evaluator->evaluate(
|
|
'{{_local.collapsedReplies?.[rootId] === false}}',
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
public function test_seo_override_exact_match_returns_value(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'showAllComments' => true,
|
|
],
|
|
]);
|
|
|
|
$context = ['_local' => []];
|
|
|
|
$result = $this->evaluator->evaluate('{{_local.showAllComments}}', $context);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
public function test_seo_override_does_not_affect_existing_values(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'collapsedReplies' => ['*' => false],
|
|
],
|
|
]);
|
|
|
|
$context = [
|
|
'_local' => [
|
|
'collapsedReplies' => ['3' => true],
|
|
],
|
|
'rootId' => '3',
|
|
];
|
|
|
|
// 실제 값이 존재하면 오버라이드가 아닌 실제 값 사용
|
|
$result = $this->evaluator->evaluate(
|
|
'{{_local.collapsedReplies?.[rootId] === true}}',
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
public function test_seo_override_without_overrides_returns_normal(): void
|
|
{
|
|
// seo_overrides 미설정 → 기존 동작 유지
|
|
$context = [
|
|
'_local' => [],
|
|
'comment' => ['id' => 5],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
'{{_local.collapsedReplies?.[comment?.id] === false}}',
|
|
$context
|
|
);
|
|
// _local.collapsedReplies 없음 → null === false → false
|
|
$this->assertSame('false', $result);
|
|
}
|
|
|
|
public function test_seo_override_comment_depth_condition_with_override(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'collapsedReplies' => ['*' => false],
|
|
],
|
|
]);
|
|
|
|
// 대댓글 (depth=1) — SEO에서 표시되어야 함
|
|
$context = [
|
|
'_local' => [],
|
|
'comment' => ['id' => 10, 'depth' => 1],
|
|
];
|
|
|
|
// (depth ?? 0) === 0 || (collapsedReplies?.[xxx] === false)
|
|
// = false || true = true → 대댓글 표시
|
|
$result = $this->evaluator->evaluate(
|
|
'{{(comment?.depth ?? 0) === 0 || (_local.collapsedReplies?.[$computed.commentRootMap?.[comment?.id]] === false)}}',
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
public function test_seo_override_root_comment_still_shows(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'collapsedReplies' => ['*' => false],
|
|
],
|
|
]);
|
|
|
|
// 루트 댓글 (depth=0) — 오버라이드와 무관하게 항상 표시
|
|
$context = [
|
|
'_local' => [],
|
|
'comment' => ['id' => 1, 'depth' => 0],
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
'{{(comment?.depth ?? 0) === 0 || (_local.collapsedReplies?.[$computed.commentRootMap?.[comment?.id]] === false)}}',
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
public function test_seo_override_global_scope(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_global' => [
|
|
'expandedSections' => ['*' => true],
|
|
],
|
|
]);
|
|
|
|
$context = [
|
|
'_global' => [],
|
|
'sectionId' => 'faq',
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
'{{_global.expandedSections?.[sectionId]}}',
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 다중 값 매칭: seo_overrides에서 배열로 설정된 값은
|
|
* === 비교 시 배열 내 어떤 값과도 매칭됨
|
|
*/
|
|
public function test_seo_override_array_multi_value_equals_match(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'activeTab' => ['info', 'reviews', 'qna'],
|
|
],
|
|
]);
|
|
|
|
$context = ['_local' => []];
|
|
|
|
// 배열 내 각 값에 대해 === 비교가 true
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{_local.activeTab === 'reviews'}}",
|
|
$context
|
|
));
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{_local.activeTab === 'info'}}",
|
|
$context
|
|
));
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{_local.activeTab === 'qna'}}",
|
|
$context
|
|
));
|
|
|
|
// 배열에 없는 값은 false
|
|
$this->assertSame('false', $this->evaluator->evaluate(
|
|
"{{_local.activeTab === 'unknown'}}",
|
|
$context
|
|
));
|
|
}
|
|
|
|
/**
|
|
* 배열 다중 값 매칭: !== 비교 시 배열 내 매칭 값이 있으면 false
|
|
*/
|
|
public function test_seo_override_array_multi_value_not_equals(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'activeTab' => ['info', 'reviews', 'qna'],
|
|
],
|
|
]);
|
|
|
|
$context = ['_local' => []];
|
|
|
|
// 배열 내 값과 매칭되므로 !== 는 false
|
|
$this->assertSame('false', $this->evaluator->evaluate(
|
|
"{{_local.activeTab !== 'reviews'}}",
|
|
$context
|
|
));
|
|
|
|
// 배열에 없는 값은 !== true
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{_local.activeTab !== 'unknown'}}",
|
|
$context
|
|
));
|
|
}
|
|
|
|
/**
|
|
* 배열 오버라이드는 context 값보다 우선함
|
|
* (init_actions로 _local.activeTab = 'reviews' 설정되어도 배열 오버라이드가 우선)
|
|
*/
|
|
public function test_seo_override_array_takes_priority_over_context(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'activeTab' => ['info', 'reviews', 'qna'],
|
|
],
|
|
]);
|
|
|
|
// context에 단일 값이 설정되어 있어도 배열 오버라이드가 우선
|
|
$context = ['_local' => ['activeTab' => 'reviews']];
|
|
|
|
// 'info'는 context 값('reviews')과 다르지만 배열 오버라이드로 매칭됨
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{_local.activeTab === 'info'}}",
|
|
$context
|
|
));
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{_local.activeTab === 'qna'}}",
|
|
$context
|
|
));
|
|
}
|
|
|
|
/**
|
|
* 배열 다중 값 매칭 + null coalescing: 실제 파셜 패턴
|
|
* (_local.activeTab ?? 'info') === 'reviews' 표현식에서 배열 오버라이드 동작
|
|
*/
|
|
public function test_seo_override_array_with_null_coalescing(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'activeTab' => ['info', 'reviews', 'qna'],
|
|
],
|
|
]);
|
|
|
|
// init_actions로 설정된 context 값이 있어도 배열 오버라이드 우선
|
|
$context = ['_local' => ['activeTab' => 'info']];
|
|
|
|
// 실제 파셜 표현식 패턴: (_local.activeTab ?? 'info') === 'reviews'
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{(_local.activeTab ?? 'info') === 'reviews'}}",
|
|
$context
|
|
));
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{(_local.activeTab ?? 'info') === 'info'}}",
|
|
$context
|
|
));
|
|
$this->assertSame('true', $this->evaluator->evaluate(
|
|
"{{(_local.activeTab ?? 'info') === 'qna'}}",
|
|
$context
|
|
));
|
|
$this->assertSame('false', $this->evaluator->evaluate(
|
|
"{{(_local.activeTab ?? 'info') === 'unknown'}}",
|
|
$context
|
|
));
|
|
|
|
// !== 비교도 정상 동작
|
|
$this->assertSame('false', $this->evaluator->evaluate(
|
|
"{{(_local.activeTab ?? 'info') !== 'reviews'}}",
|
|
$context
|
|
));
|
|
}
|
|
|
|
/**
|
|
* 와일드카드 오버라이드는 기존대로 fallback만 (회귀 테스트)
|
|
*/
|
|
public function test_seo_override_wildcard_still_fallback_only(): void
|
|
{
|
|
$this->evaluator->setSeoOverrides([
|
|
'_local' => [
|
|
'collapsedReplies' => ['*' => false],
|
|
],
|
|
]);
|
|
|
|
// context에 값이 있으면 와일드카드는 적용 안됨 (기존 동작)
|
|
$context = [
|
|
'_local' => ['collapsedReplies' => ['comment_1' => true]],
|
|
'commentId' => 'comment_1',
|
|
];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
'{{_local.collapsedReplies?.[commentId]}}',
|
|
$context
|
|
);
|
|
$this->assertSame('true', $result);
|
|
|
|
// context에 값이 없으면 와일드카드가 fallback으로 적용
|
|
$context2 = [
|
|
'_local' => [],
|
|
'commentId' => 'comment_2',
|
|
];
|
|
|
|
$result2 = $this->evaluator->evaluate(
|
|
'{{_local.collapsedReplies?.[commentId]}}',
|
|
$context2
|
|
);
|
|
$this->assertSame('false', $result2);
|
|
}
|
|
|
|
// ========================================================
|
|
// 삼항 연산자 (Ternary Operator)
|
|
// ========================================================
|
|
|
|
/**
|
|
* 삼항 연산자: 기본 true 분기를 평가합니다.
|
|
*/
|
|
public function test_ternary_basic_true_branch(): void
|
|
{
|
|
$context = ['status' => 'active'];
|
|
|
|
$result = $this->evaluator->evaluate("{{status === 'active' ? '활성' : '비활성'}}", $context);
|
|
|
|
$this->assertSame('활성', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: 기본 false 분기를 평가합니다.
|
|
*/
|
|
public function test_ternary_basic_false_branch(): void
|
|
{
|
|
$context = ['status' => 'inactive'];
|
|
|
|
$result = $this->evaluator->evaluate("{{status === 'active' ? '활성' : '비활성'}}", $context);
|
|
|
|
$this->assertSame('비활성', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: truthy/falsy 값 기반 평가입니다.
|
|
*/
|
|
public function test_ternary_truthy_falsy(): void
|
|
{
|
|
$context = ['count' => 5];
|
|
|
|
$result = $this->evaluator->evaluate("{{count ? '있음' : '없음'}}", $context);
|
|
|
|
$this->assertSame('있음', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: falsy 값(빈 문자열)은 false 분기입니다.
|
|
*/
|
|
public function test_ternary_falsy_empty_string(): void
|
|
{
|
|
$context = ['name' => ''];
|
|
|
|
$result = $this->evaluator->evaluate("{{name ? name : '이름 없음'}}", $context);
|
|
|
|
$this->assertSame('이름 없음', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: optional chaining과 함께 사용합니다.
|
|
*/
|
|
public function test_ternary_with_optional_chaining(): void
|
|
{
|
|
$context = ['user' => ['profile' => ['name' => '홍길동']]];
|
|
|
|
$result = $this->evaluator->evaluate("{{user?.profile?.name ? user.profile.name : '게스트'}}", $context);
|
|
|
|
$this->assertSame('홍길동', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: optional chaining 값이 없을 때 false 분기입니다.
|
|
*/
|
|
public function test_ternary_with_optional_chaining_missing(): void
|
|
{
|
|
$context = ['user' => []];
|
|
|
|
$result = $this->evaluator->evaluate("{{user?.profile?.name ? user.profile.name : '게스트'}}", $context);
|
|
|
|
$this->assertSame('게스트', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: 중첩 삼항 (우측 결합)을 평가합니다.
|
|
*/
|
|
public function test_ternary_nested_right_associative(): void
|
|
{
|
|
$context = ['status' => 'pending'];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
"{{status === 'active' ? '활성' : status === 'pending' ? '대기' : '비활성'}}",
|
|
$context
|
|
);
|
|
|
|
$this->assertSame('대기', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: 중첩 삼항에서 첫 번째 조건 매칭입니다.
|
|
*/
|
|
public function test_ternary_nested_first_match(): void
|
|
{
|
|
$context = ['status' => 'active'];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
"{{status === 'active' ? '활성' : status === 'pending' ? '대기' : '비활성'}}",
|
|
$context
|
|
);
|
|
|
|
$this->assertSame('활성', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: 숫자 비교를 조건으로 사용합니다.
|
|
*/
|
|
public function test_ternary_with_number_comparison(): void
|
|
{
|
|
$context = ['cart' => ['count' => 150]];
|
|
|
|
$result = $this->evaluator->evaluate("{{cart.count > 99 ? '99+' : cart.count}}", $context);
|
|
|
|
$this->assertSame('99+', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: 숫자 비교 false 분기입니다.
|
|
*/
|
|
public function test_ternary_number_comparison_false(): void
|
|
{
|
|
$context = ['cart' => ['count' => 5]];
|
|
|
|
$result = $this->evaluator->evaluate("{{cart.count > 99 ? '99+' : cart.count}}", $context);
|
|
|
|
$this->assertSame('5', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: 논리 AND 조건과 함께 사용합니다.
|
|
*/
|
|
public function test_ternary_with_logical_and(): void
|
|
{
|
|
$context = ['isLoggedIn' => true, 'isAdmin' => true];
|
|
|
|
$result = $this->evaluator->evaluate("{{isLoggedIn && isAdmin ? '관리자' : '일반'}}", $context);
|
|
|
|
$this->assertSame('관리자', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: 부정 연산자를 조건으로 사용합니다.
|
|
*/
|
|
public function test_ternary_with_negation(): void
|
|
{
|
|
$context = ['isLoggedIn' => false];
|
|
|
|
$result = $this->evaluator->evaluate("{{!isLoggedIn ? '로그인' : '마이페이지'}}", $context);
|
|
|
|
$this->assertSame('로그인', $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: evaluateRaw에서도 원본 값을 반환합니다.
|
|
*/
|
|
public function test_ternary_raw_returns_original_value(): void
|
|
{
|
|
$context = ['items' => [1, 2, 3]];
|
|
|
|
$result = $this->evaluator->evaluateRaw('{{items ? items : []}}', $context);
|
|
|
|
$this->assertSame([1, 2, 3], $result);
|
|
}
|
|
|
|
/**
|
|
* 삼항 연산자: evaluateRaw에서 false 분기 원본 값을 반환합니다.
|
|
*/
|
|
public function test_ternary_raw_false_branch(): void
|
|
{
|
|
$context = ['items' => null];
|
|
|
|
$result = $this->evaluator->evaluateRaw('{{items ? items : []}}', $context);
|
|
|
|
$this->assertSame([], $result);
|
|
}
|
|
|
|
// ========================================================
|
|
// $t() 함수 호출 구문
|
|
// ========================================================
|
|
|
|
/**
|
|
* $t() 함수: 문자열 키로 번역을 반환합니다.
|
|
*/
|
|
public function test_t_function_basic(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'shop' => ['product' => ['sold_out' => '품절']],
|
|
]);
|
|
|
|
$result = $this->evaluator->evaluate("{{\$t('shop.product.sold_out')}}", []);
|
|
|
|
$this->assertSame('품절', $result);
|
|
}
|
|
|
|
/**
|
|
* $t() 함수: 삼항 연산자 내부에서 사용합니다.
|
|
*/
|
|
public function test_t_function_inside_ternary(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'shop' => ['product' => [
|
|
'in_stock' => '판매중',
|
|
'sold_out' => '품절',
|
|
]],
|
|
]);
|
|
$context = ['product' => ['stock' => 0]];
|
|
|
|
$result = $this->evaluator->evaluate(
|
|
"{{product.stock > 0 ? \$t('shop.product.in_stock') : \$t('shop.product.sold_out')}}",
|
|
$context
|
|
);
|
|
|
|
$this->assertSame('품절', $result);
|
|
}
|
|
|
|
/**
|
|
* $t() 함수: 존재하지 않는 번역 키는 빈 문자열입니다.
|
|
*/
|
|
public function test_t_function_missing_key(): void
|
|
{
|
|
$this->evaluator->setTranslations([]);
|
|
|
|
$result = $this->evaluator->evaluate("{{\$t('nonexistent.key')}}", []);
|
|
|
|
$this->assertSame('', $result);
|
|
}
|
|
|
|
/**
|
|
* $t() 함수: 큰따옴표 문자열 키를 지원합니다.
|
|
*/
|
|
public function test_t_function_double_quotes(): void
|
|
{
|
|
$this->evaluator->setTranslations([
|
|
'common' => ['save' => '저장'],
|
|
]);
|
|
|
|
$result = $this->evaluator->evaluate('{{$t("common.save")}}', []);
|
|
|
|
$this->assertSame('저장', $result);
|
|
}
|
|
|
|
// ========================================================
|
|
// $localized() 함수
|
|
// ========================================================
|
|
|
|
/**
|
|
* $localized(): 현재 로케일(ko)에 해당하는 값을 반환합니다.
|
|
*/
|
|
public function test_localized_returns_current_locale(): void
|
|
{
|
|
app()->setLocale('ko');
|
|
$context = ['product' => ['name' => ['ko' => '운동화', 'en' => 'Sneakers']]];
|
|
|
|
$result = $this->evaluator->evaluate('{{$localized(product.name)}}', $context);
|
|
|
|
$this->assertSame('운동화', $result);
|
|
}
|
|
|
|
/**
|
|
* $localized(): 영어 로케일에서 영어 값을 반환합니다.
|
|
*/
|
|
public function test_localized_returns_en_locale(): void
|
|
{
|
|
app()->setLocale('en');
|
|
$context = ['product' => ['name' => ['ko' => '운동화', 'en' => 'Sneakers']]];
|
|
|
|
$result = $this->evaluator->evaluate('{{$localized(product.name)}}', $context);
|
|
|
|
$this->assertSame('Sneakers', $result);
|
|
}
|
|
|
|
/**
|
|
* $localized(): 현재 로케일이 없으면 ko fallback합니다.
|
|
*/
|
|
public function test_localized_fallback_to_ko(): void
|
|
{
|
|
app()->setLocale('ja');
|
|
$context = ['product' => ['name' => ['ko' => '운동화', 'en' => 'Sneakers']]];
|
|
|
|
$result = $this->evaluator->evaluate('{{$localized(product.name)}}', $context);
|
|
|
|
$this->assertSame('운동화', $result);
|
|
}
|
|
|
|
/**
|
|
* $localized(): 비배열 값은 그대로 반환합니다.
|
|
*/
|
|
public function test_localized_non_array_passthrough(): void
|
|
{
|
|
$context = ['product' => ['name' => '단순 문자열']];
|
|
|
|
$result = $this->evaluator->evaluate('{{$localized(product.name)}}', $context);
|
|
|
|
$this->assertSame('단순 문자열', $result);
|
|
}
|
|
|
|
/**
|
|
* $localized(): ko도 없으면 첫 번째 값을 반환합니다.
|
|
*/
|
|
public function test_localized_fallback_to_first_value(): void
|
|
{
|
|
app()->setLocale('ja');
|
|
$context = ['product' => ['name' => ['en' => 'Sneakers', 'zh' => '运动鞋']]];
|
|
|
|
$result = $this->evaluator->evaluate('{{$localized(product.name)}}', $context);
|
|
|
|
$this->assertSame('Sneakers', $result);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 객체 리터럴 파서 테스트
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 단순 객체 리터럴 {key: 'value'}를 파싱합니다.
|
|
*/
|
|
public function test_object_literal_simple(): void
|
|
{
|
|
$context = [];
|
|
$result = $this->evaluator->evaluateRaw("{{ {status: 'active', count: 3} }}", $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertEquals('active', $result['status']);
|
|
$this->assertEquals(3, $result['count']);
|
|
}
|
|
|
|
/**
|
|
* 객체 리터럴에서 boolean/null 값을 지원합니다.
|
|
*/
|
|
public function test_object_literal_with_boolean_null(): void
|
|
{
|
|
$context = [];
|
|
$result = $this->evaluator->evaluateRaw('{{ {enabled: true, disabled: false, empty: null} }}', $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertTrue($result['enabled']);
|
|
$this->assertFalse($result['disabled']);
|
|
$this->assertNull($result['empty']);
|
|
}
|
|
|
|
/**
|
|
* 빈 객체 리터럴 {}을 파싱합니다.
|
|
*/
|
|
public function test_object_literal_empty(): void
|
|
{
|
|
$context = [];
|
|
$result = $this->evaluator->evaluateRaw('{{ {} }}', $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertEmpty($result);
|
|
}
|
|
|
|
/**
|
|
* 객체 스프레드: {...obj, key: value}
|
|
*/
|
|
public function test_object_spread(): void
|
|
{
|
|
$context = ['defaults' => ['color' => 'red', 'size' => 'lg']];
|
|
$result = $this->evaluator->evaluateRaw("{{ {...defaults, size: 'sm'} }}", $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertEquals('red', $result['color']);
|
|
$this->assertEquals('sm', $result['size']); // 후순위 키가 덮어씀
|
|
}
|
|
|
|
/**
|
|
* 동적 키: {[expr]: value}
|
|
*/
|
|
public function test_object_dynamic_key(): void
|
|
{
|
|
$context = ['item' => ['id' => 'abc123']];
|
|
$result = $this->evaluator->evaluateRaw("{{ {[item.id]: 'found'} }}", $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertEquals('found', $result['abc123']);
|
|
}
|
|
|
|
/**
|
|
* 객체 리터럴에서 값으로 경로 참조를 사용합니다.
|
|
*/
|
|
public function test_object_literal_with_path_value(): void
|
|
{
|
|
$context = ['product' => ['name' => '에어맥스', 'price' => 129000]];
|
|
$result = $this->evaluator->evaluateRaw('{{ {name: product.name, price: product.price} }}', $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertEquals('에어맥스', $result['name']);
|
|
$this->assertEquals(129000, $result['price']);
|
|
}
|
|
|
|
/**
|
|
* 중첩 객체 리터럴: {outer: {inner: 'value'}}
|
|
*/
|
|
public function test_object_literal_nested(): void
|
|
{
|
|
$context = [];
|
|
$result = $this->evaluator->evaluateRaw("{{ {outer: {inner: 'value'}} }}", $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertIsArray($result['outer']);
|
|
$this->assertEquals('value', $result['outer']['inner']);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 배열 스프레드 테스트
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 배열 스프레드: [...arr, item]
|
|
*/
|
|
public function test_array_spread(): void
|
|
{
|
|
$context = ['items' => ['a', 'b', 'c']];
|
|
$result = $this->evaluator->evaluateRaw("{{ [...items, 'd'] }}", $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertEquals(['a', 'b', 'c', 'd'], $result);
|
|
}
|
|
|
|
/**
|
|
* 배열 스프레드에서 빈 배열을 스프레드합니다.
|
|
*/
|
|
public function test_array_spread_empty_source(): void
|
|
{
|
|
$context = ['items' => []];
|
|
$result = $this->evaluator->evaluateRaw("{{ [...items, 'new'] }}", $context);
|
|
|
|
$this->assertIsArray($result);
|
|
$this->assertEquals(['new'], $result);
|
|
}
|
|
|
|
/**
|
|
* 복합 패턴: 객체 스프레드 + 동적 키 (reduce 콜백 시뮬레이션)
|
|
* {...map, [c.id]: [...(map[c.id] ?? []), c]}
|
|
*/
|
|
public function test_spread_with_dynamic_key_reduce_pattern(): void
|
|
{
|
|
$context = [
|
|
'map' => ['g1' => [['id' => 'g1', 'text' => 'first']]],
|
|
'c' => ['id' => 'g1', 'text' => 'second'],
|
|
];
|
|
|
|
// {[c.id]: 'value'} 동적 키 기본 동작 확인
|
|
$result = $this->evaluator->evaluateRaw("{{ {[c.id]: 'value'} }}", $context);
|
|
$this->assertIsArray($result);
|
|
$this->assertEquals('value', $result['g1']);
|
|
}
|
|
|
|
/**
|
|
* (subexpr).property 패턴: 괄호 내 하위 표현식 + 후속 프로퍼티 접근
|
|
*
|
|
* SEO 렌더링에서 (array ?? []).length 같은 JavaScript 패턴을 PHP로 해석합니다.
|
|
*
|
|
* @since engine-v1.17.6
|
|
*/
|
|
public function test_parenthesized_subexpression_with_property_access_length(): void
|
|
{
|
|
$context = [
|
|
'review' => [
|
|
'images' => [
|
|
['id' => 1, 'url' => 'https://example.com/img1.jpg'],
|
|
['id' => 2, 'url' => 'https://example.com/img2.jpg'],
|
|
],
|
|
'image_count' => 2,
|
|
],
|
|
];
|
|
|
|
// (review.images ?? []).length → 2
|
|
$result = $this->evaluator->evaluate('{{(review.images ?? []).length}}', $context);
|
|
$this->assertEquals('2', $result);
|
|
}
|
|
|
|
public function test_parenthesized_subexpression_length_comparison(): void
|
|
{
|
|
$context = [
|
|
'review' => [
|
|
'images' => [
|
|
['id' => 1, 'url' => 'https://example.com/img1.jpg'],
|
|
],
|
|
'image_count' => 1,
|
|
],
|
|
];
|
|
|
|
// (review.images ?? []).length > 0 → true
|
|
$result = $this->evaluator->evaluate('{{(review.images ?? []).length > 0}}', $context);
|
|
$this->assertEquals('true', $result);
|
|
|
|
// (review.images ?? []).length > 1 → false
|
|
$result = $this->evaluator->evaluate('{{(review.images ?? []).length > 1}}', $context);
|
|
$this->assertEquals('false', $result);
|
|
}
|
|
|
|
public function test_parenthesized_subexpression_with_empty_fallback(): void
|
|
{
|
|
$context = [
|
|
'review' => [
|
|
'image_count' => 0,
|
|
],
|
|
];
|
|
|
|
// review.images가 없을 때 (review.images ?? []).length → 0
|
|
$result = $this->evaluator->evaluate('{{(review.images ?? []).length}}', $context);
|
|
$this->assertEquals('0', $result);
|
|
|
|
// (review.images ?? []).length > 0 → false
|
|
$result = $this->evaluator->evaluate('{{(review.images ?? []).length > 0}}', $context);
|
|
$this->assertEquals('false', $result);
|
|
}
|
|
|
|
public function test_parenthesized_subexpression_equality_comparison(): void
|
|
{
|
|
$context = [
|
|
'items' => [
|
|
['id' => 1],
|
|
['id' => 2],
|
|
['id' => 3],
|
|
['id' => 4],
|
|
],
|
|
];
|
|
|
|
// (items ?? []).length == 4 → true
|
|
$result = $this->evaluator->evaluate('{{(items ?? []).length == 4}}', $context);
|
|
$this->assertEquals('true', $result);
|
|
|
|
// (items ?? []).length >= 5 → false
|
|
$result = $this->evaluator->evaluate('{{(items ?? []).length >= 5}}', $context);
|
|
$this->assertEquals('false', $result);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 산술 연산 확장: *, /, % (Arithmetic Operations Extended)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* 곱셈: 경로 값 * 숫자 리터럴이 올바르게 계산됩니다.
|
|
*/
|
|
public function test_arithmetic_multiplication(): void
|
|
{
|
|
$context = ['quantity' => 5];
|
|
|
|
$result = $this->evaluator->evaluate('{{quantity * 3}}', $context);
|
|
|
|
$this->assertSame('15', $result);
|
|
}
|
|
|
|
/**
|
|
* 나눗셈: 경로 값 / 숫자 리터럴이 올바르게 계산됩니다.
|
|
*/
|
|
public function test_arithmetic_division(): void
|
|
{
|
|
$context = ['total' => 100];
|
|
|
|
$result = $this->evaluator->evaluate('{{total / 4}}', $context);
|
|
|
|
$this->assertSame('25', $result);
|
|
}
|
|
|
|
/**
|
|
* 나눗셈 소수점 결과가 올바르게 반환됩니다.
|
|
*/
|
|
public function test_arithmetic_division_with_decimal_result(): void
|
|
{
|
|
$context = ['total' => 10];
|
|
|
|
$result = $this->evaluator->evaluate('{{total / 3}}', $context);
|
|
|
|
// PHP float 나눗셈 결과
|
|
$this->assertSame((string) (10 / 3), $result);
|
|
}
|
|
|
|
/**
|
|
* 0으로 나눗셈 시 0을 반환합니다.
|
|
*/
|
|
public function test_arithmetic_division_by_zero(): void
|
|
{
|
|
$context = ['total' => 100];
|
|
|
|
$result = $this->evaluator->evaluate('{{total / 0}}', $context);
|
|
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* 나머지 연산: 경로 값 % 숫자 리터럴이 올바르게 계산됩니다.
|
|
*/
|
|
public function test_arithmetic_modulo(): void
|
|
{
|
|
$context = ['index' => 7];
|
|
|
|
$result = $this->evaluator->evaluate('{{index % 3}}', $context);
|
|
|
|
$this->assertSame('1', $result);
|
|
}
|
|
|
|
/**
|
|
* 0으로 나머지 연산 시 0을 반환합니다.
|
|
*/
|
|
public function test_arithmetic_modulo_by_zero(): void
|
|
{
|
|
$context = ['index' => 7];
|
|
|
|
$result = $this->evaluator->evaluate('{{index % 0}}', $context);
|
|
|
|
$this->assertSame('0', $result);
|
|
}
|
|
|
|
/**
|
|
* 곱셈 + null coalescing 조합이 동작합니다.
|
|
*/
|
|
public function test_arithmetic_multiplication_with_null_coalescing(): void
|
|
{
|
|
$context = ['item' => ['price' => 1500, 'quantity' => 3]];
|
|
|
|
$result = $this->evaluator->evaluate('{{(item?.quantity ?? 1) * 2}}', $context);
|
|
$this->assertSame('6', $result);
|
|
}
|
|
|
|
/**
|
|
* 소수점 곱셈이 올바르게 동작합니다.
|
|
*/
|
|
public function test_arithmetic_multiplication_decimal(): void
|
|
{
|
|
$context = ['price' => 100];
|
|
|
|
$result = $this->evaluator->evaluate('{{price * 1.1}}', $context);
|
|
$this->assertSame('110', $result);
|
|
}
|
|
|
|
// =========================================================================
|
|
// 파이프 표현식 (Pipe Expressions)
|
|
// =========================================================================
|
|
|
|
/**
|
|
* number 파이프가 evaluate에서 올바르게 동작합니다.
|
|
*/
|
|
public function test_pipe_number_in_evaluate(): void
|
|
{
|
|
$context = ['price' => 1234567];
|
|
|
|
$result = $this->evaluator->evaluate('{{price | number}}', $context);
|
|
|
|
$this->assertSame('1,234,567', $result);
|
|
}
|
|
|
|
/**
|
|
* number 파이프가 소수점 인자와 함께 동작합니다.
|
|
*/
|
|
public function test_pipe_number_with_decimals(): void
|
|
{
|
|
$context = ['price' => 1234.5];
|
|
|
|
$result = $this->evaluator->evaluate('{{price | number(2)}}', $context);
|
|
|
|
$this->assertSame('1,234.50', $result);
|
|
}
|
|
|
|
/**
|
|
* date 파이프가 evaluate에서 올바르게 동작합니다.
|
|
*/
|
|
public function test_pipe_date_in_evaluate(): void
|
|
{
|
|
$context = ['post' => ['created_at' => '2024-01-15 14:30:00']];
|
|
|
|
$result = $this->evaluator->evaluate('{{post.created_at | date}}', $context);
|
|
|
|
$this->assertSame('2024-01-15', $result);
|
|
}
|
|
|
|
/**
|
|
* datetime 파이프가 evaluate에서 올바르게 동작합니다.
|
|
*/
|
|
public function test_pipe_datetime_in_evaluate(): void
|
|
{
|
|
$context = ['post' => ['created_at' => '2024-01-15 14:30:45']];
|
|
|
|
$result = $this->evaluator->evaluate('{{post.created_at | datetime}}', $context);
|
|
|
|
$this->assertSame('2024-01-15 14:30', $result);
|
|
}
|
|
|
|
/**
|
|
* uppercase 파이프가 evaluate에서 올바르게 동작합니다.
|
|
*/
|
|
public function test_pipe_uppercase_in_evaluate(): void
|
|
{
|
|
$context = ['name' => 'hello'];
|
|
|
|
$result = $this->evaluator->evaluate('{{name | uppercase}}', $context);
|
|
|
|
$this->assertSame('HELLO', $result);
|
|
}
|
|
|
|
/**
|
|
* lowercase 파이프가 evaluate에서 올바르게 동작합니다.
|
|
*/
|
|
public function test_pipe_lowercase_in_evaluate(): void
|
|
{
|
|
$context = ['name' => 'HELLO'];
|
|
|
|
$result = $this->evaluator->evaluate('{{name | lowercase}}', $context);
|
|
|
|
$this->assertSame('hello', $result);
|
|
}
|
|
|
|
/**
|
|
* truncate 파이프가 evaluate에서 올바르게 동작합니다.
|
|
*/
|
|
public function test_pipe_truncate_in_evaluate(): void
|
|
{
|
|
$context = ['description' => '이것은 매우 긴 설명 텍스트입니다. 잘라야 합니다.'];
|
|
|
|
$result = $this->evaluator->evaluate('{{description | truncate(10)}}', $context);
|
|
|
|
$this->assertSame('이것은 매우 긴 설...', $result);
|
|
}
|
|
|
|
/**
|
|
* stripHtml 파이프가 evaluate에서 HTML 태그를 제거합니다.
|
|
*/
|
|
public function test_pipe_strip_html_in_evaluate(): void
|
|
{
|
|
$context = ['content' => '<p>안녕 <b>세계</b></p>'];
|
|
|
|
$result = $this->evaluator->evaluate('{{content | stripHtml}}', $context);
|
|
|
|
$this->assertSame('안녕 세계', $result);
|
|
}
|
|
|
|
/**
|
|
* 다중 파이프 체인이 올바르게 동작합니다.
|
|
*/
|
|
public function test_pipe_chain(): void
|
|
{
|
|
$context = ['content' => '<p>Hello World</p>'];
|
|
|
|
$result = $this->evaluator->evaluate('{{content | stripHtml | uppercase}}', $context);
|
|
|
|
$this->assertSame('HELLO WORLD', $result);
|
|
}
|
|
|
|
/**
|
|
* 파이프와 null coalescing 조합이 동작합니다.
|
|
*/
|
|
public function test_pipe_with_null_coalescing_in_base_expr(): void
|
|
{
|
|
$context = ['item' => ['name' => '테스트 상품']];
|
|
|
|
$result = $this->evaluator->evaluate('{{item.name ?? "없음" | uppercase}}', $context);
|
|
|
|
$this->assertSame('테스트 상품', $result);
|
|
}
|
|
|
|
/**
|
|
* 파이프가 없는 기존 || 연산자는 영향받지 않습니다.
|
|
*/
|
|
public function test_pipe_does_not_affect_or_operator(): void
|
|
{
|
|
$context = ['a' => '', 'b' => 'hello'];
|
|
|
|
$result = $this->evaluator->evaluate('{{a || b}}', $context);
|
|
|
|
$this->assertSame('hello', $result);
|
|
}
|
|
|
|
/**
|
|
* join 파이프가 배열을 문자열로 결합합니다.
|
|
*/
|
|
public function test_pipe_join_in_evaluate(): void
|
|
{
|
|
$context = ['tags' => ['태그1', '태그2', '태그3']];
|
|
|
|
$result = $this->evaluator->evaluate('{{tags | join}}', $context);
|
|
|
|
$this->assertSame('태그1, 태그2, 태그3', $result);
|
|
}
|
|
|
|
/**
|
|
* default 파이프가 null에 기본값을 적용합니다.
|
|
*/
|
|
public function test_pipe_default_in_evaluate(): void
|
|
{
|
|
$context = ['user' => ['name' => null]];
|
|
|
|
$result = $this->evaluator->evaluate("{{user.name | default('미지정')}}", $context);
|
|
|
|
$this->assertSame('미지정', $result);
|
|
}
|
|
|
|
/**
|
|
* evaluateRaw에서 파이프가 원본 타입을 유지합니다.
|
|
*/
|
|
public function test_pipe_in_evaluate_raw(): void
|
|
{
|
|
$context = ['items' => ['a', 'b', 'c']];
|
|
|
|
$result = $this->evaluator->evaluateRaw('{{items | length}}', $context);
|
|
|
|
$this->assertSame(3, $result);
|
|
}
|
|
|
|
/**
|
|
* evaluateRaw에서 다중 파이프 체인이 동작합니다.
|
|
*/
|
|
public function test_pipe_chain_in_evaluate_raw(): void
|
|
{
|
|
$context = ['data' => ['a' => 1, 'b' => 2, 'c' => 3]];
|
|
|
|
$result = $this->evaluator->evaluateRaw('{{data | keys}}', $context);
|
|
|
|
$this->assertSame(['a', 'b', 'c'], $result);
|
|
}
|
|
|
|
/**
|
|
* localized 파이프가 다국어 객체에서 값을 추출합니다.
|
|
*/
|
|
public function test_pipe_localized_in_evaluate(): void
|
|
{
|
|
$context = ['product' => ['name' => ['ko' => '상품명', 'en' => 'Product']]];
|
|
|
|
$result = $this->evaluator->evaluate('{{product.name | localized}}', $context);
|
|
|
|
$this->assertSame('상품명', $result);
|
|
}
|
|
|
|
/**
|
|
* 미등록 파이프는 원본 값 그대로 출력합니다.
|
|
*/
|
|
public function test_unknown_pipe_passes_through(): void
|
|
{
|
|
$context = ['name' => 'hello'];
|
|
|
|
// 미등록 파이프는 무시되어 원본 값 반환
|
|
$result = $this->evaluator->evaluate('{{name | nonexistent}}', $context);
|
|
|
|
$this->assertSame('hello', $result);
|
|
}
|
|
|
|
/**
|
|
* 복합 텍스트 내 파이프가 올바르게 동작합니다.
|
|
*/
|
|
public function test_pipe_in_mixed_text(): void
|
|
{
|
|
$context = ['price' => 15000];
|
|
|
|
$result = $this->evaluator->evaluate('가격: {{price | number}}원', $context);
|
|
|
|
$this->assertSame('가격: 15,000원', $result);
|
|
}
|
|
|
|
/**
|
|
* 비교식의 원본 값은 참/거짓입니다.
|
|
*
|
|
* 종전에는 null 을 돌려줘서, 확장 주입 props 의 `?? 기본값` 이 항상 적용되며
|
|
* 레이아웃이 선언한 판정(예: `isHtml`)이 통째로 뒤집혔습니다.
|
|
*/
|
|
public function test_raw_comparison_returns_boolean(): void
|
|
{
|
|
$context = ['post' => ['content_mode' => 'text']];
|
|
|
|
$this->assertFalse($this->evaluator->evaluateRaw("{{(post.content_mode ?? 'text') === 'html'}}", $context));
|
|
$this->assertTrue($this->evaluator->evaluateRaw("{{(post.content_mode ?? 'text') === 'text'}}", $context));
|
|
}
|
|
|
|
/**
|
|
* 부정·논리곱 표현식의 원본 값도 참/거짓입니다.
|
|
*/
|
|
public function test_raw_logical_expression_returns_boolean(): void
|
|
{
|
|
$context = ['reply' => ['is_secret' => false, 'status' => 'published']];
|
|
|
|
$this->assertTrue($this->evaluator->evaluateRaw('{{!reply.is_secret}}', $context));
|
|
$this->assertTrue($this->evaluator->evaluateRaw("{{reply.status === 'published' && !reply.is_secret}}", $context));
|
|
$this->assertFalse($this->evaluator->evaluateRaw("{{reply.status === 'blinded' && !reply.is_secret}}", $context));
|
|
}
|
|
|
|
/**
|
|
* `||` 값 폴백 관용구는 참/거짓이 아니라 피연산자 값을 유지합니다.
|
|
*/
|
|
public function test_raw_or_expression_keeps_operand_value(): void
|
|
{
|
|
$context = ['query' => ['q' => '']];
|
|
|
|
$this->assertSame('셔츠', $this->evaluator->evaluateRaw("{{query.q || '셔츠'}}", $context));
|
|
}
|
|
|
|
/**
|
|
* 삼항·화살표 함수는 비교식으로 오인되지 않습니다 (기존 동작 보존).
|
|
*/
|
|
public function test_raw_ternary_and_arrow_function_are_not_treated_as_boolean(): void
|
|
{
|
|
$context = ['_local' => ['expanded' => true], 'items' => [1, 2, 3]];
|
|
|
|
$this->assertSame('open', $this->evaluator->evaluateRaw("{{_local.expanded ? 'open' : 'closed'}}", $context));
|
|
$this->assertNotSame(true, $this->evaluator->evaluateRaw('{{items.filter(i => i > 1)}}', $context));
|
|
}
|
|
|
|
/**
|
|
* 경로·배열 해석은 종전대로 원본 값을 반환합니다 (회귀 방지).
|
|
*/
|
|
public function test_raw_path_resolution_is_unchanged(): void
|
|
{
|
|
$context = ['product' => ['tags' => ['a', 'b'], 'stock' => 0, 'name' => '상품']];
|
|
|
|
$this->assertSame(['a', 'b'], $this->evaluator->evaluateRaw('{{product.tags}}', $context));
|
|
$this->assertSame(0, $this->evaluator->evaluateRaw('{{product.stock}}', $context));
|
|
$this->assertSame('상품', $this->evaluator->evaluateRaw('{{product.name}}', $context));
|
|
}
|
|
}
|