Files
Gnuboard7/tests/Playwright/specs/binding-render-path-symmetry.spec.ts
HeuJung 65378fdeb6 fix(engine): 반복 렌더 경로의 리터럴 단일 바인딩 판정 누락 해소
판정 통일(engine-v1.55.0)에서 리터럴 해석을 BindingShape 한 곳으로 모았으나
renderItemChildren 만 `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접
갈라 리터럴을 몰랐다. 그 결과 같은 `{{true}}` 가 조건 자리에서는 true,
목록 셀·카드 등 반복 렌더 prop 자리에서는 undefined 로 갈렸다 —
통일이 없애려던 비대칭이 이 지점에만 남아 있었다.

형태 판정을 resolveSingleBindingValue 에 위임해 다른 경로와 같은 규칙을 쓴다.
같은 지점이 빈 바인딩(`{{}}`)도 몰라 경로 탐색으로 보내던 것을 함께 봉인했다.

브라우저 실측(배포 번들의 G7Core.renderItemChildren 직접 호출)으로 발견했고,
저장소 레이아웃의 리터럴 단일 바인딩 60건은 전부 if/condition 자리라
화면 표시에는 변화가 없다.

SEO 패리티: 봇 측 ExpressionEvaluator 는 이미 리터럴을 해석하고 있었으므로
이 수정은 React 경로를 봇 경로에 맞춘 것이다 (파일 헤더에 면제 사유 기재).
2026-08-04 10:08:00 +09:00

165 lines
7.0 KiB
TypeScript

/**
* E2E: 단일 바인딩 식이 렌더 경로와 무관하게 같은 값으로 해석되는지 (배포 번들 기준)
*
* 배경: "이 문자열이 단일 바인딩인가", "이 식이 표현식인가 경로인가" 를 판정하는
* 로직이 엔진 곳곳에 복제되면서 복사본마다 인식 문자 집합이 갈라졌고, 그 결과
* **같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과**를 냈다.
* (`{{query['sales_status[]']}}` 가 prop 에서는 undefined, `if` 에서는 정상 등)
*
* 단위 테스트가 소스 레벨을 잠그지만 실제 사용자가 보는 것은 빌드된 번들이므로
* 브라우저에서 배포 번들의 공개 API 를 그대로 호출해 결과를 잠근다.
*
* 파일 헤더의 시나리오 목록은 아래 test 들이 실제로 다는 `@scenario` 의 합집합이어야 한다.
* 반복 렌더의 항목별 조건 평가(`iteration_item_condition`)는 별개 축이라
* `iteration-condition-render.spec.ts` 가 담당한다.
*
* @scenario binding_quoted_bracket_symmetry, binding_pipe_type_preservation, binding_brace_in_pipe, binding_empty_expression
* @effects same_expression_same_value_across_paths
*/
import { test, expect } from '../fixtures/auth';
test.describe('렌더 경로 간 바인딩 해석 대칭', () => {
test.beforeEach(async ({ page }) => {
// 코어 번들이 부팅된 화면이면 충분하다(로그인 화면도 엔진을 부팅한다).
await page.goto('/admin/login');
await page.waitForFunction(
() => Boolean((window as any).G7Core?.renderItemChildren),
null,
{ timeout: 30_000 },
);
});
// @scenario binding_quoted_bracket_symmetry
// @effects same_expression_same_value_across_paths
test('따옴표 키 인덱싱이 반복 렌더와 조건 판정에서 같은 값을 본다', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const Span = function Span() { return null; };
const context = { query: { 'sales_status[]': ['selling', 'soldout'] } };
const nodes = g7.renderItemChildren(
[{ type: 'basic', name: 'Span', props: { title: "{{query['sales_status[]']}}" } }],
context,
{ Span },
'e2e-symmetry',
);
return {
hasEvaluateCondition: typeof g7.evaluateCondition === 'function',
viaRepeat: (nodes as any[])[0]?.props?.title ?? null,
viaCondition: typeof g7.evaluateCondition === 'function'
? g7.evaluateCondition("{{query['sales_status[]']}}", context)
: null,
};
});
expect(result.hasEvaluateCondition, 'G7Core.evaluateCondition 이 노출되지 않음').toBe(true);
// 수정 전: 좁은 방언이 "단순 경로" 로 오판해 경로 탐색 → undefined
expect(result.viaRepeat).toEqual(['selling', 'soldout']);
expect(result.viaCondition).toBe(true);
});
// @scenario binding_pipe_type_preservation
// @effects same_expression_same_value_across_paths
test('파이프 결과가 원본 타입으로 전달된다 (문자열 서식 강제 없음)', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const Span = function Span() { return null; };
const nodes = g7.renderItemChildren(
[
{ type: 'basic', name: 'Span', props: { title: '{{row.meta | keys}}' } },
{ type: 'basic', name: 'Span', props: { title: '{{row.flags | first}}' } },
{ type: 'basic', name: 'Span', props: { title: '{{row.tags | length}}' } },
],
{ row: { meta: { a: 1, b: 2 }, flags: [false, true], tags: ['a', 'b', 'c'] } },
{ Span },
'e2e-pipe-type',
);
return (nodes as any[]).map((n) => ({
value: n?.props?.title ?? null,
kind: Array.isArray(n?.props?.title) ? 'array' : typeof n?.props?.title,
}));
});
// 수정 전: 전부 문자열(`"[\"a\",\"b\"]"`, `"false"`, `"3"`)
expect(result[0].kind).toBe('array');
expect(result[0].value).toEqual(['a', 'b']);
expect(result[1].kind).toBe('boolean');
expect(result[1].value).toBe(false);
expect(result[2].kind).toBe('number');
expect(result[2].value).toBe(3);
});
// @scenario binding_brace_in_pipe
// @effects same_expression_same_value_across_paths
test('파이프 식 안의 중괄호가 원본 문자열로 새지 않는다', async ({ page }) => {
const value = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const Span = function Span() { return null; };
const nodes = g7.renderItemChildren(
[{ type: 'basic', name: 'Span', text: '{{(row.meta ?? {}) | json}}' }],
{ row: {} },
{ Span },
'e2e-brace',
);
return (nodes as any[])[0]?.props?.children ?? null;
});
// 수정 전: 정규식이 `}` 를 포함한 식을 매칭하지 못해 `{{...}}` 가 그대로 노출됐다.
expect(String(value)).toBe('{}');
expect(String(value)).not.toContain('{{');
});
// @scenario binding_literal_symmetry
// @effects same_expression_same_value_across_paths
test('리터럴이 반복 렌더와 조건 판정에서 같은 값을 본다', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const Span = function Span() { return null; };
const context = { row: { id: 1 } };
const viaRepeat = (expr: string) =>
(g7.renderItemChildren(
[{ type: 'basic', name: 'Span', props: { title: expr } }],
context,
{ Span },
'e2e-literal',
) as any[])[0]?.props?.title;
return {
repeatTrue: viaRepeat('{{true}}'),
repeatFalse: viaRepeat('{{false}}'),
repeatNull: viaRepeat('{{null}}'),
condTrue: g7.evaluateCondition('{{true}}', context),
condFalse: g7.evaluateCondition('{{false}}', context),
};
});
// 수정 전: 반복 렌더 경로만 리터럴을 몰라 경로 탐색으로 새어 전부 undefined 였다.
// 조건 경로는 리터럴을 알았으므로 같은 작성이 자리에 따라 갈렸다.
expect(result.repeatTrue).toBe(true);
expect(result.repeatFalse).toBe(false);
expect(result.repeatNull).toBeNull();
expect(result.repeatTrue).toBe(result.condTrue);
expect(result.repeatFalse).toBe(result.condFalse);
});
// @scenario binding_empty_expression
// @effects same_expression_same_value_across_paths
test('빈 바인딩이 컨텍스트 객체 전체를 노출하지 않는다', async ({ page }) => {
const value = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const Span = function Span() { return null; };
const nodes = g7.renderItemChildren(
[{ type: 'basic', name: 'Span', props: { title: '{{}}' } }],
{ row: { secret: 'do-not-leak' } },
{ Span },
'e2e-empty',
);
return (nodes as any[])[0]?.props?.title ?? null;
});
expect(value == null || value === '').toBe(true);
expect(String(value ?? '')).not.toContain('do-not-leak');
});
});