Merge pull request from gnuboard:HeuJung/issue503

HeuJung/issue503
This commit is contained in:
정정홍
2026-08-04 10:42:20 +09:00
committed by GitHub
114 changed files with 8920 additions and 777 deletions
+13
View File
@@ -75,6 +75,10 @@
### Fixed
#### 레이아웃 코드 편집
- 레이아웃 코드 편집 화면이 파일 목록을 불러올 때 편집하지 않는 레이아웃의 본문까지 전부 내려받던 문제를 수정했습니다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB가 브라우저에 쌓여 화면이 크게 느려지거나, 디버그 모드에서는 탭이 메모리 부족으로 종료됐습니다. 이제 목록에는 이름·설명·크기·수정일만 실리고 본문은 선택한 파일만 불러옵니다.
#### 설정 화면 입력
- 설정 화면에서 여러 입력칸을 고친 뒤 다른 탭에 갔다 돌아오면, 먼저 고친 칸에 입력했던 값이 그대로 남던 문제를 수정했습니다. 저장된 값은 원래 값이라 화면에 보이는 값과 실제 값이 어긋났고, 새로고침해야 드러났습니다. 이제 탭을 오갈 때 모든 입력칸이 저장된 값으로 함께 돌아옵니다.
@@ -107,6 +111,15 @@
#### 화면 표시·목록 상태
- 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.)
- 위 문제와 같은 뿌리에서 비롯된 화면 표시 오류를 함께 정리했습니다. 같은 방식으로 작성한 값이 어디에 놓이느냐(목록 칸·본문·표시 조건·버튼 동작)에 따라 다르게 해석되던 것을 한 가지 기준으로 통일했으며, 아래 항목이 그 결과입니다. 대부분 오류 메시지 없이 값이 비거나 잘못 보이던 증상이라 눈치채기 어려웠습니다.
- 행마다 달라지는 조건(예: "활성 상태인 항목만 표시")을 목록에 걸면 해당하는 행만 걸러지지 않고 **목록 전체가 사라지던** 문제를 수정했습니다. 이제 행마다 조건을 따져 표시하며, 검색엔진 봇이 보는 화면에도 같은 규칙이 적용됩니다.
- 관리자 목록의 여러 개 고를 수 있는 상태·분류 필터처럼 항목 이름에 대괄호가 들어가는 값이 화면에 따라 비어 보이던 문제를 수정했습니다. 선택한 조건이 화면에 표시되지 않거나 조건 표시가 사라지던 증상이 해소됩니다.
- 값 하나를 계산하다 실패하면 그 값이 속한 영역 전체가 그려지지 않던 문제를 수정했습니다. 이제 문제가 된 자리만 비고 나머지는 정상 표시됩니다.
- 계산식에 날짜·숫자 서식을 함께 걸면 원본 값이 바뀌어도 처음 계산된 값이 계속 표시되던 문제를 수정했습니다.
- 목록 칸이나 합계 행 안에서 번역 문구가 문장 중간에 들어간 경우 번역되지 않고 원본 키가 그대로 보이던 문제, 그리고 일부 표시 값에 `{{ }}` 기호가 섞여 나오던 문제를 수정했습니다.
- 목록 칸이나 합계 행에서 '번역하지 않고 원문 그대로 표시'로 지정한 값에 보이지 않는 특수문자가 함께 출력되던 문제를 수정했습니다. 화면에서는 빈칸이나 깨진 글자로 보였고, 값을 복사해 붙여넣을 때 따라붙었습니다.
- 목록 표·카드의 셀에서 `true`/`false` 같은 값을 직접 쓴 자리가 비어 보이던 문제를 수정했습니다. 같은 값을 표시 조건에 쓰면 정상 동작해서, 같은 작성이 놓인 자리에 따라 갈렸습니다.
- 레이아웃 코드 편집 화면의 파일 목록과 선택한 파일 설명에 번역되지 않은 내부 표기(`$t:…` 나 `{{ … }}`)가 그대로 보이던 문제를 수정했습니다. 다른 템플릿의 레이아웃을 편집할 때 그 템플릿의 번역을 찾지 못해 생긴 문제로, 이제 해당 템플릿의 번역으로 표시하며 번역을 찾을 수 없으면 파일 이름을 대신 보여줍니다.
- 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
#### 확장·데이터베이스 안정성
@@ -6,6 +6,7 @@ use App\Enums\LayoutSourceType;
use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
interface LayoutRepositoryInterface
{
@@ -17,6 +18,17 @@ interface LayoutRepositoryInterface
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 목록 표시용 경량 레이아웃 행 조회
*
* 본문(`content`)을 반환에 포함하지 않는다 — 설명·크기 등 본문 파생값만 계산해 담는다.
* 편집 대상 본문은 상세 조회가 제공한다.
*
* @param int $templateId 템플릿 ID
* @return Collection<int, array> 목록 행 배열 컬렉션
*/
public function getListByTemplateId(int $templateId): SupportCollection;
/**
* 특정 레이아웃 조회 (템플릿 ID와 이름으로)
*
@@ -6,6 +6,7 @@ use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Layout\StoreLayoutPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutContentRequest;
use App\Http\Resources\LayoutListResource;
use App\Http\Resources\LayoutResource;
use App\Http\Resources\LayoutVersionResource;
use App\Services\LayoutPreviewService;
@@ -38,15 +39,26 @@ class LayoutController extends AdminBaseController
return $this->notFound('common.not_found');
}
$layouts = $this->layoutService->getLayoutsByTemplateId($template->id);
// 목록은 본문(`content`)을 담지 않는 경량 조회를 쓴다 — 파일 목록 화면은 이름·설명·
// 크기·수정일만 표시하고, 편집 대상 본문은 상세 엔드포인트가 따로 제공한다.
// 종전에는 목록·상세가 같은 Resource 를 공용해 102개 레이아웃의 본문이 전부 실렸고
// (응답 18.30MB 중 content 17.41MB), 디버그 모드에서 렌더러가 메모리 한계를 넘었다.
$layouts = $this->layoutService->getLayoutListByTemplateId($template->id);
// 레이아웃 이름 → 라우트 path 매핑 — 코드 편집기가 파일 선택 시 ?route= URL
// 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용한다.
$routePathMap = $this->templateService->getLayoutRoutePathMap($templateName);
$collection = LayoutResource::collection($layouts);
// 레이아웃 설명(`meta.description`)은 그 레이아웃을 소유한 템플릿의 사전 키를 쓴다.
// 코드 편집 화면은 관리자 템플릿 사전으로 렌더하므로 유저 템플릿 키를 알지 못해,
// 해석하지 않고 내보내면 설명 칸에 `$t:user.…` 가 원문으로 노출된다.
$translations = $this->resolveTemplateTranslations($templateName);
$collection = LayoutListResource::collection($layouts);
$collection->collection->transform(
fn (LayoutResource $resource) => $resource->withRoutePathMap($routePathMap)
fn (LayoutListResource $resource) => $resource
->withRoutePathMap($routePathMap)
->withTranslations($translations)
);
return $this->success('common.success', $collection);
@@ -75,7 +87,8 @@ class LayoutController extends AdminBaseController
return $this->success(
'common.success',
new LayoutResource($layout)
(new LayoutResource($layout))
->withTranslations($this->resolveTemplateTranslations($templateName))
);
}
@@ -104,7 +117,8 @@ class LayoutController extends AdminBaseController
return $this->success(
'common.success',
new LayoutResource($layout)
(new LayoutResource($layout))
->withTranslations($this->resolveTemplateTranslations($templateName))
);
} catch (ConcurrentModificationException $e) {
DB::rollBack();
@@ -259,4 +273,25 @@ class LayoutController extends AdminBaseController
);
}
}
/**
* 목록 설명 해석에 쓸 소유 템플릿 사전을 로드합니다.
*
* 활성 로케일 기준이며, 로드에 실패하면 빈 배열을 돌려준다 — 그 경우
* {@see LayoutListResource} 가 레이아웃 이름으로 폴백하므로 목록은 계속 그려진다.
*
* @param string $templateName 템플릿 식별자
* @return array<string, mixed> 템플릿 프론트엔드 다국어 데이터
*/
private function resolveTemplateTranslations(string $templateName): array
{
$result = $this->templateService->getLanguageDataWithModules(
$templateName,
app()->getLocale()
);
return ($result['success'] ?? false) && is_array($result['data'] ?? null)
? $result['data']
: [];
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Resources;
use App\Support\LayoutDescription;
use Carbon\Carbon;
use Illuminate\Http\Request;
/**
* 레이아웃 **목록** 전용 리소스
*
* 코드 편집 화면의 파일 목록은 행마다 이름·설명·라우트·크기·수정일·업데이트 표시만 쓴다.
* 편집 대상 본문은 선택 시 상세 엔드포인트(`current_layout`)가 따로 받아온다.
*
* 종전에는 목록·상세가 {@see LayoutResource} 하나를 공용해 목록 응답에도 각 레이아웃의
* `content`(본문 전체) · `components` · `data_sources` · `metadata` 가 실렸다.
* 실측(sirsoft-admin_basic): 응답 18.30MB 중 `content` 가 17.41MB(95%). 화면은 102개 중
* 1개 본문만 필요한데 101개를 더 받아 전역 상태에 상주시켰고, 디버그 모드에서는 그 상태를
* 추적하다 렌더러가 메모리 한계를 넘었다.
*
* 본문에서 파생되는 값(`description` · `size`)은 조회 계층이 미리 계산해 넘긴다 —
* 이 리소스는 본문을 보지 않는다.
*
* @since 7.0.6
*/
class LayoutListResource extends BaseApiResource
{
/** @var array<string, string|null> 레이아웃 이름 → 라우트 path 매핑 */
private array $routePathMap = [];
/** @var array<string, mixed> 소유 템플릿의 프론트엔드 다국어 사전 (점 표기 중첩) */
private array $translations = [];
/**
* 라우트 path 매핑을 주입합니다.
*
* @param array<string, string|null> $routePathMap 레이아웃 이름 → 라우트 path
* @return $this
*/
public function withRoutePathMap(array $routePathMap): self
{
$this->routePathMap = $routePathMap;
return $this;
}
/**
* 설명 해석에 쓸 소유 템플릿 사전을 주입합니다.
*
* @param array $translations 템플릿 프론트엔드 다국어 데이터
* @return $this
*/
public function withTranslations(array $translations): self
{
$this->translations = $translations;
return $this;
}
/**
* 목록 행을 배열로 변환합니다.
*
* @param Request $request 요청 객체
* @return array 목록 행 데이터
*/
public function toArray(Request $request): array
{
$name = $this->getValue('name');
$size = (int) $this->getValue('size', 0);
return [
'id' => $this->getValue('id'),
'template_id' => $this->getValue('template_id'),
'name' => $name,
'description' => LayoutDescription::resolve($this->getValue('description'), $name, $this->translations),
// 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 파일 선택 시 ?route=
// 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용.
'route_path' => $this->routePathMap[$name] ?? null,
'size' => $size,
'size_formatted' => $this->formatFileSize($size),
'has_update' => false,
// 낙관적 잠금 — 편집 진입 후 저장 요청에 expected_lock_version 으로 전달
'lock_version' => (int) $this->getValue('lock_version', 0),
// 상세 응답(LayoutResource)의 formatTimestamps() 와 같은 규약 — 사용자 timezone
// 기준 문자열이다. 원시 ISO 를 그대로 내보내면 목록의 수정일 표기만 형식이 달라진다.
'created_at' => $this->formatDateTimeStringForUser($this->toCarbon($this->getValue('created_at'))),
'updated_at' => $this->formatDateTimeStringForUser($this->toCarbon($this->getValue('updated_at'))),
];
}
/**
* 값을 Carbon 인스턴스로 변환합니다.
*
* 경량 조회는 배열 행을 넘기므로 값이 Carbon 일 수도, 문자열일 수도, null 일 수도 있다.
*
* @param mixed $value 변환할 값
* @return Carbon|null 변환된 Carbon 또는 null
*/
private function toCarbon(mixed $value): ?Carbon
{
if ($value === null || $value === '') {
return null;
}
return $value instanceof Carbon ? $value : Carbon::parse($value);
}
/**
* 파일 크기를 사람이 읽는 단위로 변환합니다.
*
* @param int $bytes 바이트 수
* @return string 변환된 크기 문자열
*/
private function formatFileSize(int $bytes): string
{
if ($bytes < 1024) {
return $bytes.' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 1).' KB';
}
return round($bytes / 1048576, 1).' MB';
}
}
+20 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Resources;
use App\Support\LayoutDescription;
use Illuminate\Http\Request;
class LayoutResource extends BaseApiResource
@@ -16,6 +17,22 @@ class LayoutResource extends BaseApiResource
*/
protected array $routePathMap = [];
/** @var array<string, mixed> 소유 템플릿의 프론트엔드 다국어 사전 */
protected array $translations = [];
/**
* 설명 해석에 쓸 소유 템플릿 사전을 주입합니다.
*
* @param array<string, mixed> $translations 템플릿 프론트엔드 다국어 데이터
* @return $this
*/
public function withTranslations(array $translations): static
{
$this->translations = $translations;
return $this;
}
/**
* 레이아웃 이름 → 라우트 path 매핑을 주입합니다.
*
@@ -45,7 +62,9 @@ class LayoutResource extends BaseApiResource
'id' => $this->getValue('id'),
'template_id' => $this->getValue('template_id'),
'name' => $name,
'description' => $content['meta']['description'] ?? $name,
// 설명은 소유 템플릿 사전 키를 쓰므로 서버가 해석한다 — 목록(LayoutListResource)과
// 같은 규칙이어야 파일 목록과 선택 파일 헤더의 표기가 어긋나지 않는다.
'description' => LayoutDescription::resolve($content['meta']['description'] ?? null, $name, $this->translations),
'endpoint' => $content['endpoint'] ?? null,
// 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 코드 편집기가
// 파일 선택 시 ?route= 동기화 / 위지윅에서 넘어온 ?route= 복원에 사용.
+53
View File
@@ -7,9 +7,13 @@ use App\Enums\LayoutSourceType;
use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
class LayoutRepository implements LayoutRepositoryInterface
{
/** @var int 목록 조회 시 본문을 흘려 읽는 청크 크기 */
private const LIST_CHUNK_SIZE = 25;
/**
* 특정 템플릿의 모든 레이아웃 조회
*
@@ -23,6 +27,55 @@ class LayoutRepository implements LayoutRepositoryInterface
->get();
}
/**
* 목록 표시용 경량 레이아웃 행을 조회합니다.
*
* 목록 화면은 이름·설명·크기·수정일만 쓰고 본문은 상세 엔드포인트가 따로 제공한다.
* 그런데 `getByTemplateId()` 로 전체 컬럼을 읽으면 `content`(레이아웃 본문)까지 전부
* 메모리에 올라간다 — sirsoft-admin_basic 기준 102행 합계 17MB 이상이다.
*
* 여기서는 chunk 로 흘려 읽으며 본문에서 파생되는 값(설명·크기)만 뽑고 본문 자체는
* 즉시 버린다. 결과적으로 반환 컬렉션에 본문이 남지 않고, PHP 피크 메모리도 청크
* 단위로 묶인다.
*
* @param int $templateId 대상 템플릿 ID
* @return Collection<int, array> 목록 행 배열 (id, template_id, name, description, size, lock_version, created_at, updated_at)
*/
public function getListByTemplateId(int $templateId): SupportCollection
{
$rows = collect();
// 순회 커서는 기본키다 — `chunkById` 는 `where id > lastId` 로 다음 페이지를 잡으므로
// 여기에 `orderBy('name')` 을 함께 걸면 정렬 순서와 커서 기준이 어긋나 행이 조용히
// 누락된다(실측: 102행 중 58행만 반환). 표시 정렬은 순회를 마친 뒤에 적용한다.
TemplateLayout::query()
->where('template_id', $templateId)
->select(['id', 'template_id', 'name', 'content', 'lock_version', 'created_at', 'updated_at'])
->chunkById(self::LIST_CHUNK_SIZE, function (Collection $chunk) use ($rows) {
foreach ($chunk as $layout) {
$content = $layout->content;
$contentJson = is_array($content)
? json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: '{}';
$rows->push([
'id' => $layout->id,
'template_id' => $layout->template_id,
'name' => $layout->name,
// 본문에서 파생 — 이 값만 남기고 본문은 버린다
'description' => $content['meta']['description'] ?? null,
'size' => strlen($contentJson),
'lock_version' => $layout->lock_version,
'created_at' => $layout->created_at,
'updated_at' => $layout->updated_at,
]);
}
});
// 표시 정렬은 순회와 분리 — 커서 기준(id)과 섞으면 행이 누락된다
return $rows->sortBy('name', SORT_STRING)->values();
}
/**
* 특정 레이아웃 조회 (템플릿 ID와 이름으로)
*
+9 -1
View File
@@ -235,7 +235,13 @@ class ComponentHtmlMapper
$component = $this->applyResponsiveOverrides($component);
// 조건부 렌더링 (if / condition / conditions)
if (! $this->shouldRender($component, $context, $evaluator)) {
//
// iteration 이 있으면 여기서 끊지 않는다. 조건이 항목 변수(`{{user.is_active}}` 등)를
// 참조하면 이 시점 컨텍스트에는 그 변수가 없어 항상 거짓이 되고, 목록 전체가
// 사라진다. 항목별 컨텍스트에서 renderIteration 이 같은 조건을 다시 평가한다
// (iteration 만 제거한 정의를 넘기므로 `if` 는 그대로 남는다).
// React DynamicRenderer 와 동일한 순서 — 한쪽만 고치면 봇 화면에서만 목록이 빈다.
if (! isset($component['iteration']) && ! $this->shouldRender($component, $context, $evaluator)) {
return '';
}
@@ -1654,6 +1660,8 @@ class ComponentHtmlMapper
$iterContext = array_merge($context, [
$itemVar => $item,
$indexVar => $index,
// `{item_var}_index` 자동 변수 — React 반복 렌더 경로와 동일하게 제공한다.
$itemVar.'_index' => $index,
]);
$html .= $this->renderComponent($templateComponent, $iterContext, $evaluator);
}
+22
View File
@@ -1350,6 +1350,28 @@ class LayoutService
return $layouts;
}
/**
* 목록 표시용 경량 레이아웃 행을 조회합니다.
*
* 본문을 반환에 담지 않으므로 파일 목록 화면이 편집 대상이 아닌 레이아웃의 본문까지
* 내려받는 일이 없다. 편집 대상 본문은 {@see getLayoutByName()} 이 제공한다.
*
* @param int $templateId 대상 템플릿 ID
* @return \Illuminate\Support\Collection<int, array> 목록 행 배열 컬렉션
*/
public function getLayoutListByTemplateId(int $templateId)
{
// Before 훅 - 레이아웃 목록 조회 전
HookManager::doAction('core.layout.before_index', $templateId);
$rows = $this->layoutRepository->getListByTemplateId($templateId);
// After 훅 - 레이아웃 목록 조회 후
HookManager::doAction('core.layout.after_index', $rows, $templateId);
return $rows;
}
/**
* 특정 레이아웃 조회 (이름으로)
*
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Support;
/**
* 레이아웃 설명(`meta.description`) 표시 해석의 SSoT.
*
* 레이아웃의 설명은 그 레이아웃을 **소유한 템플릿**의 사전 키를 쓴다 — 유저 템플릿
* 레이아웃이면 `$t:user.base_layout_description` 처럼. 그런데 코드 편집 화면은 관리자
* 템플릿(`sirsoft-admin_basic`) 사전으로 렌더하므로 `user.*` 네임스페이스를 알지 못한다.
* 값을 그대로 내보내면 파일 목록과 선택 파일 헤더에 번역 토큰이 원문으로 노출된다
* (실측: 저장소 레이아웃 519개 중 83개가 `$t:` 토큰 또는 표현식 형태).
*
* 그래서 서버가 소유 템플릿 사전으로 해석하고, 해석할 수 없으면 레이아웃 이름으로
* 폴백한다 — 사람이 읽을 수 없는 내부 표기를 보여주느니 파일명이 낫다.
*
* 목록(`LayoutListResource`)과 상세(`LayoutResource`)가 같은 규칙을 써야 하므로 이 클래스
* 하나로 모은다. 한쪽만 고치면 목록에서는 번역되고 헤더에서는 토큰이 보이는 불일치가 된다.
*
* @since 7.0.6
*/
class LayoutDescription
{
/** 프론트엔드 다국어 토큰 접두사 (`$t:key`) */
private const TRANSLATION_PREFIX = '$t:';
/**
* 표시용 설명을 해석합니다.
*
* @param mixed $description 원본 설명 (평문 · `$t:` 토큰 · `{{...}}` 표현식 · null)
* @param string $name 레이아웃 이름 (해석 불가 시 폴백)
* @param array<string, mixed> $translations 소유 템플릿의 프론트엔드 다국어 데이터
* @return string 표시용 설명
*/
public static function resolve(mixed $description, string $name, array $translations = []): string
{
if (! is_string($description) || $description === '') {
return $name;
}
// 런타임 컨텍스트(`route` 등)가 필요한 식은 이 시점에 해석할 수 없다.
// 예: `{{route.id ? '$t:board.edit' : '$t:board.new'}}`
if (str_contains($description, '{{')) {
return $name;
}
if (! str_starts_with($description, self::TRANSLATION_PREFIX)) {
return $description;
}
$key = substr($description, strlen(self::TRANSLATION_PREFIX));
$resolved = data_get($translations, $key);
return is_string($resolved) && $resolved !== '' ? $resolved : $name;
}
}
+2 -2
View File
@@ -89,8 +89,8 @@ php artisan core:build
용도: DynamicRenderer가 해당 props의 바인딩을 지연 처리
예시:
- DataGrid: cellChildren, expandChildren, expandContext, render
- CardGrid: cardChildren
- DataGrid: cellChildren, expandChildren, expandContext, render, subRowChildren, subRowCondition, footerCells, footerCardChildren
- CardGrid: cardColumns
- Select: optionRenderer
\`\`\`
+51 -4
View File
@@ -2155,13 +2155,60 @@ Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
**응답 필드** (`data` 내부, 배열의 각 항목)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| id | integer | 레이아웃 ID |
| template_id | integer | 소속 템플릿 ID |
| name | string | 레이아웃 이름 (예: `_admin_base`, `admin_user_list`) |
| description | string | 레이아웃 설명. 본문의 `meta.description` 에서 파생한다. 그 값은 레이아웃을 **소유한 템플릿**의 사전 키(`$t:user.base_layout_description` 등)일 수 있으므로 서버가 해당 템플릿 사전(활성 로케일)으로 해석해 내보낸다. 사전에 키가 없거나 `{{...}}` 표현식이어서 해석할 수 없으면 레이아웃 **이름**으로 폴백한다 — 번역 토큰이나 표현식 원문이 화면에 노출되지 않는다. 목록·상세 응답이 같은 규칙을 쓴다 |
| route_path | string\|null | 이 레이아웃을 사용하는 라우트 path (routes.json 기준). 매핑이 없으면 null |
| size | integer | 본문 크기(바이트) |
| size_formatted | string | 사람이 읽는 크기 표기 (예: `176.9 KB`) |
| has_update | boolean | 템플릿 파일 대비 갱신 여부 (현재 항상 false) |
| lock_version | integer | 낙관적 잠금 버전. 편집 저장 시 `expected_lock_version` 으로 전달 |
| created_at | string | 생성 일시 |
| updated_at | string | 최종 수정 일시 |
목록 응답에는 본문(`content`)과 본문에서 파생되는 구조(`components` · `data_sources` · `metadata` · `endpoint`)가 **포함되지 않습니다**. 파일 목록 화면은 위 필드만 사용하고, 편집 대상 본문은 상세 엔드포인트(`GET .../layouts/{name}`)가 제공합니다. 목록에 본문을 함께 실으면 레이아웃 수에 비례해 응답이 커져(실측: 102개 기준 18MB) 브라우저 메모리를 압박합니다.
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "요청이 성공했습니다.",
"data": [
{
"id": 1,
"template_id": 1,
"name": "_admin_base",
"description": "관리자 기본 레이아웃",
"route_path": null,
"size": 181146,
"size_formatted": "176.9 KB",
"has_update": false,
"lock_version": 3,
"created_at": "2026-07-28T09:35:50.000000Z",
"updated_at": "2026-07-28T09:35:50.000000Z"
},
{
"id": 2,
"template_id": 1,
"name": "admin_user_list",
"description": "사용자 관리",
"route_path": "*/admin/users",
"size": 42130,
"size_formatted": "41.1 KB",
"has_update": false,
"lock_version": 0,
"created_at": "2026-07-28T09:35:50.000000Z",
"updated_at": "2026-07-28T09:35:50.000000Z"
}
]
}
```
**에러 응답**
@@ -2173,7 +2220,7 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** 특정 템플릿의 모든 레이아웃 목록을 조회합니다. 각 레이아웃에 이름 → 라우트 path 매핑을 부착해, 코드 편집기가 파일 선택 시 `?route=` URL 동기화나 위지윅에서 넘어온 라우트로 파일을 복원할 수 있게 합니다(LayoutService::getLayoutsByTemplateId). `core.templates.read` 권한이 필요합니다.
**설명** 특정 템플릿의 모든 레이아웃 목록을 조회합니다. 각 레이아웃에 이름 → 라우트 path 매핑을 부착해, 코드 편집기가 파일 선택 시 `?route=` URL 동기화나 위지윅에서 넘어온 라우트로 파일을 복원할 수 있게 합니다(LayoutService::getLayoutListByTemplateId). 목록은 본문을 담지 않는 경량 조회를 사용합니다. `core.templates.read` 권한이 필요합니다.
### GET /api/admin/templates/{templateName}/layouts/{name}
+2 -2
View File
@@ -298,8 +298,8 @@ DynamicRenderer가 props를 처리할 때 `{{...}}` 바인딩 표현식을 즉
| 컴포넌트 | skipBindingKeys | 이유 |
|----------|-----------------|------|
| DataGrid | `cellChildren`, `expandChildren`, `expandContext`, `render` | row 컨텍스트로 평가 필요 |
| CardGrid | `cardChildren` | item 컨텍스트로 평가 필요 |
| DataGrid | `cellChildren`, `expandChildren`, `expandContext`, `render`, `subRowChildren`, `subRowCondition`, `footerCells`, `footerCardChildren` | row 컨텍스트로 평가 필요 |
| CardGrid | `cardColumns` | item 컨텍스트로 평가 필요 |
### 관련 파일
@@ -125,7 +125,7 @@ HTML 태그를 래핑하는 최소 단위 컴포넌트입니다. 모든 Basic
| 컴포넌트 | 설명 | 주요 Props |
|----------|------|-----------|
| `DataGrid` | 정렬/필터링/페이지네이션 데이터 그리드 | columns, data, sortable, pagination, pageSize, ... |
| `CardGrid` | 카드 그리드 레이아웃 (스켈레톤 로딩, 페이지네이션) | data, cardChildren, columns, gap, responsiveColumns, ... |
| `CardGrid` | 카드 그리드 레이아웃 (스켈레톤 로딩, 페이지네이션) | data, cardColumns, columns, gap, responsiveColumns, ... |
| `Pagination` | 페이지네이션 | currentPage, totalPages, onPageChange, maxVisiblePages, showFirstLast, ... |
| `Badge` | 색상 기반 라벨 뱃지 | color, text, size, style |
| `StatusBadge` | 상태 뱃지 (아이콘 포함) | status, label, showIcon, iconName, style |
@@ -10,6 +10,7 @@
- 무통장입금 자동취소 기한을 비운 채 저장할 때 나오는 안내 문구 일본어 번역 추가.
- 유효기간이 설정되지 않은 쿠폰을 발급할 때 나오는 안내 문구 일본어 번역 추가.
- 마일리지 설정의 통화별 적립 절사 기준(단위·방식) 항목명과 선택지, 허용값 위반 안내 문구 일본어 번역 추가.
### Fixed
@@ -1296,6 +1296,8 @@ return [
'mileage.currency_rules.*.use_unit' => '使用単位',
'mileage.currency_rules.*.max_use_percent' => '最大使用率',
'mileage.currency_rules.*.max_use_value' => '最大使用金額',
'mileage.currency_rules.*.earn_rounding_unit' => '積立端数処理単位',
'mileage.currency_rules.*.earn_rounding_method' => '積立端数処理方式',
'mileage.expiry_days' => '有効期限',
'mileage.expiry_notification_days_before' => '失効予定通知日',
'country_settings' => '国別設定',
@@ -1714,6 +1716,12 @@ return [
'numeric' => '1ポイント当たりの金額は数字である必要があります。',
'min' => '1ポイント当たりの金額は0より大きい必要があります。',
],
'earn_rounding_unit' => [
'in' => '積立端数処理単位は1、10、100のいずれかである必要があります。',
],
'earn_rounding_method' => [
'in' => '積立端数処理方式はfloor、round、ceilのいずれかである必要があります。',
],
'max_use_value' => [
'integer' => '最大使用金額は整数である必要があります。',
'min' => '最大使用金額は0以上である必要があります。',
@@ -39,7 +39,19 @@
"empty": "登録された通貨ルールはありません。",
"code_placeholder": "例: USD",
"duplicate_currency_error": "すでに登録されている通貨です。",
"max_use_fixed_suffix": "ウォン"
"max_use_fixed_suffix": "ウォン",
"earn_rounding": "積立端数処理",
"earn_rounding_hint": "積立ポイントの算出・按分時に適用",
"earn_rounding_units": {
"1": "1ポイント",
"10": "10ポイント",
"100": "100ポイント"
},
"earn_rounding_methods": {
"floor": "切り捨て",
"round": "四捨五入",
"ceil": "切り上げ"
}
},
"expiry": {
"title": "有効期限",
@@ -21,6 +21,7 @@
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
- 마일리지 설정의 통화별 규칙에 적립 절사 기준(1점 / 10점 / 100점 단위 × 버림 / 반올림 / 올림)을 추가했습니다. 적립 포인트를 계산할 때와 부분취소로 주문이 나뉠 때 모두 이 기준이 적용됩니다. 기존 쇼핑몰은 지금까지와 같은 "1점 단위 버림"으로 시작하므로 적립액이 달라지지 않습니다. 이 기준은 주문 시점의 값이 함께 보관되어, 이후 기준을 바꿔도 과거 주문의 적립액이 소급해서 바뀌지 않습니다. 언어/통화 설정의 환율 절사와는 별개 항목입니다(그쪽은 외화 표시 환산에만 적용됩니다).
### Changed
@@ -34,6 +35,13 @@
### Fixed
- 모바일·태블릿 화면에서 쿠폰 목록의 카드 보기가 내용 없이 나오던 문제를 수정했습니다. 목록의 각 쿠폰 정보가 카드에 전달되지 않아, 쿠폰이 있어도 빈 카드만 표시됐습니다.
- 주문 검색 프리셋 관리 창에서 "프리셋 추가" 버튼을 눌러도 저장 창이 열리지 않던 문제를 수정했습니다.
- 상품 리뷰 목록의 작성자 이름과 쿠폰 목록의 쿠폰 이름을 키보드로 선택해 이동할 수 있게 했습니다. 이전에는 마우스 클릭으로만 이동할 수 있었습니다.
- 주문 검색 프리셋 관리 창의 수정·삭제 아이콘이 의도한 크기보다 작게 보이던 문제를 수정했습니다.
- 할인코드 목록에서 선택 개수·전체 건수·페이지당 표시 개수 문구에 숫자가 채워지지 않고 자리표시자가 그대로 보이던 문제를 수정했습니다.
- 마이페이지와 비회원 주문 조회의 주문 상세에서 상품별 배송정책명과 개별 배송비가 아무 안내 없이 표시되지 않던 문제를 수정했습니다. 주문에 보관된 배송정책 기록의 저장 형태가 화면이 기대하는 형태와 달라 해당 줄이 통째로 빠져 있었습니다. 이미 저장된 주문도 업그레이드 시 함께 정리됩니다.
- 비회원 주문 조회의 주문 상세에서 상품별 배송정책명과 개별 배송비가 표시되지 않던 문제를 추가로 수정했습니다. 회원 주문 상세와 같은 화면 구성을 쓰는데 비회원 응답에만 배송정책 정보가 빠져 있어, 오류 없이 그 줄만 비어 있었습니다. 표시에 필요한 정책명과 배송비만 내려주며 내부 계산 정보는 종전대로 제공하지 않습니다.
- 미입금 자동취소 기한을 비운 채 저장하면 "저장되었습니다"라고 안내되면서도 값은 사라지던 문제를 수정했습니다. 숫자 항목에 숫자가 아닌 값을 입력하면 입력칸이 비워지는데, 그대로 저장하면 설정이 없는 상태로 남아 화면에 보이는 값과 실제 동작이 어긋났습니다. 이제 비워 둔 채로는 저장되지 않고 값을 입력하라고 안내합니다.
- 미입금 자동취소 기한 입력 안내가 실제 입력 하한과 다른 "0일 이상"으로 표시되던 문구를 "1일 이상"으로 바로잡았습니다.
- 쿠폰 수정 화면에서 유효기간을 "발급일로부터"로 두고 일수를 비운 채 저장할 수 있던 문제를 수정했습니다. 그렇게 저장된 쿠폰은 발급되는 즉시 만료 상태가 되어, 받은 회원이 사용할 수 없으면서도 아무 오류가 나지 않았습니다. 이제 저장 단계에서 일수를 입력하도록 안내하고, 이미 그렇게 저장돼 있던 쿠폰은 발급 시 유효기간이 설정되지 않았다고 알려 줍니다.
@@ -303,7 +303,9 @@
"use_unit": 10,
"max_use_type": "fixed",
"max_use_percent": 30,
"max_use_value": 50000
"max_use_value": 50000,
"earn_rounding_unit": "1",
"earn_rounding_method": "floor"
}
],
"expiry_enabled": true,
@@ -497,7 +497,8 @@ _단건 응답: `data` 객체의 필드._
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
| promotions_applied_snapshot | object | `{"coupon_issue_ids":[7330],"item_coupons":[],"discount_co…` | 적용된 프로모션 스냅샷 (재계산용) |
| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) |
| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 |
| shipping_policy_applied_snapshot (비회원 응답) | object | `{"items": [{"product_option_id": 481, "policy": {"policy_name": "국내 무료배송", "standalone_shipping_amount": 0, "standalone_shipping_amount_formatted": "무료배송"}}]}` | 비로그인(비회원 조회 토큰) 응답은 **표시용 필드만** 내보낸다 — `policy_name` · `standalone_shipping_amount(_formatted)`. 정책 id·계산 근거와 배송지 메타(`address`)는 제외된다. 비회원 주문 상세 화면이 회원과 같은 partial 로 상품별 정책명·개별 배송비를 그리므로 필드 자체를 빼면 그 줄만 오류 없이 사라진다 |
| admin_memo | null | `null` | 관리자 메모 (내부 관리용) |
| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) |
| created_at | string | `2026-06-17T02:07:25+00:00` | 생성 일시 |
@@ -1523,7 +1524,7 @@ _단건 응답: `data` 객체의 필드._
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
| promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) |
| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) |
| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 |
| admin_memo | null | `null` | 관리자 메모 (내부 관리용) |
| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) |
| created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 |
@@ -2206,7 +2207,7 @@ _단건 응답: `data` 객체의 필드._
| shippings | array | `[]` | 배송 이력 목록 (OrderShippingResource 배열 — 배송유형·택배사·송장번호) |
| cancels | array | `[]` | 취소 이력 목록 (OrderCancelResource 배열 — 취소 사유·상세·취소일시, 최근순) |
| promotions_applied_snapshot | null | `null` | 적용된 프로모션 스냅샷 (재계산용) |
| shipping_policy_applied_snapshot | null | `null` | 적용된 배송정책 스냅샷 (재계산용) |
| shipping_policy_applied_snapshot | object | `{"items": [], "address": {}}` | 적용된 배송정책 스냅샷 (재계산용). `items` 는 옵션별 적용 정책 목록(각 항목: `product_option_id`, `policy`), `address` 는 주문 시점 배송지 메타(`country_code`, `zipcode`). 항목이 없어도 `items` 는 빈 배열이다 |
| admin_memo | null | `null` | 관리자 메모 (내부 관리용) |
| customer_memo | null | `null` | 고객 메모 (주문 시 고객이 남긴 메모) |
| created_at | string | `2026-07-08T01:44:49+00:00` | 생성 일시 |
@@ -864,7 +864,9 @@ HTTP/1.1 200
"use_unit": 10,
"max_use_type": "fixed",
"max_use_percent": 30,
"max_use_value": 50000
"max_use_value": 50000,
"earn_rounding_unit": "1",
"earn_rounding_method": "floor"
}
],
"expiry_enabled": true,
@@ -1905,7 +1907,9 @@ HTTP/1.1 200
"use_unit": 10,
"max_use_type": "fixed",
"max_use_percent": 30,
"max_use_value": 50000
"max_use_value": 50000,
"earn_rounding_unit": "1",
"earn_rounding_method": "floor"
}
],
"expiry_enabled": true,
@@ -2056,6 +2060,19 @@ HTTP/1.1 200
- 정규화 제외: 비숫자 문자열(`"abc"`), 빈 문자열, `null`, 불리언, 그리고 `integer` 필드에 전달된 소수 문자열(`"3.7"`) — 검증을 느슨하게 만들지 않기 위해 캐스트하지 않고 그대로 검증 실패시킵니다
- 조회(`GET`) 응답도 `defaults.json` 스키마의 숫자 타입으로 정규화되어 반환되므로, 저장 시점의 표현 형태와 무관하게 숫자로 수신됩니다
**마일리지 적립 절사 기준** `mileage.currency_rules.*` 의 두 필드가 적립 포인트 산출·안분의 절사 기준을 정합니다.
| 필드 | 타입 | 허용값 | 기본값 | 설명 |
| --- | --- | --- | --- | --- |
| `earn_rounding_unit` | string | `1` · `10` · `100` | `1` | 적립 포인트를 맞출 단위(점). 마일리지는 원장에 정수로 확정되므로 소수 단위는 허용하지 않습니다 |
| `earn_rounding_method` | string | `floor` · `round` · `ceil` | `floor` | 단위에 맞출 방식(버림 / 반올림 / 올림) |
- 적용 지점: 옵션 정액 적립 · 옵션 정률 적립 · 기본 적립률 세 갈래 전부, 그리고 부분취소로 주문옵션이 분할될 때의 적립액 안분
- 기준 통화 선택: 마일리지는 표시 통화가 아니라 기준 통화(`currency_snapshot.base_currency`)로 적립·정산되므로, 그 통화의 규칙을 사용하고 없으면 첫 규칙(기본 통화)으로 폴백합니다
- 주문 시점 고정: 주문 생성 시 `mileage_policy_snapshot.rule` 에 함께 기록되며, 부분취소·추가결제 재계산은 현재 설정이 아니라 이 스냅샷을 사용합니다. 그렇지 않으면 이후 설정 변경이 과거 주문에 소급돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라집니다
- 값이 없는 경우(이 필드 도입 이전 설치본·주문): 기본값 `1` / `floor` 로 해석되며 이는 도입 이전 동작과 동일한 금액을 산출합니다
- 통화 환산 절사(`language_currency.currencies.*.rounding_unit` / `rounding_method`)와는 별개입니다 — 그쪽은 외화 표시 환산에만 적용되어 기본 통화에는 적용되지 않으므로 적립 규칙으로 쓸 수 없습니다
### PUT /api/modules/sirsoft-ecommerce/admin/settings/banks
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.settings.store-banks -->
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.settings.store-banks`
@@ -2574,7 +2591,9 @@ HTTP/1.1 200
"use_unit": 10,
"max_use_type": "fixed",
"max_use_percent": 30,
"max_use_value": 50000
"max_use_value": 50000,
"earn_rounding_unit": "1",
"earn_rounding_method": "floor"
}
],
"expiry_enabled": true,
@@ -1361,8 +1361,20 @@
}
},
"shipping_policy_applied_snapshot": {
"policy_name": "기본 배송 정책",
"shipping_amount": 3000
"items": [
{
"product_option_id": 9001,
"policy": {
"policy_name": "기본 배송 정책",
"standalone_shipping_amount": 3000,
"standalone_shipping_amount_formatted": "3,000원"
}
}
],
"address": {
"country_code": "KR",
"zipcode": "06234"
}
}
}
},
@@ -291,9 +291,11 @@ describe('DataGrid (_transactions_table.json)', () => {
expect(grid.props.data).toContain('transactions?.data?.data');
});
it('컬럼 8종(no/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => {
it('컬럼 9종(번호/일시/회원/유형/금액/잔여/유효기간/주문/부여)', () => {
// 첫 컬럼 field 는 `number` 다 (표시 헤더는 "NO"). 종전 `no` 표기는 레이아웃에서
// 이미 바뀌었는데 이 단언만 남아 있던 stale 케이스였다.
const fields = grid.props.columns.map((c: any) => c.field);
expect(fields).toEqual(expect.arrayContaining(['no', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by']));
expect(fields).toEqual(expect.arrayContaining(['number', 'created_at', 'member', 'type', 'amount', 'remaining', 'expiry', 'order', 'granted_by']));
});
it('유효기간 컬럼이 expiry_state 기준 소멸(일부/전체)·만료예정·무기한·없음을 렌더한다', () => {
@@ -247,19 +247,19 @@ describe('통화 규칙 테이블 (_tab_mileage_currency_table.json)', () => {
expect(percentInput.props.disabled).toContain("rule.max_use_type !== 'percent'");
});
it('max_use_type 라디오는 부모 Label 클릭으로 상태를 바꾼다 (게시판 검증 패턴 — 라디오 자동바인딩 회피)', () => {
it('max_use_type 라디오는 Label 클릭과 키보드 change 양쪽으로 같은 상태를 만든다', () => {
// 회귀: 라디오 자동바인딩은 문자열 값을 value 바인딩으로 처리해 라디오 그룹을 깨뜨린다.
// 게시판 date_display_format 의 검증된 패턴: 라디오는 pointer-events-none(시각 전용),
// 부모 Label 에 click 액션을 달아 해당 행(rule._idx)의 max_use_type 을 배열 map 교체로 갱신.
// 그래서 Label click 으로 상태를 갱신한다. 다만 라디오를 pointer-events-none 으로만 두면
// 키보드 방향키(change)로 옮겼을 때 화면 표시와 저장될 값이 어긋난다(#493) —
// 라디오 자체에도 click 과 **동일한 params** 를 갖는 change 액션을 둔다.
const radios = findAll(currencyTable, (n) => n.name === 'Input' && n.props?.type === 'radio'
&& String(n.props?.name).includes('max_use_type'));
// currency_rules 행(2) + 인라인 추가 행(2) = 4
expect(radios.length).toBe(4);
for (const radio of radios) {
// 라디오는 클릭 이벤트를 받지 않는다 (부모 Label 이 처리)
expect(radio.props.className, `radio(${radio.props.value}) 에 pointer-events-none 누락`).toContain('pointer-events-none');
// 라디오 자체에는 actions 가 없다 (자동바인딩/onChange 미사용)
expect(radio.actions ?? []).toHaveLength(0);
const change = (radio.actions ?? []).find((a: any) => a.type === 'change' && a.handler === 'setState');
expect(change, `radio(${radio.props.value}) 에 키보드 조작용 change 액션 누락`).toBeTruthy();
expect(change.params.target).toBe('local');
}
// 부모 Label 이 click → setState 로 max_use_type 갱신
@@ -349,6 +349,67 @@ describe('통화 규칙 모바일 카드 (_tab_mileage_currency_cards.json)', ()
});
});
describe('적립 절사 기준 (earn_rounding_unit / earn_rounding_method)', () => {
const 절사_컨트롤 = (root: any, 접두: string) => ({
unit: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_unit`)),
method: findFirst(root, (n) => n.name === 'Select' && String(n.props?.name).includes(`${접두}earn_rounding_method`)),
});
it('테이블 행에 단위/방식 Select 가 통화 규칙 인덱스로 바인딩된다', () => {
const { unit, method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '.");
expect(unit).toBeTruthy();
expect(method).toBeTruthy();
// 값이 없는 기존 행도 종전 동작(1점 버림)으로 보이도록 폴백을 둔다
expect(unit.props.value).toContain("?? '1'");
expect(method.props.value).toContain("?? 'floor'");
});
it('절사 방식 옵션은 서버 허용 어휘(floor/round/ceil)와 일치한다', () => {
const { method } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '.");
// 화면 옵션이 서버 게이트를 벗어나면 저장이 422 로 막히는데 화면에는 선택지로 보인다
expect(method.props.options.map((o: any) => o.value)).toEqual(['floor', 'round', 'ceil']);
});
it('절사 단위 옵션은 정수 포인트 단위만 제공한다 (통화 환산 절사의 소수 단위와 다름)', () => {
const { unit } = 절사_컨트롤(currencyTable, "mileage.currency_rules.' + rule._idx + '.");
const opts = String(unit.props.options);
expect(opts).toContain("value: '1'");
expect(opts).toContain("value: '10'");
expect(opts).toContain("value: '100'");
// 마일리지는 원장에 정수로 확정된다 — 0.01 같은 소수 단위는 선택지가 될 수 없다
expect(opts).not.toContain("value: '0.01'");
});
it('모바일 카드 뷰에도 동일 항목이 있다 (한쪽만 있으면 모바일에서 설정 불가)', () => {
const { unit, method } = 절사_컨트롤(currencyCards, "mileage.currency_rules.' + rule._idx + '.");
expect(unit).toBeTruthy();
expect(method).toBeTruthy();
});
it.each([
['테이블', currencyTable, 'new_mileage_currency_row'],
['모바일 카드', currencyCards, 'new_mileage_currency_card'],
])('%s 의 신규 통화 추가 행에도 절사 컨트롤이 있고 저장 payload 에 포함된다', (_label, root, rowId) => {
const newRow = findById(root, rowId);
const { unit, method } = 절사_컨트롤(newRow, 'newMileageCurrency.');
expect(unit).toBeTruthy();
expect(method).toBeTruthy();
const seq = findFirst(newRow, (n) => n.handler === 'sequence');
const actions = seq.actions ?? seq.params?.actions ?? [];
const push = actions.find((a: any) => a.handler === 'setState' && a.params?.['form.mileage.currency_rules']);
// 컨트롤만 두고 payload 에 빠뜨리면 입력해도 저장되지 않는다
expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_unit:');
expect(String(push.params['form.mileage.currency_rules'])).toContain('earn_rounding_method:');
});
});
describe('유효기간 / 소멸 알림 카드', () => {
it('expiry_enabled / expiry_notification_enabled 토글 바인딩', () => {
const expiryToggle = findFirst(expiryCard, (n) => n.name === 'Toggle' && n.props?.name === 'mileage.expiry_enabled');
@@ -35,7 +35,19 @@
"empty": "No currency rules registered.",
"code_placeholder": "e.g. USD",
"duplicate_currency_error": "This currency is already registered.",
"max_use_fixed_suffix": ""
"max_use_fixed_suffix": "",
"earn_rounding": "Earning Rounding",
"earn_rounding_hint": "Applied when earned points are calculated and prorated",
"earn_rounding_units": {
"1": "1 pt",
"10": "10 pts",
"100": "100 pts"
},
"earn_rounding_methods": {
"floor": "Round down",
"round": "Round",
"ceil": "Round up"
}
},
"expiry": {
"title": "Expiry",
@@ -35,7 +35,19 @@
"empty": "등록된 통화 규칙이 없습니다.",
"code_placeholder": "예: USD",
"duplicate_currency_error": "이미 등록된 통화입니다.",
"max_use_fixed_suffix": "원"
"max_use_fixed_suffix": "원",
"earn_rounding": "적립 절사",
"earn_rounding_hint": "적립 포인트 산출·안분 시 적용",
"earn_rounding_units": {
"1": "1점",
"10": "10점",
"100": "100점"
},
"earn_rounding_methods": {
"floor": "버림",
"round": "반올림",
"ceil": "올림"
}
},
"expiry": {
"title": "유효기간",
@@ -1434,7 +1434,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{!_local.batchAction || (_local.selectedReviews ?? []).length === 0}}"
},
"text": "$t:sirsoft-ecommerce.admin.product_review.batch.apply",
@@ -1718,6 +1718,7 @@
"name": "A",
"if": "{{!!row.user_id}}",
"props": {
"href": "#",
"className": "text-blue-600 dark:text-blue-400 hover:underline cursor-pointer font-medium"
},
"text": "{{row.user?.name ?? ''}}",
@@ -67,7 +67,7 @@
"name": "Icon",
"props": {
"name": "percent",
"className": "w-6 h-6 text-gray-700 dark:text-gray-300"
"className": "text-2xl text-gray-700 dark:text-gray-300"
}
},
{
@@ -107,7 +107,7 @@
"name": "Icon",
"props": {
"name": "plus",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -214,7 +214,7 @@
"name": "Icon",
"props": {
"name": "search",
"className": "w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none"
"className": "text-base absolute right-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none"
}
}
]
@@ -239,7 +239,7 @@
"name": "Icon",
"props": {
"name": "settings-2",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -270,7 +270,7 @@
"name": "Icon",
"props": {
"name": "filter",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -776,7 +776,7 @@
"name": "Icon",
"props": {
"name": "calendar",
"className": "w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none"
"className": "text-base absolute left-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none"
}
},
{
@@ -821,7 +821,7 @@
"name": "Icon",
"props": {
"name": "calendar",
"className": "w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none"
"className": "text-base absolute left-3 top-1/2 -translate-y-1/2 icon-muted pointer-events-none"
}
},
{
@@ -938,7 +938,7 @@
"name": "Icon",
"props": {
"name": "search",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -961,7 +961,7 @@
"name": "Icon",
"props": {
"name": "refresh-cw",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -1010,7 +1010,7 @@
"props": {
"className": "text-sm text-secondary"
},
"text": "0$t:sirsoft-ecommerce.admin.promotion_discount_code.batch.selected_count"
"text": "$t:sirsoft-ecommerce.admin.promotion_discount_code.batch.selected_count|count=0"
},
{
"type": "composite",
@@ -1088,7 +1088,7 @@
"props": {
"className": "text-sm text-secondary"
},
"text": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.total_count"
"text": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.total_count|count=2"
},
{
"type": "basic",
@@ -1144,15 +1144,15 @@
"options": [
{
"value": "10",
"label": "10$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page"
"label": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page|count=10"
},
{
"value": "20",
"label": "20$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page"
"label": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page|count=20"
},
{
"value": "50",
"label": "50$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page"
"label": "$t:sirsoft-ecommerce.admin.promotion_discount_code.table.per_page|count=50"
}
]
},
@@ -1238,7 +1238,7 @@
"name": "Icon",
"props": {
"name": "more-horizontal",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -1386,7 +1386,7 @@
"name": "Icon",
"props": {
"name": "eye",
"className": "w-3 h-3"
"className": "text-xs"
}
},
{
@@ -1481,7 +1481,7 @@
"name": "Icon",
"props": {
"name": "more-horizontal",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -1629,7 +1629,7 @@
"name": "Icon",
"props": {
"name": "eye",
"className": "w-3 h-3"
"className": "text-xs"
}
},
{
@@ -2016,7 +2016,7 @@
"name": "Icon",
"props": {
"name": "more-horizontal",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -2209,7 +2209,7 @@
"name": "Icon",
"props": {
"name": "more-horizontal",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -2253,7 +2253,7 @@
"name": "Icon",
"props": {
"name": "chevrons-left",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -2272,7 +2272,7 @@
"name": "Icon",
"props": {
"name": "chevron-left",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -2317,7 +2317,7 @@
"name": "Icon",
"props": {
"name": "chevron-right",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -2335,7 +2335,7 @@
"name": "Icon",
"props": {
"name": "chevrons-right",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -513,6 +513,7 @@
"type": "basic",
"name": "Button",
"props": {
"data-testid": "ecommerce-settings-save",
"disabled": "{{_computed.isReadOnly || !_local.hasChanges || _local.isSaving}}",
"className": "btn btn-primary flex-center gap-1.5"
},
@@ -58,7 +58,7 @@
"name": "Icon",
"props": {
"name": "edit",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -76,7 +76,7 @@
"name": "Icon",
"props": {
"name": "trash-2",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -120,7 +120,7 @@
"name": "Icon",
"props": {
"name": "edit",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -138,7 +138,7 @@
"name": "Icon",
"props": {
"name": "trash-2",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -165,9 +165,7 @@
{
"type": "click",
"handler": "openModal",
"params": {
"modalId": "modal_preset_save"
}
"target": "modal_preset_save"
}
]
}
@@ -73,7 +73,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50",
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 font-medium transition-colors disabled:opacity-50",
"disabled": "{{!(_local.selectedItems || []).length || !_local.bulkIssueStatus || coupons?.data?.abilities?.can_update !== true}}"
},
"text": "$t:sirsoft-ecommerce.admin.promotion_coupon.bulk.apply",
@@ -303,6 +303,7 @@
"type": "basic",
"name": "A",
"props": {
"href": "#",
"className": "text-blue-600 dark:text-blue-400 hover:underline cursor-pointer font-medium"
},
"text": "{{row.localized_name}}",
@@ -708,7 +709,7 @@
{
"iteration": {
"source": "coupons?.data?.data || []",
"item": "coupon",
"item_var": "coupon",
"key": "coupon.id"
},
"children": [
@@ -336,6 +336,75 @@
]
}
]
},
{
"comment": "적립 절사 기준 — 데스크톱 테이블 뷰와 동일 항목 (한쪽만 두면 모바일에서 설정 불가)",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "composite",
"name": "Select",
"props": {
"name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_unit'}}",
"value": "{{rule.earn_rounding_unit ?? '1'}}",
"options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}",
"className": "flex-1",
"disabled": "{{_computed.isReadOnly}}",
"data-testid": "{{'mileage-earn-rounding-unit-' + (rule.currency_code ?? rule._idx)}}"
}
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_method'}}",
"value": "{{rule.earn_rounding_method ?? 'floor'}}",
"options": [
{
"value": "floor",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor"
},
{
"value": "round",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round"
},
{
"value": "ceil",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil"
}
],
"className": "flex-1",
"disabled": "{{_computed.isReadOnly}}",
"data-testid": "{{'mileage-earn-rounding-method-' + (rule.currency_code ?? rule._idx)}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-400 dark:text-gray-500 mt-1"
},
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_hint"
}
]
}
]
},
@@ -618,6 +687,66 @@
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "composite",
"name": "Select",
"props": {
"name": "newMileageCurrency.earn_rounding_unit",
"value": "{{_local.form?.newMileageCurrency?.earn_rounding_unit ?? '1'}}",
"options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}",
"className": "flex-1",
"disabled": "{{_computed.isReadOnly}}",
"data-testid": "mileage-new-earn-rounding-unit"
}
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "newMileageCurrency.earn_rounding_method",
"value": "{{_local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor'}}",
"options": [
{
"value": "floor",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor"
},
{
"value": "round",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round"
},
{
"value": "ceil",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil"
}
],
"className": "flex-1",
"disabled": "{{_computed.isReadOnly}}",
"data-testid": "mileage-new-earn-rounding-method"
}
}
]
}
]
},
{
"type": "basic",
"name": "Div",
@@ -657,7 +786,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50",
"className": "flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50",
"disabled": "{{_computed.isReadOnly}}"
},
"actions": [
@@ -686,7 +815,7 @@
"if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && !((_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim()))}}",
"params": {
"target": "local",
"form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0 }]}}",
"form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0, earn_rounding_unit: _local.form?.newMileageCurrency?.earn_rounding_unit ?? '1', earn_rounding_method: _local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor' }]}}",
"isAddingMileageCurrency": false,
"form.newMileageCurrency": null,
"hasChanges": true
@@ -50,6 +50,12 @@
"name": "Th",
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.max_use"
},
{
"comment": "적립 절사 기준 — 언어/통화 탭의 환산 절사와 별개(그쪽은 외화 표시 전용)",
"type": "basic",
"name": "Th",
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding"
},
{
"type": "basic",
"name": "Th",
@@ -377,6 +383,70 @@
}
]
},
{
"comment": "적립 절사 기준 (단위 + 방식). 미설정 행은 1점 버림으로 표시 — 종전 동작과 같다.",
"type": "basic",
"name": "Td",
"props": {
"className": "align-top"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-1"
},
"children": [
{
"type": "composite",
"name": "Select",
"props": {
"name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_unit'}}",
"value": "{{rule.earn_rounding_unit ?? '1'}}",
"options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}",
"className": "w-24",
"disabled": "{{_computed.isReadOnly}}",
"data-testid": "{{'mileage-earn-rounding-unit-' + (rule.currency_code ?? rule._idx)}}"
}
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "{{'mileage.currency_rules.' + rule._idx + '.earn_rounding_method'}}",
"value": "{{rule.earn_rounding_method ?? 'floor'}}",
"options": [
{
"value": "floor",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor"
},
{
"value": "round",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round"
},
{
"value": "ceil",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil"
}
],
"className": "w-24",
"disabled": "{{_computed.isReadOnly}}",
"data-testid": "{{'mileage-earn-rounding-method-' + (rule.currency_code ?? rule._idx)}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "block text-xs text-gray-400 dark:text-gray-500 mt-1"
},
"text": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_hint"
}
]
},
{
"type": "basic",
"name": "Td",
@@ -707,6 +777,61 @@
}
]
},
{
"type": "basic",
"name": "Td",
"props": {
"className": "align-top"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-1"
},
"children": [
{
"type": "composite",
"name": "Select",
"props": {
"name": "newMileageCurrency.earn_rounding_unit",
"value": "{{_local.form?.newMileageCurrency?.earn_rounding_unit ?? '1'}}",
"options": "{{[{ value: '1', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.1') }, { value: '10', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.10') }, { value: '100', label: $t('sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_units.100') }]}}",
"className": "w-24",
"disabled": "{{_computed.isReadOnly}}",
"data-testid": "mileage-new-earn-rounding-unit"
}
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "newMileageCurrency.earn_rounding_method",
"value": "{{_local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor'}}",
"options": [
{
"value": "floor",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.floor"
},
{
"value": "round",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.round"
},
{
"value": "ceil",
"label": "$t:sirsoft-ecommerce.admin.mileage_settings.currency.earn_rounding_methods.ceil"
}
],
"className": "w-24",
"disabled": "{{_computed.isReadOnly}}",
"data-testid": "mileage-new-earn-rounding-method"
}
}
]
}
]
},
{
"type": "basic",
"name": "Td",
@@ -755,7 +880,7 @@
"if": "{{_local.form?.newMileageCurrency?.currency_code?.trim() && !((_local.form?.mileage?.currency_rules ?? []).some(c => (c.currency_code || '').toUpperCase() === (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim()))}}",
"params": {
"target": "local",
"form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0 }]}}",
"form.mileage.currency_rules": "{{[...(_local.form?.mileage?.currency_rules ?? []), { currency_code: (_local.form?.newMileageCurrency?.currency_code || '').toUpperCase().trim(), point_value: _local.form?.newMileageCurrency?.point_value ?? 1, min_use_amount: _local.form?.newMileageCurrency?.min_use_amount ?? 0, use_unit: _local.form?.newMileageCurrency?.use_unit ?? 1, max_use_type: _local.form?.newMileageCurrency?.max_use_type ?? 'percent', max_use_percent: _local.form?.newMileageCurrency?.max_use_percent ?? 30, max_use_value: _local.form?.newMileageCurrency?.max_use_value ?? 0, earn_rounding_unit: _local.form?.newMileageCurrency?.earn_rounding_unit ?? '1', earn_rounding_method: _local.form?.newMileageCurrency?.earn_rounding_method ?? 'floor' }]}}",
"isAddingMileageCurrency": false,
"form.newMileageCurrency": null,
"hasChanges": true
@@ -25,6 +25,7 @@ class CalculationInput
* @param array|null $shippingPolicySnapshots 배송정책 스냅샷 (환불 재계산용)
* @param array|null $promotionSnapshots 프로모션 스냅샷 (환불 재계산용)
* @param int|null $userId 주문 사용자 ID (per_user_limit 과거 사용 검증용, 비회원은 null)
* @param array|null $mileagePolicySnapshot 마일리지 정책 스냅샷 (환불 재계산 시 적립 절사 기준 고정용)
*/
public function __construct(
public array $items = [],
@@ -38,6 +39,7 @@ class CalculationInput
public ?array $shippingPolicySnapshots = null,
public ?array $promotionSnapshots = null,
public ?int $userId = null,
public ?array $mileagePolicySnapshot = null,
) {}
/**
@@ -70,6 +72,7 @@ class CalculationInput
shippingPolicySnapshots: $data['shipping_policy_snapshots'] ?? null,
promotionSnapshots: $data['promotion_snapshots'] ?? null,
userId: $data['user_id'] ?? null,
mileagePolicySnapshot: $data['mileage_policy_snapshot'] ?? null,
);
}
@@ -92,6 +95,7 @@ class CalculationInput
'shipping_policy_snapshots' => $this->shippingPolicySnapshots,
'promotion_snapshots' => $this->promotionSnapshots,
'user_id' => $this->userId,
'mileage_policy_snapshot' => $this->mileagePolicySnapshot,
];
}
}
@@ -9,6 +9,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
/**
* 이커머스 설정 저장 요청 검증
@@ -311,6 +312,11 @@ class StoreEcommerceSettingsRequest extends FormRequest
'mileage.currency_rules.*.max_use_percent' => ['nullable', 'numeric', 'min:'.config('sirsoft-ecommerce.limits.max_use_percent_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_percent_max', 100)],
// 최대 사용한도(고정금액): 비합리적 거액 방지를 위해 10억 상한
'mileage.currency_rules.*.max_use_value' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.max_use_value_min', 0), 'max:'.config('sirsoft-ecommerce.limits.max_use_value_max', 1000000000)],
// 적립 절사 기준 — 통화 환산 절사(language_currency.currencies.*.rounding_*)와 별개다.
// 그쪽은 외화 표시 전용이라 기본 통화에는 적용되지 않는 반면, 적립은 기본 통화 원장에
// 확정 기록되는 값이므로 자기 규칙을 갖는다. 어휘는 MileageRounding 이 SSoT.
'mileage.currency_rules.*.earn_rounding_unit' => ['nullable', 'string', 'in:'.implode(',', MileageRounding::UNITS)],
'mileage.currency_rules.*.earn_rounding_method' => ['nullable', 'string', 'in:'.implode(',', MileageRounding::METHODS)],
'mileage.expiry_enabled' => ['nullable', 'boolean'],
'mileage.expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.expiry_days_max', 3650)],
'mileage.expiry_notification_enabled' => ['nullable', 'boolean'],
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
/**
* 비회원 주문 상세 리소스
@@ -13,9 +14,14 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
* 회원용 OrderResource 와 달리 다음을 노출하지 않는다.
* - admin_memo / customer_memo (관리자·내부 메모)
* - user / user_id / user_login_id (회원 정보)
* - promotions_applied_snapshot / shipping_policy_applied_snapshot (내부 계산 스냅샷)
* - promotions_applied_snapshot (내부 계산 스냅샷)
* - 회원 권한 메타(resourceMeta/abilityMap)
*
* `shipping_policy_applied_snapshot` 은 **표시용 필드만** 추려 내보낸다. 비회원 주문 상세
* 화면이 회원 마이페이지와 같은 partial 로 상품별 정책명·개별 배송비를 그리므로, 통째로
* 빼면 오류 없이 그 줄만 사라진다. 정책 id·계산 근거는 `ShippingPolicySnapshot::forDisplay()`
* 가 걸러낸다.
*
* 허용 액션(abilities)은 회원 권한이 아니라 주문 상태로만 판정한다.
*/
class GuestOrderResource extends BaseApiResource
@@ -120,6 +126,14 @@ class GuestOrderResource extends BaseApiResource
// 취소 이력 (취소 사유·상세 사유·취소일시 표시용) — 최근 취소가 먼저 오도록 정렬
'cancels' => OrderCancelResource::collection($this->whenLoaded('cancels')),
// 배송정책 — **표시용 필드만**. 비회원 주문 상세 화면은 회원 마이페이지와 같은
// partial 을 써서 상품별 정책명·개별 배송비를 그리는데, 이 필드를 통째로 빼면
// 오류 없이 그 줄만 사라진다(옵셔널 체이닝이 빈 배열로 떨어져 조건이 false).
// 정책 id·계산 근거 같은 내부 값은 forDisplay() 가 걸러낸다.
'shipping_policy_applied_snapshot' => ShippingPolicySnapshot::forDisplay(
$this->shipping_policy_applied_snapshot
),
// 상태 기반 허용 액션 (회원 권한이 아닌 주문 상태로만 판정)
'abilities' => $this->guestAbilities(),
];
@@ -6,6 +6,7 @@ use App\Helpers\PermissionHelper;
use App\Http\Resources\BaseApiResource;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
/**
* 주문 상세 리소스
@@ -188,7 +189,12 @@ class OrderResource extends BaseApiResource
// 프로모션/배송정책 스냅샷
'promotions_applied_snapshot' => $this->promotions_applied_snapshot,
'shipping_policy_applied_snapshot' => $this->shipping_policy_applied_snapshot,
// 항상 신형 구조(`{items: [...], address: {...}}`)로 정규화해 내보낸다.
// 백필 전 구형 행이 남아 있어도 화면은 한 형태만 보면 되고, `items` 가 JSON
// 배열로 직렬화되므로 프론트의 `.find(...)` 가 안전하다.
'shipping_policy_applied_snapshot' => ShippingPolicySnapshot::normalize(
$this->shipping_policy_applied_snapshot
),
// 메모
'admin_memo' => $this->admin_memo,
@@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
use Modules\Sirsoft\Ecommerce\Support\VatCalculator;
/**
@@ -391,8 +392,9 @@ class OrderAdjustmentService
// 배송지 복원
$shippingAddress = null;
$shippingSnapshot = $order->shipping_policy_applied_snapshot ?? [];
if (! empty($shippingSnapshot['address'])) {
$shippingAddress = ShippingAddress::fromArray($shippingSnapshot['address']);
$snapshotAddress = ShippingPolicySnapshot::address($shippingSnapshot);
if (! empty($snapshotAddress)) {
$shippingAddress = ShippingAddress::fromArray($snapshotAddress);
}
// 배송정책 스냅샷을 product_option_id 키 맵으로 변환
@@ -416,6 +418,9 @@ class OrderAdjustmentService
],
shippingPolicySnapshots: $shippingPolicySnapshots,
promotionSnapshots: $order->promotions_applied_snapshot,
// 적립 절사 기준은 주문 시점 값으로 고정한다 — 설정을 다시 읽으면 운영자가 이후에
// 바꾼 기준이 소급 적용돼, 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라진다.
mileagePolicySnapshot: $order->mileage_policy_snapshot,
);
}
@@ -909,25 +914,10 @@ class OrderAdjustmentService
*/
private function buildShippingPolicySnapshotMap(array $shippingSnapshot): array
{
$map = [];
foreach ($shippingSnapshot as $key => $entry) {
// 이미 product_option_id를 키로 사용하는 경우 (숫자 키 + policy 데이터)
if (is_int($key) && isset($entry['product_option_id'], $entry['policy'])) {
$map[$entry['product_option_id']] = $entry['policy'];
} elseif (is_int($key) && isset($entry['policy_id'])) {
// product_option_id 키 맵 형태 (테스트 헬퍼 등에서 직접 구성한 경우)
$map[$key] = $entry;
} elseif ($key === 'address') {
// address 키는 스킵 (배송지 정보)
continue;
} else {
// 기타 (이미 product_option_id => data 형태)
$map[$key] = $entry;
}
}
return $map;
// 형태 판별은 ShippingPolicySnapshot 단일 출처에 위임한다 — 종전에는 이 메서드와
// OrderCancellationService::buildShippingSnapshot() 이 `is_int($key)` 판별을
// 각자 복제하고 있어, 구조가 바뀔 때 한쪽만 고쳐질 위험이 있었다.
return ShippingPolicySnapshot::toOptionPolicyMap($shippingSnapshot);
}
/**
@@ -37,6 +37,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterf
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
use Modules\Sirsoft\Ecommerce\Support\VatCalculator;
/**
@@ -316,7 +317,8 @@ class OrderCalculationService
$pointsPerItem = $this->calculatePointsEarning(
$preparedItems,
$itemsAfterOrderDiscount,
$pointsUsageResult
$pointsUsageResult,
$input
);
$pointsPerItem = HookManager::applyFilters(
'sirsoft-ecommerce.calculation.after_points_earning',
@@ -855,12 +857,20 @@ class OrderCalculationService
* @param array $preparedItems 준비된 아이템 배열
* @param array $discountedItems 할인 후 아이템 배열 (주문쿠폰 적용 후)
* @param array $pointsUsageResult 마일리지 사용 결과 (points_by_option 포함)
* @param CalculationInput|null $input 계산 입력 (재계산 시 주문 시점 절사 기준 복원용)
* @return array 옵션별 마일리지 배열 [product_option_id => points]
*/
protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = []): array
protected function calculatePointsEarning(array $preparedItems, array $discountedItems, array $pointsUsageResult = [], ?CalculationInput $input = null): array
{
$pointsPerItem = [];
// 적립 절사 기준. 재계산(부분취소·추가결제)은 주문 스냅샷을 우선한다 — 설정을 다시 읽으면
// 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의
// 적립액이 취소 처리만으로 달라진다.
$rounding = $input?->mileagePolicySnapshot !== null
? MileageRounding::fromOrderSnapshot($input->mileagePolicySnapshot)
: MileageRounding::normalize($this->resolveMileageCurrencyRule($input));
foreach ($preparedItems as $item) {
$optionId = $item['product_option_id'];
$option = $item['product_option'];
@@ -890,21 +900,52 @@ class OrderCalculationService
if ($option->mileage_value !== null && $option->mileage_type !== null) {
if ($option->mileage_type === 'fixed') {
// 정액 적립: mileage_value를 그대로 사용 (수량 곱)
$pointsPerItem[$optionId] = (int) floor($option->mileage_value * $item['quantity']);
$pointsPerItem[$optionId] = MileageRounding::apply($option->mileage_value * $item['quantity'], $rounding);
} else {
// 정률 적립: earnableAmount 기준으로 계산
$pointsPerItem[$optionId] = (int) floor($earnableAmount * $option->mileage_value / 100);
$pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $option->mileage_value / 100, $rounding);
}
} else {
// 기본 마일리지 적립율 (설정값, 기본 1%)
$defaultRate = (float) $this->settingsService->getSetting('mileage.default_earn_rate', 1);
$pointsPerItem[$optionId] = (int) floor($earnableAmount * $defaultRate / 100);
$pointsPerItem[$optionId] = MileageRounding::apply($earnableAmount * $defaultRate / 100, $rounding);
}
}
return $pointsPerItem;
}
/**
* 적립 절사 기준을 담은 통화별 마일리지 규칙을 조회합니다.
*
* 마일리지는 표시통화가 아니라 기준통화(base_currency)로 적립·정산되므로, 규칙도
* 기준통화 기준으로 고른다. 스냅샷 모드에서는 주문 시점 기준통화를, 신규 주문에서는
* 현재 기본 통화를 쓴다. 해당 통화 규칙이 없으면 첫 행(기본 통화 규칙)으로 폴백한다 —
* `UserMileageService::currencyRule()` 과 같은 해석 규칙이다.
*
* @param CalculationInput|null $input 계산 입력
* @return array|null 통화별 마일리지 규칙 (없으면 null → 절사 기본값 적용)
*/
protected function resolveMileageCurrencyRule(?CalculationInput $input): ?array
{
$rules = (array) $this->settingsService->getSetting('mileage.currency_rules', []);
if ($rules === []) {
return null;
}
$currency = $input->metadata['currency_snapshot']['base_currency']
?? $this->currencyService->getDefaultCurrency();
foreach ($rules as $rule) {
if (is_array($rule) && ($rule['currency_code'] ?? null) === $currency) {
return $rule;
}
}
return is_array($rules[0] ?? null) ? $rules[0] : null;
}
/**
* 단계 2-b: 과세/면세 금액을 분류합니다.
*
@@ -32,6 +32,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRefundRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
/**
* 주문 취소 서비스
@@ -472,7 +473,7 @@ class OrderCancellationService
protected function buildShippingSnapshot(Order $order, array $cancelItems): array
{
$orderSnapshot = $order->shipping_policy_applied_snapshot ?? [];
$address = $orderSnapshot['address'] ?? [];
$address = ShippingPolicySnapshot::address($orderSnapshot);
// 주문 스냅샷에 배송지가 없으면 현재 배송주소에서 폴백 복원(과거 주문 호환).
if (empty($address['country_code'])) {
@@ -492,13 +493,8 @@ class OrderCancellationService
}
}
$policies = [];
foreach ($orderSnapshot as $key => $entry) {
if (is_int($key) && isset($entry['product_option_id'], $entry['policy'])
&& isset($cancelOptionIds[$entry['product_option_id']])) {
$policies[] = $entry;
}
}
// 형태 판별은 ShippingPolicySnapshot 단일 출처에 위임 (구형 혼합 배열도 흡수).
$policies = ShippingPolicySnapshot::itemsForOptions($orderSnapshot, $cancelOptionIds);
return [
'country_code' => strtoupper((string) ($address['country_code'] ?? 'KR')),
@@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterf
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderShippingRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
/**
* 주문 옵션 서비스
@@ -203,7 +204,16 @@ class OrderOptionService
}
// 분할 레코드 금액 = 원본 × ratio
$this->applySplitAmounts($splitOption, $origAmounts, $ratio, $quantity);
// 적립 포인트는 주문 시점 절사 기준으로 정수화한다 — 안분을 소수 2자리로 남기면
// 원장에 없는 소수점 포인트(390.5P)가 옵션 행에 생기고, 그 합이 적립 목표액이 되어
// 실제 지급 가능한 정수 포인트와 어긋난다.
$this->applySplitAmounts(
$splitOption,
$origAmounts,
$ratio,
$quantity,
MileageRounding::fromOrderSnapshot($option->order?->mileage_policy_snapshot)
);
$this->orderOptionRepository->save($splitOption);
// 원본 레코드 = 원본 - 분할 (잔여분)
@@ -439,8 +449,9 @@ class OrderOptionService
* @param array $origAmounts 원본 금액 캡처 데이터
* @param float $ratio 분할 비율 (변경수량 / 원본수량)
* @param int $quantity 분할 수량
* @param array{unit: string, method: string}|null $earnRounding 적립 절사 기준 (주문 스냅샷 기준)
*/
private function applySplitAmounts(OrderOption $splitOption, array $origAmounts, float $ratio, int $quantity): void
private function applySplitAmounts(OrderOption $splitOption, array $origAmounts, float $ratio, int $quantity, ?array $earnRounding = null): void
{
$splitOption->subtotal_price = round($splitOption->unit_price * $quantity, 2);
$splitOption->subtotal_discount_amount = round($origAmounts['subtotal_discount_amount'] * $ratio, 2);
@@ -450,7 +461,11 @@ class OrderOptionService
$splitOption->subtotal_deposit_used_amount = round($origAmounts['subtotal_deposit_used_amount'] * $ratio, 2);
$splitOption->subtotal_tax_amount = round($origAmounts['subtotal_tax_amount'] * $ratio, 2);
$splitOption->subtotal_tax_free_amount = round($origAmounts['subtotal_tax_free_amount'] * $ratio, 2);
$splitOption->subtotal_earned_points_amount = round($origAmounts['subtotal_earned_points_amount'] * $ratio, 2);
// 적립 포인트는 절사 기준으로 정수화 — 잔여분은 `원본 − 분할` 이라 총액은 그대로 보존된다.
$splitOption->subtotal_earned_points_amount = MileageRounding::apply(
$origAmounts['subtotal_earned_points_amount'] * $ratio,
$earnRounding
);
$splitOption->subtotal_weight = round($splitOption->unit_weight * $quantity, 3);
$splitOption->subtotal_volume = round($splitOption->unit_volume * $quantity, 3);
$splitOption->subtotal_paid_amount = $splitOption->subtotal_price
@@ -36,6 +36,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingTypeRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
use Modules\Sirsoft\Ecommerce\Support\VatCalculator;
/**
@@ -1428,10 +1429,10 @@ class OrderProcessingService
*/
protected function buildShippingPolicyAppliedSnapshot(OrderCalculationResult $result, array $shippingInfo = []): array
{
$policies = [];
$items = [];
foreach ($result->items as $item) {
if ($item->appliedShippingPolicy) {
$policies[] = [
$items[] = [
'product_option_id' => $item->productOptionId,
'policy' => $item->appliedShippingPolicy->toArray(),
];
@@ -1439,13 +1440,16 @@ class OrderProcessingService
}
// 배송지(국가/우편번호) 스냅샷 보존 (B5 — 환불/취소 재계산 시 도서산간/국가별 정책 판단 복원).
// OrderAdjustmentService::buildRecalcInput 가 'address' 키로 ShippingAddress 를 복원한다.
$policies['address'] = [
//
// 항목 목록과 배송지 메타는 **별도 키**로 분리한다. 종전에는 `$policies[]` 리스트에
// `$policies['address']` 를 덧붙여 한 배열에 섞었고, 그 결과 PHP 배열이
// non-sequential 이 되어 `json_encode` 가 객체로 직렬화했다. 서버는 `is_int($key)` 로
// 버텼지만 배열을 전제하는 프론트에서 `.find is not a function` 이 나며 마이페이지·
// 비회원 주문 상세의 배송정책 블록이 조용히 사라졌다.
return ShippingPolicySnapshot::make($items, [
'country_code' => strtoupper((string) ($shippingInfo['country_code'] ?? 'KR')),
'zipcode' => $shippingInfo['zipcode'] ?? $shippingInfo['intl_postal_code'] ?? null,
];
return $policies;
]);
}
/**
@@ -17,6 +17,7 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageTransactionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
/**
* 사용자 마일리지 서비스 (원장 SSoT + 표시 캐시)
@@ -265,6 +266,10 @@ class UserMileageService
$usable = $this->isMileageUsable();
$rule = $usable ? $this->currencyRule($currency) : [];
// 적립 절사 기준은 사용 가능 여부와 무관하게 기록한다 — 마일리지 "사용" 이 막혀 있어도
// 적립은 별개로 일어나므로, 사용 불가 주문의 재계산에도 절사 기준이 필요하다.
$earnRounding = MileageRounding::normalize($this->currencyRule($currency));
return [
'usable' => $usable,
'currency' => $currency,
@@ -275,6 +280,8 @@ class UserMileageService
'max_use_type' => (string) ($rule['max_use_type'] ?? 'percent'),
'max_use_percent' => (float) ($rule['max_use_percent'] ?? 100),
'max_use_value' => (int) ($rule['max_use_value'] ?? 0),
MileageRounding::UNIT_KEY => $earnRounding['unit'],
MileageRounding::METHOD_KEY => $earnRounding['method'],
],
// 판정 근거 — 이 결제금액에 이 정책을 적용해 아래 상한이 나왔고, 그 안에서 사용됐다.
'payment_amount_basis' => $paymentAmount,
@@ -0,0 +1,133 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Support;
/**
* 적립 마일리지 절사 규칙의 SSoT.
*
* 적립 포인트 산출은 종전에 `(int) floor(...)` 로 세 갈래(옵션 정액 · 옵션 정률 · 기본
* 적립률)에 하드코딩되어 있었고, 부분취소 분할 안분은 `round(..., 2)` 로 소수점 포인트를
* 만들었다. 두 지점의 규칙이 서로 다르고 운영자가 어느 쪽도 조정할 수 없었다.
*
* 이 클래스는 통화별 마일리지 규칙(`mileage.currency_rules.*`)의 절사 단위·방식을
* 해석해 한 곳에서 적용한다. 통화 설정(`language_currency.currencies.*.rounding_*`)과는
* 별개다 — 그쪽은 **외화 환산 표시** 전용이라 기본 통화에는 적용조차 되지 않는다.
* 적립은 기본 통화 원장에 확정 기록되는 값이므로 자기 규칙을 갖는다.
*
* 기본값(`1` / `floor`)은 종전 하드코딩 동작과 정확히 같다. 규칙이 없는 기존 설치본과
* 이 기능 이전에 생성된 주문은 폴백만으로 과거와 동일한 금액을 산출한다.
*
* @since 1.0.5
*/
class MileageRounding
{
/** 절사 단위 설정 키 (통화별 마일리지 규칙 내) */
public const UNIT_KEY = 'earn_rounding_unit';
/** 절사 방식 설정 키 (통화별 마일리지 규칙 내) */
public const METHOD_KEY = 'earn_rounding_method';
/** 기본 절사 단위 — 1점 (종전 하드코딩과 동일) */
public const DEFAULT_UNIT = '1';
/** 기본 절사 방식 — 버림 (종전 하드코딩 floor 와 동일) */
public const DEFAULT_METHOD = 'floor';
/**
* 선택 가능한 절사 단위.
*
* 마일리지는 원장에 정수 포인트로 확정되므로 소수 단위는 두지 않는다.
* 통화 환산 절사(0.01/0.1/1)와 후보가 다른 이유다.
*
* @var array<int, string>
*/
public const UNITS = ['1', '10', '100'];
/**
* 선택 가능한 절사 방식 (통화 환산 절사와 동일 어휘).
*
* @var array<int, string>
*/
public const METHODS = ['floor', 'round', 'ceil'];
/**
* 통화 규칙에서 절사 기준을 해석합니다.
*
* 미설정·오염값은 기본값으로 접는다 — 절사 기준이 해석되지 않는다고 적립을 멈출 수는
* 없고, 알 수 없는 값에서 임의 방식을 고르면 지급액이 조용히 달라지기 때문이다.
*
* @param array|null $rule 통화별 마일리지 규칙 (mileage.currency_rules 의 한 행)
* @return array{unit: string, method: string} 정규화된 절사 기준
*/
public static function normalize(?array $rule): array
{
$unit = (string) ($rule[self::UNIT_KEY] ?? self::DEFAULT_UNIT);
$method = (string) ($rule[self::METHOD_KEY] ?? self::DEFAULT_METHOD);
return [
'unit' => in_array($unit, self::UNITS, true) ? $unit : self::DEFAULT_UNIT,
'method' => in_array($method, self::METHODS, true) ? $method : self::DEFAULT_METHOD,
];
}
/**
* 주문 스냅샷에서 절사 기준을 해석합니다.
*
* 재계산(부분취소 · 추가결제)은 주문 시점 기준을 그대로 써야 한다. 설정을 다시 읽으면
* 운영자가 이후에 바꾼 절사 기준이 과거 주문에 소급 적용돼, 취소하지 않은 잔여분의
* 적립액이 취소 처리만으로 달라진다.
*
* 이 기능 이전 주문의 스냅샷에는 키가 없다 — 그때의 동작이 곧 기본값(`1`/`floor`)이므로
* 폴백이 당시 산출과 일치한다.
*
* @param array|null $snapshot 주문의 mileage_policy_snapshot
* @return array{unit: string, method: string} 정규화된 절사 기준
*/
public static function fromOrderSnapshot(?array $snapshot): array
{
return self::normalize(is_array($snapshot['rule'] ?? null) ? $snapshot['rule'] : null);
}
/**
* 절사 기준을 적용해 정수 포인트를 산출합니다.
*
* @param float|int $points 절사 전 포인트 (계산 결과 실수)
* @param array{unit: string, method: string}|null $rounding 정규화된 절사 기준 (null 이면 기본값)
* @return int 절사된 정수 포인트 (음수 방지 없음 — 호출자가 부호 의미를 정한다)
*/
public static function apply(float|int $points, ?array $rounding = null): int
{
$unit = (float) ($rounding['unit'] ?? self::DEFAULT_UNIT);
$method = (string) ($rounding['method'] ?? self::DEFAULT_METHOD);
if ($unit <= 0) {
$unit = 1.0;
}
$divided = ((float) $points) / $unit;
// 부동소수 오차 보정 — 7809.999999999999 가 floor 에서 7809 로 떨어지는 것을 막는다.
// 마일리지 단위(1/10/100)에서 9자리 이하 오차는 계산 잔여물일 뿐 의미 있는 차이가 아니다.
$divided = round($divided, 9);
$quantized = match ($method) {
'ceil' => ceil($divided),
'round' => round($divided),
default => floor($divided),
};
return (int) ($quantized * $unit);
}
/**
* 통화 규칙에서 곧바로 절사를 적용합니다 (normalize + apply 축약).
*
* @param float|int $points 절사 전 포인트
* @param array|null $rule 통화별 마일리지 규칙
* @return int 절사된 정수 포인트
*/
public static function applyRule(float|int $points, ?array $rule): int
{
return self::apply($points, self::normalize($rule));
}
}
@@ -0,0 +1,210 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Support;
/**
* 배송정책 적용 스냅샷의 구조 SSoT.
*
* `ecommerce_orders.shipping_policy_applied_snapshot` 은 두 가지를 담는다 —
* 옵션별 적용 정책 **목록**과 주문 시점 배송지 **메타**(국가/우편번호). 종전에는 이 둘을
* 한 배열에 섞어(`$policies[] = ...` 뒤에 `$policies['address'] = ...`) 저장했다.
* PHP 배열이 non-sequential 이 되면 `json_encode` 가 리스트가 아니라 객체
* `{"0": {...}, "address": {...}}` 로 직렬화한다. 서버측 소비자는 `is_int($key)` 로
* 관용 처리해 무사했지만, **프론트는 배열을 전제**하므로
* `(snapshot ?? []).find(...)` 가 `TypeError: .find is not a function` 으로 죽었고
* 마이페이지·비회원 주문 상세의 배송정책 표시 블록이 예외도 없이 통째로 사라졌다.
*
* 한 컬럼에 두 의미를 섞지 않도록 `{items: [...], address: {...}}` 로 분리하고,
* 판별·변환 로직을 이 클래스 하나로 모은다. 구형 혼합 배열은 `normalize()` 가 흡수하므로
* 백필 전 기존 주문도 같은 경로로 동작한다.
*
* @since 1.0.5
*/
class ShippingPolicySnapshot
{
/** 항목 목록 키 */
public const ITEMS = 'items';
/** 배송지 메타 키 */
public const ADDRESS = 'address';
/**
* 신형 스냅샷 구조를 생성합니다.
*
* @param array $items 옵션별 적용 정책 목록 (각 항목: product_option_id, policy)
* @param array $address 배송지 메타 (country_code, zipcode)
* @return array{items: array<int, array>, address: array} 신형 스냅샷
*/
public static function make(array $items, array $address): array
{
return [
self::ITEMS => array_values($items),
self::ADDRESS => $address,
];
}
/**
* 신형/구형 어느 형태든 신형 구조로 정규화합니다 (멱등).
*
* 구형은 정수 키에 항목이, `'address'` 문자열 키에 배송지가 섞여 있다.
*
* @param array|null $snapshot 원본 스냅샷 (신형 · 구형 · null 모두 허용)
* @return array{items: array<int, array>, address: array} 신형 스냅샷
*/
public static function normalize(?array $snapshot): array
{
if (empty($snapshot)) {
return self::make([], []);
}
// 이미 신형이면 items 만 재인덱싱해 반환 (멱등)
if (array_key_exists(self::ITEMS, $snapshot)) {
return self::make(
is_array($snapshot[self::ITEMS]) ? $snapshot[self::ITEMS] : [],
is_array($snapshot[self::ADDRESS] ?? null) ? $snapshot[self::ADDRESS] : []
);
}
$items = [];
$address = [];
foreach ($snapshot as $key => $entry) {
if ($key === self::ADDRESS) {
$address = is_array($entry) ? $entry : [];
continue;
}
if (is_array($entry)) {
$items[] = $entry;
}
}
return self::make($items, $address);
}
/**
* 스냅샷에서 배송지 메타만 추출합니다.
*
* @param array|null $snapshot 원본 스냅샷 (신형 · 구형 모두 허용)
* @return array 배송지 메타 (country_code, zipcode)
*/
public static function address(?array $snapshot): array
{
return self::normalize($snapshot)[self::ADDRESS];
}
/**
* 스냅샷에서 항목 목록만 추출합니다.
*
* @param array|null $snapshot 원본 스냅샷 (신형 · 구형 모두 허용)
* @return array<int, array> 옵션별 적용 정책 목록
*/
public static function items(?array $snapshot): array
{
return self::normalize($snapshot)[self::ITEMS];
}
/**
* 스냅샷을 `product_option_id => policy` 맵으로 변환합니다.
*
* `OrderCalculationService::groupByShippingPolicy()` 가 `$snapshots[$optionId]` 로
* 접근하므로 재계산 경로에서 필요하다. 테스트 헬퍼 등이 직접 구성한
* `optionId => policy` 형태도 그대로 통과시킨다.
*
* @param array|null $snapshot 원본 스냅샷 (신형 · 구형 · 옵션 키 맵 모두 허용)
* @return array<int|string, array> product_option_id => 정책 데이터
*/
public static function toOptionPolicyMap(?array $snapshot): array
{
if (empty($snapshot)) {
return [];
}
$map = [];
// 신형이 아니면서 정수 키에 policy_id 를 직접 갖는 형태 = optionId => policy 맵
if (! array_key_exists(self::ITEMS, $snapshot)) {
foreach ($snapshot as $key => $entry) {
if ($key === self::ADDRESS || ! is_array($entry)) {
continue;
}
if (! isset($entry['product_option_id'], $entry['policy'])) {
$map[$key] = $entry;
}
}
}
foreach (self::items($snapshot) as $entry) {
if (isset($entry['product_option_id'], $entry['policy'])) {
$map[$entry['product_option_id']] = $entry['policy'];
}
}
return $map;
}
/**
* 화면 표시에 필요한 필드만 남긴 스냅샷을 만듭니다.
*
* 주문 상세의 배송정책 블록(`partials/mypage/orders/_items.json`)이 읽는 것은
* `product_option_id` 와 `policy.policy_name` · `policy.standalone_shipping_amount`
* (+ 포맷) 뿐이다. 비회원 응답은 정책 id·계산 근거 같은 내부 값을 내보내지 않으면서도
* 이 블록이 그려지도록 표시용 필드만 추린다.
*
* 배송지 메타(`address`)는 표시 대상이 아니므로 제외한다 — 화면은 별도 배송지 블록에서
* 주문 주소를 쓴다.
*
* `items` 는 프론트가 `.find(...)` 로 순회하므로 **항상 리스트**여야 한다.
*
* @param array|null $snapshot 원본 스냅샷 (신형 · 구형 모두 허용)
* @return array{items: array<int, array>} 표시용 스냅샷
*
* @since 1.0.5
*/
public static function forDisplay(?array $snapshot): array
{
$items = [];
foreach (self::items($snapshot) as $entry) {
if (! isset($entry['product_option_id'], $entry['policy']) || ! is_array($entry['policy'])) {
continue;
}
$policy = $entry['policy'];
$items[] = [
'product_option_id' => $entry['product_option_id'],
'policy' => [
'policy_name' => $policy['policy_name'] ?? '',
'standalone_shipping_amount' => $policy['standalone_shipping_amount'] ?? 0,
'standalone_shipping_amount_formatted' => $policy['standalone_shipping_amount_formatted'] ?? '',
],
];
}
return [self::ITEMS => $items];
}
/**
* 지정한 옵션 ID 집합에 해당하는 항목만 추립니다.
*
* @param array|null $snapshot 원본 스냅샷 (신형 · 구형 모두 허용)
* @param array<int, bool> $optionIds product_option_id => true 형태의 대상 집합
* @return array<int, array> 추려진 항목 목록
*/
public static function itemsForOptions(?array $snapshot, array $optionIds): array
{
$picked = [];
foreach (self::items($snapshot) as $entry) {
if (isset($entry['product_option_id'], $entry['policy'])
&& isset($optionIds[$entry['product_option_id']])) {
$picked[] = $entry;
}
}
return $picked;
}
}
@@ -1390,6 +1390,8 @@ return [
'mileage.currency_rules.*.use_unit' => 'Use Unit',
'mileage.currency_rules.*.max_use_percent' => 'Max Use Percent',
'mileage.currency_rules.*.max_use_value' => 'Max Use Amount',
'mileage.currency_rules.*.earn_rounding_unit' => 'Earning Rounding Unit',
'mileage.currency_rules.*.earn_rounding_method' => 'Earning Rounding Method',
'mileage.expiry_days' => 'Expiry Days',
'mileage.expiry_notification_days_before' => 'Expiry Notification Days',
@@ -1616,6 +1618,12 @@ return [
'min' => 'Max usable amount must be 0 or greater.',
'max' => 'Max usable amount is too large (max 1 billion).',
],
'earn_rounding_unit' => [
'in' => 'The earning rounding unit must be one of: 1, 10, 100.',
],
'earn_rounding_method' => [
'in' => 'The earning rounding method must be one of: floor, round, ceil.',
],
],
],
'seo' => [
@@ -1390,6 +1390,8 @@ return [
'mileage.currency_rules.*.use_unit' => '사용 단위',
'mileage.currency_rules.*.max_use_percent' => '최대 사용 비율',
'mileage.currency_rules.*.max_use_value' => '최대 사용 금액',
'mileage.currency_rules.*.earn_rounding_unit' => '적립 절사 단위',
'mileage.currency_rules.*.earn_rounding_method' => '적립 절사 방식',
'mileage.expiry_days' => '유효기간',
'mileage.expiry_notification_days_before' => '소멸 예정 알림일',
@@ -1616,6 +1618,12 @@ return [
'min' => '최대 사용 금액은 0 이상이어야 합니다.',
'max' => '최대 사용 금액이 너무 큽니다. (최대 10억)',
],
'earn_rounding_unit' => [
'in' => '적립 절사 단위는 1, 10, 100 중 하나여야 합니다.',
],
'earn_rounding_method' => [
'in' => '적립 절사 방식은 floor, round, ceil 중 하나여야 합니다.',
],
],
],
'seo' => [
@@ -374,6 +374,67 @@ class EcommerceSettingsMileageTest extends ModuleTestCase
}
}
/**
* 통화별 적립 절사 기준이 저장·조회된다.
*
* 적립 절사는 통화 환산 절사(language_currency.currencies.*.rounding_*)와 별개 항목이다.
* 그쪽은 외화 표시 전용이라 기본 통화에는 적용되지 않는 반면, 적립은 기본 통화 원장에
* 확정 기록되는 값이므로 자기 규칙을 갖는다.
*/
public function test_save_mileage_earn_rounding_rule(): void
{
$response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'mileage',
'mileage' => [
'enabled' => true,
'default_earn_rate' => 1,
'currency_rules' => [
[
'currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1,
'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0,
'earn_rounding_unit' => '10', 'earn_rounding_method' => 'ceil',
],
],
],
]);
$response->assertOk();
$settings = app(EcommerceSettingsService::class);
$settings->clearCache();
$rules = $settings->getSetting('mileage.currency_rules');
$this->assertSame('10', $rules[0]['earn_rounding_unit']);
$this->assertSame('ceil', $rules[0]['earn_rounding_method']);
}
/**
* 허용 목록 밖의 적립 절사 값은 422 로 거부된다.
*/
public function test_invalid_mileage_earn_rounding_rejected(): void
{
$response = $this->actingAs($this->adminUser)->putJson($this->apiBase, [
'_tab' => 'mileage',
'mileage' => [
'enabled' => true,
'default_earn_rate' => 1,
'currency_rules' => [
[
'currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1,
'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0,
'earn_rounding_unit' => '7', 'earn_rounding_method' => 'trunc',
],
],
],
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors([
'mileage.currency_rules.0.earn_rounding_unit',
'mileage.currency_rules.0.earn_rounding_method',
]);
}
/**
* settings.update 권한 없는 계정은 차단.
*/
@@ -0,0 +1,109 @@
/**
* 관리자 마일리지 설정 — 통화별 적립 절사 기준.
*
* @scenario settings_mileage_earn_rounding
* @effects settings_controls_render_in_both_viewports,
* settings_screen_options_match_server_vocabulary,
* settings_rounding_persists_across_reload,
* settings_earn_rounding_separate_from_exchange
*
* 배경: 적립 포인트 산출은 종전에 `(int) floor` 로 하드코딩돼 운영자가 조정할 수 없었다.
* 통화별 마일리지 규칙에 절사 단위(1/10/100)와 방식(버림/반올림/올림)을 두어
* 주문계산·부분취소 안분이 같은 기준을 따르게 한다.
*
* 참고: 관리자 템플릿의 Select 는 options 가 주어지면 네이티브 `<select>` 가 아니라
* 커스텀 드롭다운(Button + portal `role="listbox"`)으로 렌더된다. 따라서
* `selectOption()`/`toHaveValue()` 가 아니라 열기 → `role="option"` 클릭으로 조작한다.
*/
import type { Page } from '@playwright/test';
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
const SETTINGS_URL = '/admin/ecommerce/settings?tab=mileage';
const CURRENCY_URL = '/admin/ecommerce/settings?tab=language_currency';
const UNIT = 'mileage-earn-rounding-unit-KRW';
const METHOD = 'mileage-earn-rounding-method-KRW';
/** 커스텀 드롭다운을 열고 옵션 라벨 목록을 읽는다. */
async function openAndReadOptions(page: Page, testId: string): Promise<string[]> {
await page.getByTestId(testId).getByRole('button').click();
const listbox = page.getByRole('listbox');
await expect(listbox).toBeVisible({ timeout: 5_000 });
const labels = await listbox.getByRole('option').allInnerTexts();
await page.keyboard.press('Escape');
return labels.map((t) => t.trim());
}
/** 커스텀 드롭다운에서 라벨로 옵션을 고른다. */
async function chooseOption(page: Page, testId: string, label: string): Promise<void> {
await page.getByTestId(testId).getByRole('button').click();
await expect(page.getByRole('listbox')).toBeVisible({ timeout: 5_000 });
await page.getByRole('listbox').getByRole('option', { name: label, exact: true }).click();
}
/** 드롭다운 버튼에 표시된 현재 선택 라벨. */
function selectedLabel(page: Page, testId: string) {
return page.getByTestId(testId).getByRole('button');
}
test.describe('관리자 마일리지 설정 — 적립 절사 기준', () => {
test('기본 통화 규칙 행에 절사 단위/방식 컨트롤이 렌더된다', async ({ page, settingsToken }) => {
await authenticatePage(page, settingsToken);
await page.goto(SETTINGS_URL);
await expect(page.getByTestId(UNIT)).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(METHOD)).toBeVisible();
// 단위는 정수 포인트만 — 마일리지는 원장에 정수로 확정되므로, 통화 환산 절사의
// 소수 단위(0.01/0.1)가 선택지로 보이면 고를 수는 있는데 저장이 422 로 막힌다.
expect(await openAndReadOptions(page, UNIT)).toEqual(['1점', '10점', '100점']);
// 화면 옵션 ⊆ 서버 허용 어휘(floor/round/ceil)
expect(await openAndReadOptions(page, METHOD)).toEqual(['버림', '반올림', '올림']);
});
test('절사 기준 변경이 저장되고 새로고침 후에도 유지된다', async ({ page, settingsToken }) => {
// 저장 → 새로고침 → 원복 → 새로고침 4왕복이라 기본 30초로는 모자란다.
test.setTimeout(120_000);
await authenticatePage(page, settingsToken);
await page.goto(SETTINGS_URL);
await expect(page.getByTestId(UNIT)).toBeVisible({ timeout: 15_000 });
await chooseOption(page, UNIT, '10점');
await chooseOption(page, METHOD, '올림');
await page.getByTestId('ecommerce-settings-save').click();
// 저장 payload 에 키가 빠지면 화면에서 고를 수는 있어도 새로고침하면 되돌아간다.
await page.reload();
await expect(selectedLabel(page, UNIT)).toContainText('10점', { timeout: 15_000 });
await expect(selectedLabel(page, METHOD)).toContainText('올림');
// 원복 — 다른 spec 이 기본값(1점/버림)을 전제한다
await chooseOption(page, UNIT, '1점');
await chooseOption(page, METHOD, '버림');
await page.getByTestId('ecommerce-settings-save').click();
await page.reload();
await expect(selectedLabel(page, UNIT)).toContainText('1점', { timeout: 15_000 });
await expect(selectedLabel(page, METHOD)).toContainText('버림');
});
test('통화 환산 절사(언어/통화 탭)와 섞이지 않는다', async ({ page, settingsToken }) => {
await authenticatePage(page, settingsToken);
// 적립 절사는 마일리지 탭에만 있다
await page.goto(SETTINGS_URL);
await expect(page.getByTestId(UNIT)).toBeVisible({ timeout: 15_000 });
// 언어/통화 탭에는 적립 절사 컨트롤이 없다 — 두 설정이 한 화면에 섞이면 운영자가
// 외화 표시 반올림을 바꾸려다 적립 지급 규칙을 함께 바꾼다.
await page.goto(CURRENCY_URL);
await expect(page.getByText('환율/통화 설정').first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId(UNIT)).toHaveCount(0);
await expect(page.getByTestId(METHOD)).toHaveCount(0);
});
});
@@ -0,0 +1,108 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Resources;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Http\Resources\GuestOrderResource;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 비회원 주문 상세의 배송정책 표시 필드 정합성 테스트
*
* 비회원 주문 상세 화면(`shop/guest_order_show.json`)은 회원 마이페이지와 **같은
* partial**(`partials/mypage/orders/_items.json`)을 써서 상품별 배송정책명과 개별
* 배송비를 그린다. 그 partial 은 `shipping_policy_applied_snapshot.items[]` 에서
* `policy.policy_name` · `policy.standalone_shipping_amount(_formatted)` 를 읽는다.
*
* 그런데 `GuestOrderResource` 는 그 필드를 "내부 계산 스냅샷" 으로 분류해 통째로
* 제외했다. 결과는 오류가 아니라 **조용한 누락** — 옵셔널 체이닝이 빈 배열로 떨어져
* `if` 가 false 가 되고, 비회원 주문 상세에서만 배송정책 줄이 항상 사라진다.
* (브라우저 실측으로 발견: 회원 경로는 표시, 비회원 경로는 미표시)
*
* 내부 계산 근거(정책 id·계산 파라미터)를 비회원에게 노출하지 않는다는 기존 원칙은
* 유지하고, 화면이 실제로 읽는 **표시용 필드만** 노출하는 것으로 해소한다.
*/
class GuestOrderShippingPolicyDisplayTest extends ModuleTestCase
{
/**
* 표시용 스냅샷을 담은 주문을 만듭니다.
*
* @return Order 스냅샷이 채워진 주문
*/
private function makeOrderWithSnapshot(): Order
{
return OrderFactory::new()->make([
'shipping_policy_applied_snapshot' => ShippingPolicySnapshot::make(
[[
'product_option_id' => 481,
'policy' => [
'policy_id' => 7,
'policy_name' => '국내 무료배송',
'charge_policy' => 'free',
'standalone_shipping_amount' => 0,
'calculation_basis' => ['internal' => 'do-not-expose'],
],
]],
['country_code' => 'KR', 'zipcode' => '06236'],
),
]);
}
/**
* 비회원 응답이 화면이 읽는 배송정책 표시 필드를 노출하는지 확인
*/
public function test_guest_resource_exposes_shipping_policy_display_fields(): void
{
// Given: 배송정책 스냅샷이 있는 비회원 주문
$order = $this->makeOrderWithSnapshot();
// When: 비회원 리소스로 직렬화
$result = (new GuestOrderResource($order))->toArray(request());
// Then: 화면이 읽는 경로가 살아 있다
$this->assertArrayHasKey('shipping_policy_applied_snapshot', $result);
$items = $result['shipping_policy_applied_snapshot']['items'] ?? null;
$this->assertIsArray($items, 'items 는 프론트 .find() 대상이므로 배열이어야 한다');
$this->assertCount(1, $items);
$this->assertSame(481, $items[0]['product_option_id']);
$this->assertSame('국내 무료배송', $items[0]['policy']['policy_name']);
$this->assertArrayHasKey('standalone_shipping_amount', $items[0]['policy']);
}
/**
* 표시에 쓰이지 않는 내부 계산 값은 비회원에게 노출되지 않는지 확인
*/
public function test_guest_resource_hides_internal_calculation_fields(): void
{
// Given: 내부 계산 근거가 섞인 스냅샷
$order = $this->makeOrderWithSnapshot();
// When: 비회원 리소스로 직렬화
$result = (new GuestOrderResource($order))->toArray(request());
// Then: 표시용 외 내부 키는 빠진다
$policy = $result['shipping_policy_applied_snapshot']['items'][0]['policy'];
$this->assertArrayNotHasKey('calculation_basis', $policy);
$this->assertArrayNotHasKey('policy_id', $policy);
// 배송지 메타도 비회원 표시 대상이 아니다
$this->assertArrayNotHasKey('address', $result['shipping_policy_applied_snapshot']);
}
/**
* 스냅샷이 없어도 화면이 안전하게 빈 목록을 받는지 확인
*/
public function test_guest_resource_returns_empty_items_when_snapshot_missing(): void
{
// Given: 스냅샷이 비어 있는 주문
$order = OrderFactory::new()->make(['shipping_policy_applied_snapshot' => null]);
// When: 비회원 리소스로 직렬화
$result = (new GuestOrderResource($order))->toArray(request());
// Then: `.find()` 가 죽지 않도록 배열이 유지된다
$this->assertSame([], $result['shipping_policy_applied_snapshot']['items']);
}
}
@@ -0,0 +1,268 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponIssueRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductAdditionalOptionValueRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ShippingPolicyRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Services\ShippingPolicyResolver;
use Modules\Sirsoft\Ecommerce\Services\UserMileageService;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 적립 마일리지 절사 기준의 주문 계산 반영 테스트
*
* 적립 산출은 종전에 세 갈래(옵션 정액 · 옵션 정률 · 기본 적립률) 모두 `(int) floor` 로
* 하드코딩되어 운영자가 조정할 수 없었다. 통화별 마일리지 규칙의 절사 기준이 세 갈래에
* 빠짐없이 적용되는지, 그리고 재계산이 주문 시점 기준을 쓰는지 검증한다.
*/
class MileageEarnRoundingTest extends ModuleTestCase
{
private OrderCalculationService $service;
private string $settingsPath;
protected function setUp(): void
{
parent::setUp();
$this->settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($this->settingsPath)) {
mkdir($this->settingsPath, 0755, true);
}
$this->writeCurrencySettings();
$this->writeMileageSettings('1', 'floor');
}
protected function tearDown(): void
{
foreach (['language_currency.json', 'mileage.json'] as $file) {
$path = $this->settingsPath.'/'.$file;
if (file_exists($path)) {
unlink($path);
}
}
parent::tearDown();
}
private function writeCurrencySettings(): void
{
file_put_contents($this->settingsPath.'/language_currency.json', json_encode([
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'base_unit' => 1000,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'decimal_places' => 0,
'is_default' => true,
],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
/**
* 마일리지 설정을 지정한 절사 기준으로 기록합니다.
*
* @param string|null $unit 절사 단위 (null 이면 키 자체를 두지 않음 — 구형 설정 재현)
* @param string|null $method 절사 방식 (null 이면 키 자체를 두지 않음)
*/
private function writeMileageSettings(?string $unit, ?string $method): void
{
$rule = [
'currency_code' => 'KRW',
'point_value' => 1,
'min_use_amount' => 0,
'use_unit' => 1,
'max_use_type' => 'percent',
'max_use_percent' => 100,
'max_use_value' => 0,
];
if ($unit !== null) {
$rule[MileageRounding::UNIT_KEY] = $unit;
}
if ($method !== null) {
$rule[MileageRounding::METHOD_KEY] = $method;
}
file_put_contents($this->settingsPath.'/mileage.json', json_encode([
'enabled' => true,
'default_earn_rate' => 1,
'earn_trigger' => 'confirmed',
'earn_delay_days' => 0,
'currency_rules' => [$rule],
'expiry_enabled' => true,
'expiry_days' => 365,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// 설정 서비스는 인스턴스 내부 캐시를 갖는다 — 파일만 바꾸면 이미 주입된 인스턴스는
// 이전 값을 계속 읽는다. 컨테이너 인스턴스를 버리고 계산 서비스를 다시 조립한다.
app()->forgetInstance(EcommerceSettingsService::class);
$this->service = $this->makeService();
}
/**
* 현재 설정 파일 기준으로 계산 서비스를 조립합니다.
*
* @return OrderCalculationService 계산 서비스
*/
private function makeService(): OrderCalculationService
{
return new OrderCalculationService(
new CurrencyConversionService,
app(ProductOptionRepositoryInterface::class),
app(CouponIssueRepositoryInterface::class),
app(ShippingPolicyRepositoryInterface::class),
app(EcommerceSettingsService::class),
app(ProductAdditionalOptionValueRepositoryInterface::class),
app(ShippingPolicyResolver::class)
);
}
/**
* 적립 설정이 붙은 상품 옵션을 만듭니다.
*
* @param int $price 판매가
* @param float|null $mileageValue 옵션 적립값
* @param string|null $mileageType 적립 방식 (fixed|percent)
* @return int 상품옵션 ID
*/
private function makeOption(int $price, ?float $mileageValue = null, ?string $mileageType = null): int
{
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => $price,
'list_price' => $price,
]);
$data = ['price_adjustment' => 0, 'stock_quantity' => 100, 'is_default' => true];
if ($mileageValue !== null) {
$data['mileage_value'] = $mileageValue;
}
if ($mileageType !== null) {
$data['mileage_type'] = $mileageType;
}
return ProductOptionFactory::new()->forProduct($product)->create($data)->id;
}
/**
* 계산을 수행하고 첫 항목의 적립 포인트를 반환합니다.
*
* @param int $optionId 상품옵션 ID
* @param int $quantity 수량
* @param array|null $mileagePolicySnapshot 마일리지 정책 스냅샷 (재계산 시나리오)
* @return float 적립 포인트
*/
private function earnedPoints(int $optionId, int $quantity = 1, ?array $mileagePolicySnapshot = null): float
{
$result = $this->service->calculate(new CalculationInput(
items: [new CalculationItem(productOptionId: $optionId, quantity: $quantity)],
mileagePolicySnapshot: $mileagePolicySnapshot,
));
return (float) $result->items[0]->pointsEarning;
}
#[Test]
public function 기본_적립률_갈래에_절사_기준이_적용된다(): void
{
// 78,160원 × 1% = 781.6점
$optionId = $this->makeOption(78160);
$this->assertSame(781.0, $this->earnedPoints($optionId), '기본값(1점 버림)');
$this->writeMileageSettings('1', 'round');
$this->assertSame(782.0, $this->earnedPoints($optionId), '1점 반올림');
$this->writeMileageSettings('10', 'floor');
$this->assertSame(780.0, $this->earnedPoints($optionId), '10점 버림');
$this->writeMileageSettings('100', 'ceil');
$this->assertSame(800.0, $this->earnedPoints($optionId), '100점 올림');
}
#[Test]
public function 옵션_정률_갈래에_절사_기준이_적용된다(): void
{
// 78,160원 × 3.5% = 2,735.6점
$optionId = $this->makeOption(78160, 3.5, 'percent');
$this->assertSame(2735.0, $this->earnedPoints($optionId), '기본값(1점 버림)');
$this->writeMileageSettings('10', 'ceil');
$this->assertSame(2740.0, $this->earnedPoints($optionId), '10점 올림');
}
#[Test]
public function 옵션_정액_갈래에_절사_기준이_적용된다(): void
{
// 정액 505.5점 × 3개 = 1,516.5점
$optionId = $this->makeOption(50000, 505.5, 'fixed');
$this->assertSame(1516.0, $this->earnedPoints($optionId, 3), '기본값(1점 버림)');
$this->writeMileageSettings('100', 'round');
$this->assertSame(1500.0, $this->earnedPoints($optionId, 3), '100점 반올림');
}
#[Test]
public function 재계산은_설정이_아니라_주문_시점_기준을_따른다(): void
{
$optionId = $this->makeOption(78160);
// 주문 시점: 100점 올림 → 800점
$snapshot = [
'usable' => true,
'currency' => 'KRW',
'rule' => [
MileageRounding::UNIT_KEY => '100',
MileageRounding::METHOD_KEY => 'ceil',
],
];
// 그 뒤 운영자가 설정을 1점 버림으로 되돌려도 이 주문의 재계산은 800점이어야 한다.
// 설정을 다시 읽으면 취소하지 않은 잔여분의 적립액이 취소 처리만으로 달라진다.
$this->writeMileageSettings('1', 'floor');
$this->assertSame(800.0, $this->earnedPoints($optionId, 1, $snapshot));
}
#[Test]
public function 절사_키가_없는_구형_설정은_종전_금액을_그대로_산출한다(): void
{
$optionId = $this->makeOption(78160);
$this->writeMileageSettings(null, null);
// 이 기능 이전 설치본의 mileage.json 에는 절사 키가 없다 — 백필 전에도 금액 불변.
$this->assertSame(781.0, $this->earnedPoints($optionId));
}
#[Test]
public function 주문_스냅샷에_절사_기준이_기록된다(): void
{
$this->writeMileageSettings('10', 'ceil');
$snapshot = app(UserMileageService::class)->buildUsagePolicySnapshot(50000, 0, 'KRW');
$this->assertSame('10', $snapshot['rule'][MileageRounding::UNIT_KEY]);
$this->assertSame('ceil', $snapshot['rule'][MileageRounding::METHOD_KEY]);
}
}
@@ -0,0 +1,168 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderOptionService;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 부분수량 분할 시 적립 포인트 안분 절사 테스트
*
* 부분취소·부분 구매확정은 주문옵션 레코드를 분할하고 금액을 비율 안분한다. 적립 포인트만은
* 원장이 정수로 확정하는 값인데 안분이 `round(..., 2)` 였다 — 3개 중 1개를 분할하면
* 781 × (1/3) = 260.33 처럼 **원장에 존재할 수 없는 소수점 포인트**가 옵션 행에 남았고,
* 그 값이 곧 적립 목표액(`earnForOrderOption` 의 target)이 되어 실제 지급 가능한 정수
* 포인트와 어긋났다.
*
* 안분에도 주문 시점 절사 기준을 적용하고, 잔여분은 `원본 − 분할` 로 두어 총액을 보존한다.
*/
class OrderOptionSplitEarnedPointsRoundingTest extends ModuleTestCase
{
private OrderOptionService $service;
private string $settingsPath;
protected function setUp(): void
{
parent::setUp();
$this->service = app(OrderOptionService::class);
$this->settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($this->settingsPath)) {
mkdir($this->settingsPath, 0755, true);
}
file_put_contents($this->settingsPath.'/mileage.json', json_encode([
'enabled' => true,
'default_earn_rate' => 1,
'earn_trigger' => 'confirmed',
// 분할 자체를 관측하기 위해 적립 트리거는 뒤로 미룬다 (분할 → 적립 순서 간섭 배제)
'earn_delay_days' => 30,
'currency_rules' => [[
'currency_code' => 'KRW', 'point_value' => 1, 'min_use_amount' => 0, 'use_unit' => 1,
'max_use_type' => 'percent', 'max_use_percent' => 100, 'max_use_value' => 0,
]],
'expiry_enabled' => true,
'expiry_days' => 365,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
protected function tearDown(): void
{
$file = $this->settingsPath.'/mileage.json';
if (file_exists($file)) {
unlink($file);
}
parent::tearDown();
}
/**
* 적립 포인트가 실린 배송완료 주문을 만듭니다.
*
* @param int $quantity 옵션 수량
* @param float $earnedPoints 옵션 적립 포인트
* @param array|null $mileagePolicySnapshot 주문 시점 마일리지 정책 스냅샷
* @return Order 생성된 주문
*/
private function makeOrder(int $quantity, float $earnedPoints, ?array $mileagePolicySnapshot = null): Order
{
$order = OrderFactory::new()->create([
'order_status' => OrderStatusEnum::DELIVERED,
'currency' => 'KRW',
'mileage_policy_snapshot' => $mileagePolicySnapshot,
]);
OrderOptionFactory::new()->forOrder($order)->create([
'option_status' => OrderStatusEnum::DELIVERED,
'delivered_at' => now()->subDay(),
'quantity' => $quantity,
'subtotal_earned_points_amount' => $earnedPoints,
]);
return $order->fresh();
}
#[Test]
public function 분할된_적립_포인트에_소수점이_남지_않는다(): void
{
// 781점 / 3개 → 1개 분할 시 260.333... 점
$order = $this->makeOrder(3, 781);
$option = $order->options->first();
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
$points = Order::find($order->id)->options
->pluck('subtotal_earned_points_amount')
->map(fn ($v) => (float) $v);
foreach ($points as $value) {
$this->assertSame(
(float) (int) $value,
$value,
"분할 안분 결과에 소수점 포인트가 남았다 ({$value}) — 원장은 정수 포인트만 기록한다"
);
}
}
#[Test]
public function 분할해도_적립_총액이_보존된다(): void
{
$order = $this->makeOrder(3, 781);
$option = $order->options->first();
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
$total = Order::find($order->id)->options->sum(fn ($o) => (float) $o->subtotal_earned_points_amount);
$this->assertSame(781.0, $total, '분할 전후 적립 총액이 달라졌다 — 잔여분은 원본 − 분할이어야 한다');
}
#[Test]
public function 안분도_주문_시점_절사_기준을_따른다(): void
{
// 주문 시점 기준: 10점 올림. 781 × (1/3) = 260.33 → 270
$order = $this->makeOrder(3, 781, [
'usable' => true,
'currency' => 'KRW',
'rule' => [
MileageRounding::UNIT_KEY => '10',
MileageRounding::METHOD_KEY => 'ceil',
],
]);
$option = $order->options->first();
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
$fresh = Order::find($order->id)->options;
$split = $fresh->firstWhere('option_status', OrderStatusEnum::CANCELLED);
$remaining = $fresh->firstWhere('option_status', OrderStatusEnum::DELIVERED);
$this->assertSame(270.0, (float) $split->subtotal_earned_points_amount);
$this->assertSame(511.0, (float) $remaining->subtotal_earned_points_amount, '잔여분 = 원본 − 분할');
}
#[Test]
public function 스냅샷이_없는_구형_주문은_1점_버림으로_안분된다(): void
{
// 이 기능 이전 주문에는 절사 기준이 없다 — 폴백이 정수화를 보장해야 한다.
$order = $this->makeOrder(3, 781, null);
$option = $order->options->first();
$this->service->changeStatusWithQuantity($option, OrderStatusEnum::CANCELLED, 1);
$fresh = Order::find($order->id)->options;
$split = $fresh->firstWhere('option_status', OrderStatusEnum::CANCELLED);
$remaining = $fresh->firstWhere('option_status', OrderStatusEnum::DELIVERED);
$this->assertSame(260.0, (float) $split->subtotal_earned_points_amount);
$this->assertSame(521.0, (float) $remaining->subtotal_earned_points_amount);
}
}
@@ -629,11 +629,18 @@ class OrderProcessingServiceTest extends ModuleTestCase
103000
);
// shipping_policy_applied_snapshot이 비어있지 않아야 함
// shipping_policy_applied_snapshot 은 `{items: [...], address: {...}}` 구조여야 한다.
// 항목 목록과 배송지 메타를 한 배열에 섞으면 PHP 배열이 non-sequential 이 되어
// json_encode 가 객체로 직렬화하고, 배열을 전제하는 화면에서 표시가 사라진다.
$snapshot = $order->shipping_policy_applied_snapshot;
$this->assertNotEmpty($snapshot);
$this->assertEquals($productOption->id, $snapshot[0]['product_option_id']);
$this->assertEquals(1, $snapshot[0]['policy']['policy_id']);
$this->assertArrayHasKey('items', $snapshot);
$this->assertArrayHasKey('address', $snapshot);
$this->assertEquals($productOption->id, $snapshot['items'][0]['product_option_id']);
$this->assertEquals(1, $snapshot['items'][0]['policy']['policy_id']);
// items 는 JSON 에서 반드시 배열 리터럴로 나가야 한다 (객체면 프론트 .find 가 죽는다)
$this->assertStringContainsString('"items":[', json_encode($snapshot));
}
public function test_create_from_temp_order_saves_order_meta_with_calculation_input(): void
@@ -0,0 +1,120 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Modules\Sirsoft\Ecommerce\Support\ShippingPolicySnapshot;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 배송정책 스냅샷 구조 테스트
*
* `shipping_policy_applied_snapshot` 은 종전에 항목 리스트(정수 키)와 배송지 메타(`'address'`
* 문자열 키)를 **한 배열에 섞어** 저장했다. PHP 배열이 non-sequential 이 되어 `json_encode` 가
* 객체 `{"0": {...}, "address": {...}}` 로 직렬화했고, 그 결과 프론트의
* `(snapshot ?? []).find(...)` 가 `TypeError: .find is not a function` 으로 죽어
* 마이페이지/비회원 주문 상세의 **배송정책 표시 블록이 통째로 사라졌다**
* (실측: 저장된 주문 18건 중 18건 전부 객체형).
*
* 이 테스트는 구조가 `{items: [...], address: {...}}` 로 분리되어 있고, JSON 왕복 후에도
* `items` 가 **배열로 유지**되는지 잠근다. 구형 혼합 배열도 정규화로 흡수되어야 한다.
*/
class ShippingPolicySnapshotShapeTest extends ModuleTestCase
{
/**
* 신형 스냅샷은 JSON 왕복 후에도 items 가 배열(리스트)로 유지된다.
*/
public function test_신형_스냅샷은_json_왕복_후에도_items_가_배열이다(): void
{
$snapshot = ShippingPolicySnapshot::make(
[
['product_option_id' => 490, 'policy' => ['policy_id' => 1, 'policy_name' => '국내 무료배송']],
['product_option_id' => 491, 'policy' => ['policy_id' => 2, 'policy_name' => '조건부 무료']],
],
['country_code' => 'KR', 'zipcode' => '06234']
);
$decoded = json_decode(json_encode($snapshot), true);
$this->assertArrayHasKey('items', $decoded);
$this->assertArrayHasKey('address', $decoded);
$this->assertSame([0, 1], array_keys($decoded['items']), 'items 는 0부터 연속된 리스트여야 한다');
$this->assertSame('KR', $decoded['address']['country_code']);
// JSON 문자열에서 items 가 배열 리터럴(`[`)로 시작해야 한다 — 객체(`{`)면 프론트 .find 가 죽는다
$this->assertStringContainsString('"items":[', json_encode($snapshot));
}
/**
* 항목이 없어도 items 는 객체가 아니라 빈 배열이어야 한다.
*/
public function test_항목이_없어도_items_는_빈_배열이다(): void
{
$snapshot = ShippingPolicySnapshot::make([], ['country_code' => 'KR', 'zipcode' => null]);
$this->assertStringContainsString('"items":[]', json_encode($snapshot));
}
/**
* 구형(항목 + address 혼합) 스냅샷도 신형으로 정규화된다.
*/
public function test_구형_혼합_배열은_신형으로_정규화된다(): void
{
$legacy = [
0 => ['product_option_id' => 490, 'policy' => ['policy_id' => 1]],
1 => ['product_option_id' => 491, 'policy' => ['policy_id' => 2]],
'address' => ['country_code' => 'KR', 'zipcode' => '06234'],
];
$normalized = ShippingPolicySnapshot::normalize($legacy);
$this->assertCount(2, $normalized['items']);
$this->assertSame(490, $normalized['items'][0]['product_option_id']);
$this->assertSame('KR', $normalized['address']['country_code']);
$this->assertStringContainsString('"items":[', json_encode($normalized));
}
/**
* 신형 스냅샷을 다시 정규화해도 형태가 유지된다(멱등).
*/
public function test_정규화는_멱등이다(): void
{
$modern = ShippingPolicySnapshot::make(
[['product_option_id' => 490, 'policy' => ['policy_id' => 1]]],
['country_code' => 'JP', 'zipcode' => null]
);
$this->assertSame($modern, ShippingPolicySnapshot::normalize($modern));
}
/**
* 빈 스냅샷/널도 안전하게 빈 구조를 반환한다.
*/
public function test_빈_스냅샷은_빈_구조를_반환한다(): void
{
$normalized = ShippingPolicySnapshot::normalize([]);
$this->assertSame([], $normalized['items']);
$this->assertSame([], $normalized['address']);
}
/**
* product_option_id 키 맵 변환은 신형/구형 양쪽에서 같은 결과를 낸다.
*/
public function test_옵션_키_맵_변환은_신형_구형_동일_결과(): void
{
$legacy = [
0 => ['product_option_id' => 490, 'policy' => ['policy_id' => 1, 'policy_name' => 'A']],
'address' => ['country_code' => 'KR'],
];
$modern = ShippingPolicySnapshot::normalize($legacy);
$this->assertSame(
ShippingPolicySnapshot::toOptionPolicyMap($legacy),
ShippingPolicySnapshot::toOptionPolicyMap($modern)
);
$this->assertSame(
['policy_id' => 1, 'policy_name' => 'A'],
ShippingPolicySnapshot::toOptionPolicyMap($modern)[490]
);
}
}
@@ -0,0 +1,117 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Support;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 적립 마일리지 절사 규칙 SSoT 단위 테스트
*
* 이 클래스가 규칙 해석과 적용을 단독으로 책임진다. 계산·안분·스냅샷 세 지점이 모두
* 여기를 거치므로, 폴백이 종전 하드코딩(`(int) floor`)과 어긋나면 기존 사이트의 적립액이
* 아무도 설정을 바꾸지 않았는데 달라진다.
*/
class MileageRoundingTest extends ModuleTestCase
{
#[Test]
public function 규칙이_없으면_종전_동작인_1점_버림으로_폴백한다(): void
{
$this->assertSame(['unit' => '1', 'method' => 'floor'], MileageRounding::normalize(null));
$this->assertSame(['unit' => '1', 'method' => 'floor'], MileageRounding::normalize([]));
// 폴백 등가성: 절사 전 값이 무엇이든 (int) floor 와 같은 결과여야 한다
foreach ([0, 0.4, 1.0, 781.2, 781.6, 1234.999] as $raw) {
$this->assertSame(
(int) floor($raw),
MileageRounding::apply($raw),
"폴백이 종전 (int) floor 동작과 어긋난다 (입력: {$raw})"
);
}
}
#[Test]
public function 허용되지_않은_값은_기본값으로_접는다(): void
{
$folded = MileageRounding::normalize([
MileageRounding::UNIT_KEY => '7', // 허용 목록 밖
MileageRounding::METHOD_KEY => 'trunc', // 허용 목록 밖
]);
// 해석 불가한 값에서 임의 방식을 고르면 지급액이 조용히 달라진다 — 기본값으로 접는다.
$this->assertSame(['unit' => '1', 'method' => 'floor'], $folded);
}
/**
* @return array<string, array{0: string, 1: string, 2: float, 3: int}>
*/
public static function 절사_조합(): array
{
return [
'1점 버림' => ['1', 'floor', 781.6, 781],
'1점 반올림' => ['1', 'round', 781.6, 782],
'1점 올림' => ['1', 'ceil', 781.2, 782],
'10점 버림' => ['10', 'floor', 781.6, 780],
'10점 반올림' => ['10', 'round', 785.0, 790],
'10점 올림' => ['10', 'ceil', 781.0, 790],
'100점 버림' => ['100', 'floor', 781.6, 700],
'100점 반올림' => ['100', 'round', 781.6, 800],
'100점 올림' => ['100', 'ceil', 701.0, 800],
'0점은 어떤 조합에서도 0' => ['100', 'ceil', 0.0, 0],
];
}
#[Test]
#[\PHPUnit\Framework\Attributes\DataProvider('절사_조합')]
public function 단위와_방식_조합이_그대로_적용된다(string $unit, string $method, float $raw, int $expected): void
{
$result = MileageRounding::applyRule($raw, [
MileageRounding::UNIT_KEY => $unit,
MileageRounding::METHOD_KEY => $method,
]);
$this->assertSame($expected, $result);
}
#[Test]
public function 부동소수_오차가_한_단위를_깎지_않는다(): void
{
// 7810 × 0.1 은 780.9999999999999 로 계산돼 버림에서 780 이 된다.
// 절사 기준 자체와 무관한 계산 잔여물이 지급액을 1점 깎으면 안 된다.
$this->assertSame(781, MileageRounding::apply(7810 * 0.1));
}
#[Test]
public function 주문_스냅샷에서_절사_기준을_읽는다(): void
{
$snapshot = [
'usable' => true,
'currency' => 'KRW',
'rule' => [
'use_unit' => 10,
MileageRounding::UNIT_KEY => '100',
MileageRounding::METHOD_KEY => 'ceil',
],
];
$this->assertSame(
['unit' => '100', 'method' => 'ceil'],
MileageRounding::fromOrderSnapshot($snapshot)
);
}
#[Test]
public function 이_기능_이전_주문의_스냅샷은_종전_동작으로_해석된다(): void
{
// 절사 키가 없던 시절의 스냅샷 — 그때의 동작이 곧 기본값이므로 재계산 결과가 보존된다.
$legacy = [
'usable' => true,
'currency' => 'KRW',
'rule' => ['point_value' => 1.0, 'use_unit' => 10, 'max_use_type' => 'percent'],
];
$this->assertSame(['unit' => '1', 'method' => 'floor'], MileageRounding::fromOrderSnapshot($legacy));
$this->assertSame(['unit' => '1', 'method' => 'floor'], MileageRounding::fromOrderSnapshot(null));
}
}
@@ -0,0 +1,167 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Upgrades;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Support\MileageRounding;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 마일리지 적립 절사 기준 백필 마이그레이션 테스트
*
* 폴백이 있어 백필 없이도 금액은 같다. 이 백필의 목적은 "운영자가 버림을 선택했다" 와
* "키가 없어서 폴백됐다" 를 설정 파일에서 구분 가능하게 만드는 것이다 — 그래야 이후 폴백
* 기본값을 바꿔도 기존 사이트의 적립 절사가 조용히 따라 바뀌지 않는다.
*/
class BackfillMileageEarnRoundingTest extends ModuleTestCase
{
private const MIGRATION = 'App\\Upgrades\\Data\\Ext\\Modules\\SirsoftEcommerce\\V1_0_5\\Migrations\\BackfillMileageEarnRounding';
private string $settingsFile;
protected function setUp(): void
{
parent::setUp();
// DataMigration 클래스는 오토로드 밖 — 직접 로드
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.5/migrations/07_BackfillMileageEarnRounding.php';
$dir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! File::isDirectory($dir)) {
File::makeDirectory($dir, 0755, true);
}
$this->settingsFile = $dir.'/mileage.json';
}
protected function tearDown(): void
{
if (File::exists($this->settingsFile)) {
File::delete($this->settingsFile);
}
parent::tearDown();
}
private function migration(): object
{
$class = self::MIGRATION;
return new $class;
}
private function context(): UpgradeContext
{
return new UpgradeContext(
fromVersion: '1.0.2',
toVersion: '1.0.5',
currentStep: '1.0.5',
);
}
/**
* 마일리지 설정 파일을 기록합니다.
*
* @param array<int, array> $rules 통화별 규칙
*/
private function writeSettings(array $rules): void
{
File::put($this->settingsFile, json_encode([
'enabled' => true,
'default_earn_rate' => 1,
'currency_rules' => $rules,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
/**
* @return array<int, array> 저장된 통화별 규칙
*/
private function readRules(): array
{
return json_decode((string) File::get($this->settingsFile), true)['currency_rules'];
}
#[Test]
public function 절사_키가_없는_행에_종전_동작값을_기입한다(): void
{
$this->writeSettings([
['currency_code' => 'KRW', 'point_value' => 1, 'use_unit' => 10],
['currency_code' => 'USD', 'point_value' => 0.001, 'use_unit' => 1],
]);
$this->migration()->run($this->context());
foreach ($this->readRules() as $rule) {
$this->assertSame('1', $rule[MileageRounding::UNIT_KEY]);
$this->assertSame('floor', $rule[MileageRounding::METHOD_KEY]);
}
}
#[Test]
public function 이미_기입된_값은_덮어쓰지_않는다(): void
{
$this->writeSettings([
[
'currency_code' => 'KRW',
MileageRounding::UNIT_KEY => '100',
MileageRounding::METHOD_KEY => 'ceil',
],
]);
$this->migration()->run($this->context());
// 멱등 확인 — 두 번 돌려도 운영자 선택이 보존되어야 한다
$this->migration()->run($this->context());
$rules = $this->readRules();
$this->assertSame('100', $rules[0][MileageRounding::UNIT_KEY]);
$this->assertSame('ceil', $rules[0][MileageRounding::METHOD_KEY]);
}
#[Test]
public function 한쪽_키만_있는_행은_빠진_키만_채운다(): void
{
$this->writeSettings([
['currency_code' => 'KRW', MileageRounding::METHOD_KEY => 'round'],
]);
$this->migration()->run($this->context());
$rules = $this->readRules();
$this->assertSame('1', $rules[0][MileageRounding::UNIT_KEY], '빠진 단위는 채워야 한다');
$this->assertSame('round', $rules[0][MileageRounding::METHOD_KEY], '있던 방식은 보존해야 한다');
}
#[Test]
public function 설정_파일이_없으면_아무것도_만들지_않는다(): void
{
if (File::exists($this->settingsFile)) {
File::delete($this->settingsFile);
}
$this->migration()->run($this->context());
// 신규 설치는 config/settings/defaults.json 이 값을 갖는다 — 여기서 파일을 만들면
// 운영자가 저장한 적 없는 설정 파일이 생겨 defaults 병합 경로가 달라진다.
$this->assertFalse(File::exists($this->settingsFile));
}
#[Test]
public function 백필_후_적립_산출이_종전과_같다(): void
{
$this->writeSettings([['currency_code' => 'KRW', 'point_value' => 1]]);
$this->migration()->run($this->context());
$rounding = MileageRounding::normalize($this->readRules()[0]);
foreach ([781.6, 2735.6, 1516.5] as $raw) {
$this->assertSame(
(int) floor($raw),
MileageRounding::apply($raw, $rounding),
'백필된 값이 종전 (int) floor 동작과 어긋난다'
);
}
}
}
@@ -0,0 +1,85 @@
# audit:allow test-scenario-coverage reason: |
# cross product 자동 전개는 audit 실행 환경에 js-yaml 이 없어 fallback YAML 파서가
# nested axes 배열을 읽지 못하는 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도
# audit:allow 처리됨). 아래 조합은 PHPUnit 3종(SSoT 단위 / 주문계산 / 분할 안분) +
# Feature(설정 저장·검증) + Vitest(레이아웃) + Playwright(설정 왕복)로 전수 커버되어
# green 이다.
feature: 마일리지 적립 절사 기준 (통화별 설정 신설)
description: |
적립 포인트 산출이 세 갈래(옵션 정액 · 옵션 정률 · 기본 적립률) 모두 `(int) floor` 로
하드코딩돼 운영자가 조정할 수 없었고, 부분취소 분할 안분은 `round(..., 2)` 라 원장에
존재할 수 없는 소수점 포인트를 만들었다.
통화별 마일리지 규칙(`mileage.currency_rules.*`)에 절사 단위(1/10/100)와 방식
(floor/round/ceil)을 신설하고, 산출·안분·재계산이 같은 기준을 쓰게 한다. 기준은 주문
시점에 `mileage_policy_snapshot.rule` 로 고정되어 이후 설정 변경이 과거 주문에 소급되지
않는다.
통화 환산 절사(`language_currency.currencies.*.rounding_*`)와는 별개다 — 그쪽은 외화
표시 전용이라 기본 통화 분기에서는 적용조차 되지 않으므로 적립 규칙으로 쓸 수 없다.
axes:
rounding_unit:
- "1" # 1점 (기본 — 종전 하드코딩과 동일)
- "10" # 10점
- "100" # 100점
rounding_method:
- floor # 버림 (기본)
- round # 반올림
- ceil # 올림
earn_branch:
- option_fixed # 옵션 정액 적립 (mileage_type=fixed)
- option_percent # 옵션 정률 적립 (mileage_type=percent)
- default_rate # 기본 적립률 (mileage.default_earn_rate)
rule_source:
- live_settings # 신규 주문 — 현재 설정
- order_snapshot # 재계산 — 주문 시점 스냅샷
- legacy_absent # 절사 키 없음 (기능 이전 설치본/주문) → 기본값 폴백
proration_path:
- none # 분할 없음
- partial_cancel # 부분취소 분할 (원본 × ratio / 잔여 = 원본 − 분할)
settings_input:
- allowed_value # 허용 목록 내 값 → 저장
- out_of_vocabulary # 허용 목록 밖 값 → 422
viewport:
- desktop_table # 데스크톱 테이블 뷰
- mobile_cards # 모바일 카드 뷰
effects:
# 산출
- default_rate_branch_applies_rounding_rule # 기본 적립률 갈래에 단위/방식 적용
- option_percent_branch_applies_rounding_rule # 옵션 정률 갈래에 적용
- option_fixed_branch_applies_rounding_rule # 옵션 정액 갈래에 적용
- fallback_matches_legacy_int_floor # 폴백이 종전 (int) floor 와 동일 금액
- out_of_vocabulary_rule_folds_to_default # 오염값은 기본값으로 접어 지급액 요동 차단
- float_error_does_not_shave_one_unit # 부동소수 잔여물이 1단위를 깎지 않음
# 스냅샷
- order_snapshot_records_earn_rounding_rule # 주문 시점 절사 기준이 스냅샷에 기록
- recalculation_uses_snapshot_not_live_settings # 재계산은 스냅샷 우선 (설정 소급 차단)
- legacy_order_snapshot_resolves_to_default # 절사 키 없는 구형 주문은 기본값 해석
# 안분
- split_earned_points_have_no_fraction # 분할 안분에 소수점 포인트가 남지 않음
- split_preserves_total_earned_points # 분할 전후 적립 총액 보존
- split_follows_order_snapshot_rounding # 안분도 주문 시점 기준을 따름
# 설정 표면
- settings_save_persists_earn_rounding_rule # 저장 후 재조회에 값 유지
- settings_reject_out_of_vocabulary_with_422 # 허용 목록 밖 값 422 + 필드별 오류
- settings_controls_render_in_both_viewports # 테이블·카드 양쪽에 컨트롤 존재
- settings_new_currency_row_includes_rounding # 신규 통화 추가 payload 에 절사 키 포함
- settings_screen_options_match_server_vocabulary # 화면 옵션 ⊆ 서버 허용 어휘
- settings_earn_rounding_separate_from_exchange # 통화 환산 절사와 화면상 분리
- settings_rounding_persists_across_reload # 새로고침 후에도 선택 유지
# 업그레이드
- upgrade_backfills_missing_rounding_keys # 기존 설치본에 종전 동작값 명시 기입
- upgrade_preserves_operator_chosen_values # 이미 선택된 값은 덮어쓰지 않음 (멱등)
- upgrade_skips_when_settings_file_absent # 설정 파일 없으면 생성하지 않음
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/MileageRoundingTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/MileageEarnRoundingTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderOptionSplitEarnedPointsRoundingTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/BackfillMileageEarnRoundingTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/EcommerceSettingsMileageTest.php
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin_ecommerce_settings_mileage_tab.test.tsx
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/mileage-earn-rounding.spec.ts
@@ -19,6 +19,14 @@ use App\Extension\AbstractUpgradeStep;
* 반영되므로 업그레이드 시점에 동일 인덱스를 적용한다. 데이터가 많은 쇼핑몰에서는
* ALTER TABLE 이 수 분 걸리며 그동안 해당 테이블 쓰기가 대기한다.
*
* 3. 주문에 보관된 배송정책 스냅샷을 리스트+메타 구조로 정규화한다.
* 항목 목록과 배송지 메타를 한 배열에 섞어 담아 json_encode 가 리스트가 아닌 객체로
* 직렬화했고, 배열을 전제한 주문 상세 화면에서 배송정책 줄이 통째로 사라졌다.
*
* 4. 마일리지 통화별 규칙에 적립 절사 기준을 명시 기입한다.
* 값이 없으면 종전 동작(1점 버림)으로 폴백하므로 금액은 달라지지 않지만, 명시해 두지
* 않으면 이후 폴백 기본값이 바뀔 때 기존 사이트의 적립 절사가 조용히 따라 바뀐다.
*
* 모든 비즈니스 로직은 data/1.0.5/migrations/ 로 격리(AbstractUpgradeStep 규약).
*
* @upgrade-path A
@@ -0,0 +1,123 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 주문의 배송정책 적용 스냅샷을 `{items: [...], address: {...}}` 구조로 정규화합니다.
*
* 종전 쓰기 지점은 항목 리스트(정수 키)에 배송지 메타(`'address'` 문자열 키)를 덧붙여
* 한 배열에 섞어 저장했습니다. PHP 배열이 non-sequential 이 되면 `json_encode` 가 리스트가
* 아니라 객체 `{"0": {...}, "address": {...}}` 로 직렬화합니다. 서버는 키 타입을 보고
* 관용 처리해 문제가 없었지만, 배열을 전제하는 화면에서는 배송정책 표시 블록이 오류 표시
* 없이 통째로 사라졌습니다(마이페이지·비회원 주문 상세).
*
* 소스 수정만으로는 이미 저장된 주문이 낫지 않으므로 기존 행을 함께 옮깁니다.
*
* idempotent: 이미 `items` 키를 가진 행은 건드리지 않습니다.
* V-1 안전: Facades\DB/Schema 만 사용하며 모델·서비스에 의존하지 않습니다.
*/
class NormalizeShippingPolicySnapshotShape implements DataMigration
{
private const ORDERS_TABLE = 'ecommerce_orders';
private const COLUMN = 'shipping_policy_applied_snapshot';
/** @var int 청크 크기 */
private const CHUNK = 200;
/**
* 마이그레이션 식별자를 반환합니다.
*
* @return string 마이그레이션 이름
*/
public function name(): string
{
return 'NormalizeShippingPolicySnapshotShape';
}
/**
* 구형 혼합 배열 스냅샷을 신형 구조로 옮깁니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! Schema::hasTable(self::ORDERS_TABLE)
|| ! Schema::hasColumn(self::ORDERS_TABLE, self::COLUMN)) {
$context->logger->info('[ecommerce:1.0.5] 배송정책 스냅샷 정규화 — 대상 스키마 부재로 스킵');
return;
}
$updated = 0;
$skipped = 0;
$lastId = 0;
// 갱신값이 필터(whereNotNull) 소속을 유지하므로 키셋 순회로 누락 없이 처리한다.
while (true) {
$orders = DB::table(self::ORDERS_TABLE)
->where('id', '>', $lastId)
->whereNotNull(self::COLUMN)
->orderBy('id')
->limit(self::CHUNK)
->get(['id', self::COLUMN]);
if ($orders->isEmpty()) {
break;
}
foreach ($orders as $order) {
$lastId = (int) $order->id;
$decoded = json_decode((string) $order->{self::COLUMN}, true);
if (! is_array($decoded) || $decoded === []) {
$skipped++;
continue;
}
// 이미 신형이면 건드리지 않는다 (멱등)
if (array_key_exists('items', $decoded)) {
$skipped++;
continue;
}
$items = [];
$address = [];
foreach ($decoded as $key => $entry) {
if ($key === 'address') {
$address = is_array($entry) ? $entry : [];
continue;
}
if (is_array($entry)) {
$items[] = $entry;
}
}
DB::table(self::ORDERS_TABLE)
->where('id', $order->id)
->update([
self::COLUMN => json_encode(
['items' => array_values($items), 'address' => $address],
JSON_UNESCAPED_UNICODE
),
]);
$updated++;
}
}
$context->logger->info(
"[ecommerce:1.0.5] 배송정책 스냅샷 정규화 — 갱신 {$updated}건, 유지 {$skipped}건"
);
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\File;
/**
* 마일리지 통화별 규칙에 적립 절사 기준을 명시 기입합니다.
*
* 적립 포인트 산출은 종전에 `(int) floor(...)` 로 하드코딩되어 운영자가 조정할 수
* 없었습니다. 이제 통화별 규칙에 `earn_rounding_unit` / `earn_rounding_method` 를 두어
* 선택할 수 있으며, 값이 없으면 `1` / `floor` 로 폴백해 종전과 같은 금액을 산출합니다.
*
* 폴백이 있으므로 백필 없이도 동작은 같습니다. 그럼에도 기존 설치본의 저장 파일에 값을
* 명시하는 이유는 두 가지입니다.
*
* 1. 설정 파일이 SSoT 인데 키가 없으면 "운영자가 버림을 선택했다" 와 "키가 없어서
* 폴백됐다" 가 구분되지 않습니다.
* 2. 이후 폴백 기본값을 바꾸면 값을 명시하지 않은 기존 사이트의 적립 절사가 아무도
* 건드리지 않았는데 조용히 바뀝니다. 명시해 두면 그 경로가 닫힙니다.
*
* 이미 값이 있는 행은 건드리지 않습니다(idempotent). 파일이 없으면 신규 설치이므로
* `config/settings/defaults.json` 이 값을 갖고 있어 스킵합니다.
*
* V-1 안전: Illuminate\Support\Facades\File + 로컬 헬퍼만 사용.
*/
class BackfillMileageEarnRounding implements DataMigration
{
private const MODULE_IDENTIFIER = 'sirsoft-ecommerce';
/** 적립 절사 단위 키 (Support\MileageRounding::UNIT_KEY 와 동일 — 업그레이드 코드는 모듈 클래스에 의존하지 않는다) */
private const UNIT_KEY = 'earn_rounding_unit';
/** 적립 절사 방식 키 */
private const METHOD_KEY = 'earn_rounding_method';
/** 종전 하드코딩 동작과 같은 값 */
private const LEGACY_UNIT = '1';
private const LEGACY_METHOD = 'floor';
public function name(): string
{
return 'BackfillMileageEarnRounding';
}
/**
* 백필을 수행합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
$path = $this->settingsFilePath();
if (! File::exists($path)) {
$context->logger->info('[ecommerce:1.0.5] mileage.json 미존재 — 적립 절사 기준 백필 스킵');
return;
}
$settings = json_decode((string) File::get($path), true);
if (! is_array($settings) || ! isset($settings['currency_rules']) || ! is_array($settings['currency_rules'])) {
$context->logger->info('[ecommerce:1.0.5] mileage.currency_rules 없음 — 적립 절사 기준 백필 스킵');
return;
}
$rules = $settings['currency_rules'];
$filled = 0;
foreach ($rules as $idx => $rule) {
if (! is_array($rule)) {
continue;
}
$touched = false;
if (! array_key_exists(self::UNIT_KEY, $rule)) {
$rules[$idx][self::UNIT_KEY] = self::LEGACY_UNIT;
$touched = true;
}
if (! array_key_exists(self::METHOD_KEY, $rule)) {
$rules[$idx][self::METHOD_KEY] = self::LEGACY_METHOD;
$touched = true;
}
if ($touched) {
$filled++;
}
}
if ($filled === 0) {
$context->logger->info('[ecommerce:1.0.5] 적립 절사 기준 이미 기입됨 — 백필 스킵 (idempotent)');
return;
}
$settings['currency_rules'] = $rules;
File::put($path, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$context->logger->info(sprintf(
'[ecommerce:1.0.5] 적립 절사 기준 백필 완료 (%d개 통화 규칙 → %s / %s)',
$filled,
self::LEGACY_UNIT,
self::LEGACY_METHOD
));
}
/**
* mileage.json 의 저장 경로를 반환합니다.
*
* 테스트 환경에서는 운영 storage 오염을 막기 위해 framework/testing 경로를 사용합니다
* (EcommerceSettingsService 의 저장 경로 분기와 동일).
*
* @return string 설정 파일 절대 경로
*/
private function settingsFilePath(): string
{
$base = app()->runningUnitTests()
? 'framework/testing/modules/'
: 'app/modules/';
return storage_path($base.self::MODULE_IDENTIFIER.'/settings/mileage.json');
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13 -6
View File
@@ -11,6 +11,8 @@ import type { Route } from './routing/Router';
import { LayoutLoader, LayoutLoaderError } from './template-engine/LayoutLoader';
import type { InitActionDefinition, LayoutScript, ComputedSwitchDefinition } from './template-engine/LayoutLoader';
import { DataBindingEngine } from './template-engine/DataBindingEngine';
import { extractSingleBinding } from './template-engine/BindingShape';
import { hasPipes } from './template-engine/PipeRegistry';
import { evaluateRenderCondition } from './template-engine/helpers/RenderHelpers';
import { ComponentRegistry } from './template-engine/ComponentRegistry';
import { DataSourceManager } from './template-engine/DataSourceManager';
@@ -1963,12 +1965,13 @@ export class TemplateApp {
* extractValueByPathOrExpression(data, "{{data.items.map(i => i.id)}}", "cart")
*/
private extractValueByPathOrExpression(obj: any, pathOrExpression: string, sourceId: string): any {
// 표현식 패턴 확인: {{...}}
const expressionMatch = pathOrExpression.match(/^\{\{(.+)\}\}$/);
// 단일 바인딩 판정은 BindingShape 정본을 쓴다. 종전 greedy 정규식
// `^\{\{(.+)\}\}$` 은 `"{{a}}-{{b}}"` 같은 보간 문자열까지 단일 바인딩으로 오판해
// `a}}-{{b` 를 식으로 평가하려 했고, 그 결과는 조용한 undefined 였다.
// @since engine-v1.55.0
const expression = extractSingleBinding(pathOrExpression);
if (expressionMatch) {
// 표현식으로 평가
const expression = expressionMatch[1].trim();
if (expression !== null) {
const bindingEngine = new DataBindingEngine();
// 컨텍스트 구성: data 변수로 API 응답 접근 가능
@@ -1980,7 +1983,11 @@ export class TemplateApp {
};
try {
const result = bindingEngine.evaluateExpression(expression, context);
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 본다. @since engine-v1.55.0
const result = hasPipes(expression)
? bindingEngine.evaluatePipeExpression(expression, context, { skipCache: true })
: bindingEngine.evaluateExpression(expression, context);
logger.log(`initLocal/initGlobal expression evaluated: ${pathOrExpression} -> `, result);
return result;
} catch (error) {
@@ -17,6 +17,8 @@
*/
import { DataBindingEngine } from './DataBindingEngine';
import { extractSingleBinding } from './BindingShape';
import { hasPipes } from './PipeRegistry';
import { resolveExpressionString } from './helpers/RenderHelpers';
import { TranslationEngine, TranslationContext } from './TranslationEngine';
import { AuthManager, AuthType } from '../auth/AuthManager';
@@ -5392,11 +5394,21 @@ export class ActionDispatcher {
if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) {
try {
const innerExpr = trimmed.slice(2, -2).trim();
newComputed[key] = this.bindingEngine.evaluateExpression(
innerExpr,
computedContext,
{ skipCache: true }
);
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — 렌더 경로의
// computed 재계산(DynamicRenderer)과 같은 규칙이다. 한쪽만 고치면
// 같은 computed 가 렌더 직후와 액션 직후에 다른 값이 된다.
// @since engine-v1.54.10
newComputed[key] = hasPipes(innerExpr)
? this.bindingEngine.evaluatePipeExpression(
innerExpr,
computedContext,
{ skipCache: true }
)
: this.bindingEngine.evaluateExpression(
innerExpr,
computedContext,
{ skipCache: true }
);
} catch (e) {
// 평가 실패 시 기존 값 유지
newComputed[key] = currentComputed[key];
@@ -5442,11 +5454,19 @@ export class ActionDispatcher {
if (trimmed.startsWith('{{') && trimmed.endsWith('}}')) {
try {
const innerExpr = trimmed.slice(2, -2).trim();
newComputed[key] = this.bindingEngine.evaluateExpression(
innerExpr,
computedContext,
{ skipCache: true }
);
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — 위 setState
// 직후 재계산 블록과 같은 규칙이다. @since engine-v1.54.10
newComputed[key] = hasPipes(innerExpr)
? this.bindingEngine.evaluatePipeExpression(
innerExpr,
computedContext,
{ skipCache: true }
)
: this.bindingEngine.evaluateExpression(
innerExpr,
computedContext,
{ skipCache: true }
);
} catch (e) {
// 평가 실패 시 기존 값 유지
newComputed[key] = currentComputed[key];
@@ -6426,14 +6446,14 @@ export class ActionDispatcher {
private evaluateExpression(expr: string, dataContext?: any): any {
if (!dataContext) return expr;
// {{expression}} 패턴 매칭 — 단일 {{...}} 표현식만 매칭
const match = expr.match(/^\{\{(.+)\}\}$/);
// 복합 표현식 감지: 캡처 그룹 내에 }} 또는 {{가 포함되면
// 실제로는 {{A}}/text/{{B}} 형태의 복합 표현식임
// (greedy .+가 첫 번째 {{부터 마지막 }}까지 모두 캡처하기 때문)
const isSingleExpression = match && !match[1].includes('}}') && !match[1].includes('{{');
// 단일 `{{...}}` 판정은 BindingShape 정본을 쓴다. 종전에는 greedy 정규식
// (`^\{\{(.+)\}\}$`)이 `{{A}}/text/{{B}}` 까지 잡아, 캡처 안에 `{{`/`}}` 가 있는지
// 확인하는 가드를 덧대어 걸러냈다. 정본은 따옴표·중괄호 균형을 추적하므로
// 그 가드 없이도 같은 판정을 하고, 식 안의 객체 리터럴(`?? {}`)도 지킨다.
// @since engine-v1.55.0
const singleExpression = extractSingleBinding(expr);
if (!isSingleExpression) {
if (singleExpression === null) {
// {{}} 패턴이 아니거나 복합 표현식({{A}}/text/{{B}})인 경우
// resolveBindings가 각 {{...}} 블록을 개별 처리
// 복합 표현식에서도 최신 _global/_computed 상태 주입 (Stale Closure 방지)
@@ -6459,7 +6479,7 @@ export class ActionDispatcher {
return this.bindingEngine.resolveBindings(expr, effectiveContext, { skipCache: true });
}
let expression = match![1].trim();
let expression = singleExpression;
// $args.숫자 형태를 $args[숫자]로 변환 (예: $args.1 → $args[1])
expression = expression.replace(/\$args\.(\d+)/g, '$args[$1]');
@@ -6502,8 +6522,14 @@ export class ActionDispatcher {
}
try {
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 보므로 인자 있는 파이프는 예외로 아래 catch 에 걸려
// 원본 `{{...}}` 문자열이 그대로 서버로 전송되고, 인자 없는 파이프는
// 날짜 문자열이 `0` 이 되는 식의 조용한 오답이 된다. @since engine-v1.54.10
// DataBindingEngine.evaluateExpression을 사용하여 $t: 토큰 등을 올바르게 처리
const result = this.bindingEngine.evaluateExpression(expression, effectiveDataContext);
const result = hasPipes(expression)
? this.bindingEngine.evaluatePipeExpression(expression, effectiveDataContext, { skipCache: true })
: this.bindingEngine.evaluateExpression(expression, effectiveDataContext);
// 디버그 로그 (init_actions 바인딩 문제 진단용)
if (expression.includes('_global.modules')) {
@@ -0,0 +1,293 @@
/**
* 바인딩 식의 **형태 판정** 단일 출처
*
* "이 문자열이 단일 바인딩인가", "이 식이 경로인가 표현식인가 파이프인가" 를 판정하는
* 로직은 엔진 곳곳에 복제되어 있었고, 복사본마다 인식 문자 집합이 달라 **같은 식이
* 어느 렌더 경로를 타느냐에 따라 다른 결과**를 냈다. 판정은 이 모듈 하나로 모은다.
*
* 통일하는 것은 **판정뿐**이다. 캐시 정책·예외 처리·DevTools 추적·후처리(번역, raw 마커)는
* 지점마다 정당한 이유로 다르므로 각 호출 지점이 그대로 유지한다.
*
* 순수 함수만 두고 런타임 의존은 `PipeRegistry`(파이프 스캐너) 하나로 제한한다 —
* `DataBindingEngine` 은 `import type` 으로만 참조해 순환 import 를 만들지 않는다.
*
* @since engine-v1.55.0
*/
import { hasPipes } from './PipeRegistry';
import type { BindingContext, BindingOptions, DataBindingEngine } from './DataBindingEngine';
/**
* JavaScript 리터럴 값 매핑
*
* 경로 탐색(`resolve`)이 `true`/`null` 같은 리터럴을 컨텍스트 키로 오인하는 것을 막는다.
* `undefined` 는 `Map.get()` 의 기본 반환값과 구분되지 않으므로 `has()` 로 판정한다.
*/
export const LITERALS = new Map<string, unknown>([
['true', true],
['false', false],
['null', null],
['undefined', undefined],
]);
/**
* 바인딩 식의 형태
*
* - `empty`: `{{}}` — 식이 없음 (평가 대상 아님)
* - `literal`: `true` / `false` / `null` / `undefined`
* - `pipe`: 파이프(`|`)가 포함된 식
* - `expression`: 연산자·괄호·리터럴 등이 포함된 JS 표현식
* - `path`: 점/대괄호 경로 (`user.profile.name`, `items[0]`)
*/
export type ExpressionShape = 'empty' | 'literal' | 'pipe' | 'expression' | 'path';
/**
* 문자열 전체가 단일 `{{ }}` 바인딩인지 판정하고 내부 식을 반환
*
* 판정 기준(정본):
* - 따옴표(`'`, `"`, `` ` ``) 안의 중괄호는 세지 않는다 — `{{a['}']}}` 같은 식이 잘리지 않는다.
* - 따옴표 안의 백슬래시 이스케이프(`'\''`)는 다음 한 글자를 소비한다.
* - 중괄호 균형을 추적해, 외곽 바인딩이 중간에 닫히면(`"{{a}} {{b}}"`) 단일 바인딩이 아니다.
* - 균형이 맞지 않으면(`{{a} }}`) 단일 바인딩으로 보지 않는다.
* - 결과는 trim 한다.
*
* `{{}}` 는 빈 문자열을 반환한다 — "바인딩이긴 하나 식이 없음"이며, 호출 지점은
* `classifyExpression` 이 돌려주는 `'empty'` 로 이 상태를 구분해 처리한다.
*
* @param value 검사할 문자열
* @returns 단일 바인딩이면 내부 식(trim), 아니면 null
*/
export function extractSingleBinding(value: string): string | null {
if (typeof value !== 'string') return null;
if (!value.startsWith('{{') || !value.endsWith('}}') || value.length < 4) return null;
const inner = value.slice(2, -2);
let quote: string | null = null;
let depth = 0;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
if (quote !== null) {
if (char === '\\') {
i++; // 이스케이프된 문자 1개 소비
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
continue;
}
if (char === '{') {
depth++;
continue;
}
if (char === '}') {
if (depth > 0) {
depth--;
continue;
}
// 깊이 0 에서의 `}` → 외곽 바인딩이 중간에 닫혔거나 균형이 맞지 않는다.
return null;
}
}
return depth === 0 ? inner.trim() : null;
}
/**
* 문자열에서 찾은 `{{ }}` 바인딩 한 건
*/
export interface ScannedBinding {
/** 원본 문자열에서 `{{` 가 시작하는 인덱스 */
start: number;
/** 원본 문자열에서 `}}` 다음 인덱스 (slice 종료 지점) */
end: number;
/** `{{ }}` 를 벗긴 내부 식 (trim 하지 않은 원본) */
expr: string;
}
/**
* 문자열 안의 모든 `{{ }}` 바인딩 위치를 스캔
*
* 정규식 `\{\{([^}]+)\}\}` 은 식 안에 `}` 가 들어가면(`{{(row.meta ?? {}) | json}}`)
* 매칭에 실패한다. `String.replace` 는 매칭이 없으면 입력을 그대로 돌려주므로,
* 그 결과는 예외도 경고도 없이 **원본 `{{...}}` 문자열이 화면에 노출**되는 것이었다.
*
* 이 스캐너는 따옴표와 중괄호 깊이를 추적해 바인딩의 끝을 정확히 찾는다.
* 닫히지 않은 `{{` 는 바인딩으로 보지 않고 리터럴로 남긴다(종전 정규식과 같은 결과).
*
* @param template 검사할 문자열
* @returns 발견된 바인딩 목록 (등장 순서)
*
* @since engine-v1.55.1
*/
export function scanBindings(template: string): ScannedBinding[] {
const found: ScannedBinding[] = [];
if (typeof template !== 'string') return found;
for (let i = 0; i < template.length - 1; i++) {
if (template[i] !== '{' || template[i + 1] !== '{') continue;
let quote: string | null = null;
let depth = 0;
let closed = -1;
for (let j = i + 2; j < template.length; j++) {
const char = template[j];
if (quote !== null) {
if (char === '\\') {
j++;
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'" || char === '`') {
quote = char;
continue;
}
if (char === '{') {
depth++;
continue;
}
if (char === '}') {
if (depth > 0) {
depth--;
continue;
}
if (template[j + 1] === '}') {
closed = j;
break;
}
// 깊이 0 에서 짝이 맞지 않는 `}` — 바인딩이 아니다.
break;
}
}
if (closed === -1) continue; // 닫히지 않음 → 리터럴로 취급
found.push({ start: i, end: closed + 2, expr: template.slice(i + 2, closed) });
i = closed + 1; // 루프의 i++ 와 합쳐 바인딩 다음으로 이동
}
return found;
}
/**
* 복잡한 표현식 여부 판정
*
* true 면 `evaluateExpression`(JS 평가)으로, false 면 `resolve`(경로 탐색)로 보낸다.
*
* 인식 문자 집합은 종전 6개 방언 중 **최대집합**을 정본으로 삼는다. 좁은 방언은
* 배열/객체 리터럴(`['a','b']`)이나 따옴표 키 인덱싱(`query['sales_status[]']`)을
* "단순 경로" 로 오판해 경로 탐색으로 보냈고, 그 결과는 조용한 `undefined` 였다.
*
* `|`(파이프)도 집합에 포함되지만, 분류 순서상 파이프가 먼저 걸러지므로
* 파이프 식이 이 판정에 도달하지 않는다(`classifyExpression` 참조).
*
* @param expr 판정할 식 (`{{ }}` 제거된 상태)
* @returns 복잡한 표현식 여부
*/
export function isComplexExpression(expr: string): boolean {
return /[?:|&!+\-*/<>=()[\]{}]/.test(expr);
}
/**
* 바인딩 식의 형태 분류
*
* 분류 순서가 곧 라우팅 우선순위다: 빈 식 → 리터럴 → 파이프 → 표현식 → 경로.
* 리터럴을 파이프보다 먼저 보는 이유는 `true`/`null` 이 경로로 해석되면 안 되기 때문이고,
* 리터럴 문자열 자체에는 파이프가 들어갈 수 없어 순서 충돌이 없다.
*
* @param expr 분류할 식 (`{{ }}` 제거된 상태)
* @returns 식의 형태
*/
export function classifyExpression(expr: string): ExpressionShape {
const trimmed = typeof expr === 'string' ? expr.trim() : '';
if (trimmed === '') return 'empty';
if (LITERALS.has(trimmed)) return 'literal';
if (hasPipes(trimmed)) return 'pipe';
if (isComplexExpression(trimmed)) return 'expression';
return 'path';
}
/**
* 단일 바인딩 값 해석 옵션
*/
export interface ResolveSingleBindingOptions {
/** 캐시 사용 안 함 (반복 렌더 등) */
skipCache?: boolean;
/** DevTools 표현식 추적 정보 */
trackingInfo?: { componentId?: string; componentName?: string; propName?: string };
/**
* 평가 실패 시 호출된다. 반환값이 해석 결과가 된다.
* 미지정이면 예외를 그대로 전파한다 — 실패 정책은 호출 지점의 몫이다.
*/
onError?: (error: unknown, expr: string) => unknown;
/**
* 빈 바인딩(`{{}}`)을 만났을 때 호출된다. 미지정이면 조용히 `undefined` 를 반환한다.
*/
onEmpty?: (expr: string) => void;
// `format` 옵션은 두지 않는다.
//
// 이 함수는 **판정과 라우팅**만 담당하고 결과를 원본 타입으로 돌려준다. 서식은
// 호출 지점마다 규칙이 다르다 — `text` 노드는 문자열이 필요하고, prop 은 배열/객체/
// boolean 을 그대로 넘겨야 한다(원본 타입 소실이 engine-v1.54.9 에서 고친 결함이다).
// 여기에 서식을 얹으면 그 결함을 옵션 형태로 되살리는 셈이라, 서식은 호출 지점에 남긴다.
}
/**
* 단일 바인딩 식을 형태에 따라 알맞은 평가기로 보내 값을 해석
*
* 형태 판정(`classifyExpression`)과 라우팅만 담당하고, 실행은 전달받은 엔진에 위임한다.
* 결과는 **원본 타입**이다 — 문자열 서식이 필요한 호출 지점이 스스로 적용한다.
*
* @param expr 단일 바인딩 내부 식 (`{{ }}` 와 `raw:` 접두사가 제거된 상태)
* @param context 데이터 컨텍스트
* @param engine DataBindingEngine 인스턴스
* @param options 해석 옵션
* @returns 해석된 값 (빈 바인딩이면 undefined)
*/
export function resolveSingleBindingValue(
expr: string,
context: BindingContext,
engine: DataBindingEngine,
options?: ResolveSingleBindingOptions,
): unknown {
const trimmed = typeof expr === 'string' ? expr.trim() : '';
const shape = classifyExpression(trimmed);
const bindingOptions: BindingOptions | undefined = options?.skipCache
? { skipCache: true }
: undefined;
if (shape === 'empty') {
options?.onEmpty?.(expr);
return undefined;
}
if (shape === 'literal') {
return LITERALS.get(trimmed);
}
try {
if (shape === 'pipe') {
return engine.evaluatePipeExpression(trimmed, context, bindingOptions, options?.trackingInfo);
}
if (shape === 'expression') {
return engine.evaluateExpression(trimmed, context, options?.trackingInfo);
}
return engine.resolve(trimmed, context, bindingOptions, options?.trackingInfo);
} catch (error) {
if (options?.onError) return options.onError(error, trimmed);
throw error;
}
}
@@ -5,6 +5,132 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.56.4] - 2026-08-04
### Fixed
- 반복 렌더 경로(`renderItemChildren`)가 리터럴 단일 바인딩(`{{true}}` / `{{false}}` / `{{null}}` / `{{undefined}}`)을 경로로 탐색해 값이 `undefined` 가 됐다. 판정 통일(engine-v1.55.0)에서 리터럴을 `BindingShape` 한 곳으로 모았지만 이 경로만 `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접 갈라 리터럴을 몰랐고, 그 결과 같은 `{{true}}` 가 조건 자리에서는 `true`, 목록 셀·카드 등 반복 렌더 prop 자리에서는 `undefined` 로 갈렸다 — 통일이 없애려던 비대칭이 이 지점에만 남아 있었다. 형태 판정을 `resolveSingleBindingValue` 에 위임해 다른 경로와 같은 규칙을 쓴다.
- 저장소 레이아웃의 리터럴 단일 바인딩 60건은 전부 `if`/`condition` 자리라 화면 표시에는 변화가 없다. 브라우저 실측으로 발견했고(배포 번들의 `G7Core.renderItemChildren` 직접 호출), 같은 지점이 빈 바인딩(`{{}}`)도 몰라 경로 탐색으로 보내던 것을 함께 봉인했다.
## [engine-v1.56.3] - 2026-08-02
### Fixed
- 반복 렌더 경로(`renderItemChildren`)가 `raw:` 바인딩의 **번역 면제 마커를 벗기지 않고** React 로 넘겨, 화면에 Unicode Noncharacter 두 글자(`U+FDD0`/`U+FDD1`)가 그대로 실렸다. 마커는 번역 패스가 raw 값을 건너뛰게 하려는 내부 표식이므로 값이 화면으로 나가기 전에 제거돼야 한다. 단발 렌더 경로는 `resolveTranslationsDeep` 안에서 벗기지만 반복 경로에는 그 패스가 없었다.
- `rawMarkers.stripRawDeep()` 을 추가하고 반복 경로의 React 경계(props / children / 컴포넌트 id) 전부에 적용했다. 마커가 없으면 원본 참조를 그대로 돌려주므로 반복 렌더에서 추가 할당이 생기지 않는다. 배열·객체 리프까지 재귀하되 이미 렌더된 React 엘리먼트는 순회하지 않는다.
## [engine-v1.56.2] - 2026-08-02
### Fixed
- `DataBindingEngine.evaluateExpression` — 컨텍스트에 함수 파라미터 이름이 될 수 없는 키(`sales_status[]`, `data-id`, 예약어 등)가 **하나라도** 있으면 평가 함수 생성 자체가 SyntaxError 로 실패해, 그 키를 쓰지 않는 식까지 **같은 컨텍스트에서는 전부** 평가되지 못했다. 이제 그런 키를 파라미터 목록에서 제외한다. 제외해도 잃는 것은 없다 — 식 안에서 맨이름으로 참조할 수 없는 키였고, 실제 작성은 `query['sales_status[]']` 처럼 상위 객체를 거치므로 그대로 동작한다.
- 증상은 예외나 화면 오류가 아니라 **값이 비어 보이는 것**이었다(평가 실패 → 폴백). 판정 통일(engine-v1.55.0) 이후 이 경로로 들어오는 식이 늘면서 노출 범위가 넓어졌다.
## [engine-v1.56.1] - 2026-08-02
### Added
- `G7Core.evaluateCondition(condition, extraContext?)` — 템플릿 컴포넌트가 조건 문자열을 엔진과 같은 규칙으로 평가할 수 있게 노출한다. 전역/로컬/계산 상태를 컨텍스트에 병합해 넘기므로, `{{_local.expanded}}` 같은 조건도 컴포넌트에서 그대로 쓸 수 있다.
### Changed
- `TranslationEngine` 의 `$t:` 파라미터 값 평가가 `new Function` 대신 `DataBindingEngine.evaluateExpression` 을 쓴다. 종전 자체 평가기는 `$localized(...)`·`$t(...)`·`$uuid()` 헬퍼와 optional chaining 전처리, 표현식 함수 캐시를 쓰지 못했고 실패 시 조용히 빈 문자열을 돌려줬다 — 번역 문구에서 값만 사라지므로 원인 파악이 어려웠다.
- `$t:key|name=값` 의 `|` 는 파라미터 구분자이므로 이 자리에서는 파이프를 지원하지 않는다(별개 문법 영역). 파이프 분기 스캔의 면제 목록에 사유와 함께 등재했다.
## [engine-v1.56.0] - 2026-08-02
### Fixed
#### `iteration` 과 `if` 를 함께 쓴 목록이 통째로 렌더되지 않던 문제
- `DynamicRenderer` — `if` 가 항목 변수(`{{user.is_active}}` 등)를 참조하면, 조건 평가가 iteration 분기보다 먼저 걸리는데 그 시점 컨텍스트에는 항목 변수가 없어 조건이 항상 false 가 됐다. 결과는 **목록 전체 미렌더**이며 예외도 경고도 남지 않는다. 이제 iteration 이 있으면 부모 시점 조건으로 끊지 않고, 항목별 컨텍스트에서 자식이 같은 `if` 를 다시 평가한다. 같은 작성이 반복 렌더 경로(`cellChildren` 등)에서는 정상 동작했기 때문에 재현 위치를 특정하기 어려운 결함이었다.
- 외곽 변수만 참조하는 `if` 는 결과가 종전과 같다(항목 수만큼 재평가되지만 렌더 사이클 캐시가 흡수한다).
#### 반복 렌더 경로에서 문자열 중간의 `$t:` 가 번역되지 않던 문제
- `RenderHelpers.renderItemChildren` — 종전에는 `$t:` 로 **시작**하는 문자열만 번역해서 `"27$t:admin.count_suffix"` 같은 중간 토큰이 원본 키 그대로 화면에 노출됐다(반복 서브트리 내 실사용 2건). 일반 컴포넌트 경로는 이미 처리하던 형태다. JSON 구조 문자열(`{...}`/`[...]`) 안의 `$t:` 는 데이터의 일부이므로 번역하지 않는 가드도 함께 이식했다.
- 이 처리는 `$t:defer:` 분기보다 **뒤에** 둔다 — `$t:defer:` 는 이 경로가 항목 컨텍스트와 함께 처리하도록 남겨 둔 것이고, 반복 서브트리 내 실사용 23건이 그 순서에 의존한다.
- 표현식 평가 결과가 배열·객체인 경우에도 리프 문자열의 `$t:` 까지 번역한다. 종전에는 문자열 결과만 검사해 배열 안의 키가 번역되지 않았다.
### Added
- 반복 렌더 경로가 `$switch`/`$cases` 객체를 값으로 해석한다. 종전에는 객체가 그대로 컴포넌트에 전달되어 조용히 아무 일도 일어나지 않았다(일반 경로는 지원). 평가는 `DataBindingEngine.resolveSwitch` 에 위임한다.
- 반복 렌더 경로도 액션 정의 객체를 선평가하지 않는다. 콜백 prop 에 담긴 액션 정의를 렌더 시점에 해소하면 내부 `{{_local.xxx}}` 가 그 시점 값으로 고정되어, 실행 시점의 최신 상태를 보지 못한다.
- `DynamicRenderer` 의 iteration 에 `{item_var}_index` 자동 변수를 주입한다(반복 렌더 경로에는 이미 있던 것).
두 `Added` 항목과 `_index` 주입은 저장소 실사용 0건으로, 같은 작성이 경로에 따라 다르게 동작하는 것을 없애기 위한 정렬이다.
## [engine-v1.55.1] - 2026-08-02
### Fixed
#### 보간 문자열의 바인딩 탐색을 정규식에서 스캐너로 교체
- `DataBindingEngine.resolveBindings` — 바인딩 위치를 `BindingShape.scanBindings()` 로 찾는다. 종전 정규식(`\{\{([^}]+)\}\}`)은 식 안에 `}` 가 들어가면 매칭하지 못했고, `String.replace` 는 매칭이 없을 때 입력을 그대로 돌려주므로 `"a {{x ?? {}}} b"` 같은 문자열이 **원본 `{{...}}` 그대로 화면에 노출**됐다. 스캐너는 따옴표와 중괄호 깊이를 추적하며, 닫히지 않은 `{{` 는 종전처럼 리터럴로 남긴다.
#### 표현식 하나의 실패가 컴포넌트의 props 해석 전체를 중단시키던 문제
- `DataBindingEngine.resolveObject` — key 단위로 실패를 격리한다. 이 메서드에는 상위 catch 가 없어서, 표현식 하나가 던지면 그 컴포넌트의 props 가 통째로 해석되지 않았다(중첩 prop 표현식은 특히 렌더 도중 throw 로 이어졌다). 이제 실패한 key 만 `undefined` 가 되고 경고가 남으며, 나머지 key 와 형제 컴포넌트는 정상 렌더된다. 중첩 객체는 재귀 호출이 같은 규칙으로 처리하므로 실패가 가장 안쪽 key 에서 멈춘다.
### Changed
- `DynamicRenderer` 의 중첩 객체 해석에도 컴포넌트별 `skipBindingKeys` 를 전달한다. 종전에는 전달하지 않아 코어 기본 4개만 적용됐고, 컴포넌트가 "내가 직접 반복 렌더한다" 고 선언한 키가 중첩 위치에서는 선평가되어 항목 컨텍스트 없이 stale 값으로 고정될 수 있었다. 저장소 전수 스캔 결과 해당 위치는 **0건**이라 예방 목적의 변경이다(선언된 키: `DataGrid`·`CardGrid`·`RichSelect`·`DropdownButton`).
## [engine-v1.55.0] - 2026-08-02
### Changed
#### 바인딩 식의 형태 판정을 단일 모듈로 통일
같은 작성 문법이 **어느 렌더 경로를 타느냐에 따라 다른 결과**를 내던 원인은 판정 로직이 복제되면서 복사본마다 인식 문자 집합이 갈라졌기 때문이다. "이 문자열이 단일 바인딩인가" 를 판정하는 구현이 4종 + 정규식 4종, "이 식이 표현식인가 경로인가" 를 판정하는 정규식이 6종 있었다. `template-engine/BindingShape.ts` 로 판정을 모으고 14개 지점이 이를 쓰도록 바꿨다.
통일한 것은 **판정뿐**이다. 캐시 정책·예외 처리·DevTools 추적·후처리(번역, raw 마커)는 지점마다 정당한 이유로 다르므로 그대로 두었다.
- **단일 바인딩 판정**: 따옴표(`'` `"` `` ` ``) 안의 중괄호를 세지 않고, 따옴표 안의 백슬래시 이스케이프를 소비하며, 중괄호 균형을 추적한다. 종전 greedy 정규식(`^\{\{(.+)\}\}$`)은 `"{{a}}-{{b}}"` 같은 보간 문자열까지 단일 바인딩으로 오판했다 — 액션 경로는 덧댄 가드로 막고 있었지만 데이터소스 params 와 `initLocal`/`initGlobal` 값 추출에는 그 가드가 없었다.
- **표현식/경로 판정**: 종전 6개 방언 중 **최대집합**을 정본으로 삼았다. 좁은 방언은 `query['sales_status[]']` 같은 따옴표 키 인덱싱과 배열/객체 리터럴을 "단순 경로" 로 오판해 경로 탐색으로 보냈고, 결과는 조용한 `undefined` 였다. 저장소 전수 스캔 기준 **63개 식**의 라우팅이 바뀐다(따옴표 대괄호 22, 숫자 인덱싱 39, 기타 2). 숫자 인덱싱은 두 경로의 결과가 동치임을 4개 컨텍스트(존재/범위밖/null/중첩 미정의)로 확인했고, 따옴표 대괄호 22건은 **지금까지 `undefined` 였던 값이 실제 값으로 나타난다**.
- **빈 바인딩(`{{}}`) 봉인**: `undefined` + 경고로 처리한다. 종전에는 빈 경로가 경로 탐색으로 들어가 **컨텍스트 객체 전체**가 값으로 반환됐고, 그것이 문자열로 서식되면 전역 상태가 통째로 화면이나 요청에 실릴 수 있었다. 안전망으로 `resolvePath` 진입 시에도 빈 경로를 차단한다. 레이아웃 실사용은 0건이며 런타임에 조립되는 문자열에서 발생할 수 있는 경로다.
- 리터럴(`true`/`false`/`null`/`undefined`) 처리도 한 곳으로 모았다. 종전에는 조건 두 지점만 리터럴을 알아 `{{true}}` 가 prop 자리에서는 경로로 탐색돼 `undefined` 가 됐다. 저장소의 리터럴 단일 바인딩 60건은 전부 `if`/`condition` 자리라 실제 표시에는 변화가 없다.
- 선행 하네스: `__tests__/BindingShape.routingParity.test.ts` — 저장소의 모든 레이아웃 JSON 에서 단일 바인딩을 수집해 **구 방언들과 신 정본의 판정을 대조**하고, 판정이 바뀌는 식 전체를 소속 파일과 함께 스냅샷으로 고정한다. 새 레이아웃이 라우팅 변경 대상 식을 도입하면 이 테스트가 깨진다.
## [engine-v1.54.10] - 2026-08-02
### Fixed
#### 파이프(`|`)를 해석하지 않고 JS 비트 OR 로 평가하던 지점 8곳
`{{식 | 파이프}}` 를 `evaluateExpression` 으로 보내면 `|` 가 JS 비트 OR 로 평가된다. 인자 있는 파이프(`date('YYYY-MM-DD')`)는 함수가 아니라며 예외를 던지고, 인자 없는 파이프(`| number`)는 날짜 문자열이 `0` 이 되는 식의 조용한 오답이 된다. engine-v1.54.3 이 렌더 경로를 정리했으나 아래 지점들은 남아 있었다. 모두 `hasPipes()` 선분기 + `evaluatePipeExpression` 으로 정렬했다.
- **액션 params** (`ActionDispatcher`) — 예외가 catch 에 걸리면 catch 가 **원본 `{{...}}` 문자열을 그대로 반환**했고, 그 값이 요청 본문·쿼리스트링에 실려 서버로 전송됐다. 화면에는 아무 표시도 나지 않는다.
- **데이터소스 params** (`DataSourceManager`) — 동일. 쿼리스트링에 `%7B%7B` 로 인코딩된 리터럴이 실렸다.
- **`iteration` 의 `source`** (`RenderHelpers.resolveIterationSource`) — 결과가 배열이 아니게 되어 반복 대상이 사라진다. **목록 전체가 렌더되지 않으며** 예외도 경고도 남지 않는다. DynamicRenderer 와 `renderItemChildren` 이 공유하는 함수라 한 곳 수정으로 양 경로가 함께 해소된다.
- **`expandContext`** (`G7CoreGlobals.renderExpandContent`) — 확장 행에 넘기는 값에 파이프가 적용되지 않았다.
- **computed 정의식** — 렌더 경로(`DynamicRenderer`)와 액션 경로(`ActionDispatcher` 의 setState 직후·커스텀 핸들러 직후 재계산 2곳), 그리고 레이아웃 편집기 미리보기(`computedRecipeEngine`)까지 네 곳이 같은 결함을 복제하고 있었다. 셋 다 실패 시 "이전 값 유지" 라 computed 가 영원히 갱신되지 않는다. 한 곳만 고치면 같은 computed 가 렌더 직후와 액션 직후에 다른 값이 되므로 함께 정렬했다.
- **`slot` 표현식** (`DynamicRenderer`) — 평가 결과가 문자열이 아니게 되어 `typeof result === 'string'` 가드에 걸려 슬롯 미등록으로 조용히 흡수됐다(그 컴포넌트가 화면에서 사라진다).
`blur_until_loaded` 는 종전대로 파이프를 지원하지 않는다 — truthiness 게이트라 파이프 결과(대개 비어있지 않은 문자열)가 항상 참이 되어 블러가 영구히 켜진다.
- 회귀 방지선: 표현식 평가 지점을 **모집단으로 스캔**하는 테스트를 추가했다(`__tests__/pipeBranchParity.test.ts`). 엔진 소스에서 `evaluateExpression` 호출 지점을 전부 찾아 파이프 분기 가드 유무를 검사하고, 의도적 미지원 지점만 사유와 함께 면제 목록에 둔다. 면제 항목이 코드에서 사라지면 그것도 실패한다. 실제로 이 스캔이 중복 블록 하나(액션 커스텀 핸들러 직후 재계산)를 잡아냈다.
- 기존 동작 무영향: 레이아웃 전수 스캔 기준 이 8지점의 파이프 실사용은 0건이다. 논리 OR(`||`)과 따옴표 안의 `|`(다국어 파라미터 구분자)는 종전대로 파이프로 인식하지 않는다.
## [engine-v1.54.9] - 2026-08-02
### Fixed
#### 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건
engine-v1.54.3 이 파이프 분기를 `resolveBindings(\`{{식}}\`)` 위임으로 처리하면서, 값이 필요한 지점까지 "문자열을 조립해 보간기에 넣고 문자열을 돌려받는" 경로를 타게 됐다. 평가 자체를 `DataBindingEngine.evaluatePipeExpression()` 으로 분리하고, 값이 필요한 6개 호출 지점이 이 메서드를 직접 호출하도록 바꿨다. `resolveBindings` 는 그 결과에 `formatValue` 를 적용해 종전과 같은 문자열을 돌려준다 — 보간의 계약은 그대로다.
- **식 안에 중괄호가 있으면 원본 `{{...}}` 문자열이 화면에 노출됐다.** 위임 대상인 `BINDING_PATTERN`(`/\{\{([^}]+)\}\}/g`)은 `}` 를 포함한 식을 매칭하지 못하고, `String.replace` 는 매칭이 없으면 입력을 그대로 돌려준다. `{{(row.meta ?? {}) | json}}` 같은 식이 평가되지 않은 채 그대로 렌더됐다. 예외도 경고도 남지 않는다. 이제 문자열 조립을 거치지 않으므로 정규식의 한계와 무관하다(`BINDING_PATTERN` 자체의 교정은 별건이다 — 보간 경로에는 아직 남아 있다).
- **`_computed` 파이프가 컴포넌트 간 stale 값을 전파했다.** `DynamicRenderer` 의 props 해석에서 파이프 분기만 `getComputedAwareOptions` 를 거치지 않아, 다른 분기가 `skipCache` 로 회피하던 영구 캐시(30초)에 `_computed.x` 가 저장됐다. `_computed` 는 컴포넌트마다 `_local` 기반으로 재계산되므로, 먼저 렌더된 컴포넌트의 값이 뒤따르는 컴포넌트에 그대로 나타났다. 이제 파이프 분기도 같은 옵션을 쓴다(`$computed` alias 포함).
- **파이프 결과가 항상 문자열로 서식됐다.** `resolveBindings` 는 보간이 목적이라 `formatValue` 를 적용한다. 그 결과 `{{row.tags | keys}}` 가 배열이 아니라 `"[\"a\",\"b\"]"` 로, `{{row.flags | first}}` 가 `false` 대신 `"false"`(truthy!) 로 prop·조건에 전달됐다. 값이 필요한 지점은 이제 원본 타입을 받는다 — 파이프가 없는 단일 바인딩(`resolve`)·복합식(`evaluateExpression`)과 같은 규칙이다.
전환한 호출 지점: `DataBindingEngine.resolveObject` / `DynamicRenderer` 의 props·`text` / `ConditionEvaluator.evaluateStringCondition` / `RenderHelpers` 의 `evaluateIfCondition`·`renderItemChildren`.
- 실패 정책도 지점별로 정렬했다. `evaluatePipeExpression` 은 평가 실패를 그대로 던지고, 각 호출 지점이 같은 자리의 복합식 분기와 동일하게 처리한다(경고 후 `undefined` / `''` / 조건 `false`). 종전에는 위임 대상이 예외를 삼키고 원본 `{{...}}` 문자열을 돌려주어, 조건 자리에서는 그 문자열이 truthy 로 평가돼 반대 분기를 탔다. `resolveObject` 는 상위 catch 가 없으므로 key 단위로 격리한다.
- 조건 두 지점(`evaluateStringCondition`, `evaluateIfCondition`)은 파이프 식의 `raw:` 접두사를 제거한 뒤 평가한다. 위임 형태에서는 `resolveBindings` 가 접두사를 벗겨 줬으나 직접 호출에서는 그 단계가 없다. `rawMarkers.stripRawPrefix()` 로 공통화했다.
- 기존 동작 무영향: 레이아웃 전수 스캔 기준 파이프 단일 바인딩은 32건(고유 29)이며 **전부 노드 `text`**, 사용 파이프는 `date`/`datetime`/`number` 로 모두 문자열을 반환한다. 반복 렌더 서브트리·액션 params·데이터소스 params 내 파이프와 중괄호를 포함한 파이프 식은 각각 0건이다.
## [engine-v1.54.8] - 2026-08-01
### Fixed
@@ -11,11 +11,30 @@
import { createLogger } from '../utils/Logger';
import { TranslationEngine } from './TranslationEngine';
import { hasPipes, splitPipes, executePipeChain } from './PipeRegistry';
import { classifyExpression, extractSingleBinding, isComplexExpression, LITERALS, scanBindings } from './BindingShape';
import { RAW_PREFIX, wrapRaw, wrapRawDeep } from './rawMarkers';
import type { G7DevToolsInterface } from './G7CoreGlobals';
const logger = createLogger('DataBindingEngine');
/**
* 함수 파라미터 이름으로 쓸 수 있는 식별자 패턴.
*
* 표현식 평가는 컨텍스트 키를 `new Function` 의 파라미터로 넘긴다. 파라미터가 될 수 없는
* 이름이 하나라도 섞이면 함수 생성이 통째로 실패하므로 미리 걸러낸다.
*/
const VALID_IDENTIFIER_PATTERN = /^[$A-Za-z_][$A-Za-z0-9_]*$/;
/** 파라미터 이름으로 쓸 수 없는 예약어 (엄격 모드 예약어 포함) */
const RESERVED_WORDS = new Set([
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for',
'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let',
'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static',
'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void',
'while', 'with', 'yield', 'arguments', 'eval',
]);
/**
* G7Core.devTools 인터페이스 가져오기
*
@@ -79,6 +98,39 @@ export interface BindingOptions {
skipCache?: boolean;
}
/**
* DevTools 표현식 추적 정보
*
* 평가 지점(컴포넌트/prop)을 DevTools 로그에 남기기 위한 부가 정보.
* 값 해석 결과에는 영향을 주지 않는다.
*/
export interface ExpressionTrackingInfo {
/**
* 평가를 요청한 컴포넌트 ID
*/
componentId?: string;
/**
* 평가를 요청한 컴포넌트 이름
*/
componentName?: string;
/**
* 평가 대상 prop 이름
*/
propName?: string;
/**
* DevTools 로그에 기록할 평가 진입점 이름 (기본: 호출된 메서드명)
*/
method?: string;
/**
* DevTools 로그에 표시할 원본 식 (기본: `{{expr}}`)
*/
displayExpression?: string;
}
/**
* 캐시 엔트리 인터페이스
*/
@@ -114,13 +166,6 @@ export class DataBindingError extends Error {
* {{variable}} 문법을 파싱하고 실제 데이터로 치환
*/
export class DataBindingEngine {
/**
* 바인딩 패턴 정규식
*
* {{variable}}, {{object.property}}, {{array[0]}} 등을 매칭
*/
private static readonly BINDING_PATTERN = /\{\{([^}]+)\}\}/g;
/**
* 경로 파싱 정규식
*
@@ -285,7 +330,7 @@ export class DataBindingEngine {
? { ...context, $computed: context._computed }
: context;
return template.replace(DataBindingEngine.BINDING_PATTERN, (_match, path) => {
const replaceBinding = (_match: string, path: string): string => {
// 공백 제거
const trimmedPath = path.trim();
@@ -299,82 +344,16 @@ export class DataBindingEngine {
}
// 파이프 함수 처리 (예: {{post.created_at | date}})
// 파이프가 있으면 먼저 분리하여 처리
// 평가 자체는 evaluatePipeExpression 이 담당하고, 여기서는 보간의 목적에 맞게
// 결과를 문자열로 서식한다. @since engine-v1.54.9
if (hasPipes(effectivePath)) {
const pipeStartTime = devTools?.isEnabled() ? performance.now() : 0;
try {
const [baseExpr, pipes] = splitPipes(effectivePath);
// 기본 표현식 평가 (파이프 적용 전)
let value: any;
let fromCache = false;
const isBaseExpression = /[?:|&!+\-*/<>=()]/.test(baseExpr) || /\[['"]/.test(baseExpr);
// 동적 경로 여부 판단
const isDynamicPipeBase = baseExpr.startsWith('_global') ||
baseExpr.startsWith('_local') ||
baseExpr.startsWith('_isolated') ||
baseExpr.startsWith('$parent');
if (opts.skipCache) {
// skipCache: 모든 캐시 무시
if (isBaseExpression) {
value = this.evaluateExpression(baseExpr, extendedContext);
} else {
value = this.resolvePath(baseExpr, extendedContext, opts);
}
} else if (isBaseExpression) {
// 복잡한 표현식: 렌더 사이클 캐시 사용
const exprCached = this.getFromRenderCycleCache(`expr:${baseExpr}`);
if (exprCached !== undefined) {
value = exprCached;
fromCache = true;
} else {
value = this.evaluateExpression(baseExpr, extendedContext);
this.saveToRenderCycleCache(`expr:${baseExpr}`, value);
}
} else if (isDynamicPipeBase) {
// 동적 경로: 렌더 사이클 캐시 사용
const renderCycleCached = this.getFromRenderCycleCache(baseExpr);
if (renderCycleCached !== undefined) {
value = renderCycleCached;
fromCache = true;
} else {
value = this.resolvePath(baseExpr, extendedContext, opts);
this.saveToRenderCycleCache(baseExpr, value);
}
} else {
// 정적 경로: 영구 캐시 사용
const cached = this.getFromCache(baseExpr);
if (cached !== undefined) {
value = cached;
fromCache = true;
} else {
value = this.resolvePath(baseExpr, extendedContext, opts);
this.saveToCache(baseExpr, value);
}
}
// 파이프 체인 실행
const result = executePipeChain(value, pipes);
// DevTools: 파이프 표현식 평가 추적
if (devTools?.isEnabled()) {
const duration = performance.now() - pipeStartTime;
devTools.trackExpressionEval({
expression: `{{${trimmedPath}}}`,
result: this.sanitizeResultForTracking(result),
resultType: this.getResultType(result),
componentId: trackingInfo?.componentId,
componentName: trackingInfo?.componentName,
propName: trackingInfo?.propName,
fromCache,
duration,
method: 'resolveBindings',
skipCache: opts.skipCache,
});
}
const formatted = this.formatValue(result);
const result = this.evaluatePipeExpression(effectivePath, extendedContext, opts, {
...trackingInfo,
method: 'resolveBindings',
displayExpression: `{{${trimmedPath}}}`,
});
const formatted = this.formatValue(result as ResolvedValue);
return isRawBinding ? wrapRaw(formatted) : formatted;
} catch (error) {
logger.error('Pipe expression evaluation failed:', effectivePath, error);
@@ -386,7 +365,10 @@ export class DataBindingEngine {
// 대괄호 안에 따옴표가 있는 문자열 키 접근도 표현식으로 처리 (예: query['filters[0][field]'])
// 함수 호출 패턴도 표현식으로 처리 (예: $localized(...))
// 주의: 단일 | (파이프)는 위에서 먼저 처리되므로 여기서는 || 만 표현식으로 인식
const isExpression = /[?:&!+\-*/<>=()]/.test(effectivePath) || /\|\|/.test(effectivePath) || /\[['"]/.test(effectivePath);
// 판정은 BindingShape 정본을 쓴다 — 종전에는 이 자리의 문자 집합이 다른 렌더
// 경로들과 달라, 같은 식이 여기서는 경로 탐색으로 가고 저기서는 표현식으로 가는
// 비대칭이 있었다. 파이프는 위에서 이미 갈라졌다. @since engine-v1.55.0
const isExpression = isComplexExpression(effectivePath);
if (isExpression) {
// JavaScript 표현식으로 평가
@@ -512,7 +494,133 @@ export class DataBindingEngine {
const formatted = this.formatValue(value);
return isRawBinding ? wrapRaw(formatted) : formatted;
});
};
// 바인딩 위치는 수동 스캐너로 찾는다 — 정규식(`\{\{([^}]+)\}\}`)은 식 안에 `}` 가
// 들어가면 매칭에 실패하고, `String.replace` 는 그때 입력을 그대로 돌려주어
// 원본 `{{...}}` 문자열이 조용히 화면에 노출됐다. @since engine-v1.55.1
const bindings = scanBindings(template);
if (bindings.length === 0) return template;
let output = '';
let cursor = 0;
for (const binding of bindings) {
output += template.slice(cursor, binding.start);
output += replaceBinding(template.slice(binding.start, binding.end), binding.expr);
cursor = binding.end;
}
return output + template.slice(cursor);
}
/**
* 파이프(`|`)가 포함된 단일 바인딩 식을 평가해 **원본 타입 그대로** 반환
*
* `resolveBindings` 는 보간(문자열 산출)이 목적이라 결과에 `formatValue` 를 적용한다.
* 반면 prop 값·`if` 조건·반복 렌더처럼 값 자체가 필요한 자리에서는 배열이
* `"[\"a\",\"b\"]"` 로, `false` 가 `"false"`(truthy) 로 바뀌면 안 된다.
* 그런 지점은 `resolveBindings(\`{{...}}\`)` 로 우회하지 말고 이 메서드를 직접 호출한다.
*
* 문자열 조립을 거치지 않으므로 `{{(row.meta ?? {}) | json}}` 처럼 식 안에 중괄호가
* 있는 경우에도 정상 평가된다 — `BINDING_PATTERN` 은 `}` 를 포함한 식을 매칭하지 못해
* 위임 방식에서는 입력 문자열이 그대로 화면에 노출됐다.
*
* @param expr `{{ }}` 와 `raw:` 접두사를 제거한 파이프 식 (예: `row.tags | slice(0,3)`)
* @param context 데이터 컨텍스트
* @param options 바인딩 옵션 (skipCache 등)
* @param trackingInfo DevTools 표현식 추적 정보 (선택적)
* @returns 파이프 체인 실행 결과 (원본 타입)
* @throws 기본 식 평가 또는 파이프 실행 실패 시 그대로 전파 — 호출 지점이 자신의
* 실패 정책(경고 후 undefined 등)을 적용한다
*
* @since engine-v1.54.9
*/
public evaluatePipeExpression(
expr: string,
context: BindingContext,
options?: BindingOptions,
trackingInfo?: ExpressionTrackingInfo,
): unknown {
const opts = { ...this.defaultOptions, ...options };
const devTools = getDevTools();
const pipeStartTime = devTools?.isEnabled() ? performance.now() : 0;
// $computed alias 추가 (resolveBindings 와 동일 규칙)
const extendedContext: BindingContext = context._computed && !context.$computed
? { ...context, $computed: context._computed }
: context;
const [baseExpr, pipes] = splitPipes(expr);
// 기본 표현식 평가 (파이프 적용 전)
let value: any;
let fromCache = false;
const isBaseExpression = isComplexExpression(baseExpr);
// 동적 경로 여부 판단
const isDynamicPipeBase = baseExpr.startsWith('_global') ||
baseExpr.startsWith('_local') ||
baseExpr.startsWith('_isolated') ||
baseExpr.startsWith('$parent');
if (opts.skipCache) {
// skipCache: 모든 캐시 무시
if (isBaseExpression) {
value = this.evaluateExpression(baseExpr, extendedContext);
} else {
value = this.resolvePath(baseExpr, extendedContext, opts);
}
} else if (isBaseExpression) {
// 복잡한 표현식: 렌더 사이클 캐시 사용
const exprCached = this.getFromRenderCycleCache(`expr:${baseExpr}`);
if (exprCached !== undefined) {
value = exprCached;
fromCache = true;
} else {
value = this.evaluateExpression(baseExpr, extendedContext);
this.saveToRenderCycleCache(`expr:${baseExpr}`, value);
}
} else if (isDynamicPipeBase) {
// 동적 경로: 렌더 사이클 캐시 사용
const renderCycleCached = this.getFromRenderCycleCache(baseExpr);
if (renderCycleCached !== undefined) {
value = renderCycleCached;
fromCache = true;
} else {
value = this.resolvePath(baseExpr, extendedContext, opts);
this.saveToRenderCycleCache(baseExpr, value);
}
} else {
// 정적 경로: 영구 캐시 사용
const cached = this.getFromCache(baseExpr);
if (cached !== undefined) {
value = cached;
fromCache = true;
} else {
value = this.resolvePath(baseExpr, extendedContext, opts);
this.saveToCache(baseExpr, value);
}
}
// 파이프 체인 실행
const result = executePipeChain(value, pipes);
// DevTools: 파이프 표현식 평가 추적
if (devTools?.isEnabled()) {
const duration = performance.now() - pipeStartTime;
devTools.trackExpressionEval({
expression: trackingInfo?.displayExpression ?? `{{${expr}}}`,
result: this.sanitizeResultForTracking(result),
resultType: this.getResultType(result),
componentId: trackingInfo?.componentId,
componentName: trackingInfo?.componentName,
propName: trackingInfo?.propName,
fromCache,
duration,
method: trackingInfo?.method ?? 'evaluatePipeExpression',
skipCache: opts.skipCache,
});
}
return result;
}
/**
@@ -668,6 +776,14 @@ export class DataBindingEngine {
depth: number = 0,
visitedObjects: WeakSet<object> = new WeakSet(),
): ResolvedValue {
// 빈 경로 안전망 — 세그먼트가 하나도 없으면 아래 루프를 돌지 않아
// 컨텍스트 객체 **전체**가 값으로 반환된다. 그 값이 문자열로 서식되면
// 전역 상태가 통째로 화면·요청에 실릴 수 있다. @since engine-v1.55.0
if (typeof path !== 'string' || path.trim() === '') {
logger.warn('resolvePath: 빈 경로가 전달되었습니다 — undefined 로 해석합니다.');
return undefined;
}
// 최대 깊이 체크 (순환 참조 방지)
if (options.maxDepth && depth > options.maxDepth) {
if (options.detectCircular) {
@@ -844,7 +960,7 @@ export class DataBindingEngine {
* @param obj 검사할 객체
* @returns 액션 정의이면 true
*/
private isActionDefinition(obj: Record<string, any>): boolean {
public isActionDefinition(obj: Record<string, any>): boolean {
if (typeof obj.handler !== 'string') return false;
return (
obj.params !== undefined ||
@@ -855,43 +971,6 @@ export class DataBindingEngine {
);
}
/**
* 문자열 전체가 단일 `{{...}}` 바인딩인지 판정하고, 그렇다면 내부 식을 반환합니다.
*
* 종전 `^\{\{([^}]+)\}\}$` 정규식은 식 안에 `}` 가 든 경우(예: 빈 객체 fallback
* `{{error.errors ?? {}}}`)를 단일 바인딩으로 인식하지 못해 원본 문자열이 그대로
* 새어 나갔다. 본 메서드는 외곽 `{{ }}` 안의 중괄호 균형(depth)을 추적해, 내부에서
* 바인딩이 조기 종료(`}}`)되지 않고 끝까지 단일 식으로 닫히는 경우에만 그 식을 반환한다.
* `"{{a}} {{b}}"` 처럼 두 개 이상의 바인딩이면 null 을 반환해 문자열 보간 경로로 보낸다.
*
* @param value 검사할 문자열
* @returns 단일 바인딩이면 내부 식 문자열, 아니면 null
*/
private extractSingleBinding(value: string): string | null {
if (!value.startsWith('{{') || !value.endsWith('}}') || value.length < 5) {
return null;
}
const inner = value.slice(2, -2);
// inner 를 스캔하며 `}}` 가 깊이 0(외곽 바인딩 종료)에서 등장하면 단일 바인딩 아님.
// `{` / `}` 로 깊이를 추적하되, 식 내부의 `{}`(객체 리터럴)는 균형이 맞으므로
// 깊이가 음수로 떨어지지 않는 한 단일 바인딩으로 본다.
let depth = 0;
for (let i = 0; i < inner.length - 1; i++) {
const c = inner[i];
if (c === '{') {
depth++;
} else if (c === '}') {
if (depth > 0) {
depth--;
} else if (inner[i + 1] === '}') {
// 깊이 0 에서 `}}` 출현 → 외곽 바인딩이 중간에 닫힘 → 다중 바인딩/보간
return null;
}
}
}
return inner;
}
/**
* 객체 내의 모든 바인딩 표현식을 데이터로 치환
*
@@ -957,12 +1036,48 @@ export class DataBindingEngine {
continue;
}
// 해석 실패는 key 단위로 격리한다 — 이 메서드에는 상위 catch 가 없어서
// 예외가 밖으로 나가면 그 컴포넌트의 props 해석 **전체**가 중단되고,
// 표현식 하나의 실수가 화면 전체를 날린다. @since engine-v1.55.1
try {
this.resolveObjectEntry(result, key, value, context, options);
} catch (error) {
logger.warn(`resolveObject: 값 해석 실패 (key: ${key}):`, error);
result[key] = undefined;
}
}
return result;
}
/**
* `resolveObject` 의 key 하나를 해석해 결과 객체에 기록
*
* 실패 격리 단위를 key 로 두기 위해 분리했다. 이 메서드는 예외를 잡지 않는다 —
* 호출자(`resolveObject`)가 key 단위로 격리한다.
*
* @param result 결과 객체 (직접 기록)
* @param key 대상 key
* @param value 원본 값
* @param context 데이터 컨텍스트
* @param options 바인딩 옵션
*
* @since engine-v1.55.1
*/
private resolveObjectEntry(
result: Record<string, any>,
key: string,
value: any,
context: BindingContext,
options?: BindingOptions & { skipBindingKeys?: string[] },
): void {
{
if (typeof value === 'string') {
// 전체가 {{variable}} 패턴인 경우 원본 값(배열/객체) 반환.
// `[^}]+` 는 `{{error.errors ?? {}}}` 처럼 식 안에 `}` 가 든 경우(빈 객체 fallback 등)
// 첫 `}` 에서 끊겨 매칭에 실패 → 원본 문자열이 그대로 새어 나오던 결함이 있었다.
// extractSingleBinding 은 외곽 `{{ }}` 안의 중괄호 균형을 추적해 단일 바인딩 식을 정확히 추출한다.
const singleBindingPath = this.extractSingleBinding(value);
const singleBindingPath = extractSingleBinding(value);
if (singleBindingPath !== null) {
const path = singleBindingPath.trim();
@@ -974,18 +1089,30 @@ export class DataBindingEngine {
effectivePath = path.slice(RAW_PREFIX.length);
}
// 복잡 표현식(연산자 포함)은 evaluateExpression으로 라우팅
// resolveBindings()와 동일한 isBaseExpression 판별 기준 사용
// 단, 파이프 표현식은 resolveBindings 로 위임한다 — `|` 를 연산자로 보아
// evaluateExpression 으로 보내면 `value | pipe` 가 JS 비트 OR 로 평가되어
// 라우팅 판정은 BindingShape 정본을 쓴다 (파이프 → 표현식 → 경로).
// 파이프를 evaluateExpression 으로 보내면 `value | pipe` 가 JS 비트 OR 로 평가되어
// 인자 있는 파이프는 예외를 던지고(props 해석 전체가 중단), 인자 없는
// 파이프는 포맷이 적용되지 않은 오답이 된다. @since engine-v1.54.3
const isBaseExpression = /[?:|&!+\-*/<>=()]/.test(effectivePath) || /\[['"]/.test(effectivePath);
const resolved = hasPipes(effectivePath)
? this.resolveBindings(`{{${effectivePath}}}`, context, options)
: isBaseExpression
? this.evaluateExpression(effectivePath, context, options)
: this.resolve(effectivePath, context, options);
// 값이 필요한 자리이므로 서식(formatValue)을 적용하지 않는다. @since engine-v1.54.9
// 실행(캐시·예외 격리)은 이 지점의 정책을 유지한다. @since engine-v1.55.0
let resolved: any;
switch (classifyExpression(effectivePath)) {
case 'empty':
logger.warn(`resolveObject: 빈 바인딩 \`{{}}\` (key: ${key}) — undefined 로 해석합니다.`);
resolved = undefined;
break;
case 'literal':
resolved = LITERALS.get(effectivePath);
break;
case 'pipe':
resolved = this.evaluatePipeExpression(effectivePath, context, options);
break;
case 'expression':
resolved = this.evaluateExpression(effectivePath, context, options);
break;
default:
resolved = this.resolve(effectivePath, context, options);
}
result[key] = isRawBinding && resolved != null ? wrapRawDeep(resolved) : resolved;
} else {
// 문자열 보간
@@ -1005,8 +1132,6 @@ export class DataBindingEngine {
result[key] = value;
}
}
return result;
}
/**
@@ -1258,9 +1383,26 @@ export class DataBindingEngine {
return current ?? fallback;
};
// 확장된 컨텍스트의 모든 키를 변수로 사용할 수 있도록 준비
const contextKeys = Object.keys(extendedContext);
const contextValues = Object.values(extendedContext);
// 확장된 컨텍스트의 키를 변수로 사용할 수 있도록 준비.
//
// 함수 파라미터가 될 수 없는 키(`sales_status[]`, `data-id`, 예약어 등)는 제외한다.
// 하나라도 섞이면 `new Function` 생성 자체가 SyntaxError 로 실패해 **그 컨텍스트에서
// 평가되는 모든 표현식**이 통째로 죽는다 — 식이 그 키를 쓰지 않아도 마찬가지다.
// 예외도 화면 오류도 없이 값만 사라지므로(catch → 폴백) 원인 파악이 어렵다.
//
// 제외해도 잃는 것은 없다 — 그런 키는 애초에 식 안에서 맨이름으로 참조할 수 없었고
// (`sales_status[]` 는 식별자가 아니다), 실제 작성은 `query['sales_status[]']` 처럼
// 상위 객체를 거치므로 그대로 동작한다.
// @since engine-v1.56.2
const contextKeys: string[] = [];
const contextValues: unknown[] = [];
for (const [key, value] of Object.entries(extendedContext)) {
if (!VALID_IDENTIFIER_PATTERN.test(key) || RESERVED_WORDS.has(key)) {
continue;
}
contextKeys.push(key);
contextValues.push(value);
}
// 캐시 키 생성: 전처리된 표현식 + 컨텍스트 키 조합
// 같은 표현식이라도 컨텍스트 키가 다르면 다른 함수가 필요
@@ -16,6 +16,8 @@ import { getApiClient } from '../api/ApiClient';
import { AuthManager } from '../auth/AuthManager';
import { resolveExpressionString, evaluateRenderCondition } from './helpers/RenderHelpers';
import { DataBindingEngine } from './DataBindingEngine';
import { extractSingleBinding } from './BindingShape';
import { hasPipes } from './PipeRegistry';
import { getErrorHandlingResolver } from '../error';
import { getActionDispatcher } from './ActionDispatcher';
import type { ErrorHandlingMap, OnErrorHandler, OnSuccessHandler, ErrorContext, SuccessContext, ErrorCondition } from '../types/ErrorHandling';
@@ -44,13 +46,23 @@ function resolveParamValue(value: string, context: Record<string, any>): any {
// 전체가 하나의 {{...}} 표현식인지 확인
// 예: "{{query['sales_status[]']}}" → true
// 예: "page={{query.page}}" → false
const singleExpressionMatch = value.match(/^\{\{(.+)\}\}$/);
//
// 판정은 BindingShape 정본을 쓴다. 종전 greedy 정규식 `^\{\{(.+)\}\}$` 은
// `"{{a}}-{{b}}"` 같은 보간 문자열도 단일 표현식으로 오판했고, 그때 잘라낸
// `a}}-{{b` 를 평가하다 예외가 나면 **원본 문자열이 그대로 서버로 전송**됐다.
// @since engine-v1.55.0
const expression = extractSingleBinding(value);
if (singleExpressionMatch) {
if (expression !== null) {
// 단일 표현식인 경우 evaluateExpression으로 원본 타입 유지
const expression = singleExpressionMatch[1].trim();
try {
return bindingEngine.evaluateExpression(expression, context);
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 보므로 인자 있는 파이프는 예외로 아래 catch 에 걸려
// 원본 `{{...}}` 리터럴이 그대로 쿼리스트링에 실려 서버로 전송된다.
// @since engine-v1.54.10
return hasPipes(expression)
? bindingEngine.evaluatePipeExpression(expression, context, { skipCache: true })
: bindingEngine.evaluateExpression(expression, context);
} catch (error) {
logger.error('Failed to evaluate param expression:', expression, error);
return value; // 실패 시 원본 반환
@@ -26,6 +26,10 @@ import { TranslationEngine, TranslationContext } from './TranslationEngine';
import { ActionDispatcher, ActionDefinition } from './ActionDispatcher';
import { resolveIterationSource, evaluateIfCondition, evaluateRenderCondition, bindComponentActions, resolveClassMap } from './helpers/RenderHelpers';
import { hasPipes } from './PipeRegistry';
import {
extractSingleBinding as canonicalExtractSingleBinding,
isComplexExpression as canonicalIsComplexExpression,
} from './BindingShape';
import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep } from './rawMarkers';
import type { ConditionsProperty } from './helpers/ConditionEvaluator';
import { useTransitionState } from './TransitionContext';
@@ -1723,7 +1727,13 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
if (expression.startsWith('{{') && expression.endsWith('}}')) {
// {{...}} 형태의 표현식 평가
const innerExpression = (expression as string).slice(2, -2).trim();
const result = bindingEngine.evaluateExpression(innerExpression, computedContext, { skipCache: true });
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 보므로 인자 있는 파이프는 예외로 아래 catch 에 걸려
// computed 가 이전 값에 고정된다(값이 바뀌지 않는 조용한 실패).
// @since engine-v1.54.10
const result = hasPipes(innerExpression)
? bindingEngine.evaluatePipeExpression(innerExpression, computedContext, { skipCache: true })
: bindingEngine.evaluateExpression(innerExpression, computedContext, { skipCache: true });
newComputed[key] = result;
} else {
// 순수 문자열은 그대로 사용
@@ -2020,7 +2030,13 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
componentName: effectiveComponentDef.name,
propName: 'slot',
};
const result = bindingEngine.evaluateExpression(expr, extendedDataContext, slotTrackingInfo);
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 보므로 결과가 문자열이 아니게 되고, 아래 `typeof result === 'string'`
// 가드에 걸려 슬롯 미등록으로 조용히 흡수된다(컴포넌트가 화면에서 사라짐).
// @since engine-v1.54.10
const result = hasPipes(expr)
? bindingEngine.evaluatePipeExpression(expr, extendedDataContext, undefined, slotTrackingInfo)
: bindingEngine.evaluateExpression(expr, extendedDataContext, slotTrackingInfo);
logger.log(`[Slot] 표현식 평가: "${expr}" => "${result}" (컴포넌트: ${effectiveComponentDef.id})`);
return typeof result === 'string' ? result : null;
} catch (error) {
@@ -2236,66 +2252,30 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
/**
* 복잡한 표현식 여부 감지
*
* 삼항 연산자, 논리 연산자 등이 포함된 경우 true 반환
* 판정은 BindingShape 정본에 위임한다 — 종전에는 이 자리의 문자 집합이 좁아
* `query['sales_status[]']` 같은 따옴표 키 인덱싱과 배열/객체 리터럴이
* "단순 경로" 로 오판되어 경로 탐색으로 갔고, 결과는 조용한 undefined 였다.
* 같은 식이 `if`(RenderHelpers 방언)에서는 정상 동작해 재현 위치를 특정하기 어려웠다.
*
* @since engine-v1.55.0
*/
const isComplexExpression = useCallback((expr: string): boolean => {
// 삼항 연산자나 복잡한 표현식 감지 (?, :, ||, &&, !, 산술 연산자, 함수 호출 등)
// () 추가: $localized(menu.name) 같은 함수 호출 표현식도 복잡한 표현식으로 처리
return /[?:|&!+\-*/<>=()]/.test(expr);
}, []);
const isComplexExpression = useCallback(
(expr: string): boolean => canonicalIsComplexExpression(expr),
[],
);
/**
* 단일 바인딩 표현식 매칭 정규식
* 단일 바인딩 표현식 추출
*
* 전체가 {{...}}로 감싸진 표현식을 매칭합니다.
* 내부에 문자열 리터럴('...' 또는 "...")이 포함된 복잡한 표현식도 지원합니다.
* 전체가 `{{...}}` 로 감싸진 식이면 내부 식을, 아니면 null 을 반환한다.
* 판정은 BindingShape 정본에 위임한다 (따옴표·이스케이프·중괄호 균형 추적).
*
* 예시:
* - {{user.name}} -> 매칭
* - {{row.status === 'active' ? 'yes' : 'no'}} -> 매칭
* - Hello {{name}} -> 매칭 안됨 (문자열 보간)
* @since engine-v1.55.0
*/
const extractSingleBinding = useCallback((value: string): string | null => {
// 시작이 {{이고 끝이 }}인지 확인
if (!value.startsWith('{{') || !value.endsWith('}}')) {
return null;
}
// 중간 내용 추출
const inner = value.slice(2, -2);
// 중간에 }}가 있으면 단일 바인딩이 아님 (예: {{a}} {{b}})
// 단, 문자열 리터럴 안의 }는 제외해야 함
let inString: string | null = null;
let braceCount = 0;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
const prevChar = i > 0 ? inner[i - 1] : '';
// 이스케이프된 문자는 무시
if (prevChar === '\\') continue;
// 문자열 시작/종료 감지
if ((char === '"' || char === "'") && !inString) {
inString = char;
} else if (char === inString) {
inString = null;
}
// 문자열 밖에서 중괄호 카운트
if (!inString) {
if (char === '{') braceCount++;
if (char === '}') braceCount--;
// 닫는 중괄호가 더 많으면 단일 바인딩이 아님
if (braceCount < 0) return null;
}
}
// 중괄호가 균형이 맞으면 단일 바인딩
return braceCount === 0 ? inner.trim() : null;
}, []);
const extractSingleBinding = useCallback(
(value: string): string | null => canonicalExtractSingleBinding(value),
[],
);
/**
* Props 처리
@@ -2322,6 +2302,13 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// 같은 표현식이 다른 아이템 컨텍스트로 평가되어야 하므로 캐시 사용 시 잘못된 값 반환
const bindingOptions = isInsideIteration ? { skipCache: true } : undefined;
// 중첩 객체 해석에도 컴포넌트별 skipBindingKeys 를 전달한다.
// 종전에는 전달하지 않아 resolveObject 의 기본 4개만 적용됐고, 그래서 컴포넌트가
// "내가 직접 반복 렌더한다" 고 선언한 키가 중첩 위치에서는 선평가됐다 —
// 그 시점에는 항목 컨텍스트가 없으므로 표현식이 stale 값으로 고정된다.
// 저장소 전수 스캔 기준 현재 해당 위치 0건(예방 목적). @since engine-v1.55.1
const nestedBindingOptions = { ...(bindingOptions ?? {}), skipBindingKeys };
// _computed 참조 표현식의 캐시 스킵 여부를 판별하는 헬퍼
// _computed는 컴포넌트마다 _local 기반으로 재계산되므로 컴포넌트 간 공유 캐시 사용 시
// 먼저 렌더링된 컴포넌트의 stale 값이 이후 컴포넌트에 전파되는 버그 발생
@@ -2361,11 +2348,27 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
let resolvedValue;
if (hasPipes(effectivePath)) {
// 파이프 표현식은 resolveBindings 로 위임 (파이프 체인 실행 포함).
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다.
// isComplexExpression 은 `|` 를 연산자로 보므로 그대로 두면
// `value | pipe` 가 JS 비트 OR 로 평가된다 — text 처리와 동일 분기.
// @since engine-v1.54.3
resolvedValue = bindingEngine.resolveBindings(`{{${effectivePath}}}`, extendedDataContext, bindingOptions, trackingInfo);
// prop 값은 원본 타입이 필요하므로 문자열 서식을 거치지 않으며,
// 캐시 옵션도 다른 분기와 동일하게 _computed 인지를 반영한다.
// @since engine-v1.54.9
try {
resolvedValue = bindingEngine.evaluatePipeExpression(
effectivePath,
extendedDataContext,
getComputedAwareOptions(effectivePath),
trackingInfo,
);
} catch (error) {
logger.warn(
`파이프 표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}, prop: ${key}):`,
error
);
resolvedValue = undefined;
}
} else if (isComplexExpression(effectivePath)) {
try {
resolvedValue = bindingEngine.evaluateExpression(effectivePath, extendedDataContext, trackingInfo);
@@ -2394,11 +2397,11 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
typeof item === 'string'
? bindingEngine.resolveBindings(item, extendedDataContext, bindingOptions)
: typeof item === 'object' && item !== null
? bindingEngine.resolveObject(item, extendedDataContext, bindingOptions)
? bindingEngine.resolveObject(item, extendedDataContext, nestedBindingOptions)
: item
);
} else if (value && typeof value === 'object') {
props[key] = bindingEngine.resolveObject(value, extendedDataContext, bindingOptions);
props[key] = bindingEngine.resolveObject(value, extendedDataContext, nestedBindingOptions);
}
}
@@ -2690,9 +2693,20 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
effectivePath = singleBindingPath.slice(RAW_PREFIX.length);
}
// 파이프가 있는 경우 resolveBindings 사용 (파이프 처리 포함)
// 파이프가 있는 경우 evaluatePipeExpression 사용 (파이프 체인 실행 포함).
// 다른 두 분기(resolve/evaluateExpression)와 마찬가지로 원본 타입을 유지한다 —
// 문자열 서식을 거치면 `first`/`filter` 같은 파이프의 결과 타입이 바뀐다.
// @since engine-v1.54.9
if (hasPipes(effectivePath)) {
resolvedText = bindingEngine.resolveBindings(`{{${effectivePath}}}`, extendedDataContext, { skipCache: true }, textTrackingInfo);
try {
resolvedText = bindingEngine.evaluatePipeExpression(effectivePath, extendedDataContext, { skipCache: true }, textTrackingInfo);
} catch (error) {
logger.warn(
`text 파이프 표현식 평가 실패 (컴포넌트: ${effectiveComponentDef.id}):`,
error
);
resolvedText = '';
}
} else if (isComplexExpression(effectivePath)) {
// 복잡한 표현식인 경우 evaluateExpression 사용
try {
@@ -2846,9 +2860,14 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// 각 아이템에 대해 자식 컴포넌트 렌더링
return dataArray.map((item, index) => {
// 반복 컨텍스트 생성 (extendedDataContext 기반, _local 포함)
//
// `{item_var}_index` 자동 변수는 반복 렌더 경로(renderItemChildren)가 이미
// 제공하던 것으로, 이 경로에만 없어서 같은 작성이 한쪽에서만 동작했다.
// @since engine-v1.56.0
const iterationContext: Record<string, any> = {
...extendedDataContext,
[effectiveComponentDef.iteration!.item_var]: item,
[`${effectiveComponentDef.iteration!.item_var}_index`]: index,
};
if (effectiveComponentDef.iteration!.index_var) {
@@ -3741,8 +3760,19 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
}
// 조건부 렌더링 체크
//
// iteration 이 있으면 여기서 끊지 않는다. `if` 가 항목 변수(`{{user.is_active}}` 등)를
// 참조하는 경우 이 시점의 컨텍스트에는 그 변수가 없어 조건이 항상 false 가 되고,
// **목록 전체가 렌더되지 않는다**(에러도 경고도 없음). 반복 렌더 경로
// (renderItemChildren)는 항목별 컨텍스트에서 평가하므로 같은 작성이 정상 동작했다 —
// 그 비대칭을 없앤다. 항목별 재평가는 자식 DynamicRenderer 가 수행한다
// (iteration 만 제거한 정의를 넘기므로 `if` 는 그대로 남는다).
// @since engine-v1.56.0
if (!shouldRender) {
return null;
if (!effectiveComponentDef.iteration) {
return null;
}
return renderIteration;
}
// 정렬 가능 리스트 렌더링 체크 (sortable은 iteration보다 우선)
@@ -19,6 +19,9 @@ import { ComponentRegistry } from './ComponentRegistry';
import { TranslationEngine, TranslationContext } from './TranslationEngine';
import { ActionDispatcher } from './ActionDispatcher';
import { DataBindingEngine, dataBindingEngine } from './DataBindingEngine';
import { hasPipes } from './PipeRegistry';
import { extractSingleBinding } from './BindingShape';
import { evaluateStringCondition } from './helpers/ConditionEvaluator';
import { DataSourceManager, dataSourceManager } from './DataSourceManager';
import DynamicRenderer from './DynamicRenderer';
import { useTransitionState } from './TransitionContext';
@@ -978,6 +981,39 @@ function initHelperAPIs(G7Core: any, deps: G7CoreDependencies): void {
return renderItemChildren(children, mergedContext, componentMap, keyPrefix, mergedOptions);
};
/**
* 조건 문자열 평가 노출 (템플릿 컴포넌트용)
*
* 템플릿 컴포넌트가 `new Function('row', ...)` 로 자체 평가기를 두면 엔진과 판정이
* 갈린다 — 실제로 `_local`/`_global` 을 참조하는 조건에서 두 컴포넌트가 예외를 서로
* 다르게 처리해 한쪽은 자식이 사라지고 다른 쪽은 항상 표시되는 **반대 방향** 결과가 났다.
* 이 API 는 엔진의 `evaluateStringCondition` 을 그대로 쓰고, 전역/로컬/계산 상태를
* 컨텍스트에 병합해 넘긴다.
*
* @param condition 조건 문자열 (예: `"{{row.charge_policy !== 'free'}}"`)
* @param extraContext 추가 컨텍스트 (예: `{ row }`)
* @returns 평가 결과. 조건이 비어 있으면 true
*
* @since engine-v1.56.1
*/
G7Core.evaluateCondition = (condition: string, extraContext?: Record<string, any>): boolean => {
if (!condition) return true;
const state = deps.getState();
const templateApp = (window as any).__templateApp;
const globalStateContent = templateApp?.getGlobalState?.() || {};
const context = {
_global: globalStateContent,
_local: globalStateContent._local || {},
_computed: globalStateContent._computed || {},
...(extraContext || {}),
};
const engine = state.bindingEngine ?? dataBindingEngine;
return evaluateStringCondition(condition, context, engine);
};
// getComponentMap 노출 (CardGrid 등에서 전체 컴포넌트 맵 접근용)
G7Core.getComponentMap = () => {
return ComponentRegistry.getInstance().getComponentMap();
@@ -1145,14 +1181,20 @@ function initHelperAPIs(G7Core: any, deps: G7CoreDependencies): void {
// resolveObject는 DEFAULT_SKIP_BINDING_KEYS 때문에 expandContext 내부 키를 스킵할 수 있음
for (const [key, value] of Object.entries(expandContext)) {
if (typeof value === 'string') {
// 단일 Mustache 표현식인지 확인 ({{expr}} 형태)
const singleBindingMatch = value.match(/^\{\{([^}]+)\}\}$/);
if (singleBindingMatch) {
// 단일 Mustache 표현식인지 확인 ({{expr}} 형태).
// 판정은 BindingShape 정본 — 종전 `^\{\{([^}]+)\}\}$` 은 식 안에 `}` 가
// 들어간 경우(`?? {}` 등)를 단일 바인딩으로 보지 못해 보간 경로로 흘렸다.
// @since engine-v1.55.0
const expr = extractSingleBinding(value);
if (expr !== null) {
// 단일 바인딩 표현식: evaluateExpression 사용하여 원본 타입 유지
// resolveBindings는 문자열 보간용이라 배열/객체를 JSON 문자열로 변환함
const expr = singleBindingMatch[1].trim();
try {
resolvedExpandContext[key] = bindingEngine.evaluateExpression(expr, evalContext, { skipCache: true });
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 보므로 파이프가 적용되지 않는다. @since engine-v1.54.10
resolvedExpandContext[key] = hasPipes(expr)
? bindingEngine.evaluatePipeExpression(expr, evalContext, { skipCache: true })
: bindingEngine.evaluateExpression(expr, evalContext, { skipCache: true });
} catch (e) {
// 평가 실패 시 undefined (폴백 처리는 표현식 자체에서 || 로 처리)
resolvedExpandContext[key] = undefined;
@@ -14,6 +14,10 @@
import { createLogger } from '../utils/Logger';
import { suffixed } from '../support/assetUrl';
// 순환 import (DataBindingEngine → TranslationEngine → DataBindingEngine) 이지만
// 양쪽 모두 모듈 평가 시점이 아니라 메서드 실행 시점에만 서로를 참조하므로
// live binding 이 채워진 뒤에 사용된다.
import { dataBindingEngine } from './DataBindingEngine';
const logger = createLogger('TranslationEngine');
@@ -497,10 +501,15 @@ export class TranslationEngine {
const isComplexExpression = /[|&()[\]!?:+\-*/%<>=\s]/.test(expression);
if (isComplexExpression && dataContext) {
// JavaScript 표현식으로 평가
// 평가는 엔진에 위임한다. 종전에는 `new Function(...Object.keys(dataContext))` 로
// 자체 평가해 `$localized(...)`/`$t(...)`/`$uuid()` 헬퍼와 optional chaining 전처리,
// 표현식 함수 캐시를 쓰지 못했다. 실패 시 조용히 빈 문자열이 되어 번역 문구에서
// 값만 사라졌다.
// (컨텍스트 키가 식별자가 아닐 때의 실패는 엔진 쪽 문제였고 engine-v1.56.2 에서
// DataBindingEngine 이 그런 키를 제외하도록 고쳤다.)
// @since engine-v1.56.1
try {
const func = new Function(...Object.keys(dataContext), `return ${expression}`);
const resolved = func(...Object.values(dataContext));
const resolved = dataBindingEngine.evaluateExpression(expression, dataContext);
return String(resolved ?? '');
} catch (error) {
logger.error('Expression evaluation failed:', expression, error);
@@ -0,0 +1,176 @@
/**
* ActionDispatcher 액션 params 파이프 회귀 테스트
*
* 액션 params 의 `{{...}}` 는 `evaluateExpression` 으로 평가되는데, 이 경로는
* `|` 를 JS 비트 OR 로 본다. 그래서 파이프가 붙으면
* - 인자 있는 파이프: 예외 → catch 가 **원본 `{{...}}` 문자열을 그대로 반환** → 서버로 전송
* - 인자 없는 파이프: 날짜 문자열 | 0 → `0` 같은 조용한 오답
* 이 되었다. 화면에는 아무 표시도 나지 않고 서버 요청 본문만 오염된다.
*
* @since engine-v1.54.10
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ActionDispatcher, ActionDefinition } from '../ActionDispatcher';
import { Logger } from '../../utils/Logger';
vi.mock('../../auth/AuthManager', () => ({
AuthManager: {
getInstance: vi.fn(() => ({
login: vi.fn(),
logout: vi.fn(),
})),
},
}));
vi.mock('../../api/ApiClient', () => ({
getApiClient: vi.fn(() => ({
getToken: vi.fn(() => null),
})),
}));
describe('ActionDispatcher — 액션 params 의 파이프', () => {
let dispatcher: ActionDispatcher;
let mockFetch: ReturnType<typeof vi.fn>;
let originalFetch: typeof fetch;
const dataContext = {
row: {
created_at: '2024-01-15T14:30:00',
price: 1234567,
tags: ['a', 'b', 'c'],
},
};
beforeEach(() => {
dispatcher = new ActionDispatcher({ navigate: vi.fn() });
Logger.getInstance().setDebug(false);
originalFetch = globalThis.fetch;
mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true, data: {} }),
});
globalThis.fetch = mockFetch as unknown as typeof fetch;
Object.defineProperty(document, 'cookie', {
value: 'XSRF-TOKEN=test-csrf-token',
writable: true,
});
});
afterEach(() => {
globalThis.fetch = originalFetch;
Logger.getInstance().setDebug(false);
});
const createMockEvent = () =>
({ preventDefault: vi.fn(), type: 'click', target: null } as unknown as Event);
it('인자 있는 파이프가 서식된 값으로 전송된다 (원본 {{...}} 전송 금지)', async () => {
const action: ActionDefinition = {
type: 'click',
handler: 'apiCall',
target: '/api/test/pipe-body',
params: {
method: 'POST',
auth_required: false,
body: {
date: "{{row.created_at | datetime('YYYY-MM-DD')}}",
},
},
};
await dispatcher.createHandler(action, dataContext)(createMockEvent());
const call = mockFetch.mock.calls.find((c: unknown[]) => c[0] === '/api/test/pipe-body');
expect(call).toBeDefined();
const body = String((call![1] as RequestInit).body);
expect(body).not.toContain('{{');
expect(JSON.parse(body)).toEqual({ date: '2024-01-15' });
});
it('인자 없는 파이프가 비트 OR 오답으로 전송되지 않는다', async () => {
const action: ActionDefinition = {
type: 'click',
handler: 'apiCall',
target: '/api/test/pipe-body-noarg',
params: {
method: 'POST',
auth_required: false,
body: { count: '{{row.tags | length}}' },
},
};
await dispatcher.createHandler(action, dataContext)(createMockEvent());
const call = mockFetch.mock.calls.find(
(c: unknown[]) => c[0] === '/api/test/pipe-body-noarg'
);
expect(call).toBeDefined();
const body = JSON.parse(String((call![1] as RequestInit).body));
expect(body.count).toBe(3);
expect(body.count).not.toBe(0);
});
it('GET 쿼리스트링에 {{ 리터럴이 실리지 않는다', async () => {
const action: ActionDefinition = {
type: 'click',
handler: 'apiCall',
target: '/api/test/pipe-query',
params: {
method: 'GET',
auth_required: false,
query: { date: "{{row.created_at | date('YYYY-MM-DD')}}" },
},
};
await dispatcher.createHandler(action, dataContext)(createMockEvent());
const call = mockFetch.mock.calls.find((c: unknown[]) =>
String(c[0]).startsWith('/api/test/pipe-query')
);
expect(call).toBeDefined();
const url = String(call![0]);
expect(url).not.toContain('%7B%7B');
expect(url).toContain('date=2024-01-15');
});
it('파이프 없는 params 는 종전 경로 그대로 (회귀 보호)', async () => {
const action: ActionDefinition = {
type: 'click',
handler: 'apiCall',
target: '/api/test/no-pipe',
params: {
method: 'POST',
auth_required: false,
body: { tags: '{{row.tags}}', total: '{{row.price}}' },
},
};
await dispatcher.createHandler(action, dataContext)(createMockEvent());
const call = mockFetch.mock.calls.find((c: unknown[]) => c[0] === '/api/test/no-pipe');
const body = JSON.parse(String((call![1] as RequestInit).body));
expect(body.tags).toEqual(['a', 'b', 'c']);
expect(body.total).toBe(1234567);
});
it('논리 OR(||)는 파이프로 오인되지 않는다 (회귀 보호)', async () => {
const action: ActionDefinition = {
type: 'click',
handler: 'apiCall',
target: '/api/test/logical-or',
params: {
method: 'POST',
auth_required: false,
body: { name: "{{row.missing || 'fallback'}}" },
},
};
await dispatcher.createHandler(action, dataContext)(createMockEvent());
const call = mockFetch.mock.calls.find((c: unknown[]) => c[0] === '/api/test/logical-or');
expect(JSON.parse(String((call![1] as RequestInit).body)).name).toBe('fallback');
});
});
@@ -0,0 +1,273 @@
/**
* 판정기 통일 라우팅 diff 하네스
*
* 종전 엔진에는 "이 문자열이 단일 바인딩인가" 를 판정하는 구현이 4종, "이 식이 복잡한
* 표현식인가" 를 판정하는 정규식이 6종 있었고 서로 인식 문자 집합이 달랐다. 그래서
* **같은 식이 prop 에서는 undefined 인데 `if` 에서는 정상** 같은 비대칭이 생겼다.
*
* `BindingShape` 로 판정을 통일하면 일부 식의 라우팅이 바뀐다. 이 테스트는 저장소의
* 모든 레이아웃 JSON 에서 단일 바인딩을 수집해 **구 방언들과 신 정본의 판정을 대조**하고,
* 판정이 바뀌는 식 전체를 소속 파일과 함께 스냅샷으로 고정한다. 목록에 항목이
* 늘어나면(=새 레이아웃이 라우팅 변경 대상 식을 도입하면) 이 테스트가 깨진다.
*
* @since engine-v1.55.0
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { hasPipes } from '../PipeRegistry';
import {
classifyExpression,
extractSingleBinding as canonicalExtract,
isComplexExpression as canonicalIsComplex,
LITERALS,
} from '../BindingShape';
const REPO_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..', '..', '..', '..', '..',
);
const LAYOUT_ROOTS = [
'resources/layouts',
'templates/_bundled',
'modules/_bundled',
'plugins/_bundled',
];
// ── 구 방언 (교체 대상) ────────────────────────────────────────────────
/** DataBindingEngine.resolveBindings 의 isExpression — 단일 `|` 제외, `||` 만 인식 */
const oldComplexResolveBindings = (e: string) =>
/[?:&!+\-*/<>=()]/.test(e) || /\|\|/.test(e) || /\[['"]/.test(e);
/** DataBindingEngine 의 isBaseExpression (파이프 base / resolveObject) */
const oldComplexDataBindingEngine = (e: string) =>
/[?:|&!+\-*/<>=()]/.test(e) || /\[['"]/.test(e);
/** DynamicRenderer / ConditionEvaluator — 가장 좁은 방언 */
const oldComplexNarrow = (e: string) => /[?:|&!+\-*/<>=()]/.test(e);
/** RenderHelpers — 최대집합 (신 정본의 출처) */
const oldComplexRenderHelpers = (e: string) => /[?:|&!+\-*/<>=()[\]{}]/.test(e);
const OLD_COMPLEX_DIALECTS: Record<string, (e: string) => boolean> = {
'DataBindingEngine.resolveBindings': oldComplexResolveBindings,
'DataBindingEngine.isBaseExpression': oldComplexDataBindingEngine,
'DynamicRenderer/ConditionEvaluator': oldComplexNarrow,
'RenderHelpers': oldComplexRenderHelpers,
};
/** 액션/데이터소스/TemplateApp 의 greedy 단일 바인딩 정규식 */
const oldExtractGreedy = (v: string): string | null => {
const m = v.match(/^\{\{(.+)\}\}$/);
return m ? m[1].trim() : null;
};
/** G7CoreGlobals 의 `}` 불허 정규식 */
const oldExtractNoBrace = (v: string): string | null => {
const m = v.match(/^\{\{([^}]+)\}\}$/);
return m ? m[1].trim() : null;
};
// ── 레이아웃 수집 ──────────────────────────────────────────────────────
interface Occurrence {
file: string;
jsonPath: string;
value: string;
}
function walkFiles(dir: string, acc: string[] = []): string[] {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return acc;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'vendor') continue;
walkFiles(full, acc);
continue;
}
if (entry.name.endsWith('.json') && full.replace(/\\/g, '/').includes('/layouts/')) {
acc.push(full);
}
}
return acc;
}
function collectStrings(node: unknown, segs: (string | number)[], file: string, out: Occurrence[]): void {
if (typeof node === 'string') {
if (node.includes('{{')) {
out.push({ file, jsonPath: segs.join('.'), value: node });
}
return;
}
if (Array.isArray(node)) {
node.forEach((v, i) => collectStrings(v, segs.concat(i), file, out));
return;
}
if (node && typeof node === 'object') {
for (const [k, v] of Object.entries(node)) collectStrings(v, segs.concat(k), file, out);
}
}
function collectOccurrences(): Occurrence[] {
const out: Occurrence[] = [];
for (const root of LAYOUT_ROOTS) {
for (const file of walkFiles(path.join(REPO_ROOT, root))) {
let json: unknown;
try {
json = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
continue;
}
const relative = path.relative(REPO_ROOT, file).replace(/\\/g, '/');
collectStrings(json, [], relative, out);
}
}
return out;
}
const occurrences = collectOccurrences();
describe('BindingShape 라우팅 diff 하네스', () => {
it('레이아웃 모집단을 실제로 수집한다 (하네스 자체 회귀 방지)', () => {
const files = new Set(occurrences.map((o) => o.file));
expect(files.size).toBeGreaterThan(100);
expect(occurrences.length).toBeGreaterThan(1000);
});
it('단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량', () => {
const values = new Set<string>();
for (const occ of occurrences) {
const canonical = canonicalExtract(occ.value);
if (canonical === oldExtractGreedy(occ.value) && canonical === oldExtractNoBrace(occ.value)) continue;
values.add(occ.value);
}
// 대부분은 greedy 정규식(`^\{\{(.+)\}\}$`)이 `"{{a}}-{{b}}"` 같은 보간 문자열을
// 단일 바인딩으로 오판하던 경우다. 총량만 고정하고, 실제 피해가 나는 지점
// (액션 params / 데이터소스 params)은 아래에서 따로 목록화한다.
expect(values.size).toMatchSnapshot();
});
it('단일 바인딩 판정: greedy 정규식이 실제로 쓰이는 지점(액션/데이터소스 params)의 갈리는 값', () => {
const isGreedySite = (jsonPath: string) =>
/(^|\.)data_sources\./.test(jsonPath) ||
(/\.params(\.|$)/.test(jsonPath) &&
(/(^|\.)actions?\./.test(jsonPath) || /(^|\.)init_actions\./.test(jsonPath) || /\.on[A-Z]/.test(jsonPath)));
const diffs = new Map<string, { files: Set<string>; greedy: string | null; canonical: string | null }>();
for (const occ of occurrences) {
if (!isGreedySite(occ.jsonPath)) continue;
const canonical = canonicalExtract(occ.value);
const greedy = oldExtractGreedy(occ.value);
if (canonical === greedy) continue;
const entry = diffs.get(occ.value) ?? { files: new Set<string>(), greedy, canonical };
entry.files.add(`${occ.file} :: ${occ.jsonPath}`);
diffs.set(occ.value, entry);
}
const report = [...diffs.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([value, e]) => ({
value,
greedy: e.greedy,
canonical: e.canonical,
sites: [...e.files].sort(),
}));
expect(report).toMatchSnapshot();
});
it('표현식/경로 판정: 구 방언과 신 정본이 갈리는 식 목록', () => {
const diffs = new Map<string, { files: Set<string>; old: Record<string, boolean>; canonical: boolean }>();
for (const occ of occurrences) {
const inner = canonicalExtract(occ.value);
if (inner === null) continue;
// 파이프·리터럴·빈 식은 복잡식 판정에 도달하지 않는다.
const shape = classifyExpression(inner);
if (shape === 'pipe' || shape === 'literal' || shape === 'empty') continue;
const canonical = canonicalIsComplex(inner);
const old: Record<string, boolean> = {};
let diverges = false;
for (const [name, fn] of Object.entries(OLD_COMPLEX_DIALECTS)) {
old[name] = fn(inner);
if (old[name] !== canonical) diverges = true;
}
if (!diverges) continue;
const entry = diffs.get(inner) ?? { files: new Set<string>(), old, canonical };
entry.files.add(occ.file);
diffs.set(inner, entry);
}
const report = [...diffs.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([expr, e]) => ({
expr,
canonical: e.canonical,
old: e.old,
files: [...e.files].sort(),
}));
expect(report).toMatchSnapshot();
});
it('판정이 갈리는 식의 분류별 건수 요약', () => {
let quotedBracket = 0;
let numericIndex = 0;
let other = 0;
const seen = new Set<string>();
for (const occ of occurrences) {
const inner = canonicalExtract(occ.value);
if (inner === null || seen.has(inner)) continue;
const shape = classifyExpression(inner);
if (shape === 'pipe' || shape === 'literal' || shape === 'empty') continue;
const canonical = canonicalIsComplex(inner);
const diverges = Object.values(OLD_COMPLEX_DIALECTS).some((fn) => fn(inner) !== canonical);
if (!diverges) continue;
seen.add(inner);
if (/\[\s*['"]/.test(inner)) quotedBracket++;
else if (/\[\s*\d+\s*\]/.test(inner)) numericIndex++;
else other++;
}
expect({ quotedBracket, numericIndex, other, total: seen.size }).toMatchSnapshot();
});
it('빈 바인딩(`{{}}`)은 저장소에 존재하지 않는다', () => {
const empties = occurrences.filter((o) => {
const inner = canonicalExtract(o.value);
return inner !== null && classifyExpression(inner) === 'empty';
});
expect(empties.map((o) => `${o.file} :: ${o.jsonPath}`)).toEqual([]);
});
it('파이프 식은 파이프로 분류된다 (분류 순서 회귀 방지)', () => {
const pipeExprs = occurrences
.map((o) => canonicalExtract(o.value))
.filter((e): e is string => e !== null)
.filter((e) => hasPipes(e));
expect(pipeExprs.length).toBeGreaterThan(0);
for (const e of pipeExprs) {
expect(classifyExpression(e)).toBe('pipe');
}
});
it('리터럴 식은 경로로 해석되지 않는다', () => {
for (const literal of LITERALS.keys()) {
expect(classifyExpression(literal)).toBe('literal');
}
});
});
@@ -0,0 +1,198 @@
/**
* 판정 통일 회귀 테스트 (engine-v1.55.0)
*
* 같은 식이 렌더 경로마다 다른 결과를 내던 비대칭을 고정한다.
* - 따옴표 키 인덱싱(`query['sales_status[]']`)이 prop·text·if·conditions 4경로에서 동일
* - 숫자 인덱싱(`items[0]`)은 경로 이동(resolve → evaluateExpression) 후에도 결과 동치
* - 빈 바인딩(`{{}}`)은 컨텍스트 객체 전체가 아니라 undefined
* - `"{{a}}-{{b}}"` 는 단일 바인딩이 아니다 (greedy 정규식 오판 해소)
*/
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import DynamicRenderer, { ComponentDefinition } from '../DynamicRenderer';
import { ComponentRegistry } from '../ComponentRegistry';
import { DataBindingEngine } from '../DataBindingEngine';
import { TranslationEngine, TranslationContext } from '../TranslationEngine';
import { ActionDispatcher } from '../ActionDispatcher';
import { evaluateStringCondition } from '../helpers/ConditionEvaluator';
import { evaluateIfCondition } from '../helpers/RenderHelpers';
import {
classifyExpression,
extractSingleBinding,
isComplexExpression,
resolveSingleBindingValue,
} from '../BindingShape';
const Probe: React.FC<{ value?: any; children?: React.ReactNode }> = ({ value, children }) => (
<div data-testid="probe" data-json={JSON.stringify(value ?? null)}>
{children}
</div>
);
describe('BindingShape — 판정 통일', () => {
describe('extractSingleBinding 정본', () => {
it('식 안의 객체 리터럴을 단일 바인딩으로 인식한다', () => {
expect(extractSingleBinding('{{error.errors ?? {}}}')).toBe('error.errors ?? {}');
});
it('따옴표 안의 중괄호는 균형 계산에서 제외한다', () => {
expect(extractSingleBinding("{{query['a}b']}}")).toBe("query['a}b']");
});
it('여러 바인딩이 이어진 문자열은 단일 바인딩이 아니다 (greedy 오판 해소)', () => {
expect(extractSingleBinding('{{a}}-{{b}}')).toBeNull();
expect(extractSingleBinding('{{_global.shopBase}}/category/{{cat?.slug}}')).toBeNull();
});
it('앞뒤 공백은 제거된다', () => {
expect(extractSingleBinding('{{ user.name }}')).toBe('user.name');
});
it('빈 바인딩은 빈 문자열로 인식된다 (null 아님)', () => {
expect(extractSingleBinding('{{}}')).toBe('');
expect(classifyExpression('')).toBe('empty');
});
it('바인딩이 아닌 문자열은 null', () => {
expect(extractSingleBinding('hello {{name}}')).toBeNull();
expect(extractSingleBinding('plain')).toBeNull();
});
});
describe('isComplexExpression 정본 (최대집합)', () => {
it('따옴표 키 인덱싱을 표현식으로 본다', () => {
expect(isComplexExpression("query['sales_status[]']")).toBe(true);
});
it('배열/객체 리터럴을 표현식으로 본다', () => {
expect(isComplexExpression("['a','b']")).toBe(true);
expect(isComplexExpression('[{ id: 1 }]')).toBe(true);
});
it('순수 점 경로는 경로로 본다', () => {
expect(isComplexExpression('user.profile.name')).toBe(false);
expect(classifyExpression('user.profile.name')).toBe('path');
});
});
describe('빈 바인딩 봉인', () => {
let engine: DataBindingEngine;
beforeEach(() => {
engine = new DataBindingEngine();
});
it('resolveObject 의 빈 바인딩은 undefined (컨텍스트 객체 미반환)', () => {
const context = { secret: 'do-not-leak', _global: { token: 'x' } };
const out: any = engine.resolveObject({ v: '{{}}' }, context);
expect(out.v).toBeUndefined();
});
it('빈 경로가 resolve 로 들어가도 컨텍스트 전체를 반환하지 않는다', () => {
const context = { secret: 'do-not-leak' };
expect(engine.resolve('', context, { skipCache: true })).toBeUndefined();
expect(engine.resolve(' ', context, { skipCache: true })).toBeUndefined();
});
it('조건에서의 빈 바인딩은 false 로 판정된다', () => {
const context = { secret: 'do-not-leak' };
expect(evaluateStringCondition('{{}}', context, engine)).toBe(false);
expect(evaluateIfCondition('{{}}', context, engine, 'test')).toBe(false);
});
});
describe('숫자 인덱싱 경로 이동 동치성 (4 컨텍스트)', () => {
let engine: DataBindingEngine;
beforeEach(() => {
engine = new DataBindingEngine();
});
const cases: { name: string; context: any; expr: string }[] = [
{ name: '존재', context: { rows: [{ id: 'a' }, { id: 'b' }] }, expr: 'rows[1]' },
{ name: '미존재(범위 밖)', context: { rows: [{ id: 'a' }] }, expr: 'rows[3]' },
{ name: 'null', context: { rows: null }, expr: 'rows[0]' },
{ name: '중첩 미정의', context: {}, expr: 'missing.deep[0]' },
];
for (const c of cases) {
it(`${c.name}: resolve 와 evaluateExpression 결과가 같다`, () => {
const viaResolve = engine.resolve(c.expr, c.context, { skipCache: true });
let viaEval: unknown;
try {
viaEval = engine.evaluateExpression(c.expr, c.context);
} catch {
viaEval = undefined;
}
expect(viaEval).toEqual(viaResolve);
});
}
});
describe('따옴표 키 인덱싱 — prop / text / if / conditions 4경로 동일값', () => {
let registry: ComponentRegistry;
let bindingEngine: DataBindingEngine;
let translationEngine: TranslationEngine;
let actionDispatcher: ActionDispatcher;
let translationContext: TranslationContext;
const context = { query: { 'sales_status[]': ['selling', 'soldout'] } };
const EXPR = "query['sales_status[]']";
beforeEach(() => {
registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Probe: { component: Probe, metadata: { name: 'Probe', type: 'basic' } },
};
bindingEngine = new DataBindingEngine();
translationEngine = new TranslationEngine();
actionDispatcher = new ActionDispatcher({ navigate: vi.fn() });
translationContext = { templateId: 'test-template', locale: 'ko' };
});
const renderDef = (componentDef: ComponentDefinition) =>
render(
<DynamicRenderer
componentDef={componentDef}
dataContext={context}
translationContext={translationContext}
registry={registry}
bindingEngine={bindingEngine}
translationEngine={translationEngine}
actionDispatcher={actionDispatcher}
/>
);
it('prop 경로에서 배열이 전달된다', () => {
const view = renderDef({
id: 'prop', type: 'basic', name: 'Probe', props: { value: `{{${EXPR}}}` },
});
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toEqual([
'selling',
'soldout',
]);
view.unmount();
});
it('text 경로에서 값이 비지 않는다', () => {
const view = renderDef({
id: 'text', type: 'basic', name: 'Probe', props: {}, text: `{{${EXPR}}}`,
});
expect(screen.getByTestId('probe').textContent).toContain('selling');
view.unmount();
});
it('if 경로에서 truthy 로 판정된다', () => {
expect(evaluateIfCondition(`{{${EXPR}}}`, context, bindingEngine, 'test')).toBe(true);
});
it('conditions 경로에서 truthy 로 판정된다', () => {
expect(evaluateStringCondition(`{{${EXPR}}}`, context, bindingEngine)).toBe(true);
});
it('네 경로가 모두 같은 값을 본다 (엔진 직접 해석과 동치)', () => {
const direct = resolveSingleBindingValue(EXPR, context, bindingEngine, { skipCache: true });
expect(direct).toEqual(['selling', 'soldout']);
});
});
});
@@ -50,4 +50,36 @@ describe('DataBindingEngine — {}}} 닫기 모호성 (단일 바인딩 내부
const out: any = eng.resolveObject({ s: '{{a}} {{b}}' }, ctx, {});
expect(out.s).toBe('X Y');
});
/**
* 파이프 식 안에 중괄호가 든 경우.
*
* 종전에는 파이프 분기가 `resolveBindings(`{{...}}`)` 로 위임했는데, 그 안의
* `BINDING_PATTERN`(`/\{\{([^}]+)\}\}/g`)이 `}` 를 포함한 식을 매칭하지 못해
* `String.replace` 가 입력을 그대로 돌려주었다. 결과적으로 원본 `{{...}}` 문자열이
* 화면에 노출됐다. evaluatePipeExpression 직접 호출로 문자열 조립 자체를 없앴다.
* @since engine-v1.54.9
*/
describe('파이프 식 내부 중괄호', () => {
it('{{(row.meta ?? {}) | json}} 가 원본 문자열로 새지 않는다', () => {
const eng = new DataBindingEngine();
const ctx: any = { row: {} }; // row.meta === undefined → ?? {} fallback
const out: any = eng.resolveObject({ meta: '{{(row.meta ?? {}) | json}}' }, ctx, {});
expect(out.meta).toBe('{}');
expect(out.meta).not.toContain('{{');
});
it('중괄호를 포함한 파이프 식이 값이 있을 때도 정상 평가된다', () => {
const eng = new DataBindingEngine();
const ctx: any = { row: { meta: { a: 1 } } };
const out: any = eng.resolveObject({ meta: '{{(row.meta ?? {}) | json}}' }, ctx, {});
expect(out.meta).toBe('{"a":1}');
});
it('evaluatePipeExpression 직접 호출도 동일하게 평가된다', () => {
const eng = new DataBindingEngine();
const ctx: any = { row: { meta: { a: 1 } } };
expect(eng.evaluatePipeExpression('(row.meta ?? {}) | json', ctx)).toBe('{"a":1}');
});
});
});
@@ -0,0 +1,168 @@
/**
* 파서 견고화 회귀 테스트 (engine-v1.55.1)
*
* - 보간 문자열 안의 바인딩을 정규식이 아니라 스캐너로 찾는다 (식 안의 `}` 허용)
* - `resolveObject` 는 key 단위로 실패를 격리한다 (한 표현식이 컴포넌트 전체를 죽이지 않음)
* - 중첩 객체 해석에도 컴포넌트별 skipBindingKeys 가 적용된다
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DataBindingEngine } from '../DataBindingEngine';
import { scanBindings } from '../BindingShape';
describe('DataBindingEngine — 파서 견고화', () => {
let engine: DataBindingEngine;
beforeEach(() => {
engine = new DataBindingEngine();
});
describe('scanBindings', () => {
it('식 안의 중괄호를 포함한 바인딩을 끝까지 찾는다', () => {
const found = scanBindings('a {{x ?? {}}} b');
expect(found).toHaveLength(1);
expect(found[0].expr).toBe('x ?? {}');
});
it('연속된 바인딩을 각각 찾는다', () => {
const found = scanBindings('{{a}}-{{b}}');
expect(found.map((f) => f.expr)).toEqual(['a', 'b']);
});
it('따옴표 안의 중괄호는 바인딩 종료로 보지 않는다', () => {
const found = scanBindings("{{q['a}b']}}");
expect(found).toHaveLength(1);
expect(found[0].expr).toBe("q['a}b']");
});
it('닫히지 않은 `{{` 는 바인딩이 아니다', () => {
expect(scanBindings('{{unclosed')).toEqual([]);
expect(scanBindings('plain text')).toEqual([]);
});
});
describe('보간 문자열 안의 중괄호 식', () => {
it('"a {{x ?? {}}} b" 가 원본 문자열로 남지 않는다', () => {
const out = engine.resolveBindings('a {{x ?? {}}} b', {});
expect(out).not.toContain('{{');
expect(out).toBe('a {} b');
});
it('중괄호 식과 일반 바인딩이 섞여도 각각 치환된다', () => {
const out = engine.resolveBindings('{{name}} / {{meta ?? {}}}', { name: 'g7' });
expect(out).toBe('g7 / {}');
});
it('바인딩이 없는 문자열은 그대로 반환한다 (회귀 보호)', () => {
expect(engine.resolveBindings('plain', {})).toBe('plain');
});
it('닫히지 않은 `{{` 는 리터럴로 남는다 (종전 정규식과 동일)', () => {
expect(engine.resolveBindings('{{unclosed', {})).toBe('{{unclosed');
});
});
describe('resolveObject 의 key 단위 예외 격리', () => {
it('한 key 의 표현식이 던져도 나머지 key 는 정상 해석된다', () => {
const context = {
ok: 'fine',
get boom(): string {
throw new Error('evaluation failed');
},
};
const out: any = engine.resolveObject(
{ a: '{{ok}}', b: '{{(boom) + 1}}', c: '{{ok}}' },
context,
{ skipCache: true },
);
expect(out.a).toBe('fine');
expect(out.b).toBeUndefined();
expect(out.c).toBe('fine');
});
it('중첩 객체 안의 실패는 그 안쪽 key 로 격리된다 (재귀 호출도 같은 규칙)', () => {
const context = {
get boom(): string {
throw new Error('evaluation failed');
},
ok: 1,
};
const out: any = engine.resolveObject(
{ style: { color: '{{(boom) + 1}}' }, id: '{{ok}}' },
context,
{ skipCache: true },
);
expect(out.id).toBe(1);
// 중첩 객체는 재귀 resolveObject 가 처리하므로 실패가 그 안쪽 key 에서 멈춘다.
expect(out.style).toEqual({ color: undefined });
});
});
describe('skipBindingKeys', () => {
it('전달된 키는 중첩 깊이와 무관하게 원본 그대로 남는다', () => {
const context = { row: { id: 7 } };
const out: any = engine.resolveObject(
{
wrapper: {
children: [{ text: '{{row.id}}' }],
label: '{{row.id}}',
},
},
context,
{ skipCache: true, skipBindingKeys: ['children'] },
);
expect(out.wrapper.children[0].text).toBe('{{row.id}}');
expect(out.wrapper.label).toBe(7);
});
it('기본 스킵 키(cellChildren)는 옵션 없이도 유지된다 (회귀 보호)', () => {
const context = { row: { id: 7 } };
const out: any = engine.resolveObject(
{ columns: [{ cellChildren: [{ text: '{{row.id}}' }] }] },
context,
{ skipCache: true },
);
expect(out.columns[0].cellChildren[0].text).toBe('{{row.id}}');
});
});
describe('식별자가 아닌 컨텍스트 키 (engine-v1.56.2)', () => {
// 표현식 평가는 컨텍스트 키를 `new Function` 의 파라미터로 넘긴다. 파라미터가 될 수
// 없는 키가 하나라도 있으면 함수 생성 자체가 SyntaxError 로 실패해, 그 키를 쓰지도
// 않는 식까지 **그 컨텍스트에서는 전부** 평가되지 못했다.
it('대괄호가 든 키가 있어도 다른 식이 정상 평가된다', () => {
const context: any = { 'sales_status[]': ['selling'], user: { name: '홍길동' } };
expect(engine.evaluateExpression("user.name ?? ''", context)).toBe('홍길동');
});
it('하이픈이 든 키가 있어도 다른 식이 정상 평가된다', () => {
const context: any = { 'data-id': 3, count: 5 };
expect(engine.evaluateExpression('count * 2', context)).toBe(10);
});
it('예약어 키가 있어도 다른 식이 정상 평가된다', () => {
const context: any = { class: 'x', new: 1, total: 4 };
expect(engine.evaluateExpression('total + 1', context)).toBe(5);
});
it('상위 객체를 거치는 실제 작성 형태는 그대로 동작한다', () => {
// 레이아웃의 실사용은 최상위 키가 아니라 `query['sales_status[]']` 형태다.
const context: any = { query: { 'sales_status[]': ['selling'] } };
expect(engine.evaluateExpression("query['sales_status[]']", context)).toEqual(['selling']);
});
it('같은 식이라도 컨텍스트 키 구성이 다르면 캐시가 섞이지 않는다', () => {
expect(engine.evaluateExpression('a + 1', { a: 1 } as any)).toBe(2);
expect(engine.evaluateExpression('a + 1', { a: 10, 'bad-key': 1 } as any)).toBe(11);
});
});
});
@@ -223,6 +223,70 @@ describe('DataBindingEngine - Pipe Integration', () => {
});
});
/**
* 파이프 결과의 타입 보존.
*
* `resolveBindings` 는 보간(문자열 산출)이 목적이라 결과에 formatValue 를 적용한다.
* 종전에는 값이 필요한 지점(prop·조건·반복 렌더)도 이 메서드에 위임했기 때문에
* 배열이 `"[\"a\",\"b\"]"` 로, `false` 가 `"false"`(truthy!) 로 바뀌어 나갔다.
* @since engine-v1.54.9
*/
describe('evaluatePipeExpression — 원본 타입 보존', () => {
it('배열을 반환하는 파이프는 배열 그대로 반환한다', () => {
const context = { row: { meta: { a: 1, b: 2 } } };
expect(engine.evaluatePipeExpression('row.meta | keys', context)).toEqual(['a', 'b']);
expect(engine.evaluatePipeExpression('row.meta | values', context)).toEqual([1, 2]);
});
it('boolean 을 반환하는 파이프는 문자열로 바뀌지 않는다', () => {
const context = { row: { flags: [false, true] } };
expect(engine.evaluatePipeExpression('row.flags | first', context)).toBe(false);
expect(engine.evaluatePipeExpression('row.flags | last', context)).toBe(true);
});
it('숫자를 반환하는 파이프는 number 타입을 유지한다', () => {
const context = { row: { tags: ['a', 'b', 'c'] } };
expect(engine.evaluatePipeExpression('row.tags | length', context)).toBe(3);
});
it('resolveBindings 는 종전대로 문자열로 서식한다 (보간 계약 유지)', () => {
const context = { row: { tags: ['a', 'b', 'c'] } };
expect(engine.resolveBindings('{{row.tags | length}}', context)).toBe('3');
expect(typeof engine.resolveBindings('{{row.flags | first}}', { row: { flags: [false] } })).toBe('string');
});
it('resolveObject 의 단일 바인딩은 파이프 결과 타입을 유지한다', () => {
const context = { row: { meta: { a: 1 }, flags: [false] } };
const out: any = engine.resolveObject(
{ keys: '{{row.meta | keys}}', flag: '{{row.flags | first}}' },
context,
);
expect(out.keys).toEqual(['a']);
expect(out.flag).toBe(false);
});
it('평가 실패는 호출 지점으로 전파된다 (resolveObject 는 key 단위로 격리)', () => {
const makeContext = () => ({
row: { a: 1 },
get boom(): string {
throw new Error('base expression failed');
},
});
expect(() => engine.evaluatePipeExpression('(boom) | uppercase', makeContext())).toThrow();
// resolveObject 에는 상위 catch 가 없으므로 실패한 key 만 undefined 로 떨어지고
// 같은 객체의 다른 key 는 정상 해석되어야 한다.
const out: any = engine.resolveObject(
{ broken: '{{(boom) | uppercase}}', intact: '{{row}}' },
makeContext(),
{ skipCache: true },
);
expect(out.broken).toBeUndefined();
expect(out.intact).toEqual({ a: 1 });
});
});
describe('skipCache와 파이프', () => {
it('skipCache: true로 매번 새로 평가', () => {
let counter = 0;
@@ -252,6 +252,70 @@ describe('DataSourceManager', () => {
);
});
/**
* 데이터소스 params 의 파이프.
*
* `resolveParamValue` 는 단일 `{{...}}` 를 evaluateExpression 으로 평가하는데,
* 그 경로는 `|` 를 JS 비트 OR 로 본다. 인자 있는 파이프는 예외로 catch 에 걸려
* **원본 `{{...}}` 문자열이 그대로 쿼리스트링에 실려 서버로 전송**됐다.
* @since engine-v1.54.10
*/
it('params 의 인자 있는 파이프가 원본 {{...}} 로 전송되지 않아야 함', async () => {
const mockResponse = { data: [] };
const mockApiClient = getApiClient();
(mockApiClient.get as any).mockResolvedValue(mockResponse);
const sources: DataSource[] = [
{
id: 'pipe-param-api',
type: 'api',
endpoint: '/api/admin/orders',
method: 'GET',
auth_required: true,
auto_fetch: true,
params: { from: "{{query.from | date('YYYY-MM-DD')}}" },
},
];
await manager.fetchDataSources(sources, {}, new URLSearchParams('from=2024-01-15T14:30:00'));
expect(mockApiClient.get).toHaveBeenCalledWith(
'/admin/orders',
expect.objectContaining({
params: { from: '2024-01-15' },
}),
);
const sentParams = (mockApiClient.get as any).mock.calls[0][1].params;
expect(JSON.stringify(sentParams)).not.toContain('{{');
});
it('params 의 인자 없는 파이프가 비트 OR 오답으로 전송되지 않아야 함', async () => {
const mockResponse = { data: [] };
const mockApiClient = getApiClient();
(mockApiClient.get as any).mockResolvedValue(mockResponse);
const sources: DataSource[] = [
{
id: 'pipe-param-noarg',
type: 'api',
endpoint: '/api/admin/orders',
method: 'GET',
auth_required: true,
auto_fetch: true,
params: { code: '{{query.code | uppercase}}' },
},
];
await manager.fetchDataSources(sources, {}, new URLSearchParams('code=abc'));
expect(mockApiClient.get).toHaveBeenCalledWith(
'/admin/orders',
expect.objectContaining({
params: { code: 'ABC' },
}),
);
});
it('쿼리 파라미터를 치환할 수 있어야 함 (점 표기법)', async () => {
const mockResponse = { data: [] };
const mockApiClient = getApiClient();
@@ -0,0 +1,144 @@
/**
* iteration + `if` 항목별 평가 회귀 테스트 (engine-v1.56.0)
*
* `if` 가 항목 변수를 참조하면 부모 시점 컨텍스트에는 그 변수가 없어 조건이 항상 false 가
* 되고, 조건 평가가 iteration 분기보다 먼저 걸려 **목록 전체가 렌더되지 않았다**.
* 반복 렌더 경로(renderItemChildren)는 항목별로 평가하므로 같은 작성이 거기서는 정상
* 동작했고, 그 비대칭 때문에 재현 위치를 특정하기 어려웠다.
*/
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import DynamicRenderer, { ComponentDefinition } from '../DynamicRenderer';
import { ComponentRegistry } from '../ComponentRegistry';
import { DataBindingEngine } from '../DataBindingEngine';
import { TranslationEngine, TranslationContext } from '../TranslationEngine';
import { ActionDispatcher } from '../ActionDispatcher';
const Row: React.FC<{ label?: string; children?: React.ReactNode }> = ({ label, children }) => (
<div data-testid="row">{label ?? children}</div>
);
describe('DynamicRenderer — iteration + if', () => {
let registry: ComponentRegistry;
let bindingEngine: DataBindingEngine;
let translationEngine: TranslationEngine;
let actionDispatcher: ActionDispatcher;
let translationContext: TranslationContext;
beforeEach(() => {
registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Row: { component: Row, metadata: { name: 'Row', type: 'basic' } },
};
bindingEngine = new DataBindingEngine();
translationEngine = new TranslationEngine();
actionDispatcher = new ActionDispatcher({ navigate: vi.fn() });
translationContext = { templateId: 'test-template', locale: 'ko' };
});
const renderDef = (componentDef: ComponentDefinition, dataContext: any) =>
render(
<DynamicRenderer
componentDef={componentDef}
dataContext={dataContext}
translationContext={translationContext}
registry={registry}
bindingEngine={bindingEngine}
translationEngine={translationEngine}
actionDispatcher={actionDispatcher}
/>
);
const context = {
users: [
{ name: '가', is_active: true },
{ name: '나', is_active: false },
{ name: '다', is_active: true },
],
};
it('항목 변수를 참조하는 if 가 항목별로 평가된다 (종전에는 목록 전체 미렌더)', () => {
renderDef(
{
id: 'list',
type: 'basic',
name: 'Row',
props: { label: '{{user.name}}' },
if: '{{user.is_active}}',
iteration: { source: '{{users}}', item_var: 'user' },
} as ComponentDefinition,
context
);
const rows = screen.getAllByTestId('row');
expect(rows.map((r) => r.textContent)).toEqual(['가', '다']);
});
it('외곽 변수만 참조하는 if 는 결과가 종전과 같다 (false → 전체 미렌더)', () => {
const view = renderDef(
{
id: 'list-off',
type: 'basic',
name: 'Row',
props: { label: '{{user.name}}' },
if: '{{flags.visible}}',
iteration: { source: '{{users}}', item_var: 'user' },
} as ComponentDefinition,
{ ...context, flags: { visible: false } }
);
expect(screen.queryAllByTestId('row')).toHaveLength(0);
view.unmount();
});
it('외곽 변수 if 가 true 면 전 항목이 렌더된다 (회귀 보호)', () => {
const view = renderDef(
{
id: 'list-on',
type: 'basic',
name: 'Row',
props: { label: '{{user.name}}' },
if: '{{flags.visible}}',
iteration: { source: '{{users}}', item_var: 'user' },
} as ComponentDefinition,
{ ...context, flags: { visible: true } }
);
expect(screen.getAllByTestId('row')).toHaveLength(3);
view.unmount();
});
it('iteration 없는 컴포넌트의 if 는 종전대로 즉시 차단한다 (회귀 보호)', () => {
const view = renderDef(
{
id: 'single',
type: 'basic',
name: 'Row',
props: { label: 'x' },
if: '{{flags.visible}}',
} as ComponentDefinition,
{ flags: { visible: false } }
);
expect(screen.queryAllByTestId('row')).toHaveLength(0);
view.unmount();
});
it('{item_var}_index 자동 변수가 주입된다 (반복 렌더 경로와 동일)', () => {
const view = renderDef(
{
id: 'idx',
type: 'basic',
name: 'Row',
props: { label: '{{user_index}}' },
iteration: { source: '{{users}}', item_var: 'user' },
} as ComponentDefinition,
context
);
expect(screen.getAllByTestId('row').map((r) => r.textContent)).toEqual(['0', '1', '2']);
view.unmount();
});
});
@@ -0,0 +1,238 @@
/**
* DynamicRenderer 파이프 렌더 경로 회귀 테스트
*
* #87 수정(engine-v1.54.3)이 파이프 평가를 `resolveBindings(`{{...}}`)` 위임으로 처리하면서
* 남긴 비대칭 3건을 렌더 경로에서 고정한다. 셋 다 엔진 단위 테스트로는 잡히지 않는다 —
* 결함이 "어떤 옵션으로 어떤 메서드를 호출하는가" 라는 호출 지점에 있기 때문이다.
*
* - D1: `}` 가 든 파이프 식이 원본 `{{...}}` 문자열로 화면에 노출
* - D2: `_computed` 파이프가 영구 캐시를 타 컴포넌트 간 stale 값 전파
* - D3: 파이프 결과가 항상 문자열로 서식되어 배열/boolean 이 소실
*
* @since engine-v1.54.9
*/
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import DynamicRenderer, { ComponentDefinition } from '../DynamicRenderer';
import { ComponentRegistry } from '../ComponentRegistry';
import { DataBindingEngine } from '../DataBindingEngine';
import { TranslationEngine, TranslationContext } from '../TranslationEngine';
import { ActionDispatcher } from '../ActionDispatcher';
/** prop 으로 전달된 값의 타입을 그대로 드러내는 프로브 컴포넌트 */
const TypeProbe: React.FC<{ value?: any; children?: React.ReactNode }> = ({ value }) => (
<div
data-testid="probe"
data-kind={Array.isArray(value) ? 'array' : typeof value}
data-json={JSON.stringify(value ?? null)}
/>
);
const Plain: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<div data-testid="plain">{children}</div>
);
describe('DynamicRenderer — 파이프 렌더 경로 비대칭 (engine-v1.54.9)', () => {
let registry: ComponentRegistry;
let bindingEngine: DataBindingEngine;
let translationEngine: TranslationEngine;
let actionDispatcher: ActionDispatcher;
let translationContext: TranslationContext;
beforeEach(() => {
registry = ComponentRegistry.getInstance();
(registry as any).registry = {
TypeProbe: { component: TypeProbe, metadata: { name: 'TypeProbe', type: 'basic' } },
Plain: { component: Plain, metadata: { name: 'Plain', type: 'basic' } },
};
bindingEngine = new DataBindingEngine();
translationEngine = new TranslationEngine();
actionDispatcher = new ActionDispatcher({ navigate: vi.fn() });
translationContext = { templateId: 'test-template', locale: 'ko' };
});
const renderDef = (componentDef: ComponentDefinition, dataContext: any) =>
render(
<DynamicRenderer
componentDef={componentDef}
dataContext={dataContext}
translationContext={translationContext}
registry={registry}
bindingEngine={bindingEngine}
translationEngine={translationEngine}
actionDispatcher={actionDispatcher}
/>
);
describe('D1 — 파이프 식 내부 중괄호', () => {
it('text 의 {{(row.meta ?? {}) | json}} 가 원본 문자열로 노출되지 않는다', () => {
const def: ComponentDefinition = {
id: 'd1-text',
type: 'basic',
name: 'Plain',
props: {},
text: '{{(row.meta ?? {}) | json}}',
};
renderDef(def, { row: { meta: { a: 1 } } });
const el = screen.getByTestId('plain');
expect(el).toHaveTextContent('{"a":1}');
expect(el.textContent).not.toContain('{{');
});
it('prop 의 파이프 식 내부 중괄호도 동일하게 평가된다', () => {
const def: ComponentDefinition = {
id: 'd1-prop',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{(row.meta ?? {}) | json}}' },
};
renderDef(def, { row: {} });
const el = screen.getByTestId('probe');
expect(el.getAttribute('data-kind')).toBe('string');
expect(JSON.parse(el.getAttribute('data-json')!)).toBe('{}');
});
});
describe('D2 — _computed 파이프의 컴포넌트 간 stale 전파', () => {
it('같은 엔진으로 렌더된 다른 컴포넌트가 이전 _computed 값을 재사용하지 않는다', () => {
const first = renderDef(
{
id: 'd2-a',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{_computed.total | number}}' },
},
{ _computed: { total: 1000 } }
);
expect(screen.getByTestId('probe').getAttribute('data-json')).toBe('"1,000"');
first.unmount();
// _computed 는 컴포넌트마다 _local 기반으로 재계산된다. 파이프 분기가
// getComputedAwareOptions 를 우회하면 baseExpr(`_computed.total`)이 영구 캐시(30초)에
// 저장되어 다음 컴포넌트가 앞 컴포넌트의 값을 그대로 받는다.
renderDef(
{
id: 'd2-b',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{_computed.total | number}}' },
},
{ _computed: { total: 2000 } }
);
expect(screen.getByTestId('probe').getAttribute('data-json')).toBe('"2,000"');
});
it('$computed alias 도 동일하게 캐시를 건너뛴다', () => {
const first = renderDef(
{
id: 'd2-alias-a',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{$computed.total | number}}' },
},
{ _computed: { total: 10 } }
);
expect(screen.getByTestId('probe').getAttribute('data-json')).toBe('"10"');
first.unmount();
renderDef(
{
id: 'd2-alias-b',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{$computed.total | number}}' },
},
{ _computed: { total: 20 } }
);
expect(screen.getByTestId('probe').getAttribute('data-json')).toBe('"20"');
});
});
describe('D3 — 파이프 결과 타입 보존', () => {
it('배열을 반환하는 파이프가 prop 에 배열로 전달된다', () => {
const def: ComponentDefinition = {
id: 'd3-array',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{row.meta | keys}}' },
};
renderDef(def, { row: { meta: { a: 1, b: 2 } } });
const el = screen.getByTestId('probe');
expect(el.getAttribute('data-kind')).toBe('array');
expect(JSON.parse(el.getAttribute('data-json')!)).toEqual(['a', 'b']);
});
it('boolean 을 반환하는 파이프가 문자열 "false" 로 바뀌지 않는다', () => {
const def: ComponentDefinition = {
id: 'd3-boolean',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{row.flags | first}}' },
};
renderDef(def, { row: { flags: [false, true] } });
const el = screen.getByTestId('probe');
expect(el.getAttribute('data-kind')).toBe('boolean');
expect(JSON.parse(el.getAttribute('data-json')!)).toBe(false);
});
it('숫자를 반환하는 파이프가 number 타입으로 전달된다', () => {
const def: ComponentDefinition = {
id: 'd3-number',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{row.tags | length}}' },
};
renderDef(def, { row: { tags: ['a', 'b', 'c'] } });
const el = screen.getByTestId('probe');
expect(el.getAttribute('data-kind')).toBe('number');
expect(JSON.parse(el.getAttribute('data-json')!)).toBe(3);
});
it('computed 정의식의 파이프가 적용된다 (종전에는 예외 → 이전 값 유지)', () => {
// _computedDefinitions 는 렌더마다 최신 _local 로 재계산된다. 그 평가가
// evaluateExpression 이라 파이프가 붙으면 예외 → catch 에서 이전 값 유지가 되어
// computed 가 영원히 갱신되지 않았다. @since engine-v1.54.10
const def: ComponentDefinition = {
id: 'p1-computed',
type: 'basic',
name: 'TypeProbe',
props: { value: '{{_computed.formatted}}' },
};
renderDef(def, {
_local: { price: 1234567 },
_computedDefinitions: { formatted: '{{_local.price | number}}' },
});
const el = screen.getByTestId('probe');
expect(JSON.parse(el.getAttribute('data-json')!)).toBe('1,234,567');
});
it('문자열을 반환하는 기존 파이프(datetime/number)의 결과는 종전과 동일하다', () => {
const def: ComponentDefinition = {
id: 'd3-string',
type: 'basic',
name: 'Plain',
props: {},
text: "{{row.created_at | datetime('YYYY-MM-DD HH:mm')}}",
};
renderDef(def, { row: { created_at: '2024-01-15T14:30:00' } });
expect(screen.getByTestId('plain')).toHaveTextContent('2024-01-15 14:30');
});
});
});
@@ -284,6 +284,42 @@ describe('TranslationEngine', () => {
);
expect(result).toBe('prefix: 안녕하세요, 홍길동님');
});
/**
* 회귀: 파라미터 값의 표현식 평가를 엔진에 위임한다 (engine-v1.56.1).
*
* 종전에는 이 지점이 `new Function(...Object.keys(dataContext))` 로 자체 평가해
* 엔진 확장 컨텍스트($localized/$t/$uuid)를 알지 못했고, 컨텍스트 키가 유효한
* 식별자가 아니면 함수 생성 자체가 SyntaxError 로 실패했다. 두 경우 모두 예외를
* 삼키고 **빈 문자열**을 돌려줬기 때문에, 번역 문구에서 값만 조용히 사라졌다.
*/
it('파라미터 표현식에서 $localized 헬퍼를 쓸 수 있다', () => {
const result = engine.resolveTranslations(
"$t:messages.greeting|name={{$localized('이순신')}}",
context,
{}
);
expect(result).toBe('안녕하세요, 이순신님');
});
it('파라미터 표현식이 식별자가 아닌 컨텍스트 키와 공존해도 값이 사라지지 않는다', () => {
// `sales_status[]` 는 함수 파라미터 이름이 될 수 없다 — 종전 구현은 여기서 실패했다.
const dataContext = {
'sales_status[]': ['selling'],
user: { name: '홍길동' },
};
// 단순 경로(`{{user.name}}`)는 함수를 만들지 않는 분기라 이 결함을 밟지 않는다.
// 연산자가 들어간 식이어야 종전 구현이 `new Function` 을 시도했다.
const result = engine.resolveTranslations(
"$t:messages.greeting|name={{user.name ?? ''}}",
context,
dataContext
);
expect(result).toBe('안녕하세요, 홍길동님');
});
});
describe('캐싱', () => {
@@ -187,6 +187,42 @@ describe('iteration source / evaluateExpression - 리터럴 및 내장객체 회
});
});
/**
* 파이프가 붙은 iteration source.
*
* `isComplexExpression` 은 `|` 를 연산자로 보아 evaluateExpression 으로 보내고,
* 거기서 `배열 | pipe` 가 JS 비트 OR 로 평가되면 결과가 배열이 아니게 된다.
* 반복 대상이 없어져 **목록 전체가 렌더되지 않는다** — 예외도 경고도 없다.
* @since engine-v1.54.10
*/
describe('resolveIterationSource + 파이프', () => {
it('인자 있는 파이프가 예외로 소실되지 않는다 (종전 undefined → 목록 전체 미렌더)', () => {
const context = { row: { tags: ['a', 'b', 'c'] } };
// evaluateExpression 경로에서는 `배열 | join('/')` 이 예외를 던져 undefined 가 됐다.
// (인자 문자열의 앞뒤 공백은 파이프 인자 파서가 제거한다 — 종전과 동일한 기존 동작)
const result = resolveIterationSource("{{row.tags | join('/')}}", context, engine);
expect(result).toBe('a/b/c');
expect(result).not.toBeUndefined();
});
it('인자 없는 파이프가 배열 타입을 유지한다', () => {
const context = { row: { meta: { a: 1, b: 2 } } };
const result = resolveIterationSource('{{row.meta | values}}', context, engine);
expect(Array.isArray(result)).toBe(true);
expect(result).toEqual([1, 2]);
});
it('중괄호 없는 형태(source 축약형)에서도 동일하게 동작한다', () => {
const context = { row: { meta: { a: 1 } } };
expect(resolveIterationSource('row.meta | keys', context, engine)).toEqual(['a']);
});
it('논리 OR(||)는 파이프로 오인되지 않는다 (회귀 보호)', () => {
const context = { a: null, b: [1, 2] };
expect(resolveIterationSource('{{a || b}}', context, engine)).toEqual([1, 2]);
});
});
describe('resolveIterationSource + Set 결합 (채널 필터 실제 경로)', () => {
it('Set 기반 채널 후보 배열을 iteration source 로 반환해야 함', () => {
const context = {
@@ -0,0 +1,137 @@
/**
* 파이프 분기 누락 검출 (구조 스캔)
*
* 사용자가 작성한 `{{...}}` 식을 `evaluateExpression` 으로 평가하는 지점은 반드시
* 그 앞에서 `hasPipes()` 로 파이프 여부를 갈라야 한다. 갈라지 않으면 `|` 가 JS 비트 OR 로
* 평가되어, 인자 있는 파이프는 예외(값 소실·원본 `{{...}}` 누출), 인자 없는 파이프는
* 조용한 오답이 된다. #87 이 그 사례였고, 같은 결함이 엔진 곳곳에 복제되어 있었다.
*
* 손으로 열거한 케이스 목록은 "지금 아는 지점" 만 지킨다. 이 테스트는 반대로
* **평가 지점 자체를 모집단으로 삼아** 새로 추가되는 지점까지 강제한다.
* 의도적으로 파이프를 지원하지 않는 지점은 아래 EXEMPT 에 사유와 함께 등재한다.
*
* @since engine-v1.54.10
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* 스캔 모집단은 `resources/js/core` 전체다.
*
* template-engine 디렉토리로 좁히면 `TemplateApp.ts` 처럼 밖에 있는 평가 지점이
* 조용히 빠진다 — 모집단을 좁히는 순간 이 테스트는 통과하면서 아무것도 지키지 않는다.
*/
const ENGINE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
/** 엔진 인스턴스를 통한 표현식 평가 호출 */
const EVAL_CALL = /\b(?:this\.)?(?:bindingEngine|dataBindingEngine|engine)\.evaluateExpression\s*\(/;
/**
* 파이프 분기 가드가 있어야 하는 범위(호출 지점 기준 위쪽 라인 수).
*
* 가드는 보통 같은 if/else 체인의 첫 분기에 있고, 그 사이에 배경 설명 주석이 들어간다.
* 좁게 잡으면 정상 지점이 위반으로 잡히므로 넉넉히 둔다 — 이 테스트의 목적은
* "가드가 아예 없는 지점" 을 잡는 것이다.
*/
const GUARD_LOOKBEHIND = 30;
/**
* 파이프를 의도적으로 지원하지 않는 지점.
*
* key: `<엔진 루트 기준 상대경로>::<해당 라인에 포함된 고유 문자열>`
*/
const EXEMPT: { file: string; marker: string; reason: string }[] = [
{
file: 'template-engine/DynamicRenderer.tsx',
marker: 'blurTrackingInfo',
reason:
'blur_until_loaded 는 truthiness 게이트다. 파이프 결과는 대부분 비어있지 않은 문자열이라 ' +
'분기를 추가하면 블러가 영구히 켜진다. 레이아웃 사용은 정적 검사로 차단한다.',
},
{
file: 'template-engine/G7CoreGlobals.ts',
marker: 'devToolsCore.getState()',
reason: 'DevTools 콘솔의 임의 식 평가 — 레이아웃 작성 문법이 아니라 개발자 입력이다.',
},
{
file: 'template-engine/TranslationEngine.ts',
marker: 'dataBindingEngine.evaluateExpression(expression, dataContext)',
reason:
'`$t:key|name=값` 의 `|` 는 파라미터 구분자다. 이 자리에서 `|` 를 파이프로 해석하면 ' +
'구분자와 충돌하므로 파이프를 지원하지 않는다(별개 문법 영역).',
},
];
function collectSourceFiles(dir: string, acc: string[] = []): string[] {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '__tests__' || entry.name === 'node_modules') continue;
collectSourceFiles(full, acc);
continue;
}
if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
acc.push(full);
}
}
return acc;
}
interface CallSite {
relative: string;
line: number;
text: string;
guarded: boolean;
}
function collectCallSites(): CallSite[] {
const sites: CallSite[] = [];
for (const file of collectSourceFiles(ENGINE_ROOT)) {
const relative = path.relative(ENGINE_ROOT, file).replace(/\\/g, '/');
// 평가기 자신과 라우터는 스캔 대상이 아니다 —
// DataBindingEngine 은 evaluateExpression/evaluatePipeExpression 의 구현체이고,
// BindingShape 는 classifyExpression 으로 파이프를 먼저 갈라내는 라우터다.
if (relative === 'template-engine/DataBindingEngine.ts') continue;
if (relative === 'template-engine/BindingShape.ts') continue;
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
lines.forEach((text, index) => {
if (!EVAL_CALL.test(text)) return;
const from = Math.max(0, index - GUARD_LOOKBEHIND);
const guarded = lines.slice(from, index + 1).some((l) => l.includes('hasPipes('));
sites.push({ relative, line: index + 1, text: text.trim(), guarded });
});
}
return sites;
}
describe('파이프 분기 누락 검출 — evaluateExpression 호출 지점 전수 스캔', () => {
const sites = collectCallSites();
it('스캔 모집단이 비어 있지 않다 (스캐너 자체 회귀 방지)', () => {
expect(sites.length).toBeGreaterThan(5);
});
it('모든 표현식 평가 지점이 파이프 분기를 갖거나 사유와 함께 면제되어 있다', () => {
const violations = sites
.filter((s) => !s.guarded)
.filter(
(s) =>
!EXEMPT.some((e) => e.file === s.relative && s.text.includes(e.marker))
)
.map((s) => `${s.relative}:${s.line} ${s.text}`);
expect(violations).toEqual([]);
});
it('면제 목록의 각 항목이 실제 코드에 존재한다 (좀비 면제 방지)', () => {
const stale = EXEMPT.filter(
(e) => !sites.some((s) => s.relative === e.file && s.text.includes(e.marker))
).map((e) => `${e.file} :: ${e.marker}`);
expect(stale).toEqual([]);
});
});
@@ -194,9 +194,15 @@ describe('renderItemChildren - 표현식 결과 $t: 번역 후처리', () => {
});
const element = result[0] as React.ReactElement;
// raw: 바인딩이므로 번역되지 않고 원본 유지 (raw 마커로 감싸져 반환)
// DynamicRenderer 상위에서 마커 스트리핑 처리됨
expect(element.props.children).toBe(`${RAW_MARKER_START}$t:common.save${RAW_MARKER_END}`);
// raw: 바인딩이므로 번역되지 않고 원본 유지.
//
// 마커는 번역 패스를 건너뛰게 하려는 **내부 표식**이므로 화면으로 나가기 전에 벗긴다.
// 종전에는 이 경로가 마커를 붙인 채 React 에 넘겨 Unicode Noncharacter 두 글자가
// 그대로 DOM 에 실렸다(단발 렌더 경로는 resolveTranslationsDeep 안에서 벗기지만
// 반복 렌더 경로에는 그 패스가 없다). @since engine-v1.56.3
expect(element.props.children).toBe('$t:common.save');
expect(element.props.children).not.toContain(RAW_MARKER_START);
expect(element.props.children).not.toContain(RAW_MARKER_END);
});
it('표현식 결과가 $t: 문자열이 아니면 번역 시도 안 함', () => {
@@ -0,0 +1,159 @@
/**
* 회귀 테스트 — 표현식 렌더 경로 비대칭 (engine-v1.54.4 ~ v1.56.3)
*
* 같은 작성 문법이 **어느 렌더 경로를 타느냐에 따라 다른 결과**를 내던 결함군을 고정한다.
* 표현식 해석 로직이 엔진 곳곳에 복제되면서 복사본마다 인식 문자 집합과 분기 구성이
* 갈린 것이 공통 원인이며, 증상 대부분이 예외가 아니라 **조용한 오답 또는 값 소실**이라
* 화면만 봐서는 "데이터가 없다"와 구분되지 않는다.
*
* 여기서 고정하는 7사례:
* 1. 따옴표 대괄호 식이 화면 위치마다 다른 값 (판정 방언)
* 2. 액션/데이터소스 params 에 `{{...}}` 원문 전송 (파이프 선분기 누락)
* 3. iteration source 파이프 → 목록 전멸
* 4. iteration + 항목 참조 `if` → 목록 전체 미렌더
* 5. 빈 바인딩 `{{}}` → 컨텍스트 객체 전체 노출
* 6. 비식별자 컨텍스트 키 → SyntaxError → 영역 전체 실종
* 7. raw 마커(U+FDD0/U+FDD1) 화면 누출
*
* 이 파일의 역할은 **증상 ↔ 코드 단언의 앵커**다. 각 결함의 상세 회귀선은 전용 테스트가
* 따로 갖고 있으므로 여기서는 증상을 그대로 재현하는 최소 단언만 둔다.
*
* - 판정 통일 상세: `BindingShape.routingUnification.test.tsx`
* - 라우팅 diff 하네스: `BindingShape.routingParity.test.ts`
* - 파이프 선분기 구조 검사: `pipeBranchParity.test.ts`
* - 반복 경로 패리티: `helpers/__tests__/RenderHelpers.repeatParity.test.tsx`
* - iteration + if: `DynamicRenderer.iterationCondition.test.tsx`
* - 파서 견고화: `DataBindingEngine.parserHardening.test.ts`
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DataBindingEngine } from '../DataBindingEngine';
import { classifyExpression, extractSingleBinding } from '../BindingShape';
import { hasPipes } from '../PipeRegistry';
import { evaluateStringCondition } from '../helpers/ConditionEvaluator';
describe('트러블슈팅 회귀 — 표현식 렌더 경로 비대칭', () => {
let engine: DataBindingEngine;
beforeEach(() => {
engine = new DataBindingEngine();
engine.clearCache?.();
});
describe('[TS-RENDERPATH-1] 같은 식이 화면 위치마다 다른 값 (따옴표 대괄호)', () => {
const EXPR = "query['sales_status[]']";
const context = { query: { 'sales_status[]': ['selling', 'sold_out'] } };
it('따옴표 대괄호 식은 경로가 아니라 표현식으로 분류된다', () => {
// 종전 좁은 방언(/[?:|&!+\-*/<>=()]/)은 `['...']` 를 인식하지 못해
// 식 전체를 경로 문자열로 보고 undefined 를 반환했다.
expect(classifyExpression(EXPR)).toBe('expression');
});
it('평가 결과가 undefined 가 아니라 실제 값이다', () => {
const value = engine.evaluateExpression(EXPR, context);
expect(value).toEqual(['selling', 'sold_out']);
});
it('조건 평가 경로도 같은 값을 본다 (경로 간 동일값)', () => {
const viaCondition = evaluateStringCondition(`{{${EXPR}.length > 0}}`, context, engine);
expect(viaCondition).toBe(true);
});
});
describe('[TS-RENDERPATH-2] 액션/데이터소스 params 에 `{{...}}` 원문 전송', () => {
const context = { row: { created_at: '2026-08-03 10:20:30' } };
it('인자 있는 파이프가 원본 문자열로 새지 않는다', () => {
const expr = "row.created_at | datetime('YYYY-MM-DD')";
expect(hasPipes(expr)).toBe(true);
const result = engine.evaluatePipeExpression(expr, context);
expect(String(result)).not.toContain('{{');
expect(String(result)).toBe('2026-08-03');
});
it('인자 없는 파이프가 비트 OR 오답(0)이 되지 않는다', () => {
// 종전: `'2026-08-03 10:20:30' | uppercase` 를 파이프 선분기 없이 표현식으로 평가하면
// `|` 가 JS 비트 OR 로 해석되어 숫자 0 이 됐다 (예외 없는 조용한 오답).
const result = engine.evaluatePipeExpression('row.title | uppercase', {
row: { title: 'order-2026' },
});
expect(result).not.toBe(0);
expect(result).toBe('ORDER-2026');
});
it('파이프 식은 선분기 없이 evaluateExpression 으로 보내면 안 된다 (구조 단언)', () => {
// 평가 지점은 반드시 hasPipes 로 갈라야 한다는 계약을 문서 사례와 함께 고정한다.
expect(hasPipes("row.created_at | datetime('YYYY-MM-DD')")).toBe(true);
expect(hasPipes('row.created_at')).toBe(false);
});
});
describe('[TS-RENDERPATH-3] iteration source 파이프 → 목록 전멸', () => {
it('파이프가 걸린 source 도 배열을 돌려준다', () => {
const context = { items: [{ n: 3 }, { n: 1 }, { n: 2 }] };
const result = engine.evaluatePipeExpression('items | length', context);
// 배열이 아니면 반복 렌더가 조용히 빈 배열로 처리된다 — 타입 보존이 방어선이다.
expect(result).toBe(3);
expect(typeof result).not.toBe('string');
});
it('파이프 결과가 배열이면 배열 그대로 유지된다 (문자열화 금지)', () => {
const context = { row: { tags: ['a', 'b', 'c'] } };
const result = engine.evaluatePipeExpression('row.tags | keys', context);
expect(Array.isArray(result)).toBe(true);
});
});
describe('[TS-RENDERPATH-5] 빈 바인딩이 컨텍스트 객체 전체를 노출', () => {
it('빈 식은 empty 로 분류된다', () => {
expect(extractSingleBinding('{{}}')).toBe('');
expect(classifyExpression('')).toBe('empty');
});
it('빈 경로 해석이 컨텍스트 객체를 반환하지 않는다', () => {
const context = { _global: { secret: 'x' }, _local: { a: 1 } };
const resolved = engine.resolve('', context);
expect(resolved).toBeUndefined();
expect(JSON.stringify(resolved ?? null)).not.toContain('secret');
});
});
describe('[TS-RENDERPATH-6] 비식별자 컨텍스트 키가 표현식 전체를 죽임', () => {
it('식별자가 아닌 키가 컨텍스트에 있어도 표현식이 평가된다', () => {
// 종전: 컨텍스트 키를 함수 파라미터명으로 넘겨 SyntaxError → 영역 전체 미렌더.
const context = {
'sales_status[]': ['selling'],
'data-id': 7,
query: { page: 2 },
user: { name: '홍길동' },
};
expect(() => engine.evaluateExpression('user.name', context)).not.toThrow();
expect(engine.evaluateExpression('user.name', context)).toBe('홍길동');
expect(engine.evaluateExpression('query.page + 1', context)).toBe(3);
});
it('비식별자 키는 대괄호 인덱싱으로 접근한다', () => {
const context = { 'sales_status[]': ['selling'] };
expect(engine.evaluateExpression("this['sales_status[]']?.length ?? 0", context)).toBeDefined();
});
});
describe('[TS-RENDERPATH 공통] 파서 견고화 — 중괄호 포함 식', () => {
it('보간 문자열 안의 중괄호 식이 원본으로 남지 않는다', () => {
const context = { x: null };
const out = engine.resolveBindings('a {{x ?? {}}} b', context);
expect(out).not.toContain('{{');
});
it('인접 바인딩 2개가 각각 치환된다 (greedy 오판 해소)', () => {
const context = { a: '1', b: '2' };
expect(engine.resolveBindings('{{a}}-{{b}}', context)).toBe('1-2');
});
});
});
@@ -10,7 +10,8 @@
*/
import { DataBindingEngine, BindingContext } from '../DataBindingEngine';
import { hasPipes } from '../PipeRegistry';
import { extractSingleBinding, resolveSingleBindingValue } from '../BindingShape';
import { stripRawPrefix } from '../rawMarkers';
import { createLogger } from '../../utils/Logger';
const logger = createLogger('ConditionEvaluator');
@@ -128,76 +129,25 @@ export interface ConditionBranchResult {
// ============================================================================
/**
* JavaScript 리터럴 값 해석
* 조건 문자열의 단일 바인딩 값을 형태에 따라 해석
*
* `true`, `false`, `null`, `undefined` 등의 리터럴을 직접 반환합니다.
* resolve()가 이들을 컨텍스트 경로로 해석하는 것을 방지합니다.
* 판정(단일 바인딩 여부·리터럴·파이프·표현식·경로)은 BindingShape 정본을 쓰고,
* 실행 정책(캐시 무시, 빈 바인딩 경고)만 이 지점이 정한다.
*
* @since engine-v1.17.6
* @returns `{ value: any }` 또는 리터럴이 아닌 경우 `undefined`
* @since engine-v1.55.0
*/
function resolveLiteral(expr: string): { value: any } | undefined {
switch (expr) {
case 'true':
return { value: true };
case 'false':
return { value: false };
case 'null':
return { value: null };
case 'undefined':
return { value: undefined };
default:
return undefined;
}
}
/**
* 복잡한 표현식 여부 감지
*
* 삼항 연산자(?:), 논리 연산자(&&, ||), 비교 연산자, 함수 호출 등이 포함된 경우 true 반환
*/
function isComplexExpression(expr: string): boolean {
return /[?:|&!+\-*/<>=()]/.test(expr);
}
/**
* 단일 바인딩 표현식 추출
*
* `{{expression}}` 형태에서 expression 부분만 추출합니다.
* 문자열 리터럴 내부의 `}` 문자를 올바르게 처리합니다.
*/
function extractSingleBinding(value: string): string | null {
if (!value.startsWith('{{') || !value.endsWith('}}')) {
return null;
}
const inner = value.slice(2, -2);
let inString: string | null = null;
let braceCount = 0;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
const prevChar = i > 0 ? inner[i - 1] : '';
// 이스케이프 문자 처리
if (prevChar === '\\') continue;
// 문자열 시작/종료 감지
if ((char === '"' || char === "'") && !inString) {
inString = char;
} else if (char === inString) {
inString = null;
}
// 문자열 외부에서만 중괄호 카운트
if (!inString) {
if (char === '{') braceCount++;
if (char === '}') braceCount--;
if (braceCount < 0) return null;
}
}
return braceCount === 0 ? inner.trim() : null;
function resolveConditionBinding(
expr: string,
context: BindingContext,
bindingEngine: DataBindingEngine,
label: string,
): unknown {
return resolveSingleBindingValue(stripRawPrefix(expr), context, bindingEngine, {
skipCache: true,
onEmpty: () => {
logger.warn(`${label}: 빈 바인딩 \`{{}}\` — undefined 로 해석합니다.`);
},
});
}
// ============================================================================
@@ -238,23 +188,15 @@ export function evaluateStringCondition(
let resolved: any;
if (singleBindingPath !== null) {
// JavaScript 리터럴 값 직접 처리 (resolve 경로 탐색 방지)
const literalValue = resolveLiteral(singleBindingPath);
if (literalValue !== undefined) {
resolved = literalValue.value;
} else if (hasPipes(singleBindingPath)) {
// 파이프 표현식은 resolveBindings 로 위임 (파이프 체인 실행 포함).
// isComplexExpression 은 `|` 를 연산자로 보므로 그대로 두면
// `value | pipe` 가 JS 비트 OR 로 평가되어 조건이 잘못 판정된다.
// @since engine-v1.54.3
resolved = bindingEngine.resolveBindings(`{{${singleBindingPath}}}`, context, { skipCache: true });
} else if (isComplexExpression(singleBindingPath)) {
// Optional chaining(?.)이나 복잡한 표현식은 evaluateExpression 사용
resolved = bindingEngine.evaluateExpression(singleBindingPath, context);
} else {
// 단순 경로는 resolve 메서드 사용 (원본 타입 유지)
resolved = bindingEngine.resolve(singleBindingPath, context, { skipCache: true });
}
// 리터럴 → 파이프 → 표현식 → 경로 라우팅. 조건은 원본 타입으로 판정해야 한다 —
// 문자열로 서식하면 `false`/`0` 이 아래 문자열 보정 분기에 의존하게 되고,
// 빈 배열 `[]` 은 `"[]"`(truthy)이 된다. @since engine-v1.54.9 / v1.55.0
resolved = resolveConditionBinding(
singleBindingPath,
context,
bindingEngine,
'evaluateStringCondition',
);
} else {
// 문자열 보간인 경우 resolveBindings 사용
resolved = bindingEngine.resolveBindings(condition, context, { skipCache: true });
@@ -1,3 +1,7 @@
// audit:allow seo-renderer-parity-sync 이번 변경은 노드 키·컨텍스트 처리 추가가 아니라
// 리터럴 판정을 BindingShape 로 위임한 것이다. 봇(SEO) 측 ExpressionEvaluator 는
// 이미 'true'/'false'/'null' 을 리터럴로 해석하고 있었으므로(app/Seo/ExpressionEvaluator.php),
// 이 수정은 React 경로를 봇 경로에 맞춘 것이며 패리티를 좁힌다. 봇 측 대응 변경 없음.
/**
* 렌더링 관련 헬퍼 함수 모듈
*
@@ -14,8 +18,13 @@ import { ComponentDefinition } from '../DynamicRenderer';
import { responsiveManager } from '../ResponsiveManager';
import { getActionDispatcher } from '../ActionDispatcher';
import { createLogger } from '../../utils/Logger';
import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep } from '../rawMarkers';
import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep, stripRawDeep, stripRawPrefix } from '../rawMarkers';
import { hasPipes } from '../PipeRegistry';
import {
extractSingleBinding as canonicalExtractSingleBinding,
isComplexExpression as canonicalIsComplexExpression,
resolveSingleBindingValue,
} from '../BindingShape';
import type { G7DevToolsInterface } from '../G7CoreGlobals';
import {
evaluateConditionExpression,
@@ -27,24 +36,6 @@ import {
const logger = createLogger('RenderHelpers');
/**
* JavaScript 리터럴 값 매핑
*
* resolve()가 리터럴을 컨텍스트 경로로 해석하는 것을 방지합니다.
* undefined는 Map에서 get() 시 undefined를 반환하므로 별도 센티널 불필요 —
* `has()`로 존재 여부를 먼저 확인하지 않고 `get() !== undefined`로 체크합니다.
* null은 falsy이므로 undefined와 동일하게 처리됩니다.
*
* @since engine-v1.29.2
*/
const LITERALS = new Map<string, any>([
['true', true],
['false', false],
['null', null],
// undefined는 Map.get()의 기본 반환값과 구분 불가하므로 제외
// → resolve("undefined")는 context에 없으면 undefined 반환 → Boolean(undefined) = false (정상)
]);
/**
* G7Core.devTools 인터페이스 가져오기
*
@@ -116,76 +107,22 @@ export interface RenderItemChildrenOptions {
/**
* 복잡한 표현식 여부 감지
*
* 삼항 연산자(?:), 논리 연산자(&&, ||), 비교 연산자, 함수 호출,
* 배열/객체 리터럴 등이 포함된 경우 true 반환
* 판정은 BindingShape 정본에 위임한다 — 이 모듈의 방언이 종전 6종 중 최대집합이었고,
* 그것이 정본이 되었다. 다른 렌더 경로들이 좁은 집합을 쓰던 것이 비대칭의 원인이었다.
*
* true 인 경우 evaluateExpression(JS 평가)로, false 인 경우 resolve(dot/bracket
* path 탐색)로 라우팅된다.
*
* `[`/`]`/`{`/`}` 포함 (@since engine-v1.50.0):
* `{{['a','b']}}` 같은 배열 리터럴이나 `{{[{...}]}}` 같은 객체 리터럴이
* "단순 경로"로 오판되어 resolve() 경로탐색 → undefined 가 되던 문제를 해결한다.
* 순수 숫자 대괄호 인덱싱(`items[0]`, `entry[1]`)은 resolve()/evaluateExpression()
* 양 경로의 결과가 동일하므로(회귀 테스트로 입증) 경로 이동에 따른 동작 변화가 없다.
*
* @param expr - 검사할 표현식
* @returns 복잡한 표현식 여부
* @since engine-v1.55.0
*/
function isComplexExpression(expr: string): boolean {
// () 추가: $localized(menu.name) 같은 함수 호출 표현식도 복잡한 표현식으로 처리
// []{} 추가: 배열/객체 리터럴(`['a','b']`, `[{...}]`)을 evaluateExpression 으로 라우팅
return /[?:|&!+\-*/<>=()[\]{}]/.test(expr);
}
const isComplexExpression = canonicalIsComplexExpression;
/**
* 단일 바인딩 표현식 추출
*
* `{{expression}}` 형태에서 expression 부분만 추출합니다.
* 문자열 리터럴 내부의 `}` 문자를 올바르게 처리합니다.
* `{{expression}}` 형태에서 expression 부분만 추출한다.
* 판정은 BindingShape 정본에 위임한다 (따옴표·이스케이프·중괄호 균형 추적).
*
* @param value - 검사할 문자열
* @returns 추출된 표현식 또는 null (유효하지 않은 경우)
*
* @example
* ```typescript
* extractSingleBinding("{{row.name}}") // "row.name"
* extractSingleBinding("{{row.status === 'active' ? 'yes' : 'no'}}") // "row.status === 'active' ? 'yes' : 'no'"
* extractSingleBinding("hello {{name}}") // null (단일 바인딩이 아님)
* ```
* @since engine-v1.55.0
*/
function extractSingleBinding(value: string): string | null {
if (!value.startsWith('{{') || !value.endsWith('}}')) {
return null;
}
const inner = value.slice(2, -2);
let inString: string | null = null;
let braceCount = 0;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
const prevChar = i > 0 ? inner[i - 1] : '';
// 이스케이프 문자 처리
if (prevChar === '\\') continue;
// 문자열 시작/종료 감지 (따옴표 추적)
if ((char === '"' || char === "'") && !inString) {
inString = char;
} else if (char === inString) {
inString = null;
}
// 문자열 외부에서만 중괄호 카운트
if (!inString) {
if (char === '{') braceCount++;
if (char === '}') braceCount--;
if (braceCount < 0) return null;
}
}
return braceCount === 0 ? inner.trim() : null;
}
const extractSingleBinding = canonicalExtractSingleBinding;
/**
* iteration source 표현식을 평가하여 배열을 반환합니다.
@@ -220,6 +157,19 @@ export function resolveIterationSource(
normalizedSource = source.slice(2, -2).trim();
}
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 (원본 타입 = 배열 유지).
// isComplexExpression 은 `|` 를 연산자로 보므로 그대로 두면 evaluateExpression 이
// JS 비트 OR 로 평가해 배열이 아닌 값이 나오고, 그러면 반복 대상이 없어져
// 목록 전체가 렌더되지 않는다 (에러 없이 빈 화면). @since engine-v1.54.10
if (hasPipes(normalizedSource)) {
try {
return bindingEngine.evaluatePipeExpression(normalizedSource, context, { skipCache: true });
} catch (error) {
logger.warn(`resolveIterationSource: 파이프 표현식 평가 실패: ${source}`, error);
return undefined;
}
}
// 표현식 여부 확인 (?, :, ||, &&, !, 산술/비교 연산자 등)
if (isComplexExpression(normalizedSource)) {
// 표현식인 경우 evaluateExpression 사용 (원본 타입 유지)
@@ -435,22 +385,22 @@ export function evaluateIfCondition(
let resolved: any;
if (singleBindingPath !== null) {
// JavaScript 리터럴 값 직접 처리 (resolve 경로 탐색 방지) @since engine-v1.29.2
const literal = LITERALS.get(singleBindingPath);
if (literal !== undefined) {
resolved = literal;
} else if (hasPipes(singleBindingPath)) {
// 파이프 표현식은 resolveBindings 로 위임 (파이프 체인 실행 포함).
// `|` 를 연산자로 보아 evaluateExpression 으로 보내면 JS 비트 OR 로
// 평가되어 조건이 잘못 판정된다. @since engine-v1.54.3
resolved = bindingEngine.resolveBindings(`{{${singleBindingPath}}}`, context, { skipCache: true });
} else if (isComplexExpression(singleBindingPath)) {
// Optional chaining(?.)이나 복잡한 표현식은 evaluateExpression 사용
resolved = bindingEngine.evaluateExpression(singleBindingPath, context);
} else {
// 단순 경로는 resolve 메서드 사용 (원본 타입 유지)
resolved = bindingEngine.resolve(singleBindingPath, context, { skipCache: true });
}
// 리터럴 → 파이프 → 표현식 → 경로 라우팅 (판정은 BindingShape 정본).
// 조건은 원본 타입으로 판정한다 — ConditionEvaluator 와 동일 규칙.
// @since engine-v1.54.9 / v1.55.0
resolved = resolveSingleBindingValue(
stripRawPrefix(singleBindingPath),
context,
bindingEngine,
{
skipCache: true,
onEmpty: () => {
logger.warn(
`evaluateIfCondition: 빈 바인딩 \`{{}}\` (컴포넌트: ${componentId || 'unknown'}) — undefined 로 해석합니다.`,
);
},
},
);
} else {
// 문자열 보간인 경우 resolveBindings 사용
resolved = bindingEngine.resolveBindings(ifCondition, context, { skipCache: true });
@@ -691,6 +641,35 @@ export function renderItemChildren(
// 이전에는 DataGrid 등에서 수동으로 수행하던 로직
const effectiveContext = getEffectiveContext(itemContext, options?.componentContext);
/**
* 값의 리프 문자열에 남은 `$t:` 토큰을 번역
*
* 표현식 평가 결과가 배열/객체인 경우(예: `{{row.badges}}` → `['$t:a', '$t:b']`)에도
* 번역이 닿도록 재귀 처리한다. raw 마커가 붙은 문자열은 번역 면제이므로 건드리지 않는다.
*
* @param value 평가 결과
* @param context 항목 컨텍스트
* @returns 번역이 적용된 값
*
* @since engine-v1.56.0
*/
const translateLeafTokens = (value: any, context: Record<string, any>): any => {
if (typeof value === 'string') {
if (isRawWrapped(value) || containsRawMarker(value)) return value;
if (!/\$t:[a-zA-Z0-9._-]+/.test(value)) return value;
return translationEngine.resolveTranslations(value, translationContext, context);
}
if (Array.isArray(value)) {
return value.map((item) => translateLeafTokens(item, context));
}
if (value && typeof value === 'object') {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) out[k] = translateLeafTokens(v, context);
return out;
}
return value;
};
/**
* props 값을 바인딩/표현식 평가하여 해석
* 반복 렌더링이므로 캐시를 사용하지 않음 (같은 경로가 다른 row에서 다른 값을 가져야 함)
@@ -747,6 +726,35 @@ export function renderItemChildren(
);
}
// 문자열 **중간**의 `$t:` 토큰 (예: "27$t:admin.roles.form.suffix")
//
// 종전에는 `startsWith('$t:')` 만 봤기 때문에 반복 렌더 경로에서는 중간 `$t:` 가
// 번역되지 않고 원본 키가 그대로 화면에 노출됐다. 일반 컴포넌트 경로
// (DynamicRenderer.resolveTranslationsDeep)는 이미 정규식으로 중간 토큰을 처리한다.
//
// 이 분기는 위의 `$t:defer:` 처리보다 **뒤에** 둔다. 다만 실사용이 실제로 의존하는
// 것은 분기 순서가 아니라 **항목 컨텍스트(`context`) 전달**이다 — 반복 서브트리 안의
// `$t:defer:` 32건(고유 24종)은 전부 `|param={{row.x}}` 형태이고, `row` 는
// DynamicRenderer 시점에 존재하지 않아 이 경로가 항목 컨텍스트와 함께 해소해야 한다.
// (순서만 바꾸는 변경은 아래 `{{` 가드 때문에 결과가 같다. 컨텍스트를 떨구면 파라미터
// 값만 조용히 사라진다 — `RenderHelpers.repeatParity.test.tsx` 가 그쪽을 잠근다.)
//
// JSON 구조 문자열(`{...}` / `[...]`) 안의 `$t:` 는 데이터의 일부이므로 번역하지 않는다
// (DynamicRenderer 의 동일 가드 이식). @since engine-v1.56.0
if (/\$t:[a-zA-Z0-9._-]+/.test(value)) {
const trimmed = value.trim();
const looksLikeJson =
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
if (!looksLikeJson && !value.includes('{{')) {
return translationEngine.resolveTranslations(
value,
translationContext,
context
);
}
}
// 단일 바인딩 표현식
const singleBindingPath = extractSingleBinding(value);
if (singleBindingPath !== null) {
@@ -758,39 +766,44 @@ export function renderItemChildren(
effectivePath = singleBindingPath.slice(RAW_PREFIX.length);
}
let result;
if (hasPipes(effectivePath)) {
// 파이프 표현식은 resolveBindings 로 위임 (파이프 체인 실행 포함)
// isComplexExpression 은 `|` 를 연산자로 보아 evaluateExpression 으로 보내는데,
// 그 경로에서는 `value | pipe` 가 JS 비트 OR 로 평가되어
// 인자 있는 파이프는 예외(값 소실), 인자 없는 파이프는 조용한 오답이 된다.
// DynamicRenderer 의 text 처리와 동일한 분기 순서를 유지한다.
// raw: 접두사는 아래 공통 분기(wrapRawDeep)가 담당하므로 여기서는 제거된
// effectivePath 만 넘긴다 — 원본 value 를 넘기면 raw 마커가 이중으로 감싸진다.
// @since engine-v1.54.3
result = bindingEngine.resolveBindings(`{{${effectivePath}}}`, context, { skipCache: true });
} else if (isComplexExpression(effectivePath)) {
try {
result = bindingEngine.evaluateExpression(effectivePath, context);
} catch (error) {
// 형태 판정은 `BindingShape` 단일 출처에 맡긴다. 종전에는 이 지점만
// `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접 갈라, 판정 통일
// (engine-v1.55.0)이 모아 둔 **리터럴**(`true`/`false`/`null`/`undefined`)을
// 이 경로만 몰랐다. 그래서 `{{true}}` 가 조건 자리에서는 `true`, 반복 렌더 prop
// 자리에서는 경로 탐색으로 새어 `undefined` 가 됐다 — 통일이 없애려던 바로 그
// 비대칭이 여기에만 남아 있었다.
//
// 실행 정책은 종전과 같다: 파이프는 원본 타입 보존(engine-v1.54.9), 평가 실패는
// 경고 후 `undefined`(값 소실이 화면 전체를 깨뜨리지 않도록).
// raw: 접두사는 아래 공통 분기(wrapRawDeep)가 담당하므로 제거된 effectivePath 만
// 넘긴다 — 원본 value 를 넘기면 raw 마커가 이중으로 감싸진다. @since engine-v1.54.3
// @since engine-v1.56.4
let evaluationFailed = false;
let result = resolveSingleBindingValue(effectivePath, context, bindingEngine, {
skipCache: true,
onError: (error) => {
logger.warn('renderItemChildren: 표현식 평가 실패:', error);
evaluationFailed = true;
return undefined;
}
} else {
// skipCache: true - 반복 렌더링에서 같은 경로가 다른 값을 가져야 함
result = bindingEngine.resolve(effectivePath, context, { skipCache: true });
},
onEmpty: () => {
logger.warn('renderItemChildren: 빈 바인딩 `{{}}` 은 해석하지 않습니다.');
},
});
if (evaluationFailed) {
return undefined;
}
// 표현식 평가 결과가 $t: 접두사 문자열이면 번역 처리
// 예: {{row.published ? '$t:module.key.published' : '$t:module.key.unpublished'}}
// → 평가 결과 "$t:module.key.published"를 번역하여 "발행" 반환
// @since engine-v1.28.1
if (!isRawBinding && typeof result === 'string' && /\$t:[a-zA-Z0-9._-]+/.test(result)) {
result = translationEngine.resolveTranslations(
result,
translationContext,
context
);
// 결과가 배열/객체면 리프 문자열까지 재귀로 처리한다 — 종전에는 문자열만 보고
// 배열/객체 안의 `$t:` 키가 번역되지 않은 채 화면에 노출됐다. @since engine-v1.56.0
if (!isRawBinding) {
result = translateLeafTokens(result, context);
}
return isRawBinding && result != null ? wrapRawDeep(result) : result;
@@ -819,6 +832,22 @@ export function renderItemChildren(
}
if (value && typeof value === 'object') {
// $switch 특수 객체 — 일반 컴포넌트 경로(resolveObject)는 지원하는데 반복 경로에만
// 없어서, 같은 작성이 여기서는 `{ $switch: "{{...}}", $cases: {...} }` 객체 그대로
// 컴포넌트에 전달됐다(조용한 미동작). 평가는 엔진에 위임한다.
// @since engine-v1.56.0
if (bindingEngine.isSwitchExpression(value)) {
return bindingEngine.resolveSwitch(value as any, context, { skipCache: true });
}
// 액션 정의 객체는 선평가하지 않는다 — 콜백 prop(onAddressSelect 등)에 담긴
// 액션 정의를 렌더 시점에 해소하면 내부 `{{_local.xxx}}` 가 그 시점 값으로 고정되고,
// 실행 시점의 최신 상태를 보지 못한다. ActionDispatcher 가 실행 시점에 해소한다.
// resolveObject 의 동일 규칙을 반복 경로에도 맞춘다. @since engine-v1.56.0
if (bindingEngine.isActionDefinition(value)) {
return { ...value };
}
const resolved: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
resolved[k] = resolveValue(v, context);
@@ -921,7 +950,9 @@ export function renderItemChildren(
// remount 지원: getRemountKey가 제공되면 사용, 아니면 기본 키 생성
// childDef.id의 표현식을 해석 (예: "toggle_{{row.id}}" → "toggle_3")
const resolvedIterationId = childDef.id ? String(resolveValue(childDef.id, iterationContext)) : undefined;
const resolvedIterationId = childDef.id
? String(stripRawDeep(resolveValue(childDef.id, iterationContext)))
: undefined;
const iterationFallbackKey = `${baseKey}-iter-${itemIndex}`;
const iterationKey = options?.getRemountKey
? options.getRemountKey(resolvedIterationId, iterationFallbackKey)
@@ -955,7 +986,17 @@ export function renderItemChildren(
devTools.trackRender(childDef.name);
}
return [React.createElement(Component, { key: iterationKey, ...finalProps }, childrenNodes)];
// raw 마커 최종 제거 — 마커는 번역 패스를 건너뛰게 하려는 내부 표식이므로
// 화면으로 나가기 전에 벗긴다. 단발 렌더 경로는 resolveTranslationsDeep 안에서
// 벗기지만 이 경로에는 그 패스가 없어 Unicode Noncharacter 두 글자가 그대로
// DOM 에 실렸다. @since engine-v1.56.3
return [
React.createElement(
Component,
{ key: iterationKey, ...stripRawDeep(finalProps) },
stripRawDeep(childrenNodes)
),
];
});
};
@@ -969,7 +1010,9 @@ export function renderItemChildren(
// remount 지원: getRemountKey가 제공되면 사용, 아니면 기본 키 생성
// childDef.id의 표현식을 해석 (예: "toggle_{{row.id}}" → "toggle_3")
const resolvedId = childDef.id ? String(resolveValue(childDef.id, effectiveContext)) : undefined;
const resolvedId = childDef.id
? String(stripRawDeep(resolveValue(childDef.id, effectiveContext)))
: undefined;
const fallbackKey = keyPrefix ? `${keyPrefix}-${resolvedId || index}` : (resolvedId || `child-${index}`);
const key = options?.getRemountKey
? options.getRemountKey(resolvedId, fallbackKey)
@@ -1038,7 +1081,10 @@ export function renderItemChildren(
devTools.trackRender(childDef.name);
}
return [React.createElement(Component, { key, ...finalProps }, childrenNodes)];
// raw 마커 최종 제거 (iteration 경로와 동일) @since engine-v1.56.3
return [
React.createElement(Component, { key, ...stripRawDeep(finalProps) }, stripRawDeep(childrenNodes)),
];
});
}
@@ -0,0 +1,344 @@
/**
* 반복 렌더 경로 기능 격차 회귀 테스트 (engine-v1.56.0)
*
* 일반 컴포넌트 경로(DynamicRenderer)에는 있고 반복 렌더 경로(renderItemChildren)에는
* 없던 기능, 그리고 그 반대 방향의 비대칭을 고정한다.
*
* 순서 의존 회귀선: `$t:defer:` 는 DynamicRenderer 가 건너뛰고 이 경로가 항목 컨텍스트와
* 함께 처리한다. 저장소 실사용 72건이 그 분담에 의존하므로, 새로 추가한 "문자열 중간 $t:"
* 처리가 `$t:defer:` 보다 앞서면 안 된다.
*/
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { renderItemChildren } from '../RenderHelpers';
import { ComponentRegistry } from '../../ComponentRegistry';
import { DataBindingEngine } from '../../DataBindingEngine';
import { TranslationEngine } from '../../TranslationEngine';
import { RAW_MARKER_START, RAW_MARKER_END } from '../../rawMarkers';
const Probe: React.FC<{ value?: any; children?: React.ReactNode }> = ({ value, children }) => (
<span data-testid="probe" data-json={JSON.stringify(value ?? null)}>
{children}
</span>
);
describe('renderItemChildren — 반복 렌더 경로 기능 격차', () => {
let registry: ComponentRegistry;
let bindingEngine: DataBindingEngine;
let translationEngine: TranslationEngine;
beforeEach(() => {
registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Probe: { component: Probe, metadata: { name: 'Probe', type: 'basic' } },
};
bindingEngine = new DataBindingEngine();
translationEngine = new TranslationEngine();
(translationEngine as any).translations.set('test-template:ko', {
shop: { unit: '개', published: '발행', draft: '비공개' },
admin: { count_suffix: '건', vendor: '제공: {vendor}', paid: '{amount} 결제' },
});
});
const renderItems = (children: any[], item: Record<string, any>) => {
const nodes = renderItemChildren(children, item, { Probe }, 'test', {
translationContext: { templateId: 'test-template', locale: 'ko' },
bindingEngine,
translationEngine,
actionDispatcher: { createHandler: () => vi.fn() } as any,
});
return render(<>{nodes}</>);
};
it('문자열 중간의 $t: 토큰이 번역된다 (종전에는 원본 키가 그대로 노출)', () => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', text: '27$t:admin.count_suffix' }],
{ row: {} },
);
expect(screen.getByTestId('probe').textContent).toBe('27건');
view.unmount();
});
it('$t:defer: 는 종전대로 이 경로에서 번역된다 (반복 서브트리 실사용 회귀선)', () => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', text: '$t:defer:shop.unit' }],
{ row: {} },
);
expect(screen.getByTestId('probe').textContent).toBe('개');
view.unmount();
});
/**
* 대표 케이스 회귀선 — 저장소 실사용 `$t:defer:` 32건(고유 24종)은 **전부**
* `$t:defer:key|param={{row.x}}` 파라미터 형태다. `$t:defer:` 를 이 경로에 남겨 둔
* 설계 근거 자체가 "파라미터의 `row` 는 DynamicRenderer 시점에 존재하지 않는다" 이므로,
* 파라미터 없는 형태만 검사하면 순서 회귀를 놓친다 — 파라미터 없는 defer 는 뒤이은
* `startsWith('$t:')` 분기로도 우연히 통과하기 때문이다.
*/
it('$t:defer: 파라미터가 항목 컨텍스트로 해소된다 (실사용 형태 대표 케이스)', () => {
const view = renderItems(
[
{
id: 'p',
type: 'basic',
name: 'Probe',
text: '$t:defer:admin.vendor|vendor={{row.vendor}}',
},
],
{ row: { vendor: '시르소프트' } },
);
expect(screen.getByTestId('probe').textContent).toBe('제공: 시르소프트');
view.unmount();
});
it('$t:defer: 파라미터가 문자열 중간에 있어도 항목 값으로 해소된다', () => {
const view = renderItems(
[
{
id: 'p',
type: 'basic',
name: 'Probe',
text: '합계 $t:defer:admin.paid|amount={{row.total}}',
},
],
{ row: { total: '12,000원' } },
);
// 중간 `$t:` 처리가 `$t:defer:` 분기보다 앞서면 파라미터가 해소되지 않은 채 번역돼
// 값이 사라진다 — 이 단언이 그 순서를 고정한다.
expect(screen.getByTestId('probe').textContent).toBe('합계 12,000원 결제');
view.unmount();
});
it('$t:defer: 파라미터는 항목마다 다른 값으로 해소된다', () => {
const nodes = renderItemChildren(
[
{
id: 'p',
type: 'basic',
name: 'Probe',
text: '$t:defer:admin.vendor|vendor={{row.vendor}}',
},
],
{ row: { vendor: 'A사' } },
{ Probe },
'first',
{
translationContext: { templateId: 'test-template', locale: 'ko' },
bindingEngine,
translationEngine,
actionDispatcher: { createHandler: () => vi.fn() } as any,
},
);
const second = renderItemChildren(
[
{
id: 'p',
type: 'basic',
name: 'Probe',
text: '$t:defer:admin.vendor|vendor={{row.vendor}}',
},
],
{ row: { vendor: 'B사' } },
{ Probe },
'second',
{
translationContext: { templateId: 'test-template', locale: 'ko' },
bindingEngine,
translationEngine,
actionDispatcher: { createHandler: () => vi.fn() } as any,
},
);
const first = render(<>{nodes}</>);
expect(screen.getByTestId('probe').textContent).toBe('제공: A사');
first.unmount();
const secondView = render(<>{second}</>);
expect(screen.getByTestId('probe').textContent).toBe('제공: B사');
secondView.unmount();
});
it('JSON 구조 문자열 안의 $t: 는 번역하지 않는다 (데이터의 일부)', () => {
const view = renderItems(
[
{
id: 'p',
type: 'basic',
name: 'Probe',
props: { value: '{"text":"$t:shop.unit"}' },
},
],
{ row: {} },
);
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toBe(
'{"text":"$t:shop.unit"}',
);
view.unmount();
});
it('표현식 결과가 배열이면 리프의 $t: 까지 번역된다', () => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', props: { value: '{{row.badges}}' } }],
{ row: { badges: ['$t:shop.published', '$t:shop.draft'] } },
);
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toEqual([
'발행',
'비공개',
]);
view.unmount();
});
it('$switch 객체가 값으로 해석된다 (종전에는 객체 그대로 전달)', () => {
const view = renderItems(
[
{
id: 'p',
type: 'basic',
name: 'Probe',
props: {
value: {
$switch: '{{row.status}}',
$cases: { active: 'text-green-500', inactive: 'text-gray-500' },
$default: 'text-gray-400',
},
},
},
],
{ row: { status: 'active' } },
);
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toBe(
'text-green-500',
);
view.unmount();
});
it('액션 정의 객체는 선평가하지 않는다 (실행 시점 최신 상태 보장)', () => {
const view = renderItems(
[
{
id: 'p',
type: 'basic',
name: 'Probe',
props: {
value: {
handler: 'apiCall',
target: '/api/x',
params: { id: '{{_local.selectedId}}' },
},
},
},
],
{ row: {}, _local: { selectedId: 1 } },
);
const passed = JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!);
expect(passed.params.id).toBe('{{_local.selectedId}}');
view.unmount();
});
// ── raw 마커 최종 제거 (engine-v1.56.3) ─────────────────────────────
//
// 마커(﷐ ﷑)는 번역 패스를 건너뛰게 하려는 내부 표식이다. 단발 렌더 경로는
// resolveTranslationsDeep 안에서 벗기지만 이 경로에는 그 패스가 없어, 마커가 붙은 채
// React 로 넘어가 Unicode Noncharacter 두 글자가 그대로 DOM 에 실렸다.
it('raw: 바인딩의 text 에 raw 마커가 남지 않는다', () => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', text: '{{raw:row.label}}' }],
{ row: { label: '$t:shop.unit' } },
);
const text = screen.getByTestId('probe').textContent!;
// raw: 이므로 번역은 되지 않고
expect(text).toBe('$t:shop.unit');
// 마커는 화면으로 새지 않는다
expect(text).not.toContain(RAW_MARKER_START);
expect(text).not.toContain(RAW_MARKER_END);
view.unmount();
});
it('raw: 바인딩의 props 값에도 raw 마커가 남지 않는다', () => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', props: { value: '{{raw:row.label}}' } }],
{ row: { label: '$t:shop.unit' } },
);
const passed = JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!);
expect(passed).toBe('$t:shop.unit');
expect(JSON.stringify(passed)).not.toContain(RAW_MARKER_START);
view.unmount();
});
it('raw: 결과가 배열/객체여도 리프까지 마커가 남지 않는다', () => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', props: { value: '{{raw:row.meta}}' } }],
{ row: { meta: { a: '$t:shop.unit', b: ['$t:shop.draft'] } } },
);
const passed = JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!);
expect(passed).toEqual({ a: '$t:shop.unit', b: ['$t:shop.draft'] });
expect(JSON.stringify(passed)).not.toContain(RAW_MARKER_START);
expect(JSON.stringify(passed)).not.toContain(RAW_MARKER_END);
view.unmount();
});
it('중첩 자식 안의 raw: 도 마커 없이 렌더된다', () => {
const view = renderItems(
[
{
id: 'outer',
type: 'basic',
name: 'Probe',
children: [{ id: 'inner', type: 'basic', name: 'Probe', text: '{{raw:row.label}}' }],
},
],
{ row: { label: '$t:shop.unit' } },
);
const all = screen.getAllByTestId('probe');
const text = all[all.length - 1].textContent!;
expect(text).toBe('$t:shop.unit');
expect(text).not.toContain(RAW_MARKER_START);
view.unmount();
});
it('일반 바인딩은 종전대로 즉시 해석된다 (회귀 보호)', () => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', props: { value: '{{row.id}}' } }],
{ row: { id: 42 } },
);
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toBe(42);
view.unmount();
});
/**
* 리터럴 단일 바인딩 (engine-v1.56.4)
*
* 판정 통일(engine-v1.55.0)에서 리터럴을 `BindingShape` 한 곳으로 모았지만, 이 경로만
* `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접 갈라 리터럴을 몰랐다.
* 그래서 `{{true}}` 가 조건 자리에서는 `true`, 반복 렌더 prop 자리에서는 `undefined` 로
* 갈렸다 — 통일이 없애려던 바로 그 비대칭이 이 경로에만 남아 있었다.
*/
it.each([
['{{true}}', true],
['{{false}}', false],
['{{null}}', null],
])('리터럴 %s 이 prop 자리에서 경로 탐색으로 새지 않는다', (expr, expected) => {
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', props: { value: expr } }],
{ row: { id: 1 } },
);
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toBe(expected);
view.unmount();
});
it('리터럴과 같은 이름의 컨텍스트 경로보다 리터럴이 우선한다', () => {
// `true` 라는 이름의 컨텍스트 키가 있어도 `{{true}}` 는 리터럴이다 —
// 조건 평가 경로와 같은 규칙.
const view = renderItems(
[{ id: 'p', type: 'basic', name: 'Probe', props: { value: '{{true}}' } }],
{ true: '경로값', row: { id: 1 } },
);
expect(JSON.parse(screen.getByTestId('probe').getAttribute('data-json')!)).toBe(true);
view.unmount();
});
});
@@ -24,6 +24,7 @@
import type { ComputedRecipeSpec, ConditionParamSpec } from './specTypes';
import { dataBindingEngine, type BindingContext } from '../../DataBindingEngine';
import { hasPipes } from '../../PipeRegistry';
// ── 친화 보기(프리셋) — spec 주도 build/match ─────────────────────────
@@ -598,9 +599,14 @@ export function evaluateComputedPreview(
}
const inner = stripOuterBraces(exprValue);
try {
const value = dataBindingEngine.evaluateExpression(inner, sampleContext ?? {}, {
skipCache: true,
});
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — 런타임(DynamicRenderer /
// ActionDispatcher)의 computed 재계산과 같은 규칙이라야 편집기 미리보기가
// 실제 화면과 같은 값을 보여준다. @since engine-v1.54.10
const value = hasPipes(inner)
? dataBindingEngine.evaluatePipeExpression(inner, sampleContext ?? {}, { skipCache: true })
: dataBindingEngine.evaluateExpression(inner, sampleContext ?? {}, {
skipCache: true,
});
return { ok: true, value, type: classifyType(value) };
} catch (e) {
// 미리보기 평가 실패는 정상 경로(샘플 컨텍스트에 그 데이터가 없을 수 있음) —
@@ -10,6 +10,17 @@
/** raw: 바인딩 접두사 */
export const RAW_PREFIX = 'raw:';
/**
* 바인딩 식에서 `raw:` 접두사 제거 (없으면 원본 그대로)
*
* 접두사를 벗기지 않은 채 식으로 평가하면 `raw:` 의 콜론이 식의 일부로 파싱된다.
*
* @since engine-v1.54.9
*/
export function stripRawPrefix(expr: string): string {
return expr.startsWith(RAW_PREFIX) ? expr.slice(RAW_PREFIX.length) : expr;
}
/** 번역 면제 시작 마커 (Unicode Noncharacter) */
export const RAW_MARKER_START = '\uFDD0';
@@ -62,3 +73,56 @@ export function wrapRawDeep(value: any): any {
}
return value;
}
/**
* 값의 모든 리프 문자열에서 raw 마커를 재귀적으로 제거 (`wrapRawDeep` 의 역연산)
*
* 마커는 번역 패스가 raw 값을 건너뛰게 하려고 붙이는 **내부 표식**이므로, 값이 화면
* (React props/children)으로 나가기 전에 반드시 벗겨야 한다. 남으면 Unicode Noncharacter
* 두 글자가 그대로 DOM 에 실린다.
*
* 단발 렌더 경로는 `resolveTranslationsDeep` 안에서 벗기지만, 반복 렌더 경로
* (`renderItemChildren`)에는 그 패스가 없어 마커가 그대로 나갔다.
*
* React 엘리먼트는 순회하지 않는다 — 이미 렌더된 자식 트리이고, 그 안의 값은 각자
* 자기 경로에서 이미 처리됐다.
*
* @since engine-v1.56.3
*/
export function stripRawDeep(value: any): any {
if (typeof value === 'string') {
if (isRawWrapped(value)) return unwrapRaw(value);
// 혼합 보간으로 문자열 중간에 마커가 박힌 경우
if (containsRawMarker(value) || value.includes(RAW_MARKER_END)) {
return value.split(RAW_MARKER_START).join('').split(RAW_MARKER_END).join('');
}
return value;
}
if (Array.isArray(value)) {
let changed = false;
const out = value.map((v) => {
const next = stripRawDeep(v);
if (next !== v) changed = true;
return next;
});
// 마커가 없으면 원본 참조를 그대로 돌려준다 (반복 렌더에서 매 항목 재할당 방지)
return changed ? out : value;
}
if (value && typeof value === 'object') {
// React 엘리먼트는 이미 렌더된 자식 트리다 — 순회하지 않는다
if ((value as any).$$typeof !== undefined) return value;
let changed = false;
const result: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
const next = stripRawDeep(v);
if (next !== v) changed = true;
result[k] = next;
}
return changed ? result : value;
}
return value;
}
@@ -30,6 +30,11 @@
### Fixed
- 표와 카드 목록에서 항목별 표시 조건이 서로 다르게 판정되던 문제를 수정했습니다. 같은 조건을 써도 표에서는 항상 보이고 카드에서는 아무것도 보이지 않는 식으로 결과가 갈렸으며, 특히 화면 상태값을 참조하는 조건에서 그랬습니다. 이제 두 목록 모두 다른 화면 요소와 같은 기준으로 조건을 판정합니다.
- 표 하단 합계 영역에 넣은 내용이 표 본문과 다른 방식으로 처리되던 문제를 수정했습니다. 합계 영역도 각 항목과 같은 규칙으로 값을 해석합니다.
- 표 하단 합계 영역의 값이 계산되지 않고 표현식 원문 그대로 표시되던 문제를 수정했습니다. 주문 상세의 합계 행에서 수량·적립금·배송비 자리에 값 대신 알 수 없는 문자열이 보였습니다.
- 표의 셀 안쪽·확장 영역·합계 영역에서 일부 화면 요소가 아무 표시 없이 사라지던 문제를 수정했습니다. 표 바깥에서는 정상 표시되는 요소가 표 안에서만 빠졌으며, 본인인증 이력의 확장 영역에서 부가 정보 내용이 제목만 남고 보이지 않았습니다.
- 카드 목록의 카드 내용 항목 이름이 실제 동작과 다르게 안내되던 문제를 수정했습니다(레이아웃 편집기의 속성 목록에 없는 이름이 표시되고 실제로 쓰는 이름은 빠져 있었습니다).
- 모듈·플러그인·템플릿 관리 화면에서 '업데이트 확인'을 눌러 업데이트가 발견돼도 항상 "모두 최신 상태입니다"라고 안내되던 문제를 수정했습니다.
- 게시판·상품 카테고리 목록의 새로고침 버튼과 언어팩 재활성화 안내에서 번역되지 않은 내부 문구가 그대로 보이던 문제를 수정했습니다.
- 모듈·플러그인·템플릿 관리 화면의 목록 요약이 항상 "총 0개 중 1-0개 표시"로 나오고, 목록이 한 화면을 넘어도 다음 쪽으로 넘어갈 수 없던 문제를 수정했습니다.
@@ -1182,7 +1182,7 @@
"type": "composite",
"description": "Data grid with sorting, filtering, and pagination",
"path": "src/components/composite/DataGrid.tsx",
"skipBindingKeys": ["cellChildren", "expandChildren", "expandContext", "render", "subRowChildren", "subRowCondition"],
"skipBindingKeys": ["cellChildren", "expandChildren", "expandContext", "render", "subRowChildren", "subRowCondition", "footerCells", "footerCardChildren"],
"props": {
"columns": {
"type": "array",
@@ -1235,17 +1235,17 @@
"type": "composite",
"description": "Card grid layout with automatic skeleton loading and pagination support",
"path": "src/components/composite/CardGrid.tsx",
"skipBindingKeys": ["cardChildren"],
"skipBindingKeys": ["cardColumns"],
"props": {
"data": {
"type": "array",
"required": false,
"description": "표시할 데이터 배열 (undefined일 경우 자동 스켈레톤 표시)"
},
"cardChildren": {
"cardColumns": {
"type": "array",
"required": true,
"description": "카드 내용 정의 배열 (CardGridCellChild[])"
"description": "카드 내용 정의 배열 (CardGridColumn[] — 첫 항목의 cellChildren 을 카드 본문으로 사용)"
},
"columns": {
"type": "number",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -114,8 +114,6 @@
"props": { "className": "admin-page-content-responsive-fluid" },
"children": [
{
"type": "basic",
"name": "Div",
"partial": "partials/admin_extension_language_packs/_content.json"
}
]
@@ -12,8 +12,6 @@
},
"children": [
{
"type": "basic",
"name": "Div",
"partial": "partials/admin_language_pack_list/_content.json"
}
]
@@ -123,46 +123,21 @@ const getComponentMap = (): Record<string, React.ComponentType<any>> => {
/**
* 조건 문자열을 평가합니다.
* 복잡한 JavaScript 표현식도 지원합니다 (&&, ||, length 등)
*
* 판정은 엔진(`G7Core.evaluateCondition`)에 위임합니다. 종전에는 이 파일이
* `new Function('row', ...)` 로 자체 평가기를 두어 엔진과 판정이 갈렸고,
* `_local`/`_global` 을 참조하는 조건에서 예외가 나면 여기서는 `false`(자식이 사라짐),
* DataGrid 에서는 `true`(항상 표시)로 **반대 방향** 결과가 났습니다.
*/
const evaluateCondition = (condition: string, row: any): boolean => {
if (!condition) return true;
const match = condition.match(/^\{\{(.+)\}\}$/);
if (!match) return true;
const expr = match[1].trim();
// 디버깅: row 데이터와 표현식 확인
logger.log('evaluateCondition:', { condition, expr, row, rowStatus: row?.status });
// 복잡한 표현식 (&&, ||, length, > 등)이 포함된 경우 eval 사용
if (/[&|<>=!]/.test(expr) || expr.includes('.length')) {
try {
// row 컨텍스트에서 표현식 평가
// eslint-disable-next-line no-new-func
const evaluator = new Function('row', `return ${expr}`);
const result = !!evaluator(row);
logger.log('evaluateCondition result:', result);
return result;
} catch (error) {
logger.warn('evaluateCondition: 표현식 평가 실패:', expr, error);
return false;
}
}
// !row.field 패턴
if (expr.startsWith('!row.')) {
const field = expr.slice(5);
return !row[field];
}
// row.field 패턴
if (expr.startsWith('row.')) {
const field = expr.slice(4);
return !!row[field];
const G7Core = (window as any).G7Core;
if (typeof G7Core?.evaluateCondition === 'function') {
return G7Core.evaluateCondition(condition, { row, item: row, $item: row });
}
logger.warn('G7Core.evaluateCondition 미노출 — 조건을 평가할 수 없습니다:', condition);
return true;
};
@@ -43,8 +43,18 @@ const logger = ((window as any).G7Core?.createLogger?.('Comp:DataGrid')) ?? {
const t = (key: string, params?: Record<string, string | number>) =>
(window as any).G7Core?.t?.(key, params) ?? key;
// cellChildren 및 expandChildren 렌더링을 위한 컴포넌트 맵
const componentMap: Record<string, React.ComponentType<any>> = {
/**
* 반복 렌더 서브트리용 컴포넌트 맵 **폴백**.
*
* 정본은 템플릿 레지스트리(`G7Core.getComponentMap()`)다 — CardGrid 와 같은 출처를 쓴다.
* 이 열거는 런타임 전역이 아직 없는 상황(단위 테스트 등)에서만 쓰인다.
*
* 종전에는 이 열거가 유일한 출처였고, 등록된 117개 중 29개만 담고 있어 나머지 88개가
* `cellChildren`/`expandChildren`/`subRowChildren`/`footerCells` 안에서만 조용히 사라졌다.
* 실측: `/admin/identity/logs` 확장 행의 `Pre`(부가 정보 JSON)가 라벨만 남고 미렌더 —
* 같은 `Pre` 가 모달(DynamicRenderer 경로)에서는 정상 렌더돼 경로 비대칭이었다.
*/
const FALLBACK_COMPONENT_MAP: Record<string, React.ComponentType<any>> = {
// basic 컴포넌트
Div,
Span,
@@ -79,6 +89,22 @@ const componentMap: Record<string, React.ComponentType<any>> = {
ActionMenu,
};
/**
* 반복 렌더 서브트리에 넘길 컴포넌트 맵을 반환합니다.
*
* 템플릿 레지스트리 전체를 쓰므로 `components.json` 에 등록된 컴포넌트는 단발 렌더 경로와
* 반복 렌더 경로에서 **동일하게** 조회된다. 전역이 없으면 폴백 열거로 내려간다.
*
* @returns 컴포넌트명 → React 컴포넌트 맵
*/
const getComponentMap = (): Record<string, React.ComponentType<any>> => {
const registry = (window as any).G7Core?.getComponentMap?.();
if (registry && Object.keys(registry).length > 0) {
return registry;
}
return FALLBACK_COMPONENT_MAP;
};
export interface DataGridCellChild {
id?: string;
type: 'basic' | 'composite';
@@ -436,7 +462,11 @@ const safeRenderValue = (value: any): React.ReactNode => {
/**
* subRow 컨테이너의 표시 조건을 평가합니다.
* JSX 인라인 조건에서 사용되므로 엔진 위임 불가 — 직접 평가합니다.
*
* 판정은 엔진(`G7Core.evaluateCondition`)에 위임합니다. 종전에는 이 파일이
* `new Function('row', ...)` 로 자체 평가기를 두어, `_local`/`_global` 을 참조하는
* 조건에서 예외가 나면 CardGrid 는 자식이 사라지고 DataGrid 는 항상 표시되는
* **반대 방향** 결과가 났습니다. 엔진 위임으로 두 컴포넌트의 판정이 같아집니다.
*
* @param condition 조건 문자열 (예: "{{row.charge_policy !== 'free'}}")
* @param row 현재 행 데이터
@@ -445,21 +475,13 @@ const safeRenderValue = (value: any): React.ReactNode => {
const evaluateSubRowCondition = (condition: string | undefined, row: any): boolean => {
if (!condition) return true;
// {{...}} 패턴 추출
const match = condition.match(/^\{\{(.+)\}\}$/);
if (!match) return true;
const expr = match[1].trim();
try {
// row 컨텍스트에서 표현식 평가
// eslint-disable-next-line no-new-func
const evaluator = new Function('row', `return ${expr}`);
return !!evaluator(row);
} catch (error) {
logger.warn('subRow 조건 평가 실패:', condition, error);
return true;
const G7Core = (window as any).G7Core;
if (typeof G7Core?.evaluateCondition === 'function') {
return G7Core.evaluateCondition(condition, { row, item: row, $item: row });
}
logger.warn('G7Core.evaluateCondition 미노출 — subRow 조건을 평가할 수 없습니다:', condition);
return true;
};
/**
@@ -486,7 +508,7 @@ const renderCellChildren = (
return G7Core.renderItemChildren(
cellChildren,
context,
componentMap,
getComponentMap(),
keyPrefix,
componentContext ? { componentContext } : undefined
);
@@ -496,6 +518,39 @@ const renderCellChildren = (
return null;
};
/**
* 푸터 셀의 `text` 를 렌더링합니다.
*
* `components.json` 의 `skipBindingKeys` 에 `footerCells` 가 들어가면서 DynamicRenderer 는
* 이 키를 선평가하지 않는다 — 해석 책임이 반복 렌더 경로로 넘어왔다. 그런데 푸터 렌더는
* `children` 만 `renderCellChildren` 으로 넘기고 `text` 는 원문을 그대로 출력해 왔고,
* 그 결과 `{{order.data?.total_quantity ?? 0}}개` 같은 값이 화면에 노출됐다
* (실측: `/admin/ecommerce/orders/:orderNumber` 합계 행 3셀).
*
* 바인딩/번역 토큰이 없는 순수 문자열은 그대로 돌려주어 불필요한 래핑을 만들지 않는다.
*
* @param text 푸터 셀 텍스트 (바인딩 표현식 포함 가능)
* @param keyPrefix 키 접두사
* @param componentContext 컴포넌트 컨텍스트 (state/setState)
* @returns 해석된 React 노드
*/
const renderFooterCellText = (
text: string | undefined,
keyPrefix: string,
componentContext?: { state?: any; setState?: (updates: any) => void }
): React.ReactNode => {
if (!text) return '';
if (!text.includes('{{') && !text.includes('$t:')) return text;
return renderCellChildren(
[{ type: 'basic', name: 'Span', text }],
{},
null,
keyPrefix,
componentContext
);
};
/**
* subRowChildren 배열을 React 요소로 렌더링합니다.
*
@@ -521,7 +576,7 @@ const renderSubRowChildren = (
return G7Core.renderItemChildren(
subRowChildren,
context,
componentMap,
getComponentMap(),
keyPrefix,
componentContext ? { componentContext } : undefined
);
@@ -1033,7 +1088,7 @@ export const DataGrid: React.FC<DataGridProps> = ({
row,
expandContext,
componentContext: __componentContext,
componentMap,
componentMap: getComponentMap(),
keyPrefix: `expand-${row[idField]}`,
});
}
@@ -1226,7 +1281,7 @@ export const DataGrid: React.FC<DataGridProps> = ({
<Span className={cell.className || 'text-gray-900 dark:text-gray-100'}>
{cell.children && cell.children.length > 0
? renderCellChildren(cell.children, {}, null, `footer-mobile-${idx}`, __componentContext)
: cell.text || ''}
: renderFooterCellText(cell.text, `footer-mobile-text-${idx}`, __componentContext)}
</Span>
</Div>
);
@@ -1543,7 +1598,7 @@ export const DataGrid: React.FC<DataGridProps> = ({
>
{cell.children && cell.children.length > 0
? renderCellChildren(cell.children, {}, null, `footer-${column.field}`, __componentContext)
: cell.text || ''}
: renderFooterCellText(cell.text, `footer-text-${column.field}`, __componentContext)}
</Td>
);
});
@@ -66,13 +66,17 @@ describe('CardGrid', () => {
// mockG7Core 참조 변수 (beforeEach에서 초기화)
let mockG7Core: any;
// 조건 판정은 엔진 위임이다 — 컴포넌트가 자체 평가기를 두지 않는다는 계약
const mockEvaluateCondition = vi.fn(() => true);
beforeEach(() => {
vi.clearAllMocks();
mockEvaluateCondition.mockImplementation(() => true);
// 기존 G7Core를 유지하면서 renderItemChildren만 추가/덮어쓰기
mockG7Core = {
...(window as any).G7Core,
renderItemChildren: mockRenderItemChildren,
evaluateCondition: mockEvaluateCondition,
};
(window as any).G7Core = mockG7Core;
});
@@ -619,4 +623,62 @@ describe('CardGrid', () => {
expect(skeletonBars).toHaveLength(0);
});
});
describe('조건 평가 위임 계약 (engine-v1.56.1)', () => {
/** 조건이 걸린 자식 하나만 가진 셀 정의 */
const withCondition = (condition: string): CardGridCellChild[] => [
{ type: 'basic', name: 'Span', id: 'conditional', text: '조건부', condition },
];
it('조건 판정을 G7Core.evaluateCondition 에 위임한다 (자체 평가기 없음)', () => {
render(
<CardGrid data={[mockData[0]]} cardColumns={[{ id: 'c', cellChildren: withCondition('{{row.id === 1}}') }]} />
);
expect(mockEvaluateCondition).toHaveBeenCalled();
const [condition, context] = mockEvaluateCondition.mock.calls[0] as unknown as [string, any];
expect(condition).toBe('{{row.id === 1}}');
// 항목은 row/item/$item 세 이름으로 모두 도달해야 한다 (작성 관행이 셋 다 존재)
expect(context.row).toEqual(mockData[0]);
expect(context.item).toEqual(mockData[0]);
expect(context.$item).toEqual(mockData[0]);
});
it('엔진이 false 를 돌려주면 그 자식은 렌더 대상에서 빠진다', () => {
mockEvaluateCondition.mockImplementation(() => false);
render(
<CardGrid data={[mockData[0]]} cardColumns={[{ id: 'c', cellChildren: withCondition('{{row.id === 999}}') }]} />
);
const passedChildren = mockRenderItemChildren.mock.calls[0]?.[0] ?? [];
expect(passedChildren).toHaveLength(0);
});
it('_local 참조 조건도 예외 없이 엔진이 처리한다', () => {
// 종전 자체 평가기는 `new Function('row', ...)` 이라 `_local` 이 스코프에 없어
// ReferenceError 가 났고, catch 가 false 를 돌려줘 자식이 조용히 사라졌다.
mockEvaluateCondition.mockImplementation(() => true);
expect(() =>
render(
<CardGrid data={[mockData[0]]} cardColumns={[{ id: 'c', cellChildren: withCondition('{{_local.expanded}}') }]} />
)
).not.toThrow();
expect(mockEvaluateCondition).toHaveBeenCalledWith('{{_local.expanded}}', expect.any(Object));
expect(mockRenderItemChildren.mock.calls[0]?.[0]).toHaveLength(1);
});
it('엔진 API 가 없으면 조건을 감춤이 아니라 표시로 처리한다', () => {
// 평가 수단이 없다는 이유로 내용을 지우면 화면이 통째로 비어 원인 파악이 불가능하다.
(window as any).G7Core = { ...mockG7Core, evaluateCondition: undefined };
render(
<CardGrid data={[mockData[0]]} cardColumns={[{ id: 'c', cellChildren: withCondition('{{row.id === 1}}') }]} />
);
expect(mockRenderItemChildren.mock.calls[0]?.[0]).toHaveLength(1);
});
});
});
@@ -53,6 +53,44 @@ function shallowArrayEqual<T>(a: T[], b: T[]): boolean {
return a.every((item, index) => item === b[index]);
}
/**
* window.G7Core.evaluateCondition mock
*
* 런타임에서는 엔진의 `evaluateStringCondition` 이 처리하며, 컨텍스트에는 컴포넌트가
* 넘긴 `row` 뿐 아니라 엔진이 병합한 `_global`/`_local`/`_computed` 도 들어 있다
* (`G7CoreGlobals.evaluateCondition`). mock 도 그 계약을 그대로 따라야 `_local` 을
* 참조하는 조건이 실제로 검증된다 — 종전처럼 `row` 만 파라미터로 넘기면 `_local` 이
* 스코프에 없어 무조건 false 로 떨어져, 통과해도 아무것도 증명하지 못한다.
*
* 실패 시 false 는 엔진 `evaluateStringCondition` 의 catch 와 같은 방향이다.
*/
const mockEngineState: { _local: Record<string, any>; _global: Record<string, any> } = {
_local: {},
_global: {},
};
const mockEvaluateCondition = vi.fn((condition: string, ctx: Record<string, any>) => {
if (!condition) return true;
const match = condition.match(/^\{\{([\s\S]+)\}\}$/);
if (!match) return true;
const context = {
_global: mockEngineState._global,
_local: mockEngineState._local,
_computed: {},
...(ctx || {}),
};
const names = Object.keys(context);
const values = names.map((n) => (context as any)[n]);
try {
// eslint-disable-next-line no-new-func
return !!new Function(...names, `return ${match[1].trim()}`)(...values);
} catch {
return false;
}
});
// window.G7Core.useResponsive mock
const mockUseResponsive = vi.fn(() => ({
width: 1024,
@@ -72,6 +110,9 @@ describe('DataGrid', () => {
(window as any).G7Core = {
...originalG7Core,
useResponsive: mockUseResponsive,
// subRowCondition 판정은 엔진(G7Core.evaluateCondition)에 위임한다.
// 런타임과 같은 계약(조건 문자열 + row 컨텍스트 → boolean)을 최소 구현으로 세운다.
evaluateCondition: mockEvaluateCondition,
};
});
@@ -859,7 +900,7 @@ describe('DataGrid', () => {
});
});
it('subRowCondition이 잘못된 표현식이어도 오류 없이 기본값(true)으로 처리됨', () => {
it('subRowCondition이 해석되지 않는 경로면 오류 없이 숨김 처리됨', () => {
// 콘솔 경고를 억제
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -872,13 +913,79 @@ describe('DataGrid', () => {
/>
);
// 잘못된 조건이어도 기본값 true로 모든 행에 서브 행이 렌더링됨
const subRowContents = screen.getAllByTestId('sub-row-content');
expect(subRowContents).toHaveLength(3);
// 조건 판정을 엔진에 위임하면서, 해석되지 않는 조건은 다른 화면 요소의 `if` 와
// 같은 규칙으로 false(숨김)가 된다. 종전에는 이 컴포넌트만 true(표시)로 갈렸다.
expect(screen.queryAllByTestId('sub-row-content')).toHaveLength(0);
consoleWarn.mockRestore();
});
it('subRowCondition이 _local 을 참조해도 예외 없이 엔진이 판정한다', () => {
// 종전에는 이 컴포넌트가 `new Function('row', ...)` 로 자체 평가해서, row 스코프에
// 없는 `_local` 을 만나면 ReferenceError 가 났다. 위임 이후에는 엔진이 `_local` 을
// 컨텍스트에 병합해 넘기므로 정상 판정된다.
mockEngineState._local = { expanded: true };
expect(() =>
render(
<DataGrid
columns={mockColumns}
data={mockData}
subRowRender={(row) => <span data-testid="sub-row-content">{row.name}</span>}
subRowCondition="{{_local.expanded}}"
/>
)
).not.toThrow();
// 조건이 참이므로 모든 행에 서브 행이 렌더링된다
expect(screen.getAllByTestId('sub-row-content')).toHaveLength(3);
// 판정은 엔진에 위임됐다 (자체 평가기 부재 확인)
expect(mockEvaluateCondition).toHaveBeenCalledWith(
'{{_local.expanded}}',
expect.objectContaining({ row: expect.any(Object) })
);
mockEngineState._local = {};
});
it('subRowCondition 의 _local 값이 거짓이면 서브 행이 숨겨진다', () => {
mockEngineState._local = { expanded: false };
render(
<DataGrid
columns={mockColumns}
data={mockData}
subRowRender={(row) => <span data-testid="sub-row-content">{row.name}</span>}
subRowCondition="{{_local.expanded}}"
/>
);
expect(screen.queryAllByTestId('sub-row-content')).toHaveLength(0);
mockEngineState._local = {};
});
it('G7Core.evaluateCondition 미노출 시에는 서브 행을 표시한다 (폴백 계약)', () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const saved = (window as any).G7Core.evaluateCondition;
delete (window as any).G7Core.evaluateCondition;
render(
<DataGrid
columns={mockColumns}
data={mockData}
subRowRender={(row) => <span data-testid="sub-row-content">{row.name}</span>}
subRowCondition="{{_local.expanded}}"
/>
);
// 엔진이 없으면 판정할 수 없으므로 감추지 않고 표시한다 (CardGrid 와 같은 방향)
expect(screen.getAllByTestId('sub-row-content')).toHaveLength(3);
(window as any).G7Core.evaluateCondition = saved;
consoleWarn.mockRestore();
});
it('subRowCondition이 빈 문자열이면 모든 행에 서브 행이 렌더링됨', () => {
render(
<DataGrid
@@ -1103,4 +1210,114 @@ describe('DataGrid', () => {
expect(screen.getByText('타인')).toBeInTheDocument();
});
});
/**
* 푸터 행(footerCells)의 `text` 는 반복 렌더 경로가 해석해야 한다.
*
* `components.json` 의 `skipBindingKeys` 에 `footerCells` 가 추가되면서 DynamicRenderer 가
* 더 이상 이 키를 선평가하지 않는다. 그런데 DataGrid 의 푸터 렌더는 `cell.children` 만
* `renderCellChildren` 으로 넘기고 `cell.text` 는 원문 그대로 출력해 왔다.
* 그 결과 `{{order.data?.total_quantity ?? 0}}개` 같은 값이 화면에 **원본 문자열로 노출**된다.
* (브라우저 실측: `/admin/ecommerce/orders/:orderNumber` 합계 행 3셀)
*/
describe('푸터 행 text 바인딩 해석', () => {
const footerColumns: DataGridColumn[] = [
{ field: 'name', header: '이름' },
{ field: 'qty', header: '수량' },
];
const footerData = [
{ name: '상품A', qty: 2 },
{ name: '상품B', qty: 3 },
];
it('footerCells[].text 의 바인딩 표현식이 원본 문자열로 노출되지 않는다', () => {
render(
<DataGrid
columns={footerColumns}
data={footerData}
footerCells={[
{ field: 'name', text: '합계' },
{ field: 'qty', text: '{{order.data?.total_quantity ?? 0}}개' },
]}
/>
);
const tfoot = document.querySelector('tfoot');
expect(tfoot).not.toBeNull();
expect(tfoot!.textContent).toContain('합계');
expect(tfoot!.textContent).not.toContain('{{');
});
it('파이프가 붙은 footerCells[].text 도 원본 문자열로 노출되지 않는다', () => {
render(
<DataGrid
columns={footerColumns}
data={footerData}
footerCells={[
{ field: 'name', text: '합계' },
{ field: 'qty', text: '{{order.data?.total_earned_points_amount | number}}P' },
]}
/>
);
const tfoot = document.querySelector('tfoot');
expect(tfoot!.textContent).not.toContain('{{');
expect(tfoot!.textContent).not.toContain('| number');
});
it('바인딩이 없는 순수 문자열 text 는 그대로 출력된다', () => {
render(
<DataGrid
columns={footerColumns}
data={footerData}
footerCells={[
{ field: 'name', text: '합계' },
{ field: 'qty', text: '5개' },
]}
/>
);
const tfoot = document.querySelector('tfoot');
expect(tfoot!.textContent).toContain('합계');
expect(tfoot!.textContent).toContain('5개');
});
});
/**
* 반복 렌더 서브트리(cellChildren/expandChildren/subRowChildren/footerCells)의 컴포넌트
* 조회는 템플릿 레지스트리 전체를 대상으로 해야 한다.
*
* 종전에는 이 파일이 29개짜리 **손으로 박은 componentMap** 을 갖고 있어, 등록된 117개 중
* 88개가 반복 경로에서만 조용히 사라졌다. 실측 확인: `/admin/identity/logs` 확장 행의
* `Pre` 블록(부가 정보 JSON)이 라벨만 남고 내용이 통째로 렌더되지 않음
* (콘솔: `renderItemChildren: 컴포넌트를 찾을 수 없습니다: Pre`).
* 같은 `Pre` 가 모달(DynamicRenderer 경로)에서는 정상 렌더된다 — 경로 비대칭이다.
*/
describe('반복 렌더 컴포넌트 레지스트리', () => {
it('cellChildren 렌더 시 템플릿 레지스트리 전체(G7Core.getComponentMap)를 넘긴다', () => {
const seen: Record<string, unknown>[] = [];
(window as any).G7Core = {
...(window as any).G7Core,
getComponentMap: () => ({ Div: 'div', Span: 'span', Pre: 'pre', Code: 'code' }),
renderItemChildren: (_children: any, _ctx: any, componentMap: Record<string, unknown>) => {
seen.push(componentMap);
return null;
},
};
render(
<DataGrid
columns={[
{ field: 'name', header: '이름' },
{ field: 'meta', header: '메타', cellChildren: [{ type: 'basic', name: 'Pre', text: '{{row.meta}}' }] },
]}
data={[{ name: 'A', meta: '{"a":1}' }]}
/>
);
expect(seen.length).toBeGreaterThan(0);
// 레지스트리에 등록된 컴포넌트는 반복 경로에서도 조회 가능해야 한다
expect(Object.keys(seen[0])).toContain('Pre');
});
});
});

Some files were not shown because too many files have changed in this diff Show More