fix(core,template): 편집기 image 위젯 값 형태·데이터 연결 값 보호·상속 노드 편집 표면 수정
공개 는 헤더 「로고 이미지」에 이미지를 지정하면 화면이 엑박이 되는 제보였다.
image 위젯은 배경용으로 설계되어 {url,size,repeat,position} 객체를 내보내는데, 값 슬롯이
하나뿐인 apply 경로(propValue/cssVar/단일 styleProp)가 그 객체를 그대로 props 에 기록해
소비 컴포넌트가 [object Object] 를 URL 로 받았다. 예외도 콘솔 오류도 서버 로그도 남지
않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)이고, 편집기
미리보기는 정상이라 조작 중에는 이상이 보이지 않는다.
방어선을 넷으로 세웠다. 쓰기 축약(공용 헬퍼 scalarizeImageValue 단일 지점, 게이트는 위젯
이름이며 값 형태 sniffing 이 아니다) · 읽기 역조립(표현식 문자열도 되감아 업로드 1클릭에
소실되지 않게 한다) · 런타임 방어(업그레이드 전 화면을 위해 템플릿 Img 가 url 을 해석하고
손상값이면 src 를 아예 붙이지 않는다) · 저장 데이터 백필(업그레이드 스텝). 런타임과 백필은
완전히 같은 엄격 판정식(키 집합 ⊆ 4키 AND url 보유)을 쓴다 — 엔진의 느슨한 판정식을 백필에
이식하면 레이아웃 전수에서 정상 props 2,219건을 파괴한다(실측).
전수조사에서 파생한 인접 결함 넷을 함께 고쳤다.
- number 위젯이 코어 레지스트리에 미등록이라 「탭 표시 게시판 수」 같은 컨트롤이
「지원하지 않는 컨트롤」로 폴백해 편집 자체가 불가했다. nodeKey apply 는 coreProps 가
선언만 하고 엔진 switch 에 case 가 없어 무음 no-op 이었다.
- 상속(base)·주입(extension) 노드 중 바인딩을 가진 것이 data_bound 로 분류돼 편집이
열려 있었는데, 저장 마스킹이 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이
사라졌다(저장은 200 이고 history 는 clear 돼 undo 도 불가). 출처 잠금이 항상 우선하도록
판정 순서를 통일하고, 단일 판정 헬퍼로 인라인 편집·복제·Delete·잘라내기·드래그 commit
까지 전 표면을 같은 기준으로 막았다.
- prop 자리의 표현식 값을 위젯이 해석하지 못해 빈 컨트롤로 보이고, 조작하는 순간 환경설정
과의 연결이 소리 없이 끊겼다. 판정·배지·잠금·해제·복구를 ControlRenderer 공용 게이트
한 곳으로 올려 신규 위젯에도 자동 적용되게 했다. 「직접 지정으로 바꾸기」에는 「되돌리기」를
동반해 편도가 되지 않게 한다.
- 편집기 모드에서 updateTemplateData 가 빈 레이아웃으로 같은 reactRoot 에 두 번째 커밋을
걸어 편집기 트리를 통째로 제거했다. renderTemplate 의 편집기 분기가 비동기라 부팅 중
setGlobalState 가 그 커밋 뒤에 도착할 때만 발현하는 경합이었다.
This commit is contained in:
@@ -278,6 +278,57 @@
|
||||
| `"item"`, `"index"` | `"item_var"`, `"index_var"` |
|
||||
| iteration 내 if 순서 무시 | if가 iteration보다 먼저 평가됨 |
|
||||
|
||||
### 위젯 값 형태 ↔ apply 경로
|
||||
|
||||
편집기 위젯 중 **값이 스칼라가 아닌 것**(`image` → `{url,size,repeat,position}` 객체)을 값 슬롯이 하나뿐인 apply 경로에 연결하면, 객체가 그대로 `props[key]` 에 저장되어 소비 컴포넌트가 `[object Object]` 를 URL 로 받는다. 이 결함은 예외도 콘솔 오류도 서버 로그도 남기지 않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 **200(HTML)** 이고, 편집기 위젯의 미리보기는 정상이라 조작 중에는 이상이 보이지 않는다. 화면의 엑박이 유일한 증상이다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| `image` 위젯을 `classToken`·`cssVar` 에 연결하거나 `apply` 를 생략 | 값이 스칼라로 축약되는 경로만 — `propValue`(맨 url 문자열) 또는 `backgroundImage` 를 포함한 `styleProp` 묶음(4속성 분해) |
|
||||
| 축약 판정을 **값 형태 sniffing** 으로 게이트 | `widget === 'image'` 게이트 — `isImageValueObject` 는 4키 중 **하나만** 있어도 참이라 `props.tooltip = {position:'left'}` 같은 정당한 객체 prop 을 이미지로 오인해 삭제한다 |
|
||||
| 축약 분기를 writer 마다 복붙 | 공용 헬퍼 `scalarizeImageValue` 단일 지점 — 이번 결함의 원인이 정확히 "방어가 `applyStyleProp` 안에만 있었다" 이다 |
|
||||
| 쓰기만 축약하고 읽기는 그대로 | `propValue` 역해석이 저장 문자열을 `{url}` 로 되감는다. **표현식 문자열도 감싼다** — 감싸지 않으면 빈 피커로 보이고 업로드 1클릭에 그 표현식이 소리 없이 소실된다 |
|
||||
| 저장되지 않는 컨트롤을 `disabled` 로 남김 | 단일 값 슬롯이면 표시모드 버튼을 **컨테이너째 미렌더** — `disabled` 는 *일시적* 비활성의 시각 언어라 "URL 을 넣으면 살아나겠지" 라는 거짓 정보를 준다 |
|
||||
| 저장되지 않는 `size` 를 미리보기에 반영 | 단일 슬롯 미리보기는 `contain` 고정 — 실제 표시 방식은 소비 컴포넌트의 클래스가 정하므로 편집기가 흉내내면 거짓 미리보기다 |
|
||||
| 코어 엔진의 느슨한 판정식을 **백필**에 이식 | 백필·런타임 방어는 **엄격 판정식**(키 집합 ⊆ 4키 **AND** `url` 키 존재). 느슨한 판정식은 레이아웃 전수에서 2,219건을 매치하고 그 대부분이 정상 props 다(`{className,name,size}` 674건 · `{name,size}` 552건) — 엄격 판정식의 매치는 0건이었다 |
|
||||
| 백필 순회 범위를 **노드 키 allowlist** 로 정의 | `props` 키 진입 시 모드 ON / `style` 키 진입 시 OFF 인 **모드 플래그 전역 재귀** — 실측상 `props` 안에 컴포넌트 노드가 1,150건 살아 allowlist 는 원리상 완결 불가다 |
|
||||
| 두 방어선(런타임 `Img` / 백필)의 판정 강도를 따로 정함 | 완전히 같은 엄격도 — 어긋나면 한쪽만 통과하는 값이 생긴다 |
|
||||
|
||||
> 상세: [editor-spec.md](docs/extension/editor-spec.md) "controls — 재사용 스타일 컨트롤"
|
||||
> 정적 검사가 두 축을 함께 본다 — editor-spec 선언과 코어 엔진의 축약 분기 실존. 선언 축만 보면 코어 분기가 삭제돼도 통과하는데 결함은 부활한다. 기설치본 보정은 DB 데이터 상태라 정적 검사 대상이 아니며, 업그레이드 스텝의 회귀 테스트가 그 축을 잠근다
|
||||
|
||||
### 편집기 컨트롤의 데이터 연결 값 보호
|
||||
|
||||
레이아웃의 prop 자리에는 `{{_global.settings?.general?.site_logo_url}}` 같은 **표현식 문자열**이 저장돼 있을 수 있다. 위젯은 그 값을 해석하지 못해 **빈 컨트롤**로 보이고, 조작하는 순간 그 연결이 사라진다 — 값 하나가 아니라 **환경설정과의 연결**이 끊기고, 원문이 화면 어디에도 남지 않아 되돌릴 수단조차 없다. 예외도 콘솔 오류도 남지 않는다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 바인딩 판정·배지·잠금을 위젯마다 구현 | `ControlRenderer` 의 **공용 게이트 한 곳** — 새 위젯을 등록해도 자동 적용된다 |
|
||||
| 해제 경로 없이 잠그기만 | 「직접 지정으로 바꾸기」로 **명시적으로만** 연다 |
|
||||
| 「직접 지정으로 바꾸기」를 편도로 두기 | 「되돌리기」 동반 — 해제 직후엔 취소로, 값을 이미 넣은 뒤엔 원문 복구로 동작한다 |
|
||||
| 파괴적 조작 표면 중 일부만 잠그기 | 그 위젯의 **전 표면** — 업로드·제거·목록 선택뿐 아니라 **관리 모달 진입**까지. 같은 동작이 두 곳에 렌더되면 하나만 잠근 것은 판단이 아니라 누락이다 |
|
||||
| 위젯이 자체 처리를 가지면서 공용 게이트도 통과 | 둘 중 하나 — 자체 처리 위젯은 제외 목록에 등재하고, 그 위젯이 **원문 표시·해제·복구 셋을 모두** 제공하는지 확인한다 |
|
||||
| 자체 분기가 공용 해제 경로를 막음 | 해제된 뒤에는 위젯이 평소대로 편집 가능해야 한다 |
|
||||
| 해석 못 하는 값을 위젯이 흉내내 표시 | 원문 배지로 대체 — 흉내내면 거짓 컨트롤이다 |
|
||||
|
||||
> 상세: [editor-spec.md](docs/extension/editor-spec.md) "데이터 연결 값 보호"
|
||||
|
||||
### 상속·주입 노드의 편집 표면
|
||||
|
||||
저장 마스킹(`stripInheritedFromLayoutContent`)은 상속(base)·주입(extension) 출처 노드를 **정상 저장에서도 항상 폐기**한다. 그래서 그 노드를 편집할 수 있게 열어 두면 편집분이 오류도 경고도 없이 사라진다 — 저장은 200 으로 성공하고 `history.clear()` 로 undo 도 불가능하다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
|--------|---------------|
|
||||
| 잠금 판정에 편집 모드 예외를 둠 (`editMode !== 'route' && isNodeLocked(...)`) | 출처 잠금이 `data_bound` 보다 **항상 우선** — "route 는 종전 동작을 한 줄도 바꾸지 않는다" 는 계약이 아니라 보수성 선언이었고, 그 보수성이 곧 결함이었다 |
|
||||
| `data_bound` 의 **의미**를 좁혀 해결 | 의미는 그대로 두고(= 편집 가능, 텍스트만 잠금) **어느 노드가 그렇게 분류되는가**만 좁힌다 — 형제 경로(DnD·오버레이)가 그 계약에 실제로 의존한다 |
|
||||
| 게이트 조건을 표면마다 복사 | 단일 판정 헬퍼 `isEditableLockKind` / `resolveDndDenial` 만 호출 — 한 곳만 빠져도 같은 소실 결함이 재발한다 |
|
||||
| ⓘ 메뉴와 드래그 핸들만 막고 끝냄 | 인라인 편집(더블클릭)·복제·키보드 `Delete`·잘라내기까지 전 표면 — 키보드 경로는 ⓘ 메뉴를 거치지 않아 무방비였다 |
|
||||
| 드래그 거부를 `zone === null` 에 기댄 간접 방어로 | commit 직전 최종 가드 — stale 슬롯이나 유효 zone 이 들어오면 그대로 이동 commit 된다 |
|
||||
| 거부된 드래그가 `activeDragPath` 를 남김 | 거부 시 즉시 비운다 — 남으면 DragOverlay 가 잡힌 노드를 따라다녀 "옮길 수 있다" 는 거짓 어포던스를 준다 |
|
||||
| 조상 산출 구현을 파일마다 복제 | `collectAncestors` 단일 출처 — 잠금 판정의 입력이 갈리면 "핸들은 있는데 드래그는 거부" 같은 어긋남이 조용히 생긴다 |
|
||||
|
||||
차단된 노드에는 「🔒 공통 레이아웃 편집」·「🔒 확장 편집」 진입 어포던스가 대신 뜬다. 상속 노드의 「데이터 영역」 라벨은 사라지지만 후자가 행동 가능한 정보이므로 순증이다.
|
||||
|
||||
### 컴포넌트 Props
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
- 업데이트 진행 중 판정이 명령줄에서 실행될 때만 커맨드 이름을 참고하도록 좁혔습니다. 일부 PHP 설정(`register_argc_argv` 활성)에서는 웹 주소의 쿼리만으로 업데이트가 진행 중인 것처럼 보이게 할 수 있었습니다.
|
||||
- 레이아웃 편집기에서 올린 배경 이미지의 주소가 「공개 자산 스토리지」 설정과 무관하게 항상 사이트 서버를 거쳐 전달되던 문제를 수정했습니다. 파일은 설정한 저장소에 정상 저장되지만 방문자 요청은 매번 사이트 서버가 저장소에서 받아 다시 내보내고 있어, CDN 을 쓰도록 설정해도 그 효과를 볼 수 없었습니다. 이제 「공개 자산 스토리지」를 지정하면 그 저장소에 올라간 배경 이미지가 저장소 주소로 직접 전달됩니다. 설정하지 않은 경우와 설정 이전에 올린 이미지는 종전과 똑같이 동작합니다. (#134 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 확장이 카테고리별로 다른 저장소를 쓰도록 설정했을 때, 확장 개발용 파일 주소·경로 조회가 그 설정을 반영하지 않고 언제나 기본 저장소를 보던 문제를 수정했습니다.
|
||||
- 화면 편집기에서 로고처럼 이미지 주소를 직접 받는 항목에 이미지를 지정하면 그림이 깨져 보이던 문제를 수정했습니다. 지정한 주소 대신 알 수 없는 값이 저장된 것이 원인이며, 편집기 화면에서는 미리보기가 정상으로 보여 저장 전에는 알아챌 수 없었습니다. 이제 주소가 그대로 저장되고, 이미 잘못 저장된 사이트도 업데이트하면 자동으로 정정됩니다. 업데이트 전에도 화면은 정상 표시됩니다. (#135 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 화면 편집기의 「탭 표시 게시판 수」처럼 숫자를 받는 항목이 「지원하지 않는 컨트롤」로 표시되어 편집할 수 없던 문제를 수정했습니다. 이제 숫자 입력칸이 나타납니다.
|
||||
- 화면 편집기에서 공통 레이아웃이나 확장이 넣은 요소를 고쳐도 저장되지 않던 문제를 수정했습니다. 편집은 되는데 저장 후 되돌아가 있었고 실행 취소로도 되살릴 수 없었습니다. 이제 그런 요소는 편집 대신 「공통 레이아웃 편집」·「확장 편집」 진입 안내가 표시되며, 옮기기·복제·삭제도 같은 기준으로 막힙니다.
|
||||
- 화면 편집기가 열리자마자 빈 화면이 되는 경우가 있던 문제를 수정했습니다. 편집기가 잠깐 보였다가 사라졌고, 새로고침에 따라 나타났다 사라졌다 했습니다.
|
||||
- 화면 편집기에서 환경설정과 연결된 항목(예: 헤더 로고)을 무심코 조작하면 그 연결이 소리 없이 끊기던 문제를 수정했습니다. 이제 그런 항목은 연결된 값이라는 안내와 원래 값이 표시되고, 「직접 지정으로 바꾸기」를 눌러야 편집할 수 있으며 「되돌리기」로 언제든 원래 연결로 복구할 수 있습니다.
|
||||
|
||||
## [7.0.10] - 2026-09-06
|
||||
|
||||
|
||||
@@ -134,15 +134,31 @@ php artisan {module|plugin|template}:update {id} --force
|
||||
|
||||
### controls — 재사용 스타일 컨트롤
|
||||
|
||||
위젯 종류(`widget`)·친화 라벨(`label`)·적용 방식(`apply`)·대상(`target`)·선택지(`options`)를 한 곳에 선언하고 `componentCapabilities.styleControls`(스타일 탭) 또는 `propControls`(속성 탭)가 키로 참조한다. `apply` 타입은 `classToken`(클래스 토큰 교체) / `styleProp`(인라인 스타일 속성) / `cssVar` / `propValue`(임의 `node.props[propKey]` 기입 — 속성 탭의 기본 적용 방식). 색·크기 등 라이브러리 대응 컨트롤은 프리셋 토큰 + 자유값(tokenTemplate)을 함께 선언한다. 상/우/하/좌 여백처럼 측마다 다른 값이 공존하는 속성은 다값을 1급으로 다룬다(일괄/개별 2모드, 단일 토큰 교체 금지).
|
||||
위젯 종류(`widget`)·친화 라벨(`label`)·적용 방식(`apply`)·대상(`target`)·선택지(`options`)를 한 곳에 선언하고 `componentCapabilities.styleControls`(스타일 탭) 또는 `propControls`(속성 탭)가 키로 참조한다. `apply` 타입은 `classToken`(클래스 토큰 교체) / `styleProp`(인라인 스타일 속성) / `cssVar` / `propValue`(임의 `node.props[propKey]` 기입 — 속성 탭의 기본 적용 방식) / `nodeKey`(노드 **최상위** 구조키 `node[nodeKey]` 기입 — `dataKey` 처럼 런타임 엔진이 props 가 아니라 노드 자신에서 읽는 키. `props`·`children`·`type` 등 예약 구조키는 지정해도 무시되고, 디바이스(breakpoint) 축은 무시하며 항상 최상위에 쓴다 — 런타임 responsive 병합은 `props`/`children`/`text`/`if`/`iteration` 5키만 보므로 분기에 쓰면 영원히 읽히지 않는다). 색·크기 등 라이브러리 대응 컨트롤은 프리셋 토큰 + 자유값(tokenTemplate)을 함께 선언한다. 상/우/하/좌 여백처럼 측마다 다른 값이 공존하는 속성은 다값을 1급으로 다룬다(일괄/개별 2모드, 단일 토큰 교체 금지).
|
||||
|
||||
**`propControls` 전용 위젯 종류**:
|
||||
|
||||
- `icon-picker` — 아이콘 prop(예 `iconName`/`triggerIcon`)을 카탈로그 그리드에서 검색·선택한다. **카탈로그는 템플릿 소유**(라이브러리 종속) — Font Awesome 등 아이콘셋을 쓰는 템플릿이 `G7Core.layoutEditor.registerWidget('icon-picker', ...)` 로 자기 위젯을 등록한다(코어는 위젯명만 디스패치, 아이콘 라이브러리 토큰 0). 프리뷰 폴백은 `preview.html`(코어 비해석) → `preview.className` → raw value 순. 카탈로그 미공급 시 자유 텍스트 입력으로 디그레이드.
|
||||
- `options-list` — `Select`/`RadioGroup` 등의 정적 `options` 배열(value/label 행)을 추가/삭제/이동 편집한다. 값이 `{{바인딩}}` 이면 "바인딩됨(코드 편집)" 디그레이드(덮어쓰기 차단). 데이터소스 바인딩(dataProps `options`)과 직교 공존(위 "선택지 직교 완화").
|
||||
- `array-items` / `array-group` / `array-cell-tree` / `number-list` — 정적 배열 prop 편집(탭/차트 데이터/카드 컬럼 등). `nodeEditor` 슬롯 또는 propControl 로 결선한다(아래 "데이터 정의 빌트인" 참조).
|
||||
- `number` — 숫자를 기대하는 prop(예 `maxVisibleBoards`)을 편집한다. 위젯이 `number` 타입을 직접 내보내므로 `"5"` 같은 문자열이 저장되지 않는다. 빈 입력은 prop 삭제, `0` 은 유효값이라 삭제되지 않으며, 숫자로 해석할 수 없는 입력은 방출하지 않아 저장본을 보존한다(조용한 변조 금지). `min`/`max`/`step` 은 선언만 하고 **클램프하지 않는다** — 사용자가 넣은 값은 그대로 보존한다. 값이 `{{바인딩}}` 문자열일 때의 보호는 위젯이 아니라 아래 「데이터 연결 값 보호」 공용 게이트가 맡는다 — 숫자 입력칸이 그 값을 빈칸으로 표시해 다음 blur 에 소실시키는 것을 그 게이트가 차단한다.
|
||||
- `i18n-text` vs `text` — **표시 텍스트**(사용자가 화면에서 읽는 라벨/메시지/플레이스홀더)는 `i18n-text` 를 쓴다. `i18n-text` 는 평문 입력 시 `createCustomKey` 로 다국어 키를 자동 생성해 `$t:custom.*` 로 치환하고(미리보기에 raw 키 미노출), 🌐 펼침으로 ko/en/ja 동시 편집을 제공하며, `{{바인딩}}` 값이면 읽기전용 디그레이드한다(공통 위젯 `I18nTextField`). **비-표시값**(URL/id/수치/색/통화코드/separator 등)만 `text` 를 쓴다. 표시 텍스트에 `text` 를 쓰면 raw `$t:` 노출·다국어 누락 회귀가 발생한다(정적 검사가 배열 라벨 필드의 `text` 사용을 차단).
|
||||
|
||||
**데이터 연결 값 보호 — 모든 위젯 공통**:
|
||||
prop 자리에 `{{...}}` 표현식이나 설정 참조가 저장돼 있으면, 그 값을 편집하는 컨트롤은 **원문 배지**로 디그레이드된다. 위젯은 그 문자열을 해석하지 못해 빈 컨트롤로 보이고, 그 상태에서 조작하면 표현식이 그대로 덮여 **환경설정과의 연결이 끊기기** 때문이다. 이 판정은 개별 위젯이 아니라 컨트롤 렌더러 한 곳에서 이뤄지므로 **새 위젯을 등록해도 자동 적용**된다 — 위젯 안에 같은 판정을 다시 넣지 않는다(넣으면 해제 경로까지 막혀 「직접 지정으로 바꾸기」가 무반응이 된다).
|
||||
|
||||
보호 상태에서는 그 컨트롤의 파괴적 조작이 함께 잠긴다. 값을 바꾸려면 「직접 지정으로 바꾸기」를 눌러 명시적으로 열고, 「되돌리기」로 원래 연결값을 복구할 수 있다(해제 직후에는 취소, 값을 넣은 뒤에는 복구로 동작).
|
||||
|
||||
예외는 표현식 자체를 다루도록 설계된 위젯뿐이다 — `text`·`i18n-text` 는 표현식을 데이터 칩으로 **분해해 보여 주는 것**이 그 위젯의 기능이고, `image`·`core-id` 는 같은 보호를 위젯 안에서 더 세밀하게(갤러리·업로드·관리 모달 진입까지) 제공한다. 새 위젯을 이 예외에 넣으려면 **원문 표시·해제·복구 셋을 모두** 갖춰야 한다.
|
||||
|
||||
**`image` 위젯의 값 형태 — apply 경로에 따라 축약된다**:
|
||||
|
||||
`image` 위젯은 배경 이미지용으로 설계되어 `{url, size, repeat, position}` **객체**를 내보낸다. 그런데 값 슬롯이 하나뿐인 apply 경로(`propValue` / `cssVar` / 단일 `styleProp`)는 그 객체를 담을 수 없다 — 엔진이 `url` 만 남겨 스칼라로 축약한다(컴포넌트 prop 은 맨 문자열, CSS 값 문맥은 `url(...)` 래핑). `url` 이 없거나 비어 있으면 그 자리를 삭제해 컴포넌트의 폴백이 살아난다.
|
||||
|
||||
축약을 전제로 위젯 표면도 달라진다 — 단일 값 슬롯 컨트롤에서는 표시모드(채움/맞춤/타일) 버튼이 **렌더되지 않는다**(`size`/`repeat`/`position` 을 저장할 자리가 없어 눌러도 저장되지 않는 죽은 컨트롤이다). 4속성을 모두 저장하려면 `styleProp` 의 `props` 배열에 `backgroundImage`·`backgroundSize`·`backgroundRepeat`·`backgroundPosition` 을 함께 선언한다.
|
||||
|
||||
`image` 를 `classToken` 이나 `cssVar` 에 연결하거나 `apply` 를 생략하면 값이 문자열로 축약될 경로가 없어 `[object Object]` 가 저장된다 — 정적 검사가 그 선언을 차단한다.
|
||||
|
||||
### componentCapabilities — 컴포넌트별 편집 역량
|
||||
|
||||
컴포넌트별로 `propControls`(속성 탭 컨트롤 화이트리스트 — `controls.json` 의 `apply.type==="propValue"` 컨트롤 참조, 비-스타일 prop 편집) / `dataProps`(데이터 연결 선언 — 아래) / `styleControls`(스타일 탭 컨트롤 화이트리스트) / `advanced`(고급 속성) / `events`(액션 편집 이벤트 — 미선언 시 동작 탭 숨김) / `flexEditor`(`container`/`item`/`auto` — 정렬 박스 편집 역할) / `visibilityCondition`(표시조건 탭 허용 여부) / `nodeEditor`·`canvasOverlay`(구조 에디터 일반 슬롯 `{kind,params}` — 종류별 고정 키 금지, kind 핸들러 레지스트리 디스패치)을 선언한다.
|
||||
@@ -172,6 +188,8 @@ php artisan {module|plugin|template}:update {id} --force
|
||||
|
||||
**코어 제공 속성(요소 ID) + opt-out**: 코어는 모든 draggable 컴포넌트의 [속성] 탭 최상단에 "요소 ID" 컨트롤을 일괄 제공한다(값 = 표준 `node.props.id`, 강제 DOM 주입 없음 — 컴포넌트 passthrough 책임). 기존 `elemId`(→id) 같은 템플릿 propControl 은 코어로 이전(중복 선언 금지 — 코어 우선). 인라인 루트가 없는 컴포넌트(서드파티 모달/Portal)는 capability 에 `"coreProps": false` 로 opt-out 한다. `"coreProps": ["id"]` 처럼 부분집합 선언도 가능(미선언 = 코어 기본 전체). 코어 id 컨트롤은 `{{바인딩}}` 값이면 "바인딩됨(코드 편집)" 디그레이드, HTML 안전 문자만 허용(한글·공백 자동 제거). 컴포넌트 측 id passthrough 규약은 `docs/frontend/components-types.md` "요소 id 패스스루" 참조.
|
||||
|
||||
**전용 UI 를 가진 코어 속성은 `coreProps` 로 다시 선언하지 않는다**: `isolatedState`·`isolatedScopeId` 는 편집기가 별도 전용 컨트롤로 제공하며 그것이 값의 단일 출처다. 이 두 키를 `coreProps` 배열에 적어도 [속성] 탭의 일반 컨트롤 목록에는 나타나지 않는다 — 같은 값을 두 경로가 쓰면 값 형태가 갈리기 때문이다(전용 UI 는 켜짐 상태를 객체로 쓰는데 일반 토글 위젯은 `true` 를 낸다). 선언 자체는 오류가 아니고 조용히 무시되므로, 격리 설정을 노출하려면 그 전용 컨트롤을 쓴다.
|
||||
|
||||
### nesting — 중첩 규칙
|
||||
|
||||
`draggable`(드래그/팔레트 배치 가능한 컴포넌트 이름) + `containers`(컨테이너별 `accepts` 자식 허용 목록). 병합 시 `draggable` 은 union, `containers` 는 key 병합. `accepts: []` 는 명시적 자식 거부, 엔트리 없는 컴포넌트는 어떤 자식도 받지 않는다(폴백 없음).
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
- 로그인 시도가 많아 잠시 차단될 때의 안내와, 본인인증 화면에서 로그인용 인증 요청을 처리할 수 없다는 안내의 일본어 번역을 추가했습니다.
|
||||
- 레이아웃 편집기 [화면 동작] 탭의 「인증번호 확인」·「인증번호 다시 받기」 항목 이름의 일본어 번역을 추가했습니다.
|
||||
- 레이아웃 편집기에서 환경설정과 연결된 항목에 표시되는 안내와 「직접 지정으로 바꾸기」·「데이터 연결로 되돌리기」 버튼의 일본어 번역을 추가했습니다.
|
||||
- 레이아웃 편집기 숫자 입력 항목의 입력 자리 표시 문구의 일본어 번역을 추가했습니다.
|
||||
|
||||
## [1.0.9] - 2026-09-06
|
||||
|
||||
|
||||
@@ -322,6 +322,9 @@
|
||||
"mode_tile": "タイル",
|
||||
"upload_failed": "画像アップロードに失敗しました"
|
||||
},
|
||||
"number": {
|
||||
"placeholder": "数値を入力"
|
||||
},
|
||||
"dimension": {
|
||||
"placeholder": "例: 320px, 50%, 24rem, auto"
|
||||
},
|
||||
@@ -335,6 +338,11 @@
|
||||
"right": "右",
|
||||
"bottom": "下",
|
||||
"left": "左"
|
||||
},
|
||||
"bound_value": {
|
||||
"notice": "データ連携の値です(自動で入ります)",
|
||||
"replace": "直接指定に変更",
|
||||
"restore": "データ連携に戻す"
|
||||
}
|
||||
},
|
||||
"recipe": {
|
||||
|
||||
@@ -343,6 +343,9 @@
|
||||
"mode_tile": "Tile",
|
||||
"upload_failed": "Image upload failed"
|
||||
},
|
||||
"number": {
|
||||
"placeholder": "Enter a number"
|
||||
},
|
||||
"dimension": {
|
||||
"placeholder": "e.g. 320px, 50%, 24rem, auto"
|
||||
},
|
||||
@@ -356,6 +359,11 @@
|
||||
"right": "Right",
|
||||
"bottom": "Bottom",
|
||||
"left": "Left"
|
||||
},
|
||||
"bound_value": {
|
||||
"notice": "Bound to data (filled automatically)",
|
||||
"replace": "Replace with a fixed value",
|
||||
"restore": "Restore the data binding"
|
||||
}
|
||||
},
|
||||
"recipe": {
|
||||
|
||||
@@ -343,6 +343,9 @@
|
||||
"mode_tile": "타일",
|
||||
"upload_failed": "이미지 업로드에 실패했습니다"
|
||||
},
|
||||
"number": {
|
||||
"placeholder": "숫자 입력"
|
||||
},
|
||||
"dimension": {
|
||||
"placeholder": "예: 320px, 50%, 24rem, auto"
|
||||
},
|
||||
@@ -356,6 +359,11 @@
|
||||
"right": "오른쪽",
|
||||
"bottom": "아래",
|
||||
"left": "왼쪽"
|
||||
},
|
||||
"bound_value": {
|
||||
"notice": "데이터 연결 값입니다(자동으로 채워집니다)",
|
||||
"replace": "직접 지정으로 바꾸기",
|
||||
"restore": "데이터 연결로 되돌리기"
|
||||
}
|
||||
},
|
||||
"recipe": {
|
||||
|
||||
+6
-6
File diff suppressed because one or more lines are too long
+31
-28
File diff suppressed because one or more lines are too long
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* template-engine.ts — 레이아웃 편집기 모드 재렌더 보호 회귀 테스트
|
||||
*
|
||||
* 결함: 편집기 모드에서 `TemplateApp.init` 은 `renderTemplate({ layoutJson: { components: [] } })`
|
||||
* 로 부르고, `renderTemplate` 의 편집기 분기가 그 빈 배열 대신 `LayoutEditorChrome` 을 렌더한다.
|
||||
* 그런데 `updateTemplateData` 에는 그 분기가 없어 `state.currentLayoutJson.components`(= 빈 배열)
|
||||
* 로 **같은 reactRoot 에 두 번째 render** 를 걸었고, React 가 그 커밋에서 편집기 트리를 통째로
|
||||
* 제거해 화면이 백지가 됐다.
|
||||
*
|
||||
* 예외도 콘솔 오류도 남지 않는다 — 편집기가 잠깐 보였다가 사라지는 것이 유일한 증상이며,
|
||||
* `renderTemplate` 의 편집기 분기가 비동기(`loadLayoutEditorBundle`)라 부팅 중 `setGlobalState`
|
||||
* 가 그 커밋 뒤에 도착할 때만 발현하는 **경합**이라 간헐적으로 보인다.
|
||||
*
|
||||
* 브라우저 실측(2026-09-08): `app-children-up 1` (668ms) → `APP-EMPTIED removed=1` (704ms),
|
||||
* 제거 스택은 `init` → startTransition → React commit. `destroyTemplate` 은 호출되지 않았다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {
|
||||
initTemplateEngine,
|
||||
renderTemplate,
|
||||
updateTemplateData,
|
||||
destroyTemplate,
|
||||
} from '../template-engine';
|
||||
|
||||
const flushReactScheduler = async (): Promise<void> => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
};
|
||||
|
||||
vi.mock('../template-engine/ComponentRegistry', () => {
|
||||
const mockInstance = {
|
||||
loadComponents: vi.fn().mockResolvedValue(undefined),
|
||||
getComponent: vi.fn().mockReturnValue(() => null),
|
||||
hasComponent: vi.fn().mockReturnValue(true),
|
||||
getInstance: vi.fn(),
|
||||
};
|
||||
mockInstance.getInstance.mockReturnValue(mockInstance);
|
||||
return { ComponentRegistry: { getInstance: vi.fn(() => mockInstance) } };
|
||||
});
|
||||
|
||||
vi.mock('../template-engine/DataBindingEngine', () => {
|
||||
const DataBindingEngine = vi.fn(function (this: any) {
|
||||
this.bind = vi.fn();
|
||||
this.unbind = vi.fn();
|
||||
this.invalidateCacheByKeys = vi.fn();
|
||||
});
|
||||
return { DataBindingEngine, dataBindingEngine: new (DataBindingEngine as any)() };
|
||||
});
|
||||
|
||||
vi.mock('../template-engine/TranslationEngine', () => {
|
||||
const mockInstance = {
|
||||
translate: vi.fn((key: string) => key),
|
||||
setLocale: vi.fn(),
|
||||
loadTranslations: vi.fn().mockResolvedValue({}),
|
||||
resolveTranslations: vi.fn((text: string) => text),
|
||||
clearCache: vi.fn(),
|
||||
};
|
||||
return {
|
||||
TranslationEngine: { getInstance: vi.fn(() => mockInstance), resetInstance: vi.fn() },
|
||||
TranslationContext: {} as any,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../template-engine/ActionDispatcher', () => ({
|
||||
ActionDispatcher: vi.fn(function (this: any) {
|
||||
this.dispatch = vi.fn();
|
||||
this.register = vi.fn();
|
||||
this.createHandler = vi.fn(() => vi.fn());
|
||||
}),
|
||||
setActionDispatcherInstance: vi.fn(),
|
||||
getActionDispatcher: vi.fn(() => ({ dispatch: vi.fn(), register: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/DynamicRenderer', () => ({ default: vi.fn(() => null) }));
|
||||
|
||||
vi.mock('../template-engine/ResponsiveManager', () => ({
|
||||
responsiveManager: {
|
||||
getWidth: vi.fn(() => 1024),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getMatchingKey: vi.fn(() => null),
|
||||
parseRange: vi.fn(() => null),
|
||||
},
|
||||
BREAKPOINT_PRESETS: {},
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/ResponsiveContext', () => ({
|
||||
ResponsiveContext: {},
|
||||
ResponsiveProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useResponsive: vi.fn(() => ({
|
||||
width: 1024,
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: true,
|
||||
matchedPreset: 'desktop',
|
||||
})),
|
||||
}));
|
||||
|
||||
const EDITOR_PATH = '/admin/layout-editor/sirsoft-basic';
|
||||
|
||||
/** 편집기 lazy 번들 stub — `loadLayoutEditorBundle` 이 즉시 반환하는 경로를 탄다. */
|
||||
function EditorChromeStub(): React.ReactElement {
|
||||
return React.createElement('div', { 'data-testid': 'editor-chrome' }, 'EDITOR');
|
||||
}
|
||||
|
||||
describe('[case:state-advanced-editor-blank] template-engine — 레이아웃 편집기 모드 재렌더 보호', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
destroyTemplate();
|
||||
vi.clearAllMocks();
|
||||
|
||||
container = document.createElement('div');
|
||||
container.id = 'app';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// `loadLayoutEditorBundle` 은 `G7Core.__LayoutEditorChrome` 이 있으면 즉시 resolve 한다.
|
||||
(window as any).G7Core = { ...(window as any).G7Core, __LayoutEditorChrome: EditorChromeStub };
|
||||
|
||||
window.history.replaceState({}, '', EDITOR_PATH);
|
||||
|
||||
await initTemplateEngine({ templateId: 'sirsoft-admin_basic', locale: 'ko' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
destroyTemplate();
|
||||
await flushReactScheduler();
|
||||
container.remove();
|
||||
delete (window as any).G7Core.__LayoutEditorChrome;
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
it('편집기 렌더 후 updateTemplateData 가 들어와도 편집기 트리가 지워지지 않는다', async () => {
|
||||
// TemplateApp.init 이 편집기 모드에서 넘기는 것과 동일한 빈 레이아웃
|
||||
await act(async () => {
|
||||
await renderTemplate({
|
||||
containerId: 'app',
|
||||
layoutJson: { components: [] } as any,
|
||||
dataContext: {},
|
||||
translationContext: { templateId: 'sirsoft-admin_basic', locale: 'ko' },
|
||||
});
|
||||
});
|
||||
await flushReactScheduler();
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="editor-chrome"]'),
|
||||
'편집기 분기가 LayoutEditorChrome 을 렌더해야 한다(사전 조건)',
|
||||
).not.toBeNull();
|
||||
|
||||
// 부팅 중 setGlobalState → updateTemplateData 가 도착하는 상황
|
||||
await act(async () => {
|
||||
updateTemplateData({ _global: { sidebarOpen: false } });
|
||||
});
|
||||
await flushReactScheduler();
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="editor-chrome"]'),
|
||||
'updateTemplateData 재렌더가 편집기 트리를 제거하면 안 된다(백지 회귀)',
|
||||
).not.toBeNull();
|
||||
expect(container.childElementCount, '#app 이 비어서는 안 된다').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('편집기 모드가 아니면 updateTemplateData 재렌더가 종전대로 동작한다 (회귀 가드)', async () => {
|
||||
window.history.replaceState({}, '', '/admin/dashboard');
|
||||
|
||||
await act(async () => {
|
||||
await renderTemplate({
|
||||
containerId: 'app',
|
||||
layoutJson: { components: [{ type: 'basic', name: 'Div' }] } as any,
|
||||
dataContext: {},
|
||||
translationContext: { templateId: 'sirsoft-admin_basic', locale: 'ko' },
|
||||
});
|
||||
});
|
||||
await flushReactScheduler();
|
||||
|
||||
// DynamicRenderer 가 null 을 반환하는 mock 이라 DOM 노드는 없지만,
|
||||
// 편집기 stub 이 렌더되지 않았다는 것이 이 케이스의 핵심이다.
|
||||
expect(container.querySelector('[data-testid="editor-chrome"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
updateTemplateData({ _global: { x: 1 } });
|
||||
});
|
||||
await flushReactScheduler();
|
||||
|
||||
expect(container.querySelector('[data-testid="editor-chrome"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -777,6 +777,23 @@ function updateTemplateData(data: Record<string, any>, options?: UpdateOptions):
|
||||
state.currentDataContext._computed = mergedGlobalState._computed;
|
||||
}
|
||||
|
||||
// 레이아웃 편집기 모드 — 재렌더 금지 (renderTemplate 의 편집기 분기와 대칭).
|
||||
//
|
||||
// 편집기 모드에서 `TemplateApp.init` 은 `renderTemplate({ layoutJson: { components: [] } })`
|
||||
// 으로 부르고, renderTemplate 의 편집기 분기가 그 빈 배열 대신 LayoutEditorChrome 을
|
||||
// 같은 reactRoot 에 렌더한다. 그래서 `state.currentLayoutJson.components` 는 **빈 배열**이다.
|
||||
// 여기서 그대로 재렌더하면 같은 루트에 빈 트리를 커밋해 편집기를 통째로 제거한다 — 화면이
|
||||
// 백지가 되고 예외도 콘솔 오류도 남지 않는다.
|
||||
//
|
||||
// renderTemplate 의 편집기 분기는 비동기(`loadLayoutEditorBundle`)라, 부팅 중 도착한
|
||||
// setGlobalState 한 번이 그 커밋 뒤에 실행되면 발현하는 **경합**이다(간헐 재현).
|
||||
// 데이터 병합은 위에서 이미 끝났으므로 여기서는 렌더만 건너뛴다 — 편집기 트리는
|
||||
// 자기 상태를 스스로 관리하고 currentLayoutJson 에 의존하지 않는다.
|
||||
if (typeof window !== 'undefined' && checkLayoutEditorMode(window.location.pathname)) {
|
||||
logger.log('레이아웃 편집기 모드 — 재렌더 건너뜀 (편집기 트리 보존)');
|
||||
return;
|
||||
}
|
||||
|
||||
const components = state.currentLayoutJson.components || [];
|
||||
const modals = state.currentLayoutJson.modals || [];
|
||||
logger.log('updateTemplateData - modals 배열:', modals);
|
||||
|
||||
@@ -5,6 +5,43 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.66.0] - 2026-09-08
|
||||
|
||||
### Added
|
||||
|
||||
#### `number` 위젯 — 숫자 prop 편집
|
||||
- `number` 위젯을 코어 레지스트리에 등록 — 종전 미등록이라 `widget:"number"` 컨트롤이 속성 모달에서 「지원하지 않는 컨트롤」로 폴백해 편집 자체가 불가했다 (registerCoreWidgets.ts)
|
||||
- `NumberWidget` 신설 — blur/Enter 커밋, `0` 은 유효값이라 삭제되지 않고, 비숫자는 미방출(저장본 무손실), 바인딩 문자열은 읽기전용 디그레이드 (StyleControlWidgets.tsx)
|
||||
- `EditorControlSpec` 에 `min`/`max`/`step` 명시 선언 (specTypes.ts)
|
||||
|
||||
#### `nodeKey` apply 프리미티브 — 노드 최상위 구조키 패치
|
||||
- `applyRecipe`/`reverseResolve` 에 `nodeKey` 분기 구현 — `coreProps` 가 선언만 하고 엔진 switch 에 case 가 없어 무음 no-op 이던 것을 실동작으로 (recipeEngine.ts)
|
||||
- 예약 노드키(`children`/`props`/`type` 등) 가드 — 템플릿이 그 키를 선언해도 노드가 파괴되지 않는다 (recipeEngine.ts)
|
||||
- 전용 UI 소유 키(`isolatedState`/`isolatedScopeId`)를 `resolveCorePropKeys` 렌더 목록에서 제외 — 같은 노드 키를 두 UI 가 쓰는 이중 경로 차단 (coreProps.ts)
|
||||
|
||||
### Fixed
|
||||
|
||||
#### `image` 위젯 값이 `[object Object]` 로 기록되던 문제
|
||||
- 단일 값 슬롯(`propValue`/`cssVar`/단일 `styleProp`)에 이미지 값 객체가 통째로 기록되던 문제 — 공용 헬퍼 `scalarizeImageValue` 로 url 만 축약한다. 게이트는 위젯 이름이며 값 형태 sniffing 이 아니다(`{position:'left'}` 같은 정당한 객체 prop 오인 삭제 방지) (recipeEngine.ts)
|
||||
- 축약 저장된 문자열을 위젯이 읽지 못하던 문제 — `propValue` 역해석이 `{url}` 로 되감는다. 표현식 문자열도 감싸 빈 피커로 보이지 않게 한다 (recipeEngine.ts)
|
||||
- 단일 값 슬롯 컨트롤에서 표시모드(채움/맞춤/타일) 버튼이 저장되지 않는데도 눌리던 문제 — 컨테이너째 미렌더하고 미리보기는 `contain` 으로 고정한다(저장되지 않는 값을 흉내내는 거짓 미리보기 제거) (ImagePickerControl.tsx)
|
||||
- 데이터 연결 값이 빈 피커로 보여 업로드 1클릭에 표현식이 소실되던 문제 — 원문 배지 표시 + 파괴적 조작 잠금 + 「직접 지정으로 바꾸기」 해제 경로 (ImagePickerControl.tsx)
|
||||
|
||||
#### 레이아웃 편집기가 열린 직후 백지가 되던 문제
|
||||
- 편집기 모드에서 `updateTemplateData` 가 빈 레이아웃(`components: []`)으로 재렌더해 같은 reactRoot 에 빈 트리를 커밋, `LayoutEditorChrome` 을 통째로 제거하던 문제 — 편집기 모드면 데이터 병합 후 렌더만 건너뛴다. `renderTemplate` 의 편집기 분기가 비동기라 부팅 중 `setGlobalState` 가 그 커밋 뒤에 도착할 때만 발현하는 경합이었다 (template-engine.ts)
|
||||
|
||||
#### 데이터 연결 값이 조작 한 번에 소실되던 문제
|
||||
- prop 자리에 저장된 `{{...}}`·설정 참조를 위젯이 해석하지 못해 빈 컨트롤로 보이고, 조작 시 그 연결이 사라지던 문제 — `ControlRenderer` 단일 게이트에서 원문 배지로 디그레이드하고 「직접 지정으로 바꾸기」로만 연다. 위젯마다 구현하지 않으므로 신규 위젯에도 자동 적용된다 (ControlRenderer.tsx, boundValueGuard.tsx)
|
||||
- 「직접 지정으로 바꾸기」가 편도라 원문을 되찾을 수 없던 문제 — 「되돌리기」 추가(해제 직후엔 취소, 값을 넣은 뒤엔 원문 복구)
|
||||
- `image` 위젯에서 업로드·제거·썸네일만 잠기고 「이미지 관리」 진입이 열려 있어, 그 창의 「배경」 버튼이 같은 값을 덮어쓰던 문제 — 진입 자체를 함께 잠근다 (ImagePickerControl.tsx)
|
||||
- `number` 위젯의 자체 바인딩 분기가 공용 해제 경로를 막던 문제 — 위젯 자체 분기를 제거하고 공용 게이트로 일원화 (StyleControlWidgets.tsx)
|
||||
|
||||
#### 공통·확장 레이아웃 노드를 편집해도 저장되지 않던 문제
|
||||
- 라우트 편집 모드에서 상속(base)·주입(extension) 노드 중 바인딩을 가진 것이 `data_bound`(편집 허용)로 분류되던 문제 — 출처 잠금이 항상 우선하도록 판정 순서를 통일했다. 저장 시 마스킹이 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이 사라졌다 (useElementSelection.ts)
|
||||
- 잠긴 노드의 드래그·인라인 편집·복제·`Delete` 키·잘라내기가 무방비이던 문제 — 단일 판정 헬퍼 `isEditableLockKind` 로 전 표면 게이트 (EditorCanvasOverlay.tsx, useCanvasDnd.ts, DndCanvasLayer.tsx)
|
||||
- 거부된 드래그가 `activeDragPath` 를 남겨 「옮길 수 있다」는 거짓 어포던스를 주던 문제 (useCanvasDnd.ts)
|
||||
- 조상 산출 구현이 두 벌로 갈라져 잠금 판정 입력이 어긋날 수 있던 문제 — `collectAncestors` 로 승격 (layoutTreeUtils.ts)
|
||||
|
||||
## [engine-v1.65.0] - 2026-09-07
|
||||
|
||||
### Added
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* ControlRenderer.boundValueGuard.test.tsx — 데이터 연결 값 보호 **공용 게이트**
|
||||
*
|
||||
* 저장값이 `{{...}}`(또는 설정 참조)이면 위젯은 그 문자열을 해석하지 못해 **빈 컨트롤**로
|
||||
* 보이고, 운영자가 조작하는 순간 환경설정과의 연결이 소리 없이 끊긴다. 원문이 화면 어디에도
|
||||
* 남지 않아 되돌릴 수단조차 없다.
|
||||
*
|
||||
* 이 보호를 위젯마다 넣으면 한 곳이 빠져도 오류가 나지 않고 그 한 곳이 우회로가 된다
|
||||
* (공개 #135 후속 실측: 「이미지 관리」 진입 하나만 열려 있었다). 그래서 `ControlRenderer`
|
||||
* 한 곳에서 걸고, 본 테스트가 **위젯 종류와 무관하게** 걸리는 것을 잠근다 — 새 위젯을
|
||||
* 등록해도 자동 적용된다는 계약이다.
|
||||
*
|
||||
* @effects bound_value_protection_is_a_single_shared_gate_for_every_widget, restore_affordance_recovers_the_original_binding_after_replacement, bound_value_shows_expression_badge_and_locks_destructive_controls, replace_affordance_reopens_editing_without_emitting_a_change
|
||||
* @since engine-v1.66.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { ControlRenderer } from '../../components/property-controls/ControlRenderer';
|
||||
import { registerCoreWidgets } from '../../spec/registerCoreWidgets';
|
||||
import { LayoutEditorProvider } from '../../LayoutEditorContext';
|
||||
import { EditorModalProvider } from '../../EditorModalContext';
|
||||
import type { EditorControlSpec } from '../../spec/specTypes';
|
||||
import type { EditorNode } from '../../utils/layoutTreeUtils';
|
||||
|
||||
// 레지스트리 디스패치를 타는 테스트라 코어 위젯이 등록돼 있어야 한다
|
||||
// (미등록이면 ControlRenderer 가 "지원하지 않는 위젯" 폴백을 그린다).
|
||||
beforeAll(() => registerCoreWidgets());
|
||||
|
||||
const t = (key: string): string => key;
|
||||
const EXPR = '{{_global.settings?.general?.site_logo_url}}';
|
||||
|
||||
/** 스칼라를 prop 자리에 기록하는 평범한 컨트롤 — image 가 아니다 */
|
||||
const numberCtrl: EditorControlSpec = {
|
||||
widget: 'number',
|
||||
apply: { type: 'propValue', propKey: 'maxVisibleBoards' },
|
||||
} as never;
|
||||
|
||||
const selectCtrl: EditorControlSpec = {
|
||||
widget: 'select',
|
||||
options: [{ value: 'sm', label: 'S' }, { value: 'lg', label: 'L' }],
|
||||
apply: { type: 'propValue', propKey: 'size' },
|
||||
} as never;
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('ControlRenderer — 데이터 연결 값 보호 공용 게이트', () => {
|
||||
it('number 위젯도 바인딩 값이면 원문 배지로 디그레이드된다 (image 전용이 아니다)', () => {
|
||||
const node: EditorNode = { type: 'basic', name: 'Header', props: { maxVisibleBoards: EXPR } };
|
||||
render(<ControlRenderer controlKey="hdrMax" control={numberCtrl} node={node} t={t} onPatch={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('g7le-control-hdrMax-expression').textContent).toBe(EXPR);
|
||||
// 위젯 자체는 렌더되지 않는다 — 해석 못 하는 값을 흉내내면 거짓 컨트롤이다.
|
||||
expect(screen.queryByTestId('g7le-number-input')).toBeNull();
|
||||
});
|
||||
|
||||
it('select 위젯도 동일하게 걸린다 (닫힌 집합이라 표현식을 표시할 수 없다)', () => {
|
||||
const node: EditorNode = { type: 'basic', name: 'Div', props: { size: EXPR } };
|
||||
render(<ControlRenderer controlKey="sz" control={selectCtrl} node={node} t={t} onPatch={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('g7le-control-sz-expression').textContent).toBe(EXPR);
|
||||
});
|
||||
|
||||
it('「직접 지정으로 바꾸기」 → 위젯이 열리고, 그 클릭만으로는 값을 바꾸지 않는다', () => {
|
||||
const onPatch = vi.fn();
|
||||
const node: EditorNode = { type: 'basic', name: 'Header', props: { maxVisibleBoards: EXPR } };
|
||||
render(<ControlRenderer controlKey="hdrMax" control={numberCtrl} node={node} t={t} onPatch={onPatch} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('g7le-control-hdrMax-expression-replace'));
|
||||
|
||||
expect(screen.queryByTestId('g7le-control-hdrMax-expression')).toBeNull();
|
||||
expect(screen.getByTestId('g7le-widget-number')).toBeInTheDocument();
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('해제 뒤 「되돌리기」가 노출되고, 원문을 다시 기록한다 (편도 금지)', () => {
|
||||
const patches: EditorNode[] = [];
|
||||
|
||||
// 실제 편집기처럼 패치가 노드로 되돌아오는 controlled 하네스 —
|
||||
// 고정 prop 으로는 "값을 바꾼 뒤" 상태를 재현할 수 없다.
|
||||
function Harness(): React.ReactElement {
|
||||
const [node, setNode] = React.useState<EditorNode>({
|
||||
type: 'basic',
|
||||
name: 'Header',
|
||||
props: { maxVisibleBoards: EXPR },
|
||||
});
|
||||
return (
|
||||
<ControlRenderer
|
||||
controlKey="hdrMax"
|
||||
control={numberCtrl}
|
||||
node={node}
|
||||
t={t}
|
||||
onPatch={(n) => {
|
||||
patches.push(n);
|
||||
setNode(n);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('g7le-control-hdrMax-expression-replace'));
|
||||
|
||||
const input = screen.getByTestId('g7le-number-input');
|
||||
fireEvent.change(input, { target: { value: '7' } });
|
||||
fireEvent.blur(input, { target: { value: '7' } });
|
||||
expect(patches[patches.length - 1].props?.maxVisibleBoards).toBe(7);
|
||||
|
||||
// 원문이 화면에서 사라진 뒤에도 되돌릴 수 있어야 한다.
|
||||
fireEvent.click(screen.getByTestId('g7le-control-hdrMax-expression-restore'));
|
||||
expect(patches[patches.length - 1].props?.maxVisibleBoards).toBe(EXPR);
|
||||
// 보호 상태로 복귀
|
||||
expect(screen.getByTestId('g7le-control-hdrMax-expression').textContent).toBe(EXPR);
|
||||
});
|
||||
|
||||
it('바인딩이 아닌 평범한 값은 종전대로 위젯이 렌더된다 (회귀 가드)', () => {
|
||||
const node: EditorNode = { type: 'basic', name: 'Header', props: { maxVisibleBoards: 5 } };
|
||||
render(<ControlRenderer controlKey="hdrMax" control={numberCtrl} node={node} t={t} onPatch={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByTestId('g7le-control-hdrMax-expression')).toBeNull();
|
||||
expect((screen.getByTestId('g7le-number-input') as HTMLInputElement).value).toBe('5');
|
||||
});
|
||||
|
||||
it('자체 처리 위젯(image)은 공용 게이트를 타지 않는다 — 위젯 자신의 배지를 쓴다', () => {
|
||||
const imageCtrl: EditorControlSpec = {
|
||||
widget: 'image',
|
||||
apply: { type: 'propValue', propKey: 'logo' },
|
||||
} as never;
|
||||
const node: EditorNode = { type: 'composite', name: 'Header', props: { logo: EXPR } };
|
||||
// ImagePickerControl 은 편집기 컨텍스트(첨부 목록·모달)를 요구한다.
|
||||
render(
|
||||
<LayoutEditorProvider templateIdentifier="sirsoft-basic" initialLocale="ko">
|
||||
<EditorModalProvider>
|
||||
<ControlRenderer controlKey="hdrLogo" control={imageCtrl} node={node} t={t} onPatch={vi.fn()} />
|
||||
</EditorModalProvider>
|
||||
</LayoutEditorProvider>,
|
||||
);
|
||||
|
||||
// 공용 게이트의 testid 가 아니라 위젯 자신의 testid 로 뜬다.
|
||||
expect(screen.queryByTestId('g7le-control-hdrLogo-expression')).toBeNull();
|
||||
expect(screen.getByTestId('g7le-image-expression').textContent).toBe(EXPR);
|
||||
});
|
||||
});
|
||||
+143
@@ -433,3 +433,146 @@ describe('DndCanvasLayer — 드래그 핸들 렌더 가드', () => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 상속·주입 노드 드래그 핸들 미렌더 (A3)
|
||||
//
|
||||
// 이 노드들은 종전에 `data_bound` 로 분류돼 핸들이 렌더됐고, 옮겨도 저장 시 폐기됐다.
|
||||
// 핸들 자체를 만들지 않아 "옮길 수 있다" 는 어포던스를 주지 않는다.
|
||||
// ============================================================================
|
||||
describe('DndCanvasLayer — 상속·주입 노드 드래그 핸들 차단 (A3)', () => {
|
||||
/** 펼침 인스턴스 2개를 owner 아래에 깔아 가상 묶음 합성 조건을 만든다. */
|
||||
function appendIterationInstances(frame: HTMLElement, ownerPath: string): void {
|
||||
const owner = frame.querySelector(`[data-editor-path="${ownerPath}"]`)!;
|
||||
for (const [i, top] of [0, 50].entries()) {
|
||||
const inst = document.createElement('div');
|
||||
const p = `${ownerPath}.iteration.${i}`;
|
||||
inst.dataset.editorPath = p;
|
||||
inst.setAttribute('data-editor-path', p);
|
||||
vi.spyOn(inst, 'getBoundingClientRect').mockReturnValue(
|
||||
domRect({ left: 0, top, width: 400, height: 50 }),
|
||||
);
|
||||
owner.appendChild(inst);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLayer(components: EditorNode[]) {
|
||||
const frame = buildFrame([
|
||||
{ path: '0', rect: { left: 0, top: 0, width: 400, height: 100 } },
|
||||
{ path: '1', rect: { left: 0, top: 100, width: 400, height: 100 } },
|
||||
]);
|
||||
render(
|
||||
<DndCanvasLayer
|
||||
frameEl={frame}
|
||||
nesting={NESTING}
|
||||
editMode="route"
|
||||
components={components}
|
||||
patchLayout={vi.fn()}
|
||||
pushHistory={vi.fn()}
|
||||
onSelectPath={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('N13 base 출처 + 바인딩 노드 → 핸들 미렌더 (route 소유 형제는 렌더)', () => {
|
||||
renderLayer([
|
||||
{ name: 'Div', id: 'a', __source: { kind: 'base' }, text: '{{site.title}}' },
|
||||
{ name: 'Div', id: 'b', __source: { kind: 'route' }, text: '{{user.name}}' },
|
||||
]);
|
||||
expect(screen.queryByTestId('g7le-dnd-handle-0')).not.toBeInTheDocument();
|
||||
// 회귀 가드 — route 소유 data_bound 는 그대로 핸들이 있다.
|
||||
expect(screen.queryByTestId('g7le-dnd-handle-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('N14 base 출처 iteration 노드 → 가상 묶음 핸들도 미렌더', () => {
|
||||
renderLayer([
|
||||
{
|
||||
name: 'Div',
|
||||
id: 'list',
|
||||
__source: { kind: 'base' },
|
||||
iteration: { source: '{{posts.data}}' },
|
||||
},
|
||||
{ name: 'Div', id: 'b', __source: { kind: 'route' } },
|
||||
]);
|
||||
expect(screen.queryByTestId('g7le-dnd-handle-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('g7le-dnd-handle-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* N14b — 가상 묶음 합성 루프(`DndCanvasLayer` 의 iterationGroups 순회)를 **실제로 진입**시켜
|
||||
* 원본 노드의 출처 잠금이 그 루프에서도 걸리는지 잰다.
|
||||
*
|
||||
* N14 는 펼침 인스턴스(`0.iteration.N`) DOM 을 만들지 않아 묶음 합성 루프에 애초에
|
||||
* 들어가지 않았다 — 그래서 그 안의 잠금 게이트를 지워도 초록이었다(라운드3 probe 실측).
|
||||
* 여기서는 인스턴스를 실제로 깔아 묶음이 합성되는 조건을 만든 뒤 부재를 단언한다.
|
||||
*/
|
||||
it('N14b base 출처 iteration 노드 → 펼침 인스턴스가 있어도 가상 묶음 핸들 미합성', () => {
|
||||
const frame = buildFrame([{ path: '0', rect: { left: 0, top: 0, width: 400, height: 100 } }]);
|
||||
appendIterationInstances(frame, '0');
|
||||
render(
|
||||
<DndCanvasLayer
|
||||
frameEl={frame}
|
||||
nesting={NESTING}
|
||||
editMode="route"
|
||||
components={[
|
||||
{
|
||||
name: 'Div',
|
||||
id: 'list',
|
||||
__source: { kind: 'base', layout: '_user_base' },
|
||||
iteration: { source: '{{posts.data}}' } as EditorNode['iteration'],
|
||||
children: [{ name: 'Span', id: 'cell' }],
|
||||
},
|
||||
]}
|
||||
patchLayout={vi.fn()}
|
||||
pushHistory={vi.fn()}
|
||||
onSelectPath={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId('g7le-dnd-handle-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* N14c — N14b 의 「부재」가 공허하지 않음을 증명하는 반증 가드.
|
||||
* 같은 픽스처에서 출처만 route 로 바꾸면 묶음 핸들이 실제로 합성된다.
|
||||
*/
|
||||
it('N14c 회귀 가드 — route 출처 iteration 노드는 가상 묶음 핸들이 합성된다', () => {
|
||||
const frame = buildFrame([{ path: '0', rect: { left: 0, top: 0, width: 400, height: 100 } }]);
|
||||
appendIterationInstances(frame, '0');
|
||||
render(
|
||||
<DndCanvasLayer
|
||||
frameEl={frame}
|
||||
nesting={NESTING}
|
||||
editMode="route"
|
||||
components={[
|
||||
{
|
||||
name: 'Div',
|
||||
id: 'list',
|
||||
__source: { kind: 'route' },
|
||||
iteration: { source: '{{posts.data}}' } as EditorNode['iteration'],
|
||||
children: [{ name: 'Span', id: 'cell' }],
|
||||
},
|
||||
]}
|
||||
patchLayout={vi.fn()}
|
||||
pushHistory={vi.fn()}
|
||||
onSelectPath={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const groupHandle = screen.queryByTestId('g7le-dnd-handle-0');
|
||||
expect(groupHandle).toBeInTheDocument();
|
||||
expect(groupHandle?.getAttribute('data-dnd-iteration-group')).toBe('true');
|
||||
});
|
||||
|
||||
it('N15 extension 출처 + 바인딩 노드 → 핸들 미렌더', () => {
|
||||
renderLayer([
|
||||
{
|
||||
name: 'Div',
|
||||
id: 'a',
|
||||
__source: { kind: 'extension', extensionId: 35 },
|
||||
text: '{{content}}',
|
||||
},
|
||||
{ name: 'Div', id: 'b', __source: { kind: 'route' } },
|
||||
]);
|
||||
expect(screen.queryByTestId('g7le-dnd-handle-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('g7le-dnd-handle-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* EditorCanvasOverlay.entryPointGuard.test.tsx — 구조 편집 콜백의 선두 가드 (A3)
|
||||
*
|
||||
* `handleDuplicate` 는 `ElementOverlay` 의 ⓘ 컨텍스트 메뉴에서만 호출되고, 그 ⓘ 는
|
||||
* 같은 잠금 판정으로 이미 가려진다(`ElementOverlay` 가 `isContextMenuAllowed` 로 게이트).
|
||||
* 그래서 ⓘ 를 통과하는 테스트만으로는 **콜백 자신의 선두 가드가 지워져도 초록**이다
|
||||
* (라운드3 probe 로 전 스위트 3,587 green 실측).
|
||||
*
|
||||
* 그러나 이 저장소는 같은 자리에서 이미 반대 입장을 취하고 있다 —
|
||||
* `handleEditProps` 는 "ElementOverlay 가 ⓘ 를 미표시하지만, 외부 호출자(Chrome stub) 등
|
||||
* 다른 경로로도 들어올 수 있으므로 여기서도 방어한다" 는 주석과 함께 자체 가드를 둔다.
|
||||
* 이 파일은 그 계약을 `handleDuplicate` 에도 적용해 **호출자를 신뢰하지 않는다**를 잠근다:
|
||||
* 오버레이를 스텁으로 바꿔 잠금 여부와 무관하게 콜백을 넘겨받아 직접 호출한다.
|
||||
*
|
||||
* @since engine-v1.66.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, act, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import type { UseLayoutDocumentResult, LoadedLayoutDocument } from '../../hooks/useLayoutDocument';
|
||||
import type { EditorNode } from '../../utils/layoutTreeUtils';
|
||||
|
||||
// ⓘ 게이트를 우회하는 스텁 — lockKind 와 무관하게 onDuplicate 트리거를 항상 렌더한다.
|
||||
// 「다른 경로로 들어온 호출자」를 그대로 흉내낸다.
|
||||
vi.mock('../../components/ElementOverlay', () => ({
|
||||
ElementOverlay: ({
|
||||
onDuplicate,
|
||||
lockKind,
|
||||
}: {
|
||||
onDuplicate: () => void;
|
||||
lockKind: string;
|
||||
}): React.ReactElement => (
|
||||
<button data-testid="stub-duplicate" data-lock-kind={lockKind} onClick={onDuplicate}>
|
||||
dup
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { EditorCanvasOverlay } from '../../components/EditorCanvasOverlay';
|
||||
import { LayoutEditorProvider, useLayoutEditor } from '../../LayoutEditorContext';
|
||||
import { LayoutDocumentProvider } from '../../LayoutDocumentContext';
|
||||
import { EditorModalProvider } from '../../EditorModalContext';
|
||||
import { TranslationProvider } from '../../../TranslationContext';
|
||||
import { TranslationEngine } from '../../../TranslationEngine';
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
function domRect(r: Rect): DOMRect {
|
||||
return {
|
||||
...r,
|
||||
right: r.left + r.width,
|
||||
bottom: r.top + r.height,
|
||||
x: r.left,
|
||||
y: r.top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function buildFrame(children: Array<{ path: string; rect: Rect }>): HTMLElement {
|
||||
const wrapper = document.createElement('div');
|
||||
const frame = document.createElement('div');
|
||||
vi.spyOn(frame, 'getBoundingClientRect').mockReturnValue(
|
||||
domRect({ left: 0, top: 0, width: 400, height: 400 }),
|
||||
);
|
||||
for (const c of children) {
|
||||
const el = document.createElement('div');
|
||||
el.dataset.editorPath = c.path;
|
||||
el.dataset.editorId = `id-${c.path}`;
|
||||
el.setAttribute('data-editor-path', c.path);
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue(domRect(c.rect));
|
||||
frame.appendChild(el);
|
||||
}
|
||||
wrapper.appendChild(frame);
|
||||
document.body.appendChild(wrapper);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function buildDocCtx(initialComponents: EditorNode[]): {
|
||||
ctx: UseLayoutDocumentResult;
|
||||
patchSpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
let document_: LoadedLayoutDocument = {
|
||||
layoutName: 'home',
|
||||
raw: { components: initialComponents },
|
||||
lockVersion: 1,
|
||||
};
|
||||
const patchSpy = vi.fn();
|
||||
const ctx: UseLayoutDocumentResult = {
|
||||
document: document_,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isDirty: false,
|
||||
saveSuccessCounter: 0,
|
||||
reload: async () => {},
|
||||
patchLayout: (patcher) => {
|
||||
patchSpy(patcher);
|
||||
const current = (document_.raw.components as EditorNode[]) ?? [];
|
||||
const next = patcher(current);
|
||||
document_ = { ...document_, raw: { ...document_.raw, components: next } };
|
||||
(ctx as any).document = document_;
|
||||
},
|
||||
setLayoutComponents: (next) => {
|
||||
document_ = { ...document_, raw: { ...document_.raw, components: next } };
|
||||
(ctx as any).document = document_;
|
||||
},
|
||||
save: async () => ({ kind: 'success', newLockVersion: 2 }),
|
||||
};
|
||||
return { ctx, patchSpy };
|
||||
}
|
||||
|
||||
function RouteSeeder({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const { dispatch } = useLayoutEditor();
|
||||
React.useEffect(() => {
|
||||
dispatch({ type: 'SELECT_ROUTE', route: { path: '/', layoutName: 'home' } });
|
||||
}, [dispatch]);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const BASE_TEXT: EditorNode = {
|
||||
name: 'Span',
|
||||
id: 'base-span',
|
||||
__source: { kind: 'base', layout: '_user_base' },
|
||||
text: '공통 문구',
|
||||
};
|
||||
const ROUTE_TEXT: EditorNode = { name: 'Span', id: 'route-span', __source: { kind: 'route' }, text: '라우트 문구' };
|
||||
|
||||
function mount(components: EditorNode[]) {
|
||||
const frame = buildFrame([
|
||||
{ path: '0', rect: { left: 0, top: 0, width: 400, height: 100 } },
|
||||
{ path: '1', rect: { left: 0, top: 100, width: 400, height: 100 } },
|
||||
]);
|
||||
const { ctx, patchSpy } = buildDocCtx(components);
|
||||
const engine = new TranslationEngine();
|
||||
|
||||
render(
|
||||
<TranslationProvider
|
||||
translationEngine={engine}
|
||||
translationContext={{ templateId: 'test', locale: 'ko' }}
|
||||
>
|
||||
<LayoutEditorProvider templateIdentifier="test-tpl" initialLocale="ko">
|
||||
<EditorModalProvider>
|
||||
<LayoutDocumentProvider value={ctx}>
|
||||
<RouteSeeder>
|
||||
<EditorCanvasOverlay frameEl={frame} spec={{ components: {} } as any} />
|
||||
</RouteSeeder>
|
||||
</LayoutDocumentProvider>
|
||||
</EditorModalProvider>
|
||||
</LayoutEditorProvider>
|
||||
</TranslationProvider>,
|
||||
);
|
||||
|
||||
const selectNode = (path: string): void => {
|
||||
const el = frame.querySelector(`[data-editor-path="${path}"]`) as HTMLElement;
|
||||
act(() => {
|
||||
fireEvent.click(el);
|
||||
});
|
||||
};
|
||||
return { selectNode, patchSpy };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
beforeEach(() => {
|
||||
delete (window as any).__g7LayoutEditorHistory;
|
||||
});
|
||||
|
||||
describe('EditorCanvasOverlay — 구조 편집 콜백의 선두 가드 (호출자 비신뢰)', () => {
|
||||
it('N24f base 출처 노드 — ⓘ 를 우회해 onDuplicate 를 직접 호출해도 patchLayout 미호출', async () => {
|
||||
const { selectNode, patchSpy } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('0');
|
||||
|
||||
const btn = document.querySelector('[data-testid="stub-duplicate"]') as HTMLElement;
|
||||
expect(btn, '스텁 오버레이가 잠금 여부와 무관하게 트리거를 내야 한다').not.toBeNull();
|
||||
expect(btn.getAttribute('data-lock-kind')).toBe('base');
|
||||
act(() => {
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
expect(patchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('N24g 회귀 가드 — route 출처 노드는 같은 경로로 실제 복제된다', async () => {
|
||||
// N24f 의 「부재」가 공허하지 않음을 증명한다 — 이 하네스에서 복제는 도달 가능하다.
|
||||
const { selectNode, patchSpy } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('1');
|
||||
|
||||
const btn = document.querySelector('[data-testid="stub-duplicate"]') as HTMLElement;
|
||||
expect(btn.getAttribute('data-lock-kind')).toBe('none');
|
||||
act(() => {
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
expect(patchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* EditorCanvasOverlay.sourceLockGuard.test.tsx — 상속·주입 노드 편집 진입 차단 (A3)
|
||||
*
|
||||
* `route` 편집 모드에서 상속(base)·주입(extension) 출처 노드는 저장 시
|
||||
* `stripInheritedNode` 가 통째로 폐기하므로 편집분이 오류도 경고도 없이 사라진다.
|
||||
* (저장은 200 으로 성공하고 `history.clear()` 로 undo 도 불가능하다.)
|
||||
*
|
||||
* 종전에는 잠금 판정이 ⓘ 컨텍스트 메뉴와 DnD 핸들에만 걸려 있었고, 아래 4개 진입점은
|
||||
* 무방비였다 — 더블클릭 인라인 편집 / 복제 / 키보드 `Delete` / 잘라내기.
|
||||
* 이 파일은 그 4개 표면 각각의 차단과, 라우트 소유 노드의 정상 동작(회귀 가드)을 잠근다.
|
||||
*
|
||||
* @since engine-v1.66.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, act, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { EditorCanvasOverlay } from '../../components/EditorCanvasOverlay';
|
||||
import { LayoutEditorProvider, useLayoutEditor } from '../../LayoutEditorContext';
|
||||
import { LayoutDocumentProvider } from '../../LayoutDocumentContext';
|
||||
import { EditorModalProvider } from '../../EditorModalContext';
|
||||
import { TranslationProvider } from '../../../TranslationContext';
|
||||
import { TranslationEngine } from '../../../TranslationEngine';
|
||||
import type { UseLayoutDocumentResult, LoadedLayoutDocument } from '../../hooks/useLayoutDocument';
|
||||
import type { EditorNode } from '../../utils/layoutTreeUtils';
|
||||
import { readClipboard, clearClipboard } from '../../utils/editorClipboard';
|
||||
|
||||
interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
function domRect(r: Rect): DOMRect {
|
||||
return {
|
||||
...r,
|
||||
right: r.left + r.width,
|
||||
bottom: r.top + r.height,
|
||||
x: r.left,
|
||||
y: r.top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
/** 미리보기 프레임 + 편집 노드 DOM 모킹 (wrapper 로 감싼다 — 리스너 등록 대상) */
|
||||
function buildFrame(children: Array<{ path: string; rect: Rect }>): HTMLElement {
|
||||
const wrapper = document.createElement('div');
|
||||
const frame = document.createElement('div');
|
||||
vi.spyOn(frame, 'getBoundingClientRect').mockReturnValue(
|
||||
domRect({ left: 0, top: 0, width: 400, height: 400 }),
|
||||
);
|
||||
for (const c of children) {
|
||||
const el = document.createElement('div');
|
||||
el.dataset.editorPath = c.path;
|
||||
el.dataset.editorId = `id-${c.path}`;
|
||||
el.setAttribute('data-editor-path', c.path);
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue(domRect(c.rect));
|
||||
frame.appendChild(el);
|
||||
}
|
||||
wrapper.appendChild(frame);
|
||||
document.body.appendChild(wrapper);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function buildDocCtx(initialComponents: EditorNode[]): {
|
||||
ctx: UseLayoutDocumentResult;
|
||||
patchSpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
let document_: LoadedLayoutDocument = {
|
||||
layoutName: 'home',
|
||||
raw: { components: initialComponents },
|
||||
lockVersion: 1,
|
||||
};
|
||||
const patchSpy = vi.fn();
|
||||
const ctx: UseLayoutDocumentResult = {
|
||||
document: document_,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isDirty: false,
|
||||
saveSuccessCounter: 0,
|
||||
reload: async () => {},
|
||||
patchLayout: (patcher) => {
|
||||
patchSpy(patcher);
|
||||
const current = (document_.raw.components as EditorNode[]) ?? [];
|
||||
const next = patcher(current);
|
||||
document_ = { ...document_, raw: { ...document_.raw, components: next } };
|
||||
(ctx as any).document = document_;
|
||||
},
|
||||
setLayoutComponents: (next) => {
|
||||
document_ = { ...document_, raw: { ...document_.raw, components: next } };
|
||||
(ctx as any).document = document_;
|
||||
},
|
||||
save: async () => ({ kind: 'success', newLockVersion: 2 }),
|
||||
};
|
||||
return { ctx, patchSpy };
|
||||
}
|
||||
|
||||
function RouteSeeder({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const { dispatch } = useLayoutEditor();
|
||||
React.useEffect(() => {
|
||||
dispatch({ type: 'SELECT_ROUTE', route: { path: '/', layoutName: 'home' } });
|
||||
}, [dispatch]);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function mount(components: EditorNode[]) {
|
||||
const frame = buildFrame([
|
||||
{ path: '0', rect: { left: 0, top: 0, width: 400, height: 100 } },
|
||||
{ path: '1', rect: { left: 0, top: 100, width: 400, height: 100 } },
|
||||
]);
|
||||
const { ctx, patchSpy } = buildDocCtx(components);
|
||||
const engine = new TranslationEngine();
|
||||
const manifest = { components: {} } as any;
|
||||
|
||||
render(
|
||||
<TranslationProvider
|
||||
translationEngine={engine}
|
||||
translationContext={{ templateId: 'test', locale: 'ko' }}
|
||||
>
|
||||
<LayoutEditorProvider templateIdentifier="test-tpl" initialLocale="ko">
|
||||
<EditorModalProvider>
|
||||
<RouteSeeder>
|
||||
<LayoutDocumentProvider value={ctx}>
|
||||
<EditorCanvasOverlay
|
||||
frameEl={frame}
|
||||
manifest={manifest}
|
||||
nesting={null}
|
||||
componentPalette={null}
|
||||
/>
|
||||
</LayoutDocumentProvider>
|
||||
</RouteSeeder>
|
||||
</EditorModalProvider>
|
||||
</LayoutEditorProvider>
|
||||
</TranslationProvider>,
|
||||
);
|
||||
|
||||
const nodeEl = (path: string): HTMLElement =>
|
||||
frame.querySelector(`[data-editor-path="${path}"]`) as HTMLElement;
|
||||
|
||||
/** 캔버스 노드 클릭 → 선택 (frame 부모 wrapper 의 capture 리스너가 잡는다) */
|
||||
const selectNode = (path: string): void => {
|
||||
act(() => {
|
||||
fireEvent.click(nodeEl(path), { clientX: 10, clientY: 10 });
|
||||
});
|
||||
};
|
||||
|
||||
return { frame, nodeEl, selectNode, patchSpy };
|
||||
}
|
||||
|
||||
/** 상속(base) 출처 + 평문 텍스트 — 더블클릭 인라인 편집의 대상이 되는 형태 */
|
||||
const BASE_TEXT: EditorNode = {
|
||||
name: 'Span',
|
||||
type: 'basic',
|
||||
__source: { kind: 'base', layout: '_user_base' },
|
||||
text: '푸터 문구',
|
||||
};
|
||||
/** 라우트 소유 + 평문 텍스트 — 회귀 가드용 대조군 */
|
||||
const ROUTE_TEXT: EditorNode = {
|
||||
name: 'Span',
|
||||
type: 'basic',
|
||||
__source: { kind: 'route' },
|
||||
text: '본문 문구',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
beforeEach(() => {
|
||||
delete (window as any).__g7LayoutEditorHistory;
|
||||
// 클립보드는 sessionStorage 라 케이스 간 누수된다 — N24d 의 「부재」 단언이 공허해진다.
|
||||
clearClipboard();
|
||||
});
|
||||
|
||||
describe('EditorCanvasOverlay — 상속·주입 노드 인라인 편집 차단 (A3)', () => {
|
||||
it('N21 base 출처 평문 노드 더블클릭 → 인라인 편집기 미마운트', async () => {
|
||||
const { nodeEl } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
act(() => {
|
||||
fireEvent.doubleClick(nodeEl('0'), { clientX: 10, clientY: 10 });
|
||||
});
|
||||
expect(document.querySelector('[data-testid="g7le-inline-text-editor"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('N22 회귀 가드 — route 출처 평문 노드 더블클릭은 그대로 진입한다', async () => {
|
||||
const { nodeEl } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
act(() => {
|
||||
fireEvent.doubleClick(nodeEl('1'), { clientX: 10, clientY: 150 });
|
||||
});
|
||||
expect(document.querySelector('[data-testid="g7le-inline-text-editor"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EditorCanvasOverlay — 상속·주입 노드 구조 편집 차단 (A3)', () => {
|
||||
it('N23 base 출처 노드 선택 + 키보드 Delete → patchLayout 미호출', async () => {
|
||||
const { selectNode, patchSpy } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('0');
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'Delete' });
|
||||
});
|
||||
expect(patchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('N24 base 출처 노드 잘라내기(Ctrl+X) → patchLayout 미호출', async () => {
|
||||
const { selectNode, patchSpy } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('0');
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'x', ctrlKey: true });
|
||||
});
|
||||
expect(patchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* N24d — `handleCut` 의 **선두 가드 고유 목적**을 잰다.
|
||||
*
|
||||
* N24 는 `patchLayout` 미호출만 단언하는데, 그것은 `handleCut` 이 호출하는
|
||||
* `handleDelete` 의 가드(G7)만으로도 성립한다 — 그래서 `handleCut` 자신의 가드를 지워도
|
||||
* 초록이었다(라운드3 probe 실측). 계획서 §7.4 가 이 가드를 따로 둔 이유는
|
||||
* 「복사는 됐는데 원본이 남는」 어긋난 상태 방지, 즉 **클립보드에 기록되지 않는 것**이다.
|
||||
*/
|
||||
it('N24d base 출처 노드 잘라내기 → 클립보드에도 기록되지 않는다', async () => {
|
||||
const { selectNode } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('0');
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'x', ctrlKey: true });
|
||||
});
|
||||
expect(readClipboard()).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* N24e — N24d 의 「부재」가 공허하지 않음을 증명하는 반증 가드.
|
||||
* 같은 하네스에서 route 출처 노드는 실제로 클립보드에 실린다.
|
||||
*/
|
||||
it('N24e 회귀 가드 — route 출처 노드 잘라내기는 클립보드에 실린다', async () => {
|
||||
const { selectNode } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('1');
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'x', ctrlKey: true });
|
||||
});
|
||||
expect(readClipboard()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('N24b base 출처 노드는 복제 진입 경로 자체가 없다 (ⓘ 미렌더)', async () => {
|
||||
const { selectNode, patchSpy } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('0');
|
||||
|
||||
// ⓘ 가 없으면 「복제」 메뉴 항목에 도달할 방법이 없다 — 복제의 유일한 호출부다.
|
||||
expect(document.querySelector('[data-testid="g7le-overlay-info-button"]')).toBeNull();
|
||||
expect(document.querySelector('[data-testid="g7le-context-menu-duplicate"]')).toBeNull();
|
||||
expect(patchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('N24c 회귀 가드 — route 출처 노드는 ⓘ→복제로 실제 복제된다', async () => {
|
||||
// 이 케이스가 없으면 N24b 의 「부재」 단언이 공허해진다 —
|
||||
// 복제 경로가 이 하네스에서 애초에 도달 가능함을 여기서 증명한다.
|
||||
const { selectNode, patchSpy } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('1');
|
||||
|
||||
const info = document.querySelector('[data-testid="g7le-overlay-info-button"]') as HTMLElement;
|
||||
expect(info).not.toBeNull();
|
||||
act(() => {
|
||||
fireEvent.click(info);
|
||||
});
|
||||
|
||||
const dup = document.querySelector('[data-testid="g7le-context-menu-duplicate"]') as HTMLElement;
|
||||
expect(dup).not.toBeNull();
|
||||
act(() => {
|
||||
fireEvent.click(dup);
|
||||
});
|
||||
expect(patchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('N25 회귀 가드 — route 출처 노드는 Delete 로 정상 삭제된다', async () => {
|
||||
const { selectNode, patchSpy } = mount([BASE_TEXT, ROUTE_TEXT]);
|
||||
await waitFor(() => expect((window as any).__g7LayoutEditorHistory).toBeTruthy());
|
||||
selectNode('1');
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'Delete' });
|
||||
});
|
||||
expect(patchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+40
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { PropertyEditorModal } from '../../components/PropertyEditorModal';
|
||||
import { registerCoreWidgets } from '../../spec/registerCoreWidgets';
|
||||
import {
|
||||
@@ -196,3 +196,42 @@ describe('PropertyEditorModal — 데이터 연결(dataProps) 영역', () => {
|
||||
expect(screen.queryByTestId('g7le-iteration-binding-section')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 코어 dataKey 컨트롤 — nodeKey apply 통합 (A2)
|
||||
//
|
||||
// 엔진 switch 에 `nodeKey` case 가 없던 동안 이 컨트롤은 값을 넣어도 아무 일도
|
||||
// 일어나지 않는 무음 no-op 이었다. 통합 축으로 "패치가 노드 최상위에 간다" 를 잠근다.
|
||||
// ============================================================================
|
||||
describe('PropertyEditorModal — 코어 dataKey(nodeKey apply) 통합', () => {
|
||||
const dataKeySpec: EditorSpec = {
|
||||
controls: {},
|
||||
componentCapabilities: {
|
||||
// 폼 컨테이너 opt-in — coreProps 에 dataKey 를 명시해야 노출된다.
|
||||
Form: { coreProps: ['id', 'dataKey'] },
|
||||
},
|
||||
};
|
||||
|
||||
it('5-15 dataKey 입력 → onPatchNode 인자의 node.dataKey 설정, props.dataKey 부재', () => {
|
||||
const onPatchNode = vi.fn();
|
||||
render(
|
||||
<PropertyEditorModal
|
||||
node={{ name: 'Form' }}
|
||||
spec={dataKeySpec}
|
||||
manifest={null}
|
||||
t={t}
|
||||
onPatchNode={onPatchNode}
|
||||
onClose={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByTestId('g7le-widget-core-datakey');
|
||||
fireEvent.change(input, { target: { value: 'orderer' } });
|
||||
fireEvent.blur(input, { target: { value: 'orderer' } });
|
||||
|
||||
expect(onPatchNode).toHaveBeenCalled();
|
||||
const patched = onPatchNode.mock.calls.at(-1)![0] as EditorNode & Record<string, unknown>;
|
||||
expect(patched.dataKey).toBe('orderer');
|
||||
expect(patched.props?.dataKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
+275
@@ -24,6 +24,7 @@ import { TagInputControl } from '../../components/property-controls/TagInputCont
|
||||
import { CompositeSettingsForm } from '../../components/property-controls/CompositeSettingsForm';
|
||||
import { AdvancedPropsForm } from '../../components/property-controls/AdvancedPropsForm';
|
||||
import { ControlRenderer } from '../../components/property-controls/ControlRenderer';
|
||||
import { NumberWidget } from '../../components/property-controls/StyleControlWidgets';
|
||||
import { registerWidget, clearWidgetRegistry } from '../../spec/widgetRegistry';
|
||||
import { registerCoreWidgets, resetCoreWidgetRegistration } from '../../spec/registerCoreWidgets';
|
||||
import { LayoutEditorProvider } from '../../LayoutEditorContext';
|
||||
@@ -253,6 +254,187 @@ describe('ImagePickerControl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ImagePickerControl — 단일 값 슬롯 / 데이터 연결 보호 (공개 #135)
|
||||
//
|
||||
// `propValue`·`cssVar`·단일 `styleProp` 은 값 슬롯이 하나뿐이라 size/repeat/position
|
||||
// 을 저장할 자리가 없다. 그런 컨트롤에서 표시모드 버튼은 눌러도 저장되지 않는 죽은
|
||||
// 컨트롤이므로 **컨테이너째 미렌더**한다(disabled 는 "URL 을 넣으면 살아난다"는 거짓
|
||||
// 정보를 준다). 판정은 블랙리스트라 `apply` 미선언 컨트롤은 현행대로 모드 버튼을 유지한다.
|
||||
// ============================================================================
|
||||
describe('ImagePickerControl — 단일 값 슬롯 / 데이터 연결 보호 (공개 #135)', () => {
|
||||
/** 실물 `hdrLogo` — sirsoft-basic 헤더 「로고 이미지」 */
|
||||
const logoCtrl: EditorControlSpec = {
|
||||
widget: 'image',
|
||||
apply: { type: 'propValue', propKey: 'logo' },
|
||||
};
|
||||
/** 실물 `backgroundImage` — 배경 4속성 묶음 */
|
||||
const bundleCtrl: EditorControlSpec = {
|
||||
widget: 'image',
|
||||
apply: {
|
||||
type: 'styleProp',
|
||||
props: ['backgroundImage', 'backgroundSize', 'backgroundRepeat', 'backgroundPosition'],
|
||||
},
|
||||
};
|
||||
|
||||
function renderWith(control: EditorControlSpec, value: unknown, onChange = vi.fn()) {
|
||||
render(
|
||||
<LayoutEditorProvider templateIdentifier="sirsoft-basic" initialLocale="ko">
|
||||
<EditorModalProvider>
|
||||
<ImagePickerControl control={control} value={value} onChange={onChange} t={t} />
|
||||
</EditorModalProvider>
|
||||
</LayoutEditorProvider>,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, data: [] }) }),
|
||||
);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** @effects single_value_slot_control_hides_display_mode_buttons_entirely */
|
||||
it('3-1 단일 슬롯(propValue) — 표시모드 컨테이너와 3버튼이 모두 미렌더', () => {
|
||||
renderWith(logoCtrl, { url: '/a.png' });
|
||||
expect(screen.queryByTestId('g7le-image-modes')).toBeNull();
|
||||
expect(screen.queryByTestId('g7le-image-mode-fill')).toBeNull();
|
||||
expect(screen.queryByTestId('g7le-image-mode-fit')).toBeNull();
|
||||
expect(screen.queryByTestId('g7le-image-mode-tile')).toBeNull();
|
||||
});
|
||||
|
||||
/** @effects single_value_slot_control_emits_url_only_object_without_size_repeat_position */
|
||||
it('3-2 단일 슬롯 — URL blur 시 onChange 인자가 정확히 { url } (다른 키 없음)', () => {
|
||||
const onChange = renderWith(logoCtrl, undefined);
|
||||
const url = screen.getByTestId('g7le-image-url');
|
||||
fireEvent.change(url, { target: { value: '/b.png' } });
|
||||
fireEvent.blur(url, { target: { value: '/b.png' } });
|
||||
expect(onChange).toHaveBeenCalledWith({ url: '/b.png' });
|
||||
});
|
||||
|
||||
/** @effects single_value_slot_preview_is_pinned_to_contain_not_a_false_preview */
|
||||
it('3-3 단일 슬롯 — 미리보기는 contain 고정 (저장되지 않는 size 를 흉내내지 않는다)', () => {
|
||||
renderWith(logoCtrl, { url: '/a.png', size: 'cover' });
|
||||
const preview = screen.getByTestId('g7le-image-preview') as HTMLElement;
|
||||
expect(preview.style.backgroundSize).toBe('contain');
|
||||
});
|
||||
|
||||
/** @effects bundle_control_keeps_display_mode_buttons_and_active_state */
|
||||
it('3-4 bundle 컨트롤 — 모드 버튼 존재 + 현행 활성 표시 보존', () => {
|
||||
renderWith(bundleCtrl, { url: '/a.png', size: 'cover', repeat: 'no-repeat' });
|
||||
expect(screen.getByTestId('g7le-image-modes')).toBeTruthy();
|
||||
expect(screen.getByTestId('g7le-image-mode-fill').getAttribute('data-active')).toBe('true');
|
||||
});
|
||||
|
||||
/** @effects apply_undeclared_control_keeps_display_mode_buttons_blacklist_not_whitelist */
|
||||
it('3-5 apply 미선언 컨트롤 — 판정 보류라 모드 버튼 유지 (기존 계약 보존)', () => {
|
||||
renderWith({ widget: 'image' }, { url: '/a.png' });
|
||||
expect(screen.getByTestId('g7le-image-modes')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('3-6 옵션-only apply 도 합집합으로 판정한다 (control.apply 부재에 속지 않음)', () => {
|
||||
const optionOnly = {
|
||||
widget: 'image',
|
||||
options: [{ value: 'a', apply: { type: 'propValue', propKey: 'logo' } }],
|
||||
} as unknown as EditorControlSpec;
|
||||
renderWith(optionOnly, { url: '/a.png' });
|
||||
expect(screen.queryByTestId('g7le-image-modes')).toBeNull();
|
||||
});
|
||||
|
||||
/** @effects bound_value_shows_expression_badge_and_locks_destructive_controls */
|
||||
it('3-7 데이터 연결 값 — 미리보기 대신 원문 배지, 업로드·제거 비활성', () => {
|
||||
renderWith(logoCtrl, { url: '{{_global.settings?.general?.site_logo_url}}' });
|
||||
expect(screen.queryByTestId('g7le-image-preview')).toBeNull();
|
||||
const badge = screen.getByTestId('g7le-image-expression');
|
||||
expect(badge.textContent).toBe('{{_global.settings?.general?.site_logo_url}}');
|
||||
expect((screen.getByTestId('g7le-image-file') as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByTestId('g7le-image-clear') as HTMLButtonElement).disabled).toBe(true);
|
||||
// 원문은 입력칸에도 남아 있고 편집만 막힌다 — 사라지지 않는다.
|
||||
expect((screen.getByTestId('g7le-image-url') as HTMLInputElement).readOnly).toBe(true);
|
||||
});
|
||||
|
||||
/** @effects replace_affordance_reopens_editing_without_emitting_a_change */
|
||||
it('3-8 「직접 지정으로 바꾸기」 → 편집 개방, 그 클릭만으로는 값을 바꾸지 않는다', () => {
|
||||
const onChange = renderWith(logoCtrl, {
|
||||
url: '{{_global.settings?.general?.site_logo_url}}',
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('g7le-image-expression-replace'));
|
||||
expect((screen.getByTestId('g7le-image-file') as HTMLInputElement).disabled).toBe(false);
|
||||
expect((screen.getByTestId('g7le-image-url') as HTMLInputElement).readOnly).toBe(false);
|
||||
expect(screen.queryByTestId('g7le-image-expression')).toBeNull();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** @effects bound_value_shows_expression_badge_and_locks_destructive_controls */
|
||||
it('3-9 데이터 연결 값 — 「이미지 관리」 진입도 잠근다 (창 안의 「배경」이 우회로다)', () => {
|
||||
renderWith(logoCtrl, { url: '{{_global.settings?.general?.site_logo_url}}' });
|
||||
// 관리 모달은 onSelect 를 받아 각 카드에 「배경」 버튼을 띄우고, 그 클릭이 같은
|
||||
// setUrl 을 호출한다 — 인라인 썸네일 「사용」과 동일 동작의 다른 렌더 위치다.
|
||||
// 하나만 잠그면 나머지 하나가 조용한 우회로가 된다.
|
||||
expect((screen.getByTestId('g7le-image-manage') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
/** @effects replace_affordance_reopens_editing_without_emitting_a_change */
|
||||
it('3-10 「직접 지정으로 바꾸기」 후 되돌리기 — 아직 안 바꿨으면 값 변경 없이 배지로 복귀', () => {
|
||||
const expr = '{{_global.settings?.general?.site_logo_url}}';
|
||||
const onChange = renderWith(logoCtrl, { url: expr });
|
||||
|
||||
fireEvent.click(screen.getByTestId('g7le-image-expression-replace'));
|
||||
expect(screen.queryByTestId('g7le-image-expression')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId('g7le-image-expression-restore'));
|
||||
// 배지 복귀 + 잠금 재적용
|
||||
expect(screen.getByTestId('g7le-image-expression').textContent).toBe(expr);
|
||||
expect((screen.getByTestId('g7le-image-manage') as HTMLButtonElement).disabled).toBe(true);
|
||||
// 바꾼 것이 없으므로 불필요한 history push 를 만들지 않는다
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** @effects replace_affordance_reopens_editing_without_emitting_a_change */
|
||||
it('3-11 URL 을 넣은 뒤에도 되돌리기로 원래 연결값을 복구한다', () => {
|
||||
const expr = '{{_global.settings?.general?.site_logo_url}}';
|
||||
const seen: unknown[] = [];
|
||||
|
||||
// 실제 편집기처럼 값이 되돌아오는 controlled 하네스 —
|
||||
// 고정 prop 으로는 "값을 바꾼 뒤" 상태를 재현할 수 없다.
|
||||
function Harness(): React.ReactElement {
|
||||
const [v, setV] = React.useState<unknown>({ url: expr });
|
||||
return (
|
||||
<ImagePickerControl
|
||||
control={logoCtrl}
|
||||
value={v}
|
||||
onChange={(next) => {
|
||||
seen.push(next);
|
||||
setV(next);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<LayoutEditorProvider templateIdentifier="sirsoft-basic" initialLocale="ko">
|
||||
<EditorModalProvider>
|
||||
<Harness />
|
||||
</EditorModalProvider>
|
||||
</LayoutEditorProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('g7le-image-expression-replace'));
|
||||
const url = screen.getByTestId('g7le-image-url');
|
||||
fireEvent.change(url, { target: { value: '/uploaded.png' } });
|
||||
fireEvent.blur(url, { target: { value: '/uploaded.png' } });
|
||||
expect(seen[seen.length - 1]).toEqual({ url: '/uploaded.png' });
|
||||
|
||||
// 원문이 화면에서 사라진 뒤에도 되돌릴 수 있어야 한다 — 없으면 편도가 된다.
|
||||
fireEvent.click(screen.getByTestId('g7le-image-expression-restore'));
|
||||
expect(seen[seen.length - 1]).toEqual({ url: expr });
|
||||
expect(screen.getByTestId('g7le-image-expression').textContent).toBe(expr);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TagInputControl
|
||||
// ============================================================================
|
||||
@@ -739,3 +921,96 @@ describe('SpacingWidget (여백 방향+크기)', () => {
|
||||
expect((screen.getByTestId('g7le-spacing-all-enabled') as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// NumberWidget — 숫자 prop 편집 (A1)
|
||||
//
|
||||
// 이 위젯이 없어 `widget:"number"` 컨트롤은 「지원하지 않는 컨트롤」로 폴백했다.
|
||||
// 출력 타입이 `number` 여야 컴포넌트 prop 계약(`maxVisibleBoards?: number`)을 만족한다.
|
||||
// ============================================================================
|
||||
describe('NumberWidget', () => {
|
||||
const ctrl: EditorControlSpec = { widget: 'number' };
|
||||
|
||||
it('4-6 숫자 입력 후 blur → onChange(숫자) — 문자열이 아니다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberWidget control={ctrl} value={undefined} onChange={onChange} t={t} />);
|
||||
const input = screen.getByTestId('g7le-number-input');
|
||||
fireEvent.change(input, { target: { value: '7' } });
|
||||
fireEvent.blur(input, { target: { value: '7' } });
|
||||
expect(onChange).toHaveBeenCalledWith(7);
|
||||
expect(typeof onChange.mock.calls.at(-1)![0]).toBe('number');
|
||||
});
|
||||
|
||||
it('4-7 빈 입력 → onChange(undefined) (prop 삭제)', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberWidget control={ctrl} value={5} onChange={onChange} t={t} />);
|
||||
const input = screen.getByTestId('g7le-number-input');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
fireEvent.blur(input, { target: { value: '' } });
|
||||
expect(onChange).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('4-8 비숫자 입력 → onChange 미호출 (저장본 무손실 보존)', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberWidget control={ctrl} value={5} onChange={onChange} t={t} />);
|
||||
const input = screen.getByTestId('g7le-number-input');
|
||||
fireEvent.blur(input, { target: { value: 'abc' } });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('4-9 0 은 유효값이라 삭제가 아니라 0 으로 방출된다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberWidget control={ctrl} value={undefined} onChange={onChange} t={t} />);
|
||||
fireEvent.blur(screen.getByTestId('g7le-number-input'), { target: { value: '0' } });
|
||||
expect(onChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('4-10 Enter 키도 blur 와 같이 커밋한다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberWidget control={ctrl} value={undefined} onChange={onChange} t={t} />);
|
||||
const input = screen.getByTestId('g7le-number-input');
|
||||
fireEvent.change(input, { target: { value: '3' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('4-11 min/max/step 을 HTML 속성으로 전달한다 (클램프하지 않는다)', () => {
|
||||
const bounded: EditorControlSpec = { widget: 'number', min: 1, max: 10, step: 1 };
|
||||
const onChange = vi.fn();
|
||||
render(<NumberWidget control={bounded} value={undefined} onChange={onChange} t={t} />);
|
||||
const input = screen.getByTestId('g7le-number-input') as HTMLInputElement;
|
||||
expect(input.getAttribute('min')).toBe('1');
|
||||
expect(input.getAttribute('max')).toBe('10');
|
||||
expect(input.getAttribute('step')).toBe('1');
|
||||
// 범위 밖 값도 사용자가 넣은 그대로 방출된다 — 조용한 변조 금지.
|
||||
fireEvent.blur(input, { target: { value: '99' } });
|
||||
expect(onChange).toHaveBeenCalledWith(99);
|
||||
});
|
||||
|
||||
// 계약 이전(engine-v1.66.0): 바인딩 값 보호는 **위젯이 아니라** `ControlRenderer` 의
|
||||
// 공용 게이트(`boundValueGuard`)가 담당한다. 종전에는 이 위젯이 자체 읽기전용 배지를
|
||||
// 가졌는데, 그 방식이면 위젯마다 복붙이 필요하고 한 곳이 빠져도 오류가 나지 않는다 —
|
||||
// 실제로 image 위젯에서 「이미지 관리」 진입 하나만 잠기지 않은 채 남아 있었다.
|
||||
// 게다가 위젯 자체 분기는 공용 게이트의 「직접 지정으로 바꾸기」 해제 경로까지 막았다.
|
||||
// 공용 게이트의 동작은 ControlRenderer.boundValueGuard.test.tsx 가 잠근다.
|
||||
it('4-12 위젯 자체는 바인딩 값을 특별 취급하지 않는다 (보호는 공용 게이트 소관)', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberWidget control={ctrl} value="{{_global.x}}" onChange={onChange} t={t} />);
|
||||
// 위젯 전용 배지는 더 이상 존재하지 않는다
|
||||
expect(screen.queryByTestId('g7le-number-binding')).toBeNull();
|
||||
// 해제된 뒤에는 정상 입력칸이어야 한다 — 여기서 막으면 해제가 무의미해진다
|
||||
expect(screen.getByTestId('g7le-number-input')).toBeInTheDocument();
|
||||
// 렌더만으로는 값을 건드리지 않는다
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('4-13 외부 value 변경 시 입력칸 draft 를 동기화한다', () => {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(
|
||||
<NumberWidget control={ctrl} value={5} onChange={onChange} t={t} />,
|
||||
);
|
||||
expect((screen.getByTestId('g7le-number-input') as HTMLInputElement).value).toBe('5');
|
||||
rerender(<NumberWidget control={ctrl} value={9} onChange={onChange} t={t} />);
|
||||
expect((screen.getByTestId('g7le-number-input') as HTMLInputElement).value).toBe('9');
|
||||
});
|
||||
});
|
||||
|
||||
+112
-2
@@ -802,8 +802,8 @@ describe('useCanvasDnd — 모달 편집 편집루트 confine', () => {
|
||||
})
|
||||
);
|
||||
// 딤 호스트 자식(Span, path 0.children.0) 드래그 시작 시도 — 편집 루트(모달[1]) 밖.
|
||||
// onDragStart 는 activeDragPath 를 항상 설정하나(기존 설계 — 시각 표시), 편집 루트 밖이면
|
||||
// decision='denied_data_bound' 로 적재되고, onDragMove 가 드롭존을 계산하지 않으며,
|
||||
// 편집 루트 밖이면 decision='denied_data_bound' 로 적재되고, 거부된 드래그는
|
||||
// activeDragPath 를 남기지 않으며, onDragMove 가 드롭존을 계산하지 않고
|
||||
// onDragEnd 의 commit confine 가 차단한다(실제 이동 불가).
|
||||
act(() => result.current.onDragStart(startEvent('0.children.0')));
|
||||
const denied = trackSpy.mock.calls.find(
|
||||
@@ -824,3 +824,113 @@ describe('useCanvasDnd — 모달 편집 편집루트 confine', () => {
|
||||
expect(result.current.activeDropZone, '편집 루트 밖 드래그는 드롭존이 없어야 한다').toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 상속·주입 노드 드래그 차단 — 시작 / 이동 / commit 3층 (A3)
|
||||
//
|
||||
// 종전에는 route 모드에서 이 노드들이 `data_bound` 로 분류돼 드래그가 열렸고, 옮겨도
|
||||
// 저장 시 `stripInheritedNode` 가 통째로 폐기했다. onDragEnd 에는 잠금 재검사가 아예
|
||||
// 없어 `zone === null` 에 기댄 간접 방어뿐이었다.
|
||||
// ============================================================================
|
||||
describe('useCanvasDnd — 상속·주입 노드 드래그 차단 (A3)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
function scene(components: EditorNode[]) {
|
||||
const patchSpy = vi.fn();
|
||||
const pushSpy = vi.fn();
|
||||
const frame = buildFrame([
|
||||
{ path: '0', rect: { left: 0, top: 0, width: 400, height: 100 } },
|
||||
{ path: '1', rect: { left: 0, top: 100, width: 400, height: 100 } },
|
||||
]);
|
||||
const { result } = renderHook(() =>
|
||||
useCanvasDnd({
|
||||
frameEl: frame,
|
||||
nesting: NESTING,
|
||||
editMode: 'route',
|
||||
components,
|
||||
patchLayout: patchSpy,
|
||||
pushHistory: pushSpy,
|
||||
}),
|
||||
);
|
||||
return { result, patchSpy, pushSpy };
|
||||
}
|
||||
|
||||
const BASE_BOUND: EditorNode = {
|
||||
name: 'Div',
|
||||
id: 'a',
|
||||
__source: { kind: 'base' },
|
||||
text: '{{site.title}}',
|
||||
};
|
||||
const EXT_BOUND: EditorNode = {
|
||||
name: 'Div',
|
||||
id: 'a',
|
||||
__source: { kind: 'extension', extensionId: 35 },
|
||||
text: '{{content}}',
|
||||
};
|
||||
const ROUTE_BOUND: EditorNode = {
|
||||
name: 'Div',
|
||||
id: 'b',
|
||||
__source: { kind: 'route' },
|
||||
props: { src: '{{product.image}}' },
|
||||
};
|
||||
|
||||
it('N16 base 출처 → onDragStart 가 denied_base_locked + activeDragPath 미보관', () => {
|
||||
const trackSpy = vi.spyOn(trackers, 'trackEditorDnd');
|
||||
const { result } = scene([BASE_BOUND, ROUTE_BOUND]);
|
||||
act(() => result.current.onDragStart(startEvent('0')));
|
||||
const denied = trackSpy.mock.calls.find((c) => c[0].result === 'denied');
|
||||
expect(denied?.[0].decision).toBe('denied_base_locked');
|
||||
// 거부된 드래그가 activeDragPath 를 남기면 DragOverlay 가 따라다녀 거짓 어포던스가 된다.
|
||||
expect(result.current.activeDragPath).toBeNull();
|
||||
});
|
||||
|
||||
it('N17 extension 출처 → denied_extension_locked', () => {
|
||||
const trackSpy = vi.spyOn(trackers, 'trackEditorDnd');
|
||||
const { result } = scene([EXT_BOUND, ROUTE_BOUND]);
|
||||
act(() => result.current.onDragStart(startEvent('0')));
|
||||
const denied = trackSpy.mock.calls.find((c) => c[0].result === 'denied');
|
||||
expect(denied?.[0].decision).toBe('denied_extension_locked');
|
||||
expect(result.current.activeDragPath).toBeNull();
|
||||
});
|
||||
|
||||
it('N18 onDragMove — 잠긴 노드는 드롭존을 계산하지 않는다', () => {
|
||||
const { result } = scene([BASE_BOUND, ROUTE_BOUND]);
|
||||
act(() => result.current.onDragStart(startEvent('0')));
|
||||
act(() =>
|
||||
result.current.onDragMove({
|
||||
active: { id: '0' },
|
||||
over: { id: slot('', 1) },
|
||||
activatorEvent: { clientX: 0, clientY: 0 } as PointerEvent,
|
||||
delta: { x: 0, y: 0 },
|
||||
} as any),
|
||||
);
|
||||
expect(result.current.activeDropZone).toBeNull();
|
||||
});
|
||||
|
||||
it('N19 onDragEnd 최종 가드 — 유효 zone 을 강제 주입해도 commit 되지 않는다', () => {
|
||||
const trackSpy = vi.spyOn(trackers, 'trackEditorDnd');
|
||||
const { result, patchSpy, pushSpy } = scene([BASE_BOUND, ROUTE_BOUND]);
|
||||
act(() => result.current.onDragStart(startEvent('0')));
|
||||
// stale 슬롯/직접 호출 등으로 유효 zone 이 들어와도 commit 직전 가드가 막아야 한다.
|
||||
act(() => result.current.onDragEnd(endEvent('0', slot('', 2))));
|
||||
expect(patchSpy).not.toHaveBeenCalled();
|
||||
expect(pushSpy).not.toHaveBeenCalled();
|
||||
const denied = trackSpy.mock.calls.find(
|
||||
(c) => c[0].result === 'denied' && c[0].decision === 'denied_base_locked',
|
||||
);
|
||||
expect(denied).toBeTruthy();
|
||||
});
|
||||
|
||||
it('N20 회귀 가드 — route 출처 data_bound 노드는 드래그·이동 모두 허용', () => {
|
||||
const trackSpy = vi.spyOn(trackers, 'trackEditorDnd');
|
||||
const { result, patchSpy } = scene([ROUTE_BOUND, { name: 'Div', id: 'c' }]);
|
||||
act(() => result.current.onDragStart(startEvent('0')));
|
||||
expect(trackSpy.mock.calls.find((c) => c[0].result === 'denied')).toBeUndefined();
|
||||
expect(result.current.activeDragPath).toBe('0');
|
||||
act(() => result.current.onDragEnd(endEvent('0', slot('', 2))));
|
||||
expect(patchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+60
@@ -433,3 +433,63 @@ describe('useElementSelection — 식별 라벨 (base 파일명 / 데이터소
|
||||
expect(result.current.selectedDataSourceId).toBe('_local');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// route 모드 상속·주입 노드 선택 → 진입 어포던스 배선 (A3)
|
||||
//
|
||||
// 종전에는 이 노드들이 `data_bound` 로 분류돼 「데이터 영역」 라벨만 뜨고 편집이 열렸다.
|
||||
// 이제 출처 잠금으로 분류되어 「🔒 공통 레이아웃 편집」/「🔒 확장 편집」 어포던스가 뜬다.
|
||||
// 어포던스 노출의 핵심 게이트는 `selectedBaseLayout` 이므로 그것까지 함께 단언한다.
|
||||
// ============================================================================
|
||||
describe('useElementSelection — 상속·주입 노드 진입 어포던스 (A3)', () => {
|
||||
const affordanceTree: EditorNode = {
|
||||
children: [
|
||||
// base 출처 + props 바인딩 — 종전 data_bound, 이제 base
|
||||
{
|
||||
name: 'Header',
|
||||
__source: { kind: 'base', layout: '_user_base' },
|
||||
props: { logo: '{{_global.settings?.general?.site_logo_url}}' },
|
||||
},
|
||||
// extension 출처 + 텍스트 바인딩 — 종전 data_bound, 이제 extension
|
||||
{
|
||||
name: 'Span',
|
||||
__source: { kind: 'extension', extensionId: 35 },
|
||||
text: '{{content}}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('N10 base 출처 + 바인딩 선택 → lockKind=base AND selectedBaseLayout=_user_base', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useElementSelection({ rootNode: affordanceTree, editMode: 'route' }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.handleSelect('', selectByPath('0'));
|
||||
});
|
||||
expect(result.current.selectedLockKind).toBe('base');
|
||||
// 이 값이 「🔒 공통 레이아웃 편집」 버튼 노출의 게이트다.
|
||||
expect(result.current.selectedBaseLayout).toBe('_user_base');
|
||||
});
|
||||
|
||||
it('N11 같은 노드 → 데이터 영역 라벨·반복 진입은 사라진다 (잠금이 이겼으므로)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useElementSelection({ rootNode: affordanceTree, editMode: 'route' }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.handleSelect('', selectByPath('0'));
|
||||
});
|
||||
expect(result.current.selectedDataSourceId).toBeNull();
|
||||
expect(result.current.selectedIsIteration).toBe(false);
|
||||
});
|
||||
|
||||
it('N12 extension 출처 + 바인딩 선택 → lockKind=extension + 확장 PK 로 진입 가능', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useElementSelection({ rootNode: affordanceTree, editMode: 'route' }),
|
||||
);
|
||||
act(() => {
|
||||
result.current.handleSelect('', selectByPath('1'));
|
||||
});
|
||||
expect(result.current.selectedLockKind).toBe('extension');
|
||||
expect(result.current.selectedNode?.__source?.extensionId).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
+156
-4
@@ -14,6 +14,9 @@ import {
|
||||
isContextMenuAllowed,
|
||||
sliceDomPathToDepth,
|
||||
resolveSourceExtensionId,
|
||||
isEditableLockKind,
|
||||
resolveDndDenial,
|
||||
type SelectionLockKind,
|
||||
} from '../../hooks/useElementSelection';
|
||||
import type { EditorNode } from '../../utils/layoutTreeUtils';
|
||||
|
||||
@@ -198,16 +201,32 @@ describe('classifyLockKind', () => {
|
||||
expect(classifyLockKind(node, 'base')).toBe('extension');
|
||||
});
|
||||
|
||||
// route 모드는 종전 동작(data_bound 우선)을 그대로 보존해야 한다.
|
||||
it('route 모드 + 확장 출처 data_bound 노드 → data_bound (종전 동작 보존, 회귀 방지)', () => {
|
||||
// 확장 주입 영역 안의 바인딩 노드. route 모드에서는 data_bound 로 분류돼 "데이터 영역" 표식
|
||||
// 을 유지하고, 잠긴 확장 루트는 별도로 extension 어포던스를 띄운다(별도 노드).
|
||||
// 계약 변경(의도적): 종전에는 route 모드에서 data_bound 를 먼저 판정해 이 노드가
|
||||
// `data_bound`(= 편집 허용)로 분류됐다. 그런데 저장 시 `stripInheritedNode` 가 확장 출처
|
||||
// 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이 사라졌다(저장은 200 성공, history
|
||||
// clear 로 undo 불가). 종전 기대값이 고정하던 것은 "route 는 한 줄도 바꾸지 않는다" 는
|
||||
// 보수성 선언이었고, 그 보수성이 곧 결함이었다.
|
||||
it('route 모드 + 확장 출처 data_bound 노드 → extension (편집 차단 + 확장 편집 유도)', () => {
|
||||
const node: EditorNode = {
|
||||
name: 'Span',
|
||||
__source: { kind: 'extension', extensionId: 35 },
|
||||
text: '{{content}}',
|
||||
};
|
||||
expect(classifyLockKind(node, 'route')).toBe('extension');
|
||||
expect(isContextMenuAllowed(classifyLockKind(node, 'route'))).toBe(false);
|
||||
});
|
||||
|
||||
// ⚠ 위 변경이 좁힌 것은 "어느 노드가 data_bound 로 분류되는가" 뿐이다.
|
||||
// `data_bound ⇒ 선택·드래그·구조 편집 허용` 계약은 그대로다 — 아래가 그 반증 가드이며,
|
||||
// 반대 방향으로 되돌리는 변경은 여기서 막힌다.
|
||||
it('route 모드 + route 출처 data_bound 노드 → data_bound (편집 허용 계약 보존)', () => {
|
||||
const node: EditorNode = {
|
||||
name: 'Img',
|
||||
__source: { kind: 'route' },
|
||||
props: { src: '{{product.image}}' },
|
||||
};
|
||||
expect(classifyLockKind(node, 'route')).toBe('data_bound');
|
||||
expect(isContextMenuAllowed(classifyLockKind(node, 'route'))).toBe(true);
|
||||
});
|
||||
|
||||
it('route 모드 + 확장 출처 (바인딩 없음) 노드 → extension 잠금(확장 편집 어포던스)', () => {
|
||||
@@ -282,3 +301,136 @@ describe('resolveSourceExtensionId', () => {
|
||||
expect(resolveSourceExtensionId(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// route 모드 출처 잠금 우선 — 상속·주입 노드 편집 소실 차단 (A3)
|
||||
//
|
||||
// 종전에는 route 모드만 `data_bound` 를 먼저 판정해, 상속(base)·주입(extension) 노드 중
|
||||
// props 값 하나라도 `{{ }}` 인 것이 "편집 가능" 으로 분류됐다. 그런데 저장 시
|
||||
// `stripInheritedNode` 가 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이
|
||||
// 사라졌다(저장은 200 성공 + history clear 로 undo 불가).
|
||||
//
|
||||
// N5~N9 는 `data_bound ⇒ 선택·드래그·구조 편집 허용` 계약의 **반증 가드**다 —
|
||||
// 되돌리는 변경은 여기서 막힌다.
|
||||
// ============================================================================
|
||||
describe('classifyLockKind — route 모드 출처 잠금 우선 (A3)', () => {
|
||||
const menu = (kind: SelectionLockKind) => isContextMenuAllowed(kind);
|
||||
const drag = (kind: SelectionLockKind) => resolveDndDenial(kind) === null;
|
||||
|
||||
it('N1 base 출처 + 텍스트 바인딩 → base (ⓘ·드래그 차단)', () => {
|
||||
const node: EditorNode = {
|
||||
name: 'H1',
|
||||
__source: { kind: 'base', layout: '_user_base' },
|
||||
text: '{{site.title}}',
|
||||
};
|
||||
const kind = classifyLockKind(node, 'route');
|
||||
expect(kind).toBe('base');
|
||||
expect(menu(kind)).toBe(false);
|
||||
expect(drag(kind)).toBe(false);
|
||||
});
|
||||
|
||||
it('N2 base 출처 + props 바인딩 → base', () => {
|
||||
const node: EditorNode = {
|
||||
name: 'Div',
|
||||
__source: { kind: 'base' },
|
||||
props: { className: '{{theme}}' },
|
||||
};
|
||||
const kind = classifyLockKind(node, 'route');
|
||||
expect(kind).toBe('base');
|
||||
expect(menu(kind)).toBe(false);
|
||||
expect(drag(kind)).toBe(false);
|
||||
});
|
||||
|
||||
it('N3 base 출처 + iteration → base (반복 항목 편집 진입도 함께 닫힌다)', () => {
|
||||
const node: EditorNode = {
|
||||
name: 'List',
|
||||
__source: { kind: 'base' },
|
||||
iteration: { source: '{{recent.data}}' },
|
||||
};
|
||||
const kind = classifyLockKind(node, 'route');
|
||||
expect(kind).toBe('base');
|
||||
expect(menu(kind)).toBe(false);
|
||||
expect(drag(kind)).toBe(false);
|
||||
});
|
||||
|
||||
it('N4 extension 출처 + 바인딩 → extension', () => {
|
||||
const node: EditorNode = {
|
||||
name: 'Span',
|
||||
__source: { kind: 'extension', extensionId: 35 },
|
||||
text: '{{content}}',
|
||||
};
|
||||
const kind = classifyLockKind(node, 'route');
|
||||
expect(kind).toBe('extension');
|
||||
expect(menu(kind)).toBe(false);
|
||||
expect(drag(kind)).toBe(false);
|
||||
});
|
||||
|
||||
it('N5 회귀 가드 — route 출처 + props 바인딩은 data_bound (편집 허용)', () => {
|
||||
const node: EditorNode = {
|
||||
name: 'Img',
|
||||
__source: { kind: 'route' },
|
||||
props: { src: '{{product.image}}' },
|
||||
};
|
||||
const kind = classifyLockKind(node, 'route');
|
||||
expect(kind).toBe('data_bound');
|
||||
expect(menu(kind)).toBe(true);
|
||||
expect(drag(kind)).toBe(true);
|
||||
});
|
||||
|
||||
it('N6 회귀 가드 — route 출처 + iteration 도 data_bound (편집 허용)', () => {
|
||||
const node: EditorNode = {
|
||||
name: 'Div',
|
||||
__source: { kind: 'route' },
|
||||
iteration: { source: '{{posts.data}}' },
|
||||
};
|
||||
const kind = classifyLockKind(node, 'route');
|
||||
expect(kind).toBe('data_bound');
|
||||
expect(menu(kind)).toBe(true);
|
||||
expect(drag(kind)).toBe(true);
|
||||
});
|
||||
|
||||
it('N7 회귀 가드 — 조상이 route 출처 iteration 이면 data_bound', () => {
|
||||
const node: EditorNode = { name: 'Span' };
|
||||
const ancestors: EditorNode[] = [
|
||||
{ name: 'List', __source: { kind: 'route' }, iteration: { source: '{{posts.data}}' } },
|
||||
];
|
||||
const kind = classifyLockKind(node, 'route', undefined, ancestors);
|
||||
expect(kind).toBe('data_bound');
|
||||
expect(menu(kind)).toBe(true);
|
||||
});
|
||||
|
||||
it('N8 회귀 가드 — __source 미부여(신규 삽입) + 바인딩은 data_bound', () => {
|
||||
const node: EditorNode = { name: 'Span', text: '{{x}}' };
|
||||
const kind = classifyLockKind(node, 'route');
|
||||
expect(kind).toBe('data_bound');
|
||||
expect(menu(kind)).toBe(true);
|
||||
});
|
||||
|
||||
it('N9 회귀 가드 — 조상만 base 이고 자신은 미태깅이면 none (조상은 보지 않는다)', () => {
|
||||
const node: EditorNode = { name: 'Span' };
|
||||
const ancestors: EditorNode[] = [{ name: 'Div', __source: { kind: 'base' } }];
|
||||
const kind = classifyLockKind(node, 'route', undefined, ancestors);
|
||||
expect(kind).toBe('none');
|
||||
expect(menu(kind)).toBe(true);
|
||||
expect(drag(kind)).toBe(true);
|
||||
});
|
||||
|
||||
it('isEditableLockKind / resolveDndDenial 이 같은 판정을 공유한다 (조건 복사 금지)', () => {
|
||||
const kinds: SelectionLockKind[] = [
|
||||
'none',
|
||||
'data_bound',
|
||||
'base',
|
||||
'partial',
|
||||
'extension',
|
||||
'extension_point',
|
||||
];
|
||||
for (const k of kinds) {
|
||||
expect(resolveDndDenial(k) === null).toBe(isEditableLockKind(k));
|
||||
expect(isContextMenuAllowed(k)).toBe(isEditableLockKind(k));
|
||||
}
|
||||
expect(resolveDndDenial('base')).toBe('denied_base_locked');
|
||||
expect(resolveDndDenial('partial')).toBe('denied_base_locked');
|
||||
expect(resolveDndDenial('extension')).toBe('denied_extension_locked');
|
||||
expect(resolveDndDenial('extension_point')).toBe('denied_extension_locked');
|
||||
});
|
||||
});
|
||||
|
||||
+17
@@ -47,4 +47,21 @@ describe('coreProps — dataKey', () => {
|
||||
expect(CORE_PROP_CONTROLS.dataKey.label).toContain('layout_editor.core_props.dataKey');
|
||||
expect(CORE_PROP_CONTROLS.dataKey.placeholder).toContain('layout_editor.core_props.dataKey');
|
||||
});
|
||||
|
||||
// nodeKey apply 구현으로 격리 2키가 ControlRenderer 경유로도 렌더 가능해졌다.
|
||||
// 그러면 「격리 영역」 전용 UI 와 같은 노드 키를 두 경로가 쓰게 되고, 그 순간 값 타입이
|
||||
// 갈린다 — IsolatedScopeControl 은 ON 시 `{}`(빈 객체), toggle 위젯은 `true`.
|
||||
it('5-12 전용 UI 소유 키(isolatedState)는 렌더 목록에서 제외한다 (이중 경로 차단)', () => {
|
||||
expect(resolveCorePropKeys(['id', 'isolatedState'])).toEqual(['id']);
|
||||
expect(resolveCorePropKeys(['id', 'isolatedScopeId'])).toEqual(['id']);
|
||||
expect(resolveCorePropKeys(['isolatedState', 'isolatedScopeId'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('5-13 제외는 격리 2키에만 — id·dataKey 는 회귀 없음', () => {
|
||||
expect(resolveCorePropKeys(['id', 'dataKey'])).toEqual(['id', 'dataKey']);
|
||||
expect(resolveCorePropKeys(undefined)).toEqual(['id']);
|
||||
// SSoT 선언 자체는 남는다 — 전용 UI 가 이 정의를 읽는다.
|
||||
expect(CORE_PROP_CONTROLS.isolatedState).toBeTruthy();
|
||||
expect(CORE_PROP_CONTROLS.isolatedScopeId).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// @scenario apply_type=cssVar, consumer=none, storage_scope=template_layouts, stored_shape=object, widget_output=image_object
|
||||
// @scenario apply_type=propValue, consumer=none, storage_scope=template_layouts, stored_shape=string, widget_output=scalar_string
|
||||
// @scenario apply_type=propValue, consumer=none, storage_scope=template_layouts, stored_shape=string, widget_output=image_object_empty_url
|
||||
// @scenario apply_type=classToken, consumer=none, storage_scope=template_layouts, stored_shape=absent, widget_output=undefined_cleared
|
||||
/**
|
||||
* recipeEngine.test.ts — 컨트롤 레시피 ↔ 노드 패치 변환
|
||||
*
|
||||
@@ -528,3 +532,335 @@ describe('propValue 임의 prop 편집 (icon-picker / options-list / text)', ()
|
||||
expect(out).toBe(node);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// image 위젯 × 단일 값 슬롯 — 값 축약 / 역조립 (공개 #135)
|
||||
//
|
||||
// `image` 위젯은 배경 이미지용으로 설계되어 `{url,size,repeat,position}` **객체**를
|
||||
// 내보낸다. 그 값을 노드에 기록하는 apply 4종 중 `styleProp` 의 다중 props(배경 묶음)
|
||||
// 만 객체를 4속성으로 분해했고, `propValue`·`cssVar`·단일 `styleProp` 은 객체를 그대로
|
||||
// 기록했다 → 소비 컴포넌트가 `<Img src={객체}>` 로 받아 `[object Object]` 가 URL 이 됐다.
|
||||
//
|
||||
// 게이트는 **위젯 이름**(`widget === 'image'`)이며 값 형태 sniffing 이 아니다 —
|
||||
// `isImageValueObject` 는 4키 중 하나만 있어도 참이라, 값만 보면 `{position:'left'}`
|
||||
// 같은 정당한 객체 prop 을 이미지로 오인해 삭제한다(1-6/1-7 이 그 가드다).
|
||||
// ============================================================================
|
||||
describe('image 위젯 × 단일 값 슬롯 (공개 #135)', () => {
|
||||
/** 실물 `hdrLogo` — sirsoft-basic 헤더 「로고 이미지」 */
|
||||
const logoCtrl: EditorControlSpec = {
|
||||
widget: 'image',
|
||||
apply: { type: 'propValue', propKey: 'logo' },
|
||||
};
|
||||
const bgBundle: EditorControlSpec = {
|
||||
widget: 'image',
|
||||
group: 'bg-image',
|
||||
apply: {
|
||||
type: 'styleProp',
|
||||
props: ['backgroundImage', 'backgroundSize', 'backgroundRepeat', 'backgroundPosition'],
|
||||
},
|
||||
};
|
||||
|
||||
/** @effects image_object_narrows_to_url_string_in_propvalue_slot */
|
||||
it('1-1 값 객체를 url 문자열로 축약해 props 에 기록한다', () => {
|
||||
const next = applyRecipe({ name: 'Header' }, logoCtrl, {
|
||||
url: '/a.png',
|
||||
size: 'cover',
|
||||
repeat: 'no-repeat',
|
||||
position: 'center',
|
||||
});
|
||||
expect(next.props?.logo).toBe('/a.png');
|
||||
expect(typeof next.props?.logo).toBe('string');
|
||||
});
|
||||
|
||||
/** @effects legacy_css_url_wrapping_is_stripped_for_component_prop_sink */
|
||||
it('1-2 레거시 url(...) 래핑을 벗긴다 (컴포넌트 prop 은 CSS 문맥이 아니다)', () => {
|
||||
const next = applyRecipe({ name: 'Header' }, logoCtrl, { url: 'url(/a.png)' });
|
||||
expect(next.props?.logo).toBe('/a.png');
|
||||
});
|
||||
|
||||
/** @effects image_object_without_url_or_with_empty_url_deletes_the_prop */
|
||||
it('1-3 url 없이 모드만 담긴 값은 prop 을 삭제한다 (기존 빈값 술어에 위임)', () => {
|
||||
const seeded: EditorNode = { name: 'Header', props: { logo: '/old.png' } };
|
||||
expect(applyRecipe(seeded, logoCtrl, { size: 'contain' }).props?.logo).toBeUndefined();
|
||||
});
|
||||
|
||||
it('1-4 빈 url 은 prop 을 삭제한다', () => {
|
||||
expect(applyRecipe({ name: 'Header' }, logoCtrl, { url: '' }).props?.logo).toBeUndefined();
|
||||
});
|
||||
|
||||
it('1-5 undefined(기본으로 되돌리기) 는 prop 을 삭제한다', () => {
|
||||
const seeded: EditorNode = { name: 'Header', props: { logo: '/old.png' } };
|
||||
expect(applyRecipe(seeded, logoCtrl, undefined).props?.logo).toBeUndefined();
|
||||
});
|
||||
|
||||
/** @effects non_image_widget_object_and_array_props_are_preserved_intact */
|
||||
it('1-6 오탐 가드 — 비-image 위젯의 객체 prop 은 통째로 보존한다', () => {
|
||||
// `{position:'left'}` 는 isImageValueObject 를 통과한다(4키 중 하나 보유).
|
||||
// 값 sniffing 으로 게이트했다면 여기서 정당한 prop 이 삭제됐을 것이다.
|
||||
const tooltipCtrl: EditorControlSpec = {
|
||||
widget: 'select',
|
||||
apply: { type: 'propValue', propKey: 'tooltip' },
|
||||
};
|
||||
const next = applyRecipe({ name: 'Button' }, tooltipCtrl, { position: 'left' });
|
||||
expect(next.props?.tooltip).toEqual({ position: 'left' });
|
||||
});
|
||||
|
||||
it('1-7 오탐 가드 — options-list 배열은 그대로 보존한다', () => {
|
||||
const optionsCtrl: EditorControlSpec = {
|
||||
widget: 'options-list',
|
||||
apply: { type: 'propValue', propKey: 'options' },
|
||||
};
|
||||
const next = applyRecipe({ name: 'Select' }, optionsCtrl, [{ value: 'a' }]);
|
||||
expect(next.props?.options).toEqual([{ value: 'a' }]);
|
||||
});
|
||||
|
||||
/** @effects image_object_narrows_to_wrapped_css_url_in_cssvar_and_single_styleprop */
|
||||
it('1-8 cssVar 슬롯은 CSS 문맥이므로 url(...) 로 감싼다', () => {
|
||||
const heroVar: EditorControlSpec = {
|
||||
widget: 'image',
|
||||
apply: { type: 'cssVar', varName: '--hero' },
|
||||
};
|
||||
const next = applyRecipe({ name: 'Div' }, heroVar, { url: '/a.png', size: 'cover' });
|
||||
expect((next.props?.style as Record<string, unknown>)['--hero']).toBe('url(/a.png)');
|
||||
});
|
||||
|
||||
it('1-9 단일 styleProp 슬롯도 CSS 문맥이므로 url(...) 로 감싼다', () => {
|
||||
const singleBg: EditorControlSpec = {
|
||||
widget: 'image',
|
||||
apply: { type: 'styleProp', prop: 'backgroundImage' },
|
||||
};
|
||||
const next = applyRecipe({ name: 'Div' }, singleBg, { url: '/a.png', size: 'cover' });
|
||||
expect((next.props?.style as Record<string, unknown>).backgroundImage).toBe('url(/a.png)');
|
||||
});
|
||||
|
||||
/** @effects styleprop_bundle_four_property_decomposition_is_unchanged */
|
||||
it('1-10 bundle(다중 props) 경로는 무회귀 — 4속성 분해가 그대로', () => {
|
||||
const value = { url: '/a.png', size: 'cover', repeat: 'no-repeat', position: 'center' };
|
||||
const style = applyRecipe({ name: 'Div' }, bgBundle, value).props?.style as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(style).toEqual({
|
||||
backgroundImage: 'url(/a.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
});
|
||||
});
|
||||
|
||||
/** @effects apply_fixed_value_wins_over_control_value_then_gets_narrowed */
|
||||
it('1-11 apply.value 고정값이 값보다 우선하고, 그 뒤에 축약이 적용된다', () => {
|
||||
const fixed: EditorControlSpec = {
|
||||
widget: 'image',
|
||||
apply: { type: 'propValue', propKey: 'logo', value: '/fixed.png' },
|
||||
};
|
||||
expect(applyRecipe({ name: 'Header' }, fixed, { url: '/x.png' }).props?.logo).toBe(
|
||||
'/fixed.png',
|
||||
);
|
||||
});
|
||||
|
||||
/** @effects stored_string_is_rewrapped_as_url_object_for_the_widget */
|
||||
it('2-1 저장된 문자열을 위젯이 이해하는 객체로 되감는다', () => {
|
||||
const node: EditorNode = { name: 'Header', props: { logo: '/a.png' } };
|
||||
expect(reverseResolve(node, logoCtrl)).toEqual({ value: { url: '/a.png' }, matched: true });
|
||||
});
|
||||
|
||||
it('2-2 값이 없으면 미매칭', () => {
|
||||
expect(reverseResolve({ name: 'Header', props: {} }, logoCtrl)).toEqual({
|
||||
value: undefined,
|
||||
matched: false,
|
||||
});
|
||||
});
|
||||
|
||||
/** @effects binding_expression_is_rewrapped_so_one_click_upload_cannot_silently_lose_it */
|
||||
it('2-3 표현식 문자열도 감싼다 — 빈 피커로 보여 1클릭에 소실되지 않도록', () => {
|
||||
const expr = '{{_global.settings?.general?.site_logo_url}}';
|
||||
const node: EditorNode = { name: 'Header', props: { logo: expr } };
|
||||
expect(reverseResolve(node, logoCtrl)).toEqual({ value: { url: expr }, matched: true });
|
||||
});
|
||||
|
||||
it('2-4 레거시 url(...) 저장값은 언래핑해 되감는다', () => {
|
||||
const node: EditorNode = { name: 'Header', props: { logo: 'url(/a.png)' } };
|
||||
expect(reverseResolve(node, logoCtrl)).toEqual({ value: { url: '/a.png' }, matched: true });
|
||||
});
|
||||
|
||||
/** @effects legacy_object_stored_value_is_returned_losslessly */
|
||||
it('2-5 레거시 객체 저장값은 무손실로 그대로 돌려준다', () => {
|
||||
const legacy = { url: '/a.png', size: 'cover' };
|
||||
const node: EditorNode = { name: 'Header', props: { logo: legacy } };
|
||||
expect(reverseResolve(node, logoCtrl)).toEqual({ value: legacy, matched: true });
|
||||
});
|
||||
|
||||
/** @effects non_image_widget_values_are_not_rewrapped */
|
||||
it('2-6 비-image 위젯은 감싸지 않는다 (문자열 그대로)', () => {
|
||||
const widthProp: EditorControlSpec = {
|
||||
widget: 'select',
|
||||
apply: { type: 'propValue', propKey: 'size' },
|
||||
};
|
||||
const node: EditorNode = { name: 'Button', props: { size: 'sm' } };
|
||||
expect(reverseResolve(node, widthProp)).toEqual({ value: 'sm', matched: true });
|
||||
});
|
||||
|
||||
/** @effects node_axis_roundtrip_is_a_fixed_point_after_first_pass */
|
||||
it('2-7 노드 축 고정점 — apply → reverse → apply 2회차가 1회차와 동일', () => {
|
||||
const first = applyRecipe({ name: 'Header' }, logoCtrl, {
|
||||
url: '/a.png',
|
||||
size: 'cover',
|
||||
repeat: 'no-repeat',
|
||||
position: 'center',
|
||||
});
|
||||
const back = reverseResolve(first, logoCtrl);
|
||||
const second = applyRecipe(first, logoCtrl, back.value);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
/** @effects dark_scope_stays_readonly_for_inline_apply */
|
||||
it('2-8 다크 scope 는 인라인이라 읽기 전용', () => {
|
||||
const node: EditorNode = { name: 'Header', props: { logo: '/a.png' } };
|
||||
expect(reverseResolve(node, logoCtrl, { colorScheme: 'dark', breakpoint: 'base' })).toEqual({
|
||||
value: undefined,
|
||||
matched: false,
|
||||
darkReadonly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// number 위젯 값 — propValue 숫자 기록 (A1)
|
||||
//
|
||||
// `applyPropValue` 는 값을 가공하지 않으므로 위젯이 `number` 를 내보내야 한다.
|
||||
// `0` 은 유효값이며 빈값 술어(`''|null|undefined`)에 걸리지 않는다.
|
||||
// ============================================================================
|
||||
describe('number 위젯 값 — propValue 숫자 기록', () => {
|
||||
const maxBoards: EditorControlSpec = {
|
||||
widget: 'number',
|
||||
apply: { type: 'propValue', propKey: 'maxVisibleBoards' },
|
||||
};
|
||||
|
||||
it('4-1 숫자를 그대로 props 에 기록한다 (문자열로 변질되지 않음)', () => {
|
||||
const next = applyRecipe({ name: 'Header' }, maxBoards, 5);
|
||||
expect(next.props?.maxVisibleBoards).toBe(5);
|
||||
expect(typeof next.props?.maxVisibleBoards).toBe('number');
|
||||
});
|
||||
|
||||
it('4-2 0 은 유효값이라 삭제되지 않는다', () => {
|
||||
const next = applyRecipe({ name: 'Header' }, maxBoards, 0);
|
||||
expect(next.props?.maxVisibleBoards).toBe(0);
|
||||
});
|
||||
|
||||
it('4-3 빈 문자열·undefined 는 prop 을 삭제한다', () => {
|
||||
const seeded: EditorNode = { name: 'Header', props: { maxVisibleBoards: 5 } };
|
||||
expect(applyRecipe(seeded, maxBoards, '').props?.maxVisibleBoards).toBeUndefined();
|
||||
expect(applyRecipe(seeded, maxBoards, undefined).props?.maxVisibleBoards).toBeUndefined();
|
||||
});
|
||||
|
||||
it('4-4 라운드트립 — 숫자 그대로 역해석', () => {
|
||||
const applied = applyRecipe({ name: 'Header' }, maxBoards, 7);
|
||||
expect(reverseResolve(applied, maxBoards)).toEqual({ value: 7, matched: true });
|
||||
// 0 도 역해석된다(undefined 와 구분).
|
||||
const zero = applyRecipe({ name: 'Header' }, maxBoards, 0);
|
||||
expect(reverseResolve(zero, maxBoards)).toEqual({ value: 0, matched: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// nodeKey apply — 노드 최상위 구조키 패치 (A2)
|
||||
//
|
||||
// `coreProps.ts` 가 `{type:'nodeKey', nodeKey:'dataKey'}` 를 선언했는데 엔진 switch 에
|
||||
// case 가 없어 **무음 no-op** 이었다 — 값을 넣어도 아무 일도 일어나지 않고 역해석도
|
||||
// 항상 undefined 였다. props 로 흘리면 `props.dataKey` 가 돼 런타임이 영영 읽지 않는다.
|
||||
// ============================================================================
|
||||
describe('nodeKey apply — 노드 최상위 구조키 (A2)', () => {
|
||||
const dataKeyCtrl: EditorControlSpec = {
|
||||
widget: 'core-datakey',
|
||||
apply: { type: 'nodeKey', nodeKey: 'dataKey' } as unknown as string,
|
||||
};
|
||||
|
||||
it('5-1 노드 최상위에 기록하고 props 를 오염시키지 않는다', () => {
|
||||
const next = applyRecipe({ name: 'Form' }, dataKeyCtrl, 'orderer') as EditorNode &
|
||||
Record<string, unknown>;
|
||||
expect(next.dataKey).toBe('orderer');
|
||||
expect(next.props?.dataKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('5-2 빈 문자열은 키를 삭제한다', () => {
|
||||
const seeded = { name: 'Form', dataKey: 'orderer' } as EditorNode;
|
||||
const next = applyRecipe(seeded, dataKeyCtrl, '') as EditorNode & Record<string, unknown>;
|
||||
expect('dataKey' in next).toBe(false);
|
||||
});
|
||||
|
||||
it('5-3 undefined 도 키를 삭제한다', () => {
|
||||
const seeded = { name: 'Form', dataKey: 'orderer' } as EditorNode;
|
||||
const next = applyRecipe(seeded, dataKeyCtrl, undefined) as EditorNode &
|
||||
Record<string, unknown>;
|
||||
expect('dataKey' in next).toBe(false);
|
||||
});
|
||||
|
||||
it('5-4 역해석 — 노드 최상위에서 읽는다', () => {
|
||||
const node = { name: 'Form', dataKey: 'orderer' } as EditorNode;
|
||||
expect(reverseResolve(node, dataKeyCtrl)).toEqual({ value: 'orderer', matched: true });
|
||||
});
|
||||
|
||||
it('5-5 역해석 — 값이 없으면 미매칭', () => {
|
||||
expect(reverseResolve({ name: 'Form' }, dataKeyCtrl)).toEqual({
|
||||
value: undefined,
|
||||
matched: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('5-6 예약키 가드 — nodeKey:"children" 은 no-op (노드 파괴 차단)', () => {
|
||||
const evil: EditorControlSpec = {
|
||||
widget: 'text',
|
||||
apply: { type: 'nodeKey', nodeKey: 'children' } as unknown as string,
|
||||
};
|
||||
const node: EditorNode = { name: 'Div', children: [{ name: 'Span' }] };
|
||||
const out = applyRecipe(node, evil, 'boom');
|
||||
expect(out).toBe(node); // 참조 동일 — 사본조차 만들지 않는다
|
||||
expect(node.children).toEqual([{ name: 'Span' }]);
|
||||
expect(reverseResolve(node, evil)).toEqual({ value: undefined, matched: false });
|
||||
});
|
||||
|
||||
it('5-7 디바이스 scope 도 최상위에 쓴다 (responsive 브랜치는 런타임이 안 읽는다)', () => {
|
||||
const next = applyRecipe({ name: 'Form' }, dataKeyCtrl, 'orderer', {
|
||||
colorScheme: 'light',
|
||||
breakpoint: 'mobile',
|
||||
}) as EditorNode & Record<string, unknown>;
|
||||
expect(next.dataKey).toBe('orderer');
|
||||
expect(next.responsive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('5-8 디바이스 scope 역해석도 최상위에서 — placeholder 흐림을 만들지 않는다', () => {
|
||||
const node = { name: 'Form', dataKey: 'orderer' } as EditorNode;
|
||||
expect(
|
||||
reverseResolve(node, dataKeyCtrl, { colorScheme: 'light', breakpoint: 'mobile' }),
|
||||
).toEqual({ value: 'orderer', matched: true });
|
||||
});
|
||||
|
||||
it('5-9 다크 scope 는 no-op (인라인 무손실 보존)', () => {
|
||||
const node = { name: 'Form', dataKey: 'orderer' } as EditorNode;
|
||||
expect(applyRecipe(node, dataKeyCtrl, 'other', { colorScheme: 'dark', breakpoint: 'base' })).toBe(
|
||||
node,
|
||||
);
|
||||
});
|
||||
|
||||
it('5-10 입력 노드는 변경되지 않는다 (불변)', () => {
|
||||
const node = { name: 'Form' } as EditorNode;
|
||||
const snapshot = JSON.stringify(node);
|
||||
applyRecipe(node, dataKeyCtrl, 'orderer');
|
||||
expect(JSON.stringify(node)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('5-11 비-문자열/빈 nodeKey 는 no-op', () => {
|
||||
const bad: EditorControlSpec = {
|
||||
widget: 'text',
|
||||
apply: { type: 'nodeKey', nodeKey: 42 } as unknown as string,
|
||||
};
|
||||
const node: EditorNode = { name: 'Form' };
|
||||
expect(applyRecipe(node, bad, 'x')).toBe(node);
|
||||
const empty: EditorControlSpec = {
|
||||
widget: 'text',
|
||||
apply: { type: 'nodeKey', nodeKey: '' } as unknown as string,
|
||||
};
|
||||
expect(applyRecipe(node, empty, 'x')).toBe(node);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,14 @@ describe('registerCoreWidgets — Phase 4 위젯 7종', () => {
|
||||
expect(isCoreWidgetsRegistered()).toBe(true);
|
||||
});
|
||||
|
||||
it('4-5 number 위젯을 등록한다 (미등록 시 「지원하지 않는 컨트롤」 폴백)', () => {
|
||||
// `widget:"number"` 컨트롤(예 Header 「탭 표시 게시판 수」)이 속성 모달에서 편집
|
||||
// 불가였던 원인. 레지스트리 등록만이 그 폴백을 없앤다.
|
||||
registerCoreWidgets();
|
||||
expect(getWidget('number')).toBeTruthy();
|
||||
expect(getRegisteredWidgetNames()).toContain('number');
|
||||
});
|
||||
|
||||
it('component-target-picker 위젯을 등록한다', () => {
|
||||
// 캔버스 컴포넌트 영역 picker. [로딩 화면] target/fallback·navigate transition_overlay_target·
|
||||
// 향후 요소 ID param 공용. editor-spec param widget 타입으로 어느 폼에서나 선언 가능.
|
||||
|
||||
@@ -37,8 +37,12 @@ import {
|
||||
type OverlayBox,
|
||||
} from '../utils/overlayGeometry';
|
||||
import { isDraggableNode } from '../dnd/nestingRules';
|
||||
import { findNodeByPath, isInsideIterationInstance, serializeEditorPath, isResponsiveSegment, type EditorNode, type ComponentPath } from '../utils/layoutTreeUtils';
|
||||
import { classifyLockKind, parseEditorPath } from '../hooks/useElementSelection';
|
||||
import { collectAncestors, findNodeByPath, isInsideIterationInstance, serializeEditorPath, type EditorNode } from '../utils/layoutTreeUtils';
|
||||
import {
|
||||
classifyLockKind,
|
||||
isEditableLockKind,
|
||||
parseEditorPath,
|
||||
} from '../hooks/useElementSelection';
|
||||
import { useCanvasDnd, type UseCanvasDndParams } from '../hooks/useCanvasDnd';
|
||||
import { buildDropSlots, type DropSlot } from '../dnd/dropSlots';
|
||||
import { dndHandleZIndex, DND_DROP_SLOT, DND_DRAG_OVERLAY } from '../utils/overlayZIndex';
|
||||
@@ -311,7 +315,7 @@ export function DndCanvasLayer(props: DndCanvasLayerProps): React.ReactElement |
|
||||
// 제외 조건에서 data_bound 를 빼야 한다 — 종전에는 `classifyLockKind !== 'none'`
|
||||
// 으로 data_bound 까지 핸들을 막아, 데이터 바인딩 composite(상품 이미지 갤러리 등)가
|
||||
// 선택·이동 불가였고 클릭이 조상 핸들에 가로채였다.
|
||||
const ancestors = ancestorsOf(root, indexes);
|
||||
const ancestors = collectAncestors(root, indexes);
|
||||
// 반복(iteration) 인스턴스 **내부** 노드는 핸들 제외 — 펼침 인스턴스를 개별로
|
||||
// 선택/드래그하면 안 된다. 묶음 단위 편집은 아래 가상 묶음으로.
|
||||
// 예외: 반복 항목 편집 모드의 편집 대상 iteration 인스턴스 내부는 개별 핸들 허용.
|
||||
@@ -323,11 +327,7 @@ export function DndCanvasLayer(props: DndCanvasLayerProps): React.ReactElement |
|
||||
}
|
||||
const lockKind = classifyLockKind(node, editMode, currentExtensionId, ancestors);
|
||||
// 편집 대상 iteration 인스턴스 내부는 그 모드의 편집 대상이므로 잠금/데이터바운드 무관 허용.
|
||||
if (
|
||||
lockKind !== 'none' &&
|
||||
lockKind !== 'data_bound' &&
|
||||
!isInsideEditableIteration(path, editableRootSourcePath)
|
||||
) {
|
||||
if (!isEditableLockKind(lockKind) && !isInsideEditableIteration(path, editableRootSourcePath)) {
|
||||
return;
|
||||
}
|
||||
const box = measureOverlay(el, frameEl);
|
||||
@@ -353,9 +353,9 @@ export function DndCanvasLayer(props: DndCanvasLayerProps): React.ReactElement |
|
||||
if (!name) continue;
|
||||
// 원본 노드 자체의 잠금만 검사(base/partial/extension). 원본은 iteration 정의
|
||||
// 노드라 data_bound 지만 대로 묶음 선택/이동은 허용.
|
||||
const ancestors = ancestorsOf(root, indexes);
|
||||
const ancestors = collectAncestors(root, indexes);
|
||||
const lockKind = classifyLockKind(ownerNode, editMode, currentExtensionId, ancestors);
|
||||
if (lockKind !== 'none' && lockKind !== 'data_bound') continue;
|
||||
if (!isEditableLockKind(lockKind)) continue;
|
||||
const box = unionBoxes(boxes);
|
||||
if (!box) continue;
|
||||
// 인스턴스를 frame 가시 영역으로 이미 걸렀으나, union 결과도 frame 과 겹치는지
|
||||
@@ -782,23 +782,3 @@ function DraggableHandle(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** path 조상 노드 배열(루트→부모, 자기 제외) — classifyLockKind ancestors 입력 */
|
||||
function ancestorsOf(root: EditorNode, path: ComponentPath): EditorNode[] {
|
||||
const out: EditorNode[] = [];
|
||||
let current: EditorNode = root;
|
||||
let childArray: EditorNode[] = Array.isArray(root.children) ? (root.children as EditorNode[]) : [];
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const seg = path[i]!;
|
||||
if (isResponsiveSegment(seg)) {
|
||||
const branch = current.responsive?.[seg.responsive];
|
||||
childArray = branch && Array.isArray(branch.children) ? (branch.children as EditorNode[]) : [];
|
||||
continue;
|
||||
}
|
||||
const next = childArray[seg] ?? null;
|
||||
if (!next) break;
|
||||
out.push(next);
|
||||
current = next;
|
||||
childArray = Array.isArray(next.children) ? (next.children as EditorNode[]) : [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLayoutEditor } from '../LayoutEditorContext';
|
||||
import type { RouteTreeNode } from '../LayoutEditorContext';
|
||||
import { useLayoutDocumentContext } from '../LayoutDocumentContext';
|
||||
import {
|
||||
useElementSelection,
|
||||
parseEditorPath,
|
||||
isContextMenuAllowed,
|
||||
isEditableLockKind,
|
||||
classifyLockKind,
|
||||
} from '../hooks/useElementSelection';
|
||||
import { useInsertionPoints, type InsertionPoint } from '../hooks/useInsertionPoints';
|
||||
import { useEditorHistory } from '../hooks/useEditorHistory';
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
type OverlayBox,
|
||||
} from '../utils/overlayGeometry';
|
||||
import {
|
||||
collectAncestors,
|
||||
duplicateNode as duplicateNodeUtil,
|
||||
insertNode,
|
||||
removeNode,
|
||||
@@ -299,6 +301,16 @@ export function EditorCanvasOverlay(props: EditorCanvasOverlayProps): React.Reac
|
||||
if (!pathIdx) return;
|
||||
const root: EditorNode = { children: liveDataRef.current.components };
|
||||
const node = findNodeByPath(root, pathIdx);
|
||||
// 출처 잠금 가드 — 상속(base)·주입(extension) 노드는 인라인 편집해도 저장 시
|
||||
// `stripInheritedNode` 가 통째로 폐기해 편집분이 조용히 사라진다. 진입 자체를 막고
|
||||
// 「🔒 공통 레이아웃 편집」/「🔒 확장 편집」 어포던스로 유도한다.
|
||||
const lockKind = classifyLockKind(
|
||||
node,
|
||||
state.editMode,
|
||||
currentExtensionId,
|
||||
collectAncestors(root, pathIdx),
|
||||
);
|
||||
if (!isEditableLockKind(lockKind)) return;
|
||||
const cls = inlineEdit.classify(node);
|
||||
if (!cls.editable) {
|
||||
trackEditorI18n({
|
||||
@@ -341,7 +353,7 @@ export function EditorCanvasOverlay(props: EditorCanvasOverlayProps): React.Reac
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
[frameEl, inlineEdit],
|
||||
[frameEl, inlineEdit, state.editMode, currentExtensionId],
|
||||
);
|
||||
|
||||
// 코어 placeholder 오버레이는 회수됨. 신규 노드 시각화는 템플릿의
|
||||
@@ -999,6 +1011,8 @@ export function EditorCanvasOverlay(props: EditorCanvasOverlayProps): React.Reac
|
||||
// 컨텍스트 메뉴 — 복사 / 삭제
|
||||
const handleDuplicate = useCallback((): void => {
|
||||
if (!docCtx || !selection.selectedNode || !selectedPathIndexes) return;
|
||||
// 출처 잠금 가드 — 사본도 `__source` 를 물고 가 저장 시 폐기된다(무반영 복제).
|
||||
if (!isEditableLockKind(selection.selectedLockKind)) return;
|
||||
const dup = duplicateNodeUtil(selection.selectedNode);
|
||||
let nextComponentsCaptured: EditorNode[] = [];
|
||||
docCtx.patchLayout((current) => {
|
||||
@@ -1011,10 +1025,13 @@ export function EditorCanvasOverlay(props: EditorCanvasOverlayProps): React.Reac
|
||||
return nextComponents;
|
||||
});
|
||||
history.push({ actionKind: 'insert', label: 'duplicate', snapshot: nextComponentsCaptured });
|
||||
}, [docCtx, selection.selectedNode, selectedPathIndexes, history]);
|
||||
}, [docCtx, selection.selectedNode, selection.selectedLockKind, selectedPathIndexes, history]);
|
||||
|
||||
const handleDelete = useCallback((): void => {
|
||||
if (!docCtx || !selectedPathIndexes) return;
|
||||
// 출처 잠금 가드 — 키보드 `Delete` 는 ⓘ 메뉴를 거치지 않아 무방비였다. 상속·주입 노드는
|
||||
// 지워도 저장에 반영되지 않으므로(마스킹이 어차피 되살린다) 조작 자체를 막는다.
|
||||
if (!isEditableLockKind(selection.selectedLockKind)) return;
|
||||
let nextComponentsCaptured: EditorNode[] = [];
|
||||
docCtx.patchLayout((current) => {
|
||||
const root: EditorNode = { children: current };
|
||||
@@ -1151,9 +1168,12 @@ export function EditorCanvasOverlay(props: EditorCanvasOverlayProps): React.Reac
|
||||
// 잘라내기: 복사 후 선택 노드 삭제.
|
||||
const handleCut = useCallback((): void => {
|
||||
if (!selection.selectedNode) return;
|
||||
// 출처 잠금 가드 — 잘라내기는 삭제를 동반하므로 같은 기준으로 막는다. `handleDelete`
|
||||
// 안의 가드에만 기대면 "복사는 됐는데 원본이 남는" 어긋난 상태가 된다.
|
||||
if (!isEditableLockKind(selection.selectedLockKind)) return;
|
||||
writeClipboard(selection.selectedNode);
|
||||
handleDelete();
|
||||
}, [selection.selectedNode, handleDelete]);
|
||||
}, [selection.selectedNode, selection.selectedLockKind, handleDelete]);
|
||||
|
||||
// 붙여넣기: 버퍼 노드를 선택 노드의 **다음 형제**로 삽입(선택 없으면 루트 끝). 다른
|
||||
// 레이아웃으로 이동해도 sessionStorage 라 동작. duplicateNodeUtil 로 새 id 부여(중복 id 회피).
|
||||
|
||||
+55
@@ -22,6 +22,7 @@ import { applyRecipe, reverseResolve } from '../../spec/recipeEngine';
|
||||
import { BASE_SCOPE, isDarkEditable, type StyleScope } from '../../spec/styleScope';
|
||||
import { I18nTextField } from './I18nTextField';
|
||||
import { DataChipValueInput } from '../page-settings/DataChipValueInput';
|
||||
import { useBoundValueGuard, BoundValueNotice, BoundValueRestore } from './boundValueGuard';
|
||||
|
||||
export interface ControlRendererProps {
|
||||
/** 컨트롤 키 (`textAlign` 등) — 라벨 fallback 에 사용 */
|
||||
@@ -60,6 +61,19 @@ function resolveLabel(controlKey: string, control: EditorControlSpec, t: Control
|
||||
return controlKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자체 바인딩 처리를 가진 위젯 — `ControlRenderer` 의 공용 게이트를 타지 않는다.
|
||||
*
|
||||
* 새 위젯을 여기에 넣기 전에 **그 위젯이 실제로 원문 표시·해제·복구 셋을 모두 제공하는지**
|
||||
* 확인한다. 그렇지 않은데 등재하면 공용 보호가 그 위젯에서만 사라진다.
|
||||
*/
|
||||
const SELF_GUARDED_WIDGETS: ReadonlySet<string> = new Set([
|
||||
'image', // ImagePickerControl — 배지 + 업로드/제거/갤러리/관리 잠금 + 되돌리기
|
||||
'core-id', // CoreIdControl — chipEditing 디그레이드(선례)
|
||||
'i18n-text', // I18nTextField — 표현식 칩 분해가 설계된 처리
|
||||
'text', // DataChipValueInput — 동일
|
||||
]);
|
||||
|
||||
export function ControlRenderer({
|
||||
controlKey,
|
||||
control,
|
||||
@@ -122,6 +136,29 @@ export function ControlRenderer({
|
||||
[node, control, onPatch, scope, darkReadonly],
|
||||
);
|
||||
|
||||
/**
|
||||
* 데이터 연결 값 보호 — **모든 위젯 공용 단일 게이트**.
|
||||
*
|
||||
* 저장값이 `{{...}}`·설정 참조이면 위젯은 그것을 해석하지 못해 빈 컨트롤로 보이고,
|
||||
* 운영자가 조작하는 순간 **환경설정과의 연결**이 소리 없이 끊긴다. 원문은 화면
|
||||
* 어디에도 남지 않아 되돌릴 수단조차 없다.
|
||||
*
|
||||
* 이 판정을 위젯마다 넣으면 한 곳이 빠져도 오류가 나지 않고 그 한 곳이 우회로가
|
||||
* 된다(공개 #135 후속 실측에서 실제로 「이미지 관리」 진입 하나만 열려 있었다).
|
||||
* 그래서 위젯이 아니라 **여기 한 곳**에서 건다 — 새 위젯을 등록해도 자동 적용된다.
|
||||
*/
|
||||
const boundGuard = useBoundValueGuard(resolution.value, handleChange);
|
||||
|
||||
/**
|
||||
* 자체 처리 위젯은 이 게이트를 타지 않는다.
|
||||
*
|
||||
* - `image` — 배지·잠금·복구를 위젯 안에서 더 세밀하게 제공한다(갤러리·업로드까지 잠금)
|
||||
* - `core-id` — `chipEditing` 으로 같은 형태의 디그레이드를 이미 갖는다
|
||||
* - `i18n-text`/`text`(propValue) — 위 분기에서 표현식을 **칩으로 분해해 보여 주는** 것이
|
||||
* 설계된 처리다. 여기서 가리면 그 기능이 통째로 사라진다.
|
||||
*/
|
||||
const guardApplies = !SELF_GUARDED_WIDGETS.has(control.widget ?? '');
|
||||
|
||||
return (
|
||||
<div className="g7le-control-row" data-testid={`g7le-control-${controlKey}`} style={row}>
|
||||
<span className="g7le-control-label" style={labelStyle}>
|
||||
@@ -169,8 +206,26 @@ export function ControlRenderer({
|
||||
>
|
||||
{t('layout_editor.property_modal.dark_code_only')}
|
||||
</span>
|
||||
) : guardApplies && boundGuard.bound ? (
|
||||
// 데이터 연결 값 — 위젯은 이 문자열을 해석하지 못해 빈 컨트롤로 보이고,
|
||||
// 조작하는 순간 연결이 소리 없이 끊긴다. 원문을 보여 주고 해제 경로로만 연다.
|
||||
<BoundValueNotice
|
||||
expression={String(resolution.value)}
|
||||
t={t}
|
||||
onReplace={boundGuard.beginReplace}
|
||||
testIdPrefix={`g7le-control-${controlKey}`}
|
||||
/>
|
||||
) : Widget ? (
|
||||
<>
|
||||
<Widget control={control} value={resolution.value} onChange={handleChange} t={t} candidates={candidates} bindingCandidates={bindingCandidates} freeValueDisabled={freeValueDisabled} />
|
||||
{guardApplies && boundGuard.replacing && boundGuard.original !== null && (
|
||||
<BoundValueRestore
|
||||
t={t}
|
||||
onRestore={boundGuard.restore}
|
||||
testIdPrefix={`g7le-control-${controlKey}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span data-testid={`g7le-control-unsupported-${controlKey}`} style={unsupported}>
|
||||
{t('layout_editor.property_modal.unsupported_widget')}
|
||||
|
||||
+112
-12
@@ -22,6 +22,8 @@
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { WidgetProps } from '../../spec/widgetRegistry';
|
||||
import type { EditorControlSpec } from '../../spec/specTypes';
|
||||
import { useBoundValueGuard, BoundValueNotice, BoundValueRestore } from './boundValueGuard';
|
||||
import { useLayoutEditor } from '../../LayoutEditorContext';
|
||||
import {
|
||||
listLayoutAttachments,
|
||||
@@ -55,8 +57,54 @@ function modeOf(v: ImageValue): string {
|
||||
return m?.value ?? 'fill';
|
||||
}
|
||||
|
||||
export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.ReactElement {
|
||||
/**
|
||||
* 이 컨트롤의 `apply` 가 **값 슬롯을 하나만** 가지는가.
|
||||
*
|
||||
* 그렇다면 `size`/`repeat`/`position` 을 저장할 곳이 없으므로 표시모드(채움/맞춤/타일)
|
||||
* 버튼은 죽은 컨트롤이다 — 눌러도 저장되지 않는데 눌리는 표면이 남으면 "고칠 수 있는
|
||||
* 것처럼 보이지만 저장되지 않는" 상태가 된다.
|
||||
*
|
||||
* 판정 대상은 **control-level apply + 모든 option-level apply 의 합집합**이다.
|
||||
* `control.apply` 만 보면 옵션-only 컨트롤에서 `undefined` → 보류 → 표시로 조용히
|
||||
* 잘못된 결론에 도달한다.
|
||||
*
|
||||
* **화이트리스트가 아니라 블랙리스트다** — `apply` 미선언(합집합이 공집합)이면 판정을
|
||||
* 보류하고 `false`(현행대로 표시)를 돌려준다. 화이트리스트("bundle 일 때만 표시")로
|
||||
* 짜면 apply 없이 렌더되는 기존 계약이 깨진다.
|
||||
*/
|
||||
function hasSingleValueSlot(control: EditorControlSpec | undefined): boolean {
|
||||
if (!control) return false;
|
||||
const applies: Array<{ type?: unknown; props?: unknown }> = [];
|
||||
const own = control.apply as unknown;
|
||||
if (own && typeof own === 'object') applies.push(own as { type?: unknown; props?: unknown });
|
||||
for (const opt of (control.options ?? []) as Array<{ apply?: unknown }>) {
|
||||
if (opt && typeof opt === 'object' && opt.apply && typeof opt.apply === 'object') {
|
||||
applies.push(opt.apply as { type?: unknown; props?: unknown });
|
||||
}
|
||||
}
|
||||
if (applies.length === 0) return false; // 판정 보류 — 현행 동작 보존
|
||||
return applies.every(
|
||||
(a) =>
|
||||
a.type === 'propValue' ||
|
||||
a.type === 'cssVar' ||
|
||||
(a.type === 'styleProp' && !Array.isArray(a.props)),
|
||||
);
|
||||
}
|
||||
|
||||
export function ImagePickerControl({ control, value, onChange, t }: WidgetProps): React.ReactElement {
|
||||
const current = readValue(value);
|
||||
/** 단일 값 슬롯 — size/repeat/position 을 저장할 자리가 없다 */
|
||||
const singleSlot = hasSingleValueSlot(control);
|
||||
/**
|
||||
* 데이터 연결 값 보호 — 판정·배지·해제·복구를 공용 프리미티브에 위임한다.
|
||||
* 위젯마다 복붙하면 한 곳이 빠져도 오류가 나지 않고 그 한 곳이 우회로가 된다.
|
||||
*/
|
||||
const guard = useBoundValueGuard(
|
||||
value,
|
||||
(original) => onChange({ url: original }),
|
||||
(v) => readValue(v).url,
|
||||
);
|
||||
const bound = guard.bound;
|
||||
const { state } = useLayoutEditor();
|
||||
const modal = useEditorModal();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -91,6 +139,12 @@ export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.R
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
// 단일 값 슬롯 — size/repeat/position 은 저장될 자리가 없다. 만들지 않으면
|
||||
// 엔진 축약(scalarizeImageValue)이 버릴 것도 없어 라운드트립이 애초에 대칭이 된다.
|
||||
if (singleSlot) {
|
||||
onChange({ url });
|
||||
return;
|
||||
}
|
||||
const mode = DISPLAY_MODES.find((d) => d.value === modeOf(current)) ?? DISPLAY_MODES[0];
|
||||
onChange({
|
||||
url,
|
||||
@@ -155,21 +209,34 @@ export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.R
|
||||
|
||||
return (
|
||||
<div className="g7le-widget g7le-widget--image" data-testid="g7le-widget-image" style={wrap}>
|
||||
{current.url && (
|
||||
{/* 데이터 연결 값 — 미리보기는 깨지므로 원문 배지로 대체한다(거짓 미리보기 금지) */}
|
||||
{bound ? (
|
||||
<BoundValueNotice
|
||||
expression={current.url ?? ''}
|
||||
t={t}
|
||||
onReplace={guard.beginReplace}
|
||||
testIdPrefix="g7le-image"
|
||||
/>
|
||||
) : (
|
||||
current.url && (
|
||||
<div
|
||||
data-testid="g7le-image-preview"
|
||||
style={{
|
||||
...preview,
|
||||
backgroundImage: `url(${current.url})`,
|
||||
backgroundSize: current.size ?? 'cover',
|
||||
backgroundRepeat: current.repeat ?? 'no-repeat',
|
||||
backgroundPosition: current.position ?? 'center',
|
||||
// 단일 슬롯에서는 size/repeat/position 이 저장되지 않는다. 저장되지도 않는
|
||||
// current.size 를 미리보기에 반영하면 거짓 미리보기가 된다 — 실제 표시 방식은
|
||||
// 소비 컴포넌트(<Img>)의 클래스가 정하므로 편집기가 흉내낼 수 없다.
|
||||
backgroundSize: singleSlot ? 'contain' : current.size ?? 'cover',
|
||||
backgroundRepeat: singleSlot ? 'no-repeat' : current.repeat ?? 'no-repeat',
|
||||
backgroundPosition: singleSlot ? 'center' : current.position ?? 'center',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<div style={row}>
|
||||
<label style={uploadBtn}>
|
||||
<label style={{ ...uploadBtn, ...(bound ? disabledBtn : null) }}>
|
||||
{uploading ? '…' : t('layout_editor.control.background_image.upload')}
|
||||
<input
|
||||
ref={fileRef}
|
||||
@@ -177,11 +244,17 @@ export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.R
|
||||
accept="image/*"
|
||||
data-testid="g7le-image-file"
|
||||
onChange={onFileChange}
|
||||
disabled={uploading}
|
||||
disabled={uploading || bound}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" data-testid="g7le-image-clear" onClick={() => onChange(undefined)} style={clearBtn}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="g7le-image-clear"
|
||||
disabled={bound}
|
||||
onClick={() => onChange(undefined)}
|
||||
style={{ ...clearBtn, ...(bound ? disabledBtn : null) }}
|
||||
>
|
||||
{t('layout_editor.control.background_image.clear')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -191,11 +264,27 @@ export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.R
|
||||
data-testid="g7le-image-url"
|
||||
placeholder="https://… / URL"
|
||||
value={urlDraft}
|
||||
// 데이터 연결 값은 원문을 보이되 편집을 막는다 — 지워지지 않고 남는다.
|
||||
readOnly={bound}
|
||||
onChange={(e) => setUrlDraft(e.target.value)}
|
||||
onBlur={(e) => setUrl(e.target.value.trim() || undefined)}
|
||||
onBlur={(e) => {
|
||||
if (bound) return;
|
||||
setUrl(e.target.value.trim() || undefined);
|
||||
}}
|
||||
style={urlInput}
|
||||
/>
|
||||
|
||||
{/* 보호를 해제한 동안의 편도 방지 — 아직 안 바꿨으면 취소로, 이미 값을 넣었으면
|
||||
원래 연결값 복구로 동작한다. 이 경로가 없으면 실수로 한 번 누른 운영자가
|
||||
원문을 되찾을 방법이 없다. */}
|
||||
{guard.replacing && guard.original !== null && (
|
||||
<BoundValueRestore t={t} onRestore={guard.restore} testIdPrefix="g7le-image" />
|
||||
)}
|
||||
|
||||
{/* 단일 값 슬롯이면 표시모드는 저장될 자리가 없다 — 컨테이너째 미렌더.
|
||||
`disabled` 로 두면 "URL 을 넣으면 살아나겠지"라는 거짓 정보를 준다
|
||||
(기존 `disabled={!current.url}` 은 일시적 비활성이라 의미가 다르다). */}
|
||||
{!singleSlot && (
|
||||
<div style={modeRow} data-testid="g7le-image-modes">
|
||||
{DISPLAY_MODES.map((m) => {
|
||||
const active = modeOf(current) === m.value;
|
||||
@@ -205,7 +294,7 @@ export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.R
|
||||
type="button"
|
||||
data-testid={`g7le-image-mode-${m.value}`}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
disabled={!current.url}
|
||||
disabled={!current.url || bound}
|
||||
onClick={() => setMode(m.value)}
|
||||
style={{
|
||||
...modeBtn,
|
||||
@@ -218,6 +307,7 @@ export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.R
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 인라인 미니 갤러리 — 현재 레이아웃 첨부 썸네일 가로 스트립 + 관리 링크 */}
|
||||
<div style={galleryWrap} data-testid="g7le-image-gallery">
|
||||
@@ -225,11 +315,15 @@ export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.R
|
||||
<span style={{ fontSize: 11, color: '#64748b' }}>
|
||||
{t('layout_editor.attachment_manager.recent')}
|
||||
</span>
|
||||
{/* 관리 모달은 각 카드에 「배경」 버튼을 띄우고 그 클릭이 같은 setUrl 을 부른다 —
|
||||
인라인 썸네일 「사용」과 동일 동작의 다른 렌더 위치다. 하나만 잠그면
|
||||
나머지 하나가 조용한 우회로가 되므로 진입 자체를 함께 잠근다. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="g7le-image-manage"
|
||||
disabled={bound}
|
||||
onClick={openManager}
|
||||
style={manageLink}
|
||||
style={{ ...manageLink, ...(bound ? disabledBtn : null) }}
|
||||
>
|
||||
🖼 {t('layout_editor.attachment_manager.manage_link')}
|
||||
</button>
|
||||
@@ -241,8 +335,13 @@ export function ImagePickerControl({ value, onChange, t }: WidgetProps): React.R
|
||||
<button
|
||||
type="button"
|
||||
title={att.original_name}
|
||||
disabled={bound}
|
||||
onClick={() => setUrl(att.url)}
|
||||
style={{ ...thumbBtn, backgroundImage: `url(${att.url})` }}
|
||||
style={{
|
||||
...thumbBtn,
|
||||
backgroundImage: `url(${att.url})`,
|
||||
...(bound ? disabledBtn : null),
|
||||
}}
|
||||
data-testid={`g7le-image-thumb-use-${att.id}`}
|
||||
/>
|
||||
<button
|
||||
@@ -279,6 +378,7 @@ const row: React.CSSProperties = { display: 'flex', gap: 6 };
|
||||
const uploadBtn: React.CSSProperties = { padding: '5px 10px', fontSize: 12, border: '1px solid #2563eb', borderRadius: 6, background: '#fff', color: '#2563eb', cursor: 'pointer', display: 'inline-flex', alignItems: 'center' };
|
||||
const clearBtn: React.CSSProperties = { padding: '5px 10px', fontSize: 12, border: '1px solid #cbd5e1', borderRadius: 6, background: '#fff', color: '#64748b', cursor: 'pointer' };
|
||||
const urlInput: React.CSSProperties = { padding: '5px 8px', fontSize: 12, border: '1px solid #cbd5e1', borderRadius: 6 };
|
||||
const disabledBtn: React.CSSProperties = { opacity: 0.45, cursor: 'not-allowed' };
|
||||
const modeRow: React.CSSProperties = { display: 'inline-flex', border: '1px solid #cbd5e1', borderRadius: 6, overflow: 'hidden' };
|
||||
const modeBtn: React.CSSProperties = { padding: '4px 10px', fontSize: 12, border: 'none', borderRight: '1px solid #e2e8f0', cursor: 'pointer' };
|
||||
const galleryWrap: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 4, border: '1px solid #e2e8f0', borderRadius: 6, padding: 6, marginTop: 2 };
|
||||
|
||||
+86
@@ -288,6 +288,92 @@ export function DimensionWidget({ control, value, onChange, t, freeValueDisabled
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number — 숫자 prop 편집 위젯.
|
||||
*
|
||||
* `propValue` 로 숫자를 기대하는 prop(예 `Header.maxVisibleBoards?: number`)에 연결된다.
|
||||
* `applyPropValue` 는 값을 가공하지 않으므로 **위젯이 직접 `number` 를 내보내야** 한다 —
|
||||
* 문자열을 내면 `"5"` 가 JSON 에 박혀 컴포넌트 타입 계약이 깨진다.
|
||||
*
|
||||
* 정책:
|
||||
* - 빈 입력 → `onChange(undefined)` → prop 삭제("기본").
|
||||
* - `0` 은 **유효값**이며 삭제되지 않는다(엔진 빈값 술어는 `''|null|undefined` 만 본다).
|
||||
* - 비숫자 입력은 **미방출**(draft 만 유지) — "0 으로 강제"나 "삭제"는 조용한 데이터 변조다.
|
||||
* - **클램프하지 않는다** — `min`/`max`/`step` 은 HTML 속성으로 그대로 전달만 한다.
|
||||
* 사용자가 넣은 값은 보존한다.
|
||||
* - 매 키 입력마다 커밋하지 않는다 — propValue 패치는 history push 라 blur/Enter 커밋
|
||||
* (`DimensionWidget` 선례).
|
||||
* - 저장값이 바인딩 문자열(`{{…}}`)일 때의 보호는 **위젯이 하지 않는다** — `ControlRenderer`
|
||||
* 의 공용 게이트(`boundValueGuard`)가 원문 배지·해제·복구를 담당한다. 위젯마다 복붙하면
|
||||
* 한 곳이 빠져도 오류가 나지 않고 그 한 곳이 우회로가 된다.
|
||||
*
|
||||
* @since engine-v1.66.0
|
||||
*/
|
||||
export function NumberWidget({ control, value, onChange, t }: WidgetProps): React.ReactElement {
|
||||
const spec = control as unknown as { min?: unknown; max?: unknown; step?: unknown };
|
||||
const currentStr = value === undefined || value === null ? '' : String(value);
|
||||
const [draft, setDraft] = React.useState<string>(currentStr);
|
||||
|
||||
// 외부(역해석/다른 탭)에서 값이 바뀌면 입력칸 동기 — 양방향 동기.
|
||||
React.useEffect(() => {
|
||||
setDraft(currentStr);
|
||||
}, [currentStr]);
|
||||
|
||||
const commit = (raw: string): void => {
|
||||
const s = raw.trim();
|
||||
if (s === '') {
|
||||
onChange(undefined); // → prop 삭제
|
||||
return;
|
||||
}
|
||||
const n = Number(s);
|
||||
// Number.isFinite 가드 — ''→0 오변환·Infinity·NaN 차단. 비숫자는 저장본 보존(무손실).
|
||||
if (!Number.isFinite(n)) return;
|
||||
onChange(n);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="g7le-widget g7le-widget--number" data-testid="g7le-widget-number" style={dimensionWrap}>
|
||||
<div style={dimensionRow}>
|
||||
<input
|
||||
// `type="number"` 가 아니다 — 그 타입은 비숫자 입력을 브라우저가 `''` 로 보고하므로
|
||||
// blur 시 "빈 입력" 과 구분되지 않아 **prop 이 조용히 삭제**된다(비숫자 미방출 정책
|
||||
// 위반). 원문을 그대로 쥐고 있어야 `Number.isFinite` 가드가 실제로 동작한다.
|
||||
// `DimensionWidget` 도 같은 이유로 text 다.
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
data-testid="g7le-number-input"
|
||||
value={draft}
|
||||
placeholder={t('layout_editor.control.number.placeholder')}
|
||||
min={typeof spec.min === 'number' ? spec.min : undefined}
|
||||
max={typeof spec.max === 'number' ? spec.max : undefined}
|
||||
step={typeof spec.step === 'number' ? spec.step : undefined}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={(e) => commit(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit((e.target as HTMLInputElement).value);
|
||||
}
|
||||
}}
|
||||
style={dimensionInput}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="g7le-number-clear"
|
||||
title={t('layout_editor.control.default')}
|
||||
onClick={() => {
|
||||
setDraft('');
|
||||
onChange(undefined);
|
||||
}}
|
||||
style={dimensionClear}
|
||||
>
|
||||
{t('layout_editor.control.default')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spacing — 여백(안쪽/바깥쪽)의 **측별 독립 크기** 편집 (§항목B 재설계).
|
||||
*
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* boundValueGuard.tsx — 데이터 연결 값(바인딩 표현식) 보호 공용 프리미티브
|
||||
*
|
||||
* 레이아웃의 prop 자리에는 `{{_global.settings?.general?.site_logo_url}}` 같은 **표현식
|
||||
* 문자열**이 저장돼 있을 수 있다. 그 값을 편집기 위젯이 그대로 읽으면 위젯은 그것을
|
||||
* 해석하지 못해 **빈 컨트롤**로 보이고, 운영자가 무심코 조작하는 순간 그 연결이
|
||||
* 소리 없이 사라진다 — 값 하나가 아니라 **환경설정과의 연결**이 끊기고, 원문이 화면
|
||||
* 어디에도 남지 않아 되돌릴 수단조차 없다.
|
||||
*
|
||||
* 그래서 바인딩 값은 ① 원문을 배지로 보여 주고 ② 파괴적 조작을 잠그고
|
||||
* ③ 「직접 지정으로 바꾸기」로만 명시적으로 열고 ④ 「되돌리기」로 원래 연결을 복구한다.
|
||||
*
|
||||
* **이 판정과 UI 를 위젯마다 복붙하지 않는다.** 복붙하면 한 곳이 빠져도 오류가 나지
|
||||
* 않고, 그 한 곳이 조용한 우회로가 된다(공개 #135 후속 실측에서 실제로 「이미지 관리」
|
||||
* 진입 하나만 열려 있었다). 새 위젯을 만들 때도 본 훅만 쓴다.
|
||||
*
|
||||
* 선례: `CoreIdControl`(chipEditing) · `I18nTextField`(settingsEditing) 이 같은 형태의
|
||||
* 디그레이드를 이미 갖고 있다. 본 모듈은 그 패턴을 위젯 공용으로 승격한 것이다.
|
||||
*
|
||||
* @since engine-v1.66.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { hasInlineBinding, hasSettingsRef } from '../../spec/inlineBindingUtils';
|
||||
|
||||
/** 다국어 해석 함수 — `WidgetProps.t` 와 동일 시그니처 */
|
||||
export type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* 값이 데이터 연결(바인딩/설정 참조) 문자열인지 판정한다.
|
||||
*
|
||||
* 인라인 바인딩(`{{...}}`)과 설정 참조를 모두 본다 — 둘 다 위젯이 해석하지 못하고
|
||||
* 덮어쓰면 소실되는 형태다.
|
||||
*
|
||||
* @param value 판정할 값 (문자열이 아니면 false)
|
||||
* @return 데이터 연결 문자열이면 true
|
||||
*/
|
||||
export function isBoundValue(value: unknown): value is string {
|
||||
return typeof value === 'string' && (hasInlineBinding(value) || hasSettingsRef(value));
|
||||
}
|
||||
|
||||
/** `useBoundValueGuard` 반환 계약 */
|
||||
export interface BoundValueGuard {
|
||||
/** 지금 보호 상태인가 — true 면 파괴적 조작을 잠그고 배지를 렌더한다 */
|
||||
readonly bound: boolean;
|
||||
/** 보호를 해제하고 편집 중인가 (「직접 지정으로 바꾸기」 이후) */
|
||||
readonly replacing: boolean;
|
||||
/** 보호 해제 시점에 붙잡아 둔 원문 — 「되돌리기」의 복구 대상 */
|
||||
readonly original: string | null;
|
||||
/** 「직접 지정으로 바꾸기」 — 원문을 붙잡고 편집을 연다 */
|
||||
readonly beginReplace: () => void;
|
||||
/** 「되돌리기」 — 원문을 복구하고 보호 상태로 되돌린다 */
|
||||
readonly restore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바인딩 값 보호 상태를 관리한다.
|
||||
*
|
||||
* 위젯의 값이 객체인 경우(예 `image` 는 `{url,…}`)를 위해 문자열 추출자를 받는다.
|
||||
*
|
||||
* @param rawValue 현재 값 (위젯이 받은 원본)
|
||||
* @param onRestore 원문 복구를 위해 호출할 쓰기 함수. 값이 이미 원문과 같으면 호출하지
|
||||
* 않는다 — 바꾼 것이 없는데 history push 를 만들지 않기 위함이다.
|
||||
* @param extract 값에서 판정 대상 문자열을 뽑는다 (기본: 값 자신)
|
||||
* @return 보호 상태와 조작
|
||||
*/
|
||||
export function useBoundValueGuard(
|
||||
rawValue: unknown,
|
||||
onRestore: (original: string) => void,
|
||||
extract: (value: unknown) => unknown = (v) => v,
|
||||
): BoundValueGuard {
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [original, setOriginal] = useState<string | null>(null);
|
||||
|
||||
const current = extract(rawValue);
|
||||
const bound = isBoundValue(current) && !replacing;
|
||||
|
||||
const beginReplace = useCallback(() => {
|
||||
// 해제 시점의 원문을 붙잡는다 — 이후 값이 바뀌어도 복구 대상은 이것이다.
|
||||
setOriginal(isBoundValue(current) ? current : null);
|
||||
setReplacing(true);
|
||||
}, [current]);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
if (original !== null && current !== original) onRestore(original);
|
||||
setReplacing(false);
|
||||
setOriginal(null);
|
||||
}, [original, current, onRestore]);
|
||||
|
||||
return { bound, replacing, original, beginReplace, restore };
|
||||
}
|
||||
|
||||
const noticeWrap: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
padding: '8px 10px',
|
||||
border: '1px dashed #cbd5e1',
|
||||
borderRadius: 6,
|
||||
background: '#f8fafc',
|
||||
};
|
||||
|
||||
const noticeCode: React.CSSProperties = {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontSize: 11,
|
||||
color: '#0f172a',
|
||||
wordBreak: 'break-all',
|
||||
};
|
||||
|
||||
const noticeLink: React.CSSProperties = {
|
||||
alignSelf: 'flex-start',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
fontSize: 11,
|
||||
color: '#2563eb',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
};
|
||||
|
||||
/**
|
||||
* 보호 상태에서 원문과 해제 경로를 보여 주는 공용 배지.
|
||||
*
|
||||
* 미리보기·입력칸을 대신해 렌더한다 — 위젯이 해석하지 못하는 값을 흉내 내면 거짓
|
||||
* 미리보기가 되기 때문이다.
|
||||
*
|
||||
* @param expression 표시할 원문
|
||||
* @param t 다국어 해석 함수
|
||||
* @param onReplace 「직접 지정으로 바꾸기」 클릭 핸들러
|
||||
* @param testIdPrefix testid 접두사 (위젯별 구분 — 예 `g7le-image`)
|
||||
*/
|
||||
export function BoundValueNotice({
|
||||
expression,
|
||||
t,
|
||||
onReplace,
|
||||
testIdPrefix,
|
||||
}: {
|
||||
expression: string;
|
||||
t: TranslateFn;
|
||||
onReplace: () => void;
|
||||
testIdPrefix: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div style={noticeWrap}>
|
||||
<code data-testid={`${testIdPrefix}-expression`} style={noticeCode}>
|
||||
{expression}
|
||||
</code>
|
||||
<span style={{ fontSize: 11, color: '#64748b' }}>
|
||||
{t('layout_editor.control.bound_value.notice')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-expression-replace`}
|
||||
onClick={onReplace}
|
||||
style={noticeLink}
|
||||
>
|
||||
{t('layout_editor.control.bound_value.replace')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보호를 해제한 동안 노출하는 「되돌리기」 어포던스.
|
||||
*
|
||||
* 해제 직후(아직 안 바꿈)에는 취소로, URL 등을 이미 넣은 뒤에는 원래 연결값 복구로
|
||||
* 동작한다 — 둘 다 같은 버튼이다. 이 경로가 없으면 「직접 지정으로 바꾸기」가 편도가
|
||||
* 되어, 실수로 한 번 누른 운영자가 원문을 되찾을 방법이 없다.
|
||||
*
|
||||
* @param t 다국어 해석 함수
|
||||
* @param onRestore 「되돌리기」 클릭 핸들러
|
||||
* @param testIdPrefix testid 접두사
|
||||
*/
|
||||
export function BoundValueRestore({
|
||||
t,
|
||||
onRestore,
|
||||
testIdPrefix,
|
||||
}: {
|
||||
t: TranslateFn;
|
||||
onRestore: () => void;
|
||||
testIdPrefix: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-expression-restore`}
|
||||
onClick={onRestore}
|
||||
style={noticeLink}
|
||||
>
|
||||
{t('layout_editor.control.bound_value.restore')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export interface DropZone {
|
||||
index: number;
|
||||
}
|
||||
import {
|
||||
collectAncestors,
|
||||
findNodeByPath,
|
||||
isInsideIterationInstance,
|
||||
moveNode,
|
||||
@@ -47,7 +48,12 @@ import {
|
||||
type EditorNode,
|
||||
type ComponentPath,
|
||||
} from '../utils/layoutTreeUtils';
|
||||
import { classifyLockKind, parseEditorPath } from './useElementSelection';
|
||||
import {
|
||||
classifyLockKind,
|
||||
isEditableLockKind,
|
||||
parseEditorPath,
|
||||
resolveDndDenial,
|
||||
} from './useElementSelection';
|
||||
import { trackEditorDnd, type EditorDndDecision } from '../devtools/editorTrackers';
|
||||
import type { NestingSpec } from '../spec/specTypes';
|
||||
|
||||
@@ -301,13 +307,15 @@ export function useCanvasDnd(params: UseCanvasDndParams): UseCanvasDndReturn {
|
||||
// 단, 편집 대상 iteration/모달(원본 노드 및 그 자손)은 그 모드의 편집 대상이므로
|
||||
// data_bound 잠금을 무시하고 컨테이너로 허용한다.
|
||||
if (!isInsideEditableRootContainer) {
|
||||
const ancestors = ancestorsOf(rootOf(live.components), path);
|
||||
const ancestors = collectAncestors(rootOf(live.components), path);
|
||||
const lockKind = classifyLockKind(
|
||||
containerNode,
|
||||
live.editMode,
|
||||
live.currentExtensionId,
|
||||
ancestors
|
||||
);
|
||||
// 여기는 의도적으로 `data_bound` 도 거부한다 — 데이터가 결정하는 컨테이너 안에
|
||||
// 정적 노드를 넣으면 반복/바인딩 결과와 어긋난다(`isEditableLockKind` 보다 엄격).
|
||||
if (lockKind !== 'none') return false;
|
||||
}
|
||||
|
||||
@@ -497,7 +505,7 @@ export function useCanvasDnd(params: UseCanvasDndParams): UseCanvasDndReturn {
|
||||
if (!name || !isDraggableNode(name, live.nesting)) {
|
||||
decision = 'denied_no_draggable';
|
||||
} else if (node) {
|
||||
const ancestors = ancestorsOf(rootOf(live.components), indexes);
|
||||
const ancestors = collectAncestors(rootOf(live.components), indexes);
|
||||
const lockKind = classifyLockKind(node, live.editMode, live.currentExtensionId, ancestors);
|
||||
// 편집 루트 confine(modal/iteration_item) — 편집 대상 루트 서브트리 **밖** 노드의 드래그
|
||||
// 시작을 거부한다. 선택 자체가 편집 루트로 제한되나(useElementSelection),
|
||||
@@ -520,9 +528,18 @@ export function useCanvasDnd(params: UseCanvasDndParams): UseCanvasDndReturn {
|
||||
}
|
||||
// 자신 바인딩 data_bound(상품 이미지 갤러리 등)는 명세상 선택·드래그·구조
|
||||
// 편집 허용 — 위치 이동은 정당한 구조 편집이므로 막지 않는다.
|
||||
else if (lockKind === 'base' || lockKind === 'partial') decision = 'denied_base_locked';
|
||||
else if (lockKind === 'extension' || lockKind === 'extension_point')
|
||||
decision = 'denied_extension_locked';
|
||||
// 그 외 잠금 종류의 거부 사유는 `resolveDndDenial` 단일 지점이 정한다(조건 복사 금지).
|
||||
else {
|
||||
const denial = resolveDndDenial(lockKind);
|
||||
if (denial) decision = denial;
|
||||
}
|
||||
}
|
||||
|
||||
// 거부된 드래그는 `activeDragPath` 도 남기지 않는다 — 남으면 DragOverlay 가 잡힌
|
||||
// 노드를 따라다녀 "옮길 수 있다" 는 거짓 어포던스를 준다(종전 결함).
|
||||
// `activeDragName` 은 지우지 않는다 — onDragEnd 가 그것으로 취소 사유를 적재한다.
|
||||
if (decision !== 'allowed') {
|
||||
setActiveDragPath(null);
|
||||
}
|
||||
|
||||
trackEditorDnd({
|
||||
@@ -558,7 +575,7 @@ export function useCanvasDnd(params: UseCanvasDndParams): UseCanvasDndReturn {
|
||||
setActiveDropZone(null);
|
||||
return;
|
||||
}
|
||||
const ancestors = ancestorsOf(rootOf(live.components), indexes);
|
||||
const ancestors = collectAncestors(rootOf(live.components), indexes);
|
||||
// 편집 루트 confine(modal/iteration_item) — 편집 대상 루트 밖 노드는 드롭존 미계산.
|
||||
if (
|
||||
live.editableRootSourcePath &&
|
||||
@@ -574,7 +591,7 @@ export function useCanvasDnd(params: UseCanvasDndParams): UseCanvasDndReturn {
|
||||
const insideEditableIter = isInsideEditableIteration(draggedPath, live.editableRootSourcePath);
|
||||
if (
|
||||
(isInsideIterationInstance(ancestors) && !insideEditableIter) ||
|
||||
(moveLockKind !== 'none' && moveLockKind !== 'data_bound' && !insideEditableIter)
|
||||
(!isEditableLockKind(moveLockKind) && !insideEditableIter)
|
||||
) {
|
||||
setActiveDropZone(null);
|
||||
return;
|
||||
@@ -670,6 +687,35 @@ export function useCanvasDnd(params: UseCanvasDndParams): UseCanvasDndReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
// 드래그 노드 출처 잠금 — commit 직전 최종 가드.
|
||||
//
|
||||
// 종전에는 `zone === null` 에 기댄 간접 방어뿐이라, stale 슬롯이나 유효 zone 이 들어오면
|
||||
// 상속·주입 노드가 그대로 이동 commit 됐다(그리고 저장 시 통째로 폐기됐다).
|
||||
const draggedNodeAtEnd = findNodeByPath(rootOf(live.components), fromPath);
|
||||
const endDenial = draggedNodeAtEnd
|
||||
? resolveDndDenial(
|
||||
classifyLockKind(
|
||||
draggedNodeAtEnd,
|
||||
live.editMode,
|
||||
live.currentExtensionId,
|
||||
collectAncestors(rootOf(live.components), fromPath),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
if (endDenial) {
|
||||
trackEditorDnd({
|
||||
source: 'drag',
|
||||
draggedComponentName: draggedName,
|
||||
targetContainerName,
|
||||
targetContainerPath: zone.containerPath || null,
|
||||
decision: endDenial,
|
||||
result: 'denied',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
finishDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
// no-op 가드 — 같은 위치면 변형/이력 생략.
|
||||
//
|
||||
// zone.index 는 **원본 트리 인덱스**.
|
||||
@@ -758,28 +804,6 @@ export function useCanvasDnd(params: UseCanvasDndParams): UseCanvasDndReturn {
|
||||
};
|
||||
}
|
||||
|
||||
/** path 의 조상 노드 배열(루트→부모, 자기 자신 제외) — classifyLockKind 의 ancestors 입력 */
|
||||
function ancestorsOf(root: EditorNode, path: ComponentPath): EditorNode[] {
|
||||
const out: EditorNode[] = [];
|
||||
let current: EditorNode = root;
|
||||
let childArray: EditorNode[] = Array.isArray(root.children) ? (root.children as EditorNode[]) : [];
|
||||
// 마지막 세그먼트(자기 자신)는 제외. responsive 세그먼트는 노드를 내리지 않고
|
||||
// childArray 만 분기로 전환 — 조상 목록에 추가하지 않는다.
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const seg = path[i]!;
|
||||
if (isResponsiveSegment(seg)) {
|
||||
const branch = current.responsive?.[seg.responsive];
|
||||
childArray = branch && Array.isArray(branch.children) ? (branch.children as EditorNode[]) : [];
|
||||
continue;
|
||||
}
|
||||
const next = childArray[seg] ?? null;
|
||||
if (!next) break;
|
||||
out.push(next);
|
||||
current = next;
|
||||
childArray = Array.isArray(next.children) ? (next.children as EditorNode[]) : [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pathsEqual(a: ComponentPath, b: ComponentPath): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
|
||||
@@ -398,34 +398,63 @@ export function classifyLockKind(
|
||||
return 'extension_point';
|
||||
}
|
||||
|
||||
// 별도 편집 모드(extension/base/modal/iteration)에서만 **출처 잠금을 data_bound 보다 우선**
|
||||
// 적용한다. 그 모드들에서는 호스트 본체 노드가 폼 입력
|
||||
// 등으로 data_bound 인 경우가 많은데, data_bound 를 먼저 판정하면 잠겨야 할 호스트 노드가
|
||||
// "텍스트만 잠금(선택/스타일 허용)"으로 새어 편집된다. 그래서 잠긴 노드는 출처로 분류한다.
|
||||
// **출처 잠금이 data_bound 보다 항상 우선**한다 (편집 모드 무관).
|
||||
//
|
||||
// route 모드는 **종전 동작을 그대로 보존**한다 — data_bound 를 먼저 판정해,
|
||||
// 확장 주입 영역의 data_bound 노드가 "데이터 영역" 표식을 유지하고, 잠긴 확장/ base 노드는
|
||||
// 아래 출처 분류로 "확장 편집"/"공통 레이아웃 편집" 어포던스를 띄운다. route 동작을 한 줄도
|
||||
// 바꾸지 않아 기존 확장 주입 영역 표식/진입이 그대로 복원된다.
|
||||
if (editMode !== 'route' && isNodeLocked(node, editMode, currentExtensionId)) {
|
||||
// 종전에는 route 모드만 예외로 두어 data_bound 를 먼저 판정했다. 그 결과 상속(base)·
|
||||
// 주입(extension) 노드 중 props 값 하나라도 `{{ }}` 인 것은 `data_bound` 로 분류됐고,
|
||||
// `data_bound` 는 편집 허용이라 속성 모달·드래그·인라인 편집이 모두 열렸다. 그런데 저장
|
||||
// 시 `stripInheritedNode` 가 그 노드를 통째로 폐기하므로 **편집분이 오류도 경고도 없이
|
||||
// 사라진다**(저장은 200 으로 성공하고 history 는 clear 돼 undo 도 불가). 그 "종전 동작
|
||||
// 보존" 은 계약이 아니라 보수성 선언이었고, 그 보수성이 곧 결함이었다.
|
||||
//
|
||||
// `data_bound` 의 **의미는 그대로 두고**(= 편집 가능, 텍스트만 잠금) 어느 노드가
|
||||
// data_bound 로 분류되는가만 좁힌다. `isNodeLocked('route')` 는 `__source.kind` 하나만
|
||||
// 보고 조상은 보지 않으므로, 라우트 소유 노드는 계속 편집 가능하다(회귀 없음).
|
||||
if (isNodeLocked(node, editMode, currentExtensionId)) {
|
||||
const src = node.__source?.kind;
|
||||
if (src === 'base') return 'base';
|
||||
if (src === 'partial') return 'partial';
|
||||
if (src === 'extension') return 'extension';
|
||||
// 출처 메타가 없는데 잠금(별도 모드 한정) — 보수적으로 base 취급(선택 차단).
|
||||
// 출처 메타가 없는데 잠금 — 보수적으로 base 취급(선택 차단).
|
||||
return 'base';
|
||||
}
|
||||
|
||||
// ── 종전 분류 순서 (route 모드 + 별도 모드의 미잠금 노드) ──
|
||||
// ── 미잠금(라우트 소유·신규 삽입) 노드 ──
|
||||
if (isDataBoundNode(node, ancestors)) return 'data_bound';
|
||||
if (!isNodeLocked(node, editMode, currentExtensionId)) return 'none';
|
||||
const src = node.__source?.kind;
|
||||
if (src === 'base') return 'base';
|
||||
if (src === 'partial') return 'partial';
|
||||
if (src === 'extension') return 'extension';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 노드가 현재 편집 중인 레이아웃의 **소유 노드**인가 — 즉 저장 마스킹
|
||||
* (`stripInheritedFromLayoutContent`)을 통과해 영속되는가.
|
||||
*
|
||||
* `false` 인 노드(base/partial/extension/extension_point)는 편집해도 저장 시 폐기되므로
|
||||
* 속성·구조·드래그·인라인 편집을 **모두 같은 기준으로** 차단하고 진입 어포던스
|
||||
* (「🔒 공통 레이아웃 편집」 / 「🔒 확장 편집」)로 유도한다.
|
||||
*
|
||||
* 새 편집 진입점을 만들 때 반드시 본 함수를 게이트로 쓸 것 — 조건을 복사하면
|
||||
* 한 곳만 빠져도 같은 소실 결함이 재발한다(이번 결함의 원인이 정확히 그것이다).
|
||||
*/
|
||||
export function isEditableLockKind(lockKind: SelectionLockKind): boolean {
|
||||
return lockKind === 'none' || lockKind === 'data_bound';
|
||||
}
|
||||
|
||||
/**
|
||||
* 잠금 종류 → DnD 거부 사유(devtools `EditorDndDecision`). `null` = 허용.
|
||||
*
|
||||
* `isEditableLockKind` 와 같은 판정을 쓰되 거부 사유 문자열까지 단일 지점에서 정한다.
|
||||
* 사유 어휘는 기존 `EditorDndDecision` 그대로다(신설 없음) — base/partial 은
|
||||
* `denied_base_locked`, extension/extension_point 는 `denied_extension_locked`.
|
||||
*/
|
||||
export function resolveDndDenial(
|
||||
lockKind: SelectionLockKind,
|
||||
): 'denied_base_locked' | 'denied_extension_locked' | null {
|
||||
if (isEditableLockKind(lockKind)) return null;
|
||||
return lockKind === 'extension' || lockKind === 'extension_point'
|
||||
? 'denied_extension_locked'
|
||||
: 'denied_base_locked';
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드의 확장 출처 PK 를 해석한다 (devtools editor-selection __sourceExtensionId).
|
||||
*
|
||||
@@ -536,7 +565,8 @@ function normalizeDomPathToExtensionEntry(
|
||||
* @returns ⓘ 메뉴를 띄워도 되면 true
|
||||
*/
|
||||
export function isContextMenuAllowed(lockKind: SelectionLockKind): boolean {
|
||||
return lockKind === 'none' || lockKind === 'data_bound';
|
||||
// 판정은 `isEditableLockKind` 단일 지점 — 조건을 복사하면 한 곳만 빠져도 결함이 남는다.
|
||||
return isEditableLockKind(lockKind);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -580,8 +610,8 @@ export function useElementSelection(params: UseElementSelectionParams): UseEleme
|
||||
if (!node) return true;
|
||||
const ancestors = resolveAncestors(rootNode, path, pathParser);
|
||||
const kind = classifyLockKind(node, editMode, currentExtensionId, ancestors);
|
||||
// none = 편집 가능, data_bound = 편집 중 대상의 데이터 영역(선택 허용). 그 외는 잠금 → 차단.
|
||||
return kind === 'none' || kind === 'data_bound';
|
||||
// 판정은 `isEditableLockKind` 단일 지점 — 조건을 복사하면 한 곳만 빠져도 결함이 남는다.
|
||||
return isEditableLockKind(kind);
|
||||
},
|
||||
[rootNode, pathParser, editMode, currentExtensionId, editableRootPath]
|
||||
);
|
||||
|
||||
@@ -98,7 +98,23 @@ export const DEFAULT_CORE_PROP_KEYS: CorePropKey[] = ['id'];
|
||||
export function resolveCorePropKeys(coreProps: unknown): CorePropKey[] {
|
||||
if (coreProps === false) return [];
|
||||
if (Array.isArray(coreProps)) {
|
||||
return coreProps.filter((k): k is CorePropKey => k in CORE_PROP_CONTROLS);
|
||||
return coreProps.filter(
|
||||
(k): k is CorePropKey => k in CORE_PROP_CONTROLS && !DEDICATED_UI_KEYS.has(k as CorePropKey),
|
||||
);
|
||||
}
|
||||
return [...DEFAULT_CORE_PROP_KEYS];
|
||||
return DEFAULT_CORE_PROP_KEYS.filter((k) => !DEDICATED_UI_KEYS.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전용 UI(`IsolatedScopeControl`)가 SSoT 인 코어 속성 키 — `ControlRenderer` 경유 렌더 금지.
|
||||
*
|
||||
* 이 두 키는 「격리 영역」 그룹이 직접 렌더한다. 어떤 템플릿이든 capability 에
|
||||
* `coreProps:["id","isolatedState"]` 한 줄을 쓰면 **같은 키를 두 UI 가 쓰는 이중 경로**가
|
||||
* 생기고, 그 순간 값 타입이 갈린다 — `IsolatedScopeControl` 은 ON 시 `isolatedState = {}`
|
||||
* (빈 객체)를 쓰는데 `toggle` 위젯은 `true` 를 낸다. 렌더 목록 층에서만 제외하고
|
||||
* `CORE_PROP_CONTROLS` 의 정의 자체는 남긴다(SSoT 선언 유지).
|
||||
*/
|
||||
const DEDICATED_UI_KEYS: ReadonlySet<CorePropKey> = new Set<CorePropKey>([
|
||||
'isolatedState',
|
||||
'isolatedScopeId',
|
||||
]);
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from './styleScope';
|
||||
|
||||
/** apply 프리미티브 종류 */
|
||||
export type RecipeApplyType = 'classToken' | 'styleProp' | 'propValue' | 'cssVar';
|
||||
export type RecipeApplyType = 'classToken' | 'styleProp' | 'propValue' | 'cssVar' | 'nodeKey';
|
||||
|
||||
/**
|
||||
* 컨트롤/옵션의 `apply` 선언. 컨트롤 자체에 직접 두거나(`color`/`image`/`width`
|
||||
@@ -65,6 +65,13 @@ export interface RecipeApply {
|
||||
propKey?: string;
|
||||
/** cssVar — CSS 변수명 (`--brand-color` 등) */
|
||||
varName?: string;
|
||||
/**
|
||||
* nodeKey — 노드 **최상위** 키 이름(`dataKey`/`isolatedState` 등).
|
||||
*
|
||||
* `props` 가 아니라 노드 자신에 쓰는 구조키다. `propValue` 로 흘리면
|
||||
* `props.dataKey` 가 되어 런타임 엔진이 영영 읽지 않는다.
|
||||
*/
|
||||
nodeKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +117,16 @@ interface NormalizedControl {
|
||||
* 이전 방향 토큰 제거). buildGroupTokenMatcher 의 빈 prefix 전체 매칭은 배제한다.
|
||||
*/
|
||||
groupPrefixes?: string[];
|
||||
/**
|
||||
* 이 컨트롤이 쓰는 위젯 이름(`image`/`text`/`select` …).
|
||||
*
|
||||
* 엔진은 원칙적으로 위젯을 모르지만, **값 형태가 스칼라가 아닌 위젯**은 단일 값
|
||||
* 슬롯(`propValue`/`cssVar`/단일 `styleProp`)에 그대로 기록하면 `[object Object]`
|
||||
* 가 저장된다. 그 축약·역조립의 게이트로 쓰기 위해 위젯 이름을 보존한다.
|
||||
* **값 형태 sniffing 으로 대신할 수 없다** — `isImageValueObject` 는 4키 중 하나만
|
||||
* 있어도 참이라 `{position:'left'}` 같은 정당한 객체 prop 을 이미지로 오인한다.
|
||||
*/
|
||||
widget?: string;
|
||||
}
|
||||
|
||||
/** EditorControlSpec → 정규화 (작성자 자유 필드 보존하되 엔진이 쓰는 키만 추출) */
|
||||
@@ -117,6 +134,7 @@ function normalize(control: EditorControlSpec): NormalizedControl {
|
||||
const gp = (control as { groupPrefixes?: unknown }).groupPrefixes;
|
||||
return {
|
||||
group: typeof control.group === 'string' ? control.group : undefined,
|
||||
widget: typeof control.widget === 'string' ? control.widget : undefined,
|
||||
apply: (control.apply && typeof control.apply === 'object'
|
||||
? (control.apply as unknown as RecipeApply)
|
||||
: undefined) as RecipeApply | undefined,
|
||||
@@ -298,6 +316,34 @@ function isImageBundleProps(props: string[]): boolean {
|
||||
return props.includes('backgroundImage');
|
||||
}
|
||||
|
||||
/**
|
||||
* `image` 위젯 값 객체(`{ url, size, repeat, position }`)를 **단일 값 슬롯**
|
||||
* (`propValue` / `cssVar` / 단일 `styleProp`)에 쓸 스칼라로 축약한다.
|
||||
*
|
||||
* 단일 슬롯은 값을 하나만 담을 수 있으므로 `url` 만 살아남는다(size/repeat/position 은
|
||||
* 저장할 자리가 없다 — `ImagePickerControl` 이 단일 슬롯 컨트롤에서는 애초에 그 3필드를
|
||||
* 만들지 않는다).
|
||||
*
|
||||
* **호출 게이트는 `widget === 'image'` 이며 값 형태 sniffing 이 아니다.**
|
||||
* `isImageValueObject` 는 4키 중 **하나만** 있어도 참이므로, 값만 보고 게이트하면
|
||||
* `props.tooltip = { position: 'left' }` 같은 정당한 객체 prop 을 이미지로 오인해
|
||||
* 삭제한다. 그래서 이 함수 안의 `isImageValueObject` 는 2중 가드일 뿐이고, 1차
|
||||
* 게이트는 호출부의 위젯 판정이다.
|
||||
*
|
||||
* @param value 컨트롤이 만든 값
|
||||
* @param cssContext CSS 값 문맥(styleProp/cssVar)이면 true — `url(...)` 로 다시 감싼다
|
||||
* @return 이미지 값 객체가 아니면 value 그대로, 맞으면 축약된 스칼라
|
||||
*/
|
||||
function scalarizeImageValue(value: unknown, cssContext: boolean): unknown {
|
||||
if (!isImageValueObject(value)) return value;
|
||||
const raw = (value as Record<string, unknown>).url;
|
||||
// url 부재/빈 문자열/비-문자열 → '' 로 축약해 호출부의 **기존 빈값 삭제 술어**에 위임한다
|
||||
// (새 술어를 신설하지 않는다). "url 없이 모드만" 은 위젯이 표현할 수 없는 상태다.
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return '';
|
||||
const bare = unwrapCssUrl(raw);
|
||||
return cssContext ? wrapCssUrl(bare) : bare;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 손상값 정화 — 과거(수정 이전) 저장본은 각 background 속성에 image 값 객체가
|
||||
* 통째로 들어가 있을 수 있다(`backgroundPosition: { url, size, ... }`). 그런 객체/배열은
|
||||
@@ -342,11 +388,67 @@ function readImageObject(style: Record<string, unknown>): Record<string, unknown
|
||||
return out;
|
||||
}
|
||||
|
||||
/** styleProp 적용 — 단일/다중 CSS 속성 설정 (value=null/undefined 면 제거) */
|
||||
/**
|
||||
* `nodeKey` apply 가 절대 건드릴 수 없는 노드 구조키.
|
||||
*
|
||||
* 템플릿이 `nodeKey:"children"` 을 선언하면 노드가 파괴된다. 코어는 메커니즘이고
|
||||
* 방어는 코어 책임이다 — 여기서 막지 않으면 그 파괴는 예외도 경고도 없이 일어난다.
|
||||
*/
|
||||
const RESERVED_NODE_KEYS: ReadonlySet<string> = new Set([
|
||||
'type',
|
||||
'name',
|
||||
'props',
|
||||
'children',
|
||||
'text',
|
||||
'if',
|
||||
'iteration',
|
||||
'responsive',
|
||||
'__source',
|
||||
'__injectedProps',
|
||||
]);
|
||||
|
||||
/**
|
||||
* nodeKey 적용 — 노드 **최상위**(`node[key]`)에 값을 쓴다(불변, 새 사본 반환).
|
||||
*
|
||||
* `props`/`style` 컨테이너를 다루지 않으므로 `withScopedProps` 를 경유하지 **않는다** —
|
||||
* 그 함수는 mutator 에 `(props, style)` 만 넘기고 `{...node, props}` 로 재조립하므로
|
||||
* 노드 최상위 임의 키를 만질 방법이 없고, 빈 props 프루닝 로직도 nodeKey 에는 개념이
|
||||
* 없다. `setScopedIf`(styleScope) 가 `node.if` 라는 최상위 키를 별도 함수로 다루는
|
||||
* 선례를 그대로 따른다.
|
||||
*
|
||||
* **디바이스(breakpoint) 축은 무시한다** — 런타임 `DynamicRenderer` 의 responsive 병합은
|
||||
* `props`/`children`/`text`/`if`/`iteration` 5키만 본다. `responsive.mobile.dataKey` 는
|
||||
* 런타임이 영원히 읽지 않는 죽은 데이터이므로 항상 최상위에 쓴다. bp≠base 에서 no-op 으로
|
||||
* 만드는 대안은 "모바일 탭에서 입력했는데 아무 일도 안 일어남" 이라는 또 다른 무음 결함이다.
|
||||
*
|
||||
* @param node 대상 노드 (변경되지 않음)
|
||||
* @param apply nodeKey apply 선언
|
||||
* @param value 쓸 값. 빈값(undefined|null|'')이면 키 삭제
|
||||
* @return 패치된 노드 사본. 예약키/비-문자열 nodeKey 는 no-op(원본 반환)
|
||||
*/
|
||||
function applyNodeKey(node: EditorNode, apply: RecipeApply, value: unknown): EditorNode {
|
||||
const key = apply.nodeKey;
|
||||
if (typeof key !== 'string' || key === '' || RESERVED_NODE_KEYS.has(key)) return node;
|
||||
const next = { ...node } as EditorNode & Record<string, unknown>;
|
||||
if (value === undefined || value === null || value === '') {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* styleProp 적용 — 단일/다중 CSS 속성 설정 (value=null/undefined 면 제거)
|
||||
*
|
||||
* @param imageWidget 컨트롤 위젯이 `image` 인지 — 단일 `apply.prop` 경로에서 값 객체를
|
||||
* 축약할지 결정한다. 다중 `apply.props`(배경 묶음) 경로는 무영향.
|
||||
*/
|
||||
function applyStyleProp(
|
||||
style: Record<string, unknown>,
|
||||
apply: RecipeApply,
|
||||
value: unknown,
|
||||
imageWidget = false,
|
||||
): void {
|
||||
const setOne = (prop: string, v: unknown): void => {
|
||||
if (v === undefined || v === null || v === '') {
|
||||
@@ -387,14 +489,27 @@ function applyStyleProp(
|
||||
}
|
||||
|
||||
if (typeof apply.prop === 'string') {
|
||||
setOne(apply.prop, apply.value !== undefined ? apply.value : value);
|
||||
// 단일 값 슬롯 — 고정값(apply.value) 선택 **이후**에 축약한다. 정규화 지점이 한 곳으로
|
||||
// 수렴하고, 작성자가 `apply.value: {url:…}` 를 실수로 선언해도 같은 규칙으로 구제된다.
|
||||
const raw = apply.value !== undefined ? apply.value : value;
|
||||
setOne(apply.prop, imageWidget ? scalarizeImageValue(raw, true) : raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** cssVar 적용 — props.style 에 `--var: value` 설정 */
|
||||
function applyCssVar(style: Record<string, unknown>, apply: RecipeApply, value: unknown): void {
|
||||
/**
|
||||
* cssVar 적용 — props.style 에 `--var: value` 설정
|
||||
*
|
||||
* @param imageWidget 컨트롤 위젯이 `image` 인지 — 값 객체를 CSS `url(...)` 스칼라로 축약
|
||||
*/
|
||||
function applyCssVar(
|
||||
style: Record<string, unknown>,
|
||||
apply: RecipeApply,
|
||||
value: unknown,
|
||||
imageWidget = false,
|
||||
): void {
|
||||
if (typeof apply.varName !== 'string') return;
|
||||
const v = apply.value !== undefined ? apply.value : value;
|
||||
const raw = apply.value !== undefined ? apply.value : value;
|
||||
const v = imageWidget ? scalarizeImageValue(raw, true) : raw;
|
||||
if (v === undefined || v === null || v === '') {
|
||||
delete style[apply.varName];
|
||||
} else {
|
||||
@@ -402,14 +517,22 @@ function applyCssVar(style: Record<string, unknown>, apply: RecipeApply, value:
|
||||
}
|
||||
}
|
||||
|
||||
/** propValue 적용 — props[key] 설정 */
|
||||
/**
|
||||
* propValue 적용 — props[key] 설정
|
||||
*
|
||||
* @param imageWidget 컨트롤 위젯이 `image` 인지 — 값 객체를 **맨 url 문자열**로 축약한다.
|
||||
* 이 값은 `<Img src={…}>` 같은 컴포넌트 prop 으로 흐르므로 `url(...)`
|
||||
* 래핑을 반드시 벗긴다(CSS 문맥이 아니다).
|
||||
*/
|
||||
function applyPropValue(
|
||||
props: Record<string, unknown>,
|
||||
apply: RecipeApply,
|
||||
value: unknown,
|
||||
imageWidget = false,
|
||||
): void {
|
||||
if (typeof apply.propKey !== 'string') return;
|
||||
const v = apply.value !== undefined ? apply.value : value;
|
||||
const raw = apply.value !== undefined ? apply.value : value;
|
||||
const v = imageWidget ? scalarizeImageValue(raw, false) : raw;
|
||||
if (v === undefined || v === null || v === '') {
|
||||
delete props[apply.propKey];
|
||||
} else {
|
||||
@@ -490,6 +613,12 @@ export function applyRecipe(
|
||||
return node;
|
||||
}
|
||||
|
||||
// nodeKey — 노드 최상위 구조키. props/style 컨테이너를 다루지 않으므로 withScopedProps
|
||||
// 앞에서 처리한다(scope 컨테이너 개념이 없다). 다크는 위 short-circuit 으로 도달 불가.
|
||||
if (effectiveApply?.type === 'nodeKey') {
|
||||
return applyNodeKey(node, effectiveApply, clearGroup ? undefined : value);
|
||||
}
|
||||
|
||||
// B안 className 시드.
|
||||
//
|
||||
// DynamicRenderer 의 responsive 머지는 props 얕은 머지(`{...base.props, ...override.props}`)
|
||||
@@ -536,19 +665,22 @@ export function applyRecipe(
|
||||
}
|
||||
}
|
||||
} else if (apply) {
|
||||
// 비-스칼라 값을 내보내는 위젯 — 단일 값 슬롯에 그대로 쓰면 `[object Object]` 가
|
||||
// 저장된다(공개 #135). 게이트는 위젯 이름이며 값 형태 sniffing 이 아니다.
|
||||
const imageWidget = normalized.widget === 'image';
|
||||
switch (apply.type) {
|
||||
case 'classToken':
|
||||
applyClassToken(props, normalized, apply, value, dark);
|
||||
break;
|
||||
case 'styleProp':
|
||||
// 다크는 위 short-circuit 으로 도달 불가 (인라인 no-op)
|
||||
applyStyleProp(style, apply, value);
|
||||
applyStyleProp(style, apply, value, imageWidget);
|
||||
break;
|
||||
case 'cssVar':
|
||||
applyCssVar(style, apply, value);
|
||||
applyCssVar(style, apply, value, imageWidget);
|
||||
break;
|
||||
case 'propValue':
|
||||
applyPropValue(props, apply, value);
|
||||
applyPropValue(props, apply, value, imageWidget);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -720,9 +852,19 @@ function resolveFromContainer(
|
||||
case 'propValue': {
|
||||
if (!apply.propKey) return { value: undefined, matched: false };
|
||||
const v = props[apply.propKey];
|
||||
return v === undefined
|
||||
? { value: undefined, matched: false }
|
||||
: { value: v, matched: true };
|
||||
if (v === undefined) return { value: undefined, matched: false };
|
||||
// image 위젯은 객체(`{url,…}`)를 이해한다 — 축약 저장된 문자열을 그 형태로 되감아야
|
||||
// 위젯이 현재 값을 읽을 수 있다(쓰기 4.1 과 대칭). `apply.type` 만으로는 판정 불가하고
|
||||
// (propValue 는 text/toggle/icon-picker 가 대부분 쓴다), 값이 평범한 문자열이라 형태
|
||||
// sniffing 도 불가하다 — **widget 이 유일한 신호다.**
|
||||
//
|
||||
// 표현식 문자열(`{{…}}`)도 감싼다. 감싸지 않으면 위젯이 빈 피커로 보이고 운영자가
|
||||
// 업로드 1클릭에 그 표현식을 소리 없이 잃는다. 깨진 미리보기 억제와 실수 덮어쓰기
|
||||
// 방지는 미리보기·업로드 UI 를 소유한 위젯이 맡는다(ImagePickerControl).
|
||||
if (normalized.widget === 'image' && typeof v === 'string') {
|
||||
return { value: { url: unwrapCssUrl(v) }, matched: true };
|
||||
}
|
||||
return { value: v, matched: true };
|
||||
}
|
||||
case 'classToken': {
|
||||
const tokens = scopedClassTokens(props.className, dark);
|
||||
@@ -790,6 +932,18 @@ export function reverseResolve(
|
||||
return { value: undefined, matched: false, darkReadonly: true };
|
||||
}
|
||||
|
||||
// nodeKey — 노드 최상위에서 읽는다(쓰기와 대칭). scope 컨테이너 개념이 없으므로
|
||||
// scopedValue/baseFallback 을 부여하지 않는다 — 디바이스 탭에서 placeholder 흐림이
|
||||
// 생기면 "그 디바이스에만 값이 없다" 는 거짓 정보가 된다.
|
||||
if (normalized.apply?.type === 'nodeKey') {
|
||||
const key = normalized.apply.nodeKey;
|
||||
if (typeof key !== 'string' || key === '' || RESERVED_NODE_KEYS.has(key)) {
|
||||
return { value: undefined, matched: false };
|
||||
}
|
||||
const v = (node as EditorNode & Record<string, unknown>)[key];
|
||||
return v === undefined ? { value: undefined, matched: false } : { value: v, matched: true };
|
||||
}
|
||||
|
||||
// scope 컨테이너에서 역해석
|
||||
const scopedProps = getScopedProps(node, scope);
|
||||
const scopedStyle = styleOf(scopedProps);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SelectWidget,
|
||||
ToggleWidget,
|
||||
DimensionWidget,
|
||||
NumberWidget,
|
||||
SpacingWidget,
|
||||
} from '../components/property-controls/StyleControlWidgets';
|
||||
import { ColorPickerControl } from '../components/property-controls/ColorPickerControl';
|
||||
@@ -46,6 +47,10 @@ export function registerCoreWidgets(): void {
|
||||
registerWidget('slider', SliderWidget);
|
||||
registerWidget('select', SelectWidget);
|
||||
registerWidget('dimension', DimensionWidget);
|
||||
// 숫자 prop(propValue) 편집 위젯. 종전 미등록이라 `widget:"number"` 컨트롤
|
||||
// (예 Header 「탭 표시 게시판 수」)이 속성 모달에서 "지원하지 않는 컨트롤" 로 폴백돼
|
||||
// 편집 자체가 불가했다. `number` 타입을 직접 내보내 컴포넌트 prop 계약을 만족시킨다.
|
||||
registerWidget('number', NumberWidget);
|
||||
registerWidget('spacing', SpacingWidget);
|
||||
registerWidget('toggle', ToggleWidget);
|
||||
registerWidget('color', ColorPickerControl);
|
||||
|
||||
@@ -328,6 +328,20 @@ export interface EditorControlSpec {
|
||||
iconColumns?: number;
|
||||
/** 검색 입력 placeholder — `$t:...` 키 */
|
||||
iconSearchPlaceholder?: string;
|
||||
/**
|
||||
* `number` 위젯이 입력칸에 그대로 실어 보내는 HTML 입력 제약 선언.
|
||||
*
|
||||
* 그 입력칸은 `type="text"`(비숫자 입력을 원문 그대로 쥐고 있어야 미방출 정책이
|
||||
* 성립한다) 이므로 **브라우저 검증도 스피너도 동작하지 않는다** — 이 세 필드는 값을
|
||||
* 제한하는 장치가 아니라 작성자 의도의 선언이다.
|
||||
*
|
||||
* 위젯은 이 값으로 **클램프하지 않는다** — 사용자가 넣은 값은 보존한다(조용한 데이터
|
||||
* 변조 금지). 아래 세 필드는 index signature 가 이미 허용하므로 하위호환 100% 이고,
|
||||
* 명시 선언은 발견성만 개선한다.
|
||||
*/
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
/** 작성자 자유 필드 — 코어는 widget/apply 외 필드를 그대로 보존만 한다 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -450,6 +450,39 @@ function asResponsiveChildren(node: EditorNode, key: string): EditorNode[] {
|
||||
return branch && Array.isArray(branch.children) ? (branch.children as EditorNode[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* path 의 조상 노드 배열 (루트의 **자식**부터 부모까지, 자기 자신·루트 제외).
|
||||
*
|
||||
* `classifyLockKind` 의 `ancestors` 입력 SSoT. responsive 세그먼트는 노드를 내리지 않고
|
||||
* childArray 만 분기로 전환하므로 조상 목록에 추가하지 않는다.
|
||||
*
|
||||
* 종전에는 `useCanvasDnd` · `DndCanvasLayer` 가 바이트 동일 사본을 각자 들고 있었다 —
|
||||
* 잠금 판정의 입력이 갈리면 "핸들은 있는데 드래그는 거부" 같은 어긋남이 조용히 생긴다.
|
||||
* (`useElementSelection.resolveAncestors` 는 **루트를 포함**하는 다른 계약이라 별개다.)
|
||||
*
|
||||
* @param root 루트 노드
|
||||
* @param path 대상 노드의 인덱스 경로
|
||||
* @returns 조상 노드 배열 (루트 자식 → 부모 순)
|
||||
*/
|
||||
export function collectAncestors(root: EditorNode, path: ComponentPath): EditorNode[] {
|
||||
const out: EditorNode[] = [];
|
||||
let current: EditorNode = root;
|
||||
let childArray: EditorNode[] = asChildrenArray(root);
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const seg = path[i]!;
|
||||
if (isResponsiveSegment(seg)) {
|
||||
childArray = asResponsiveChildren(current, seg.responsive);
|
||||
continue;
|
||||
}
|
||||
const next = childArray[seg] ?? null;
|
||||
if (!next) break;
|
||||
out.push(next);
|
||||
current = next;
|
||||
childArray = asChildrenArray(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로로 노드 조회. 경로가 유효하지 않으면 null.
|
||||
*
|
||||
|
||||
@@ -160,7 +160,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 0개 | — |
|
||||
| Vitest | 147개 | `vitest.config.ts` |
|
||||
| Vitest | 148개 | `vitest.config.ts` |
|
||||
| Playwright | 8개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 4개 | `tests/scenarios` |
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
### Fixed
|
||||
|
||||
- 로그인 시도 초과로 계정이 잠겼을 때 해제 시각이 화면에 표시되지 않던 문제를 수정했습니다. 언제 다시 시도할 수 있는지 알 수 없어 계속 눌러 보게 되었습니다.
|
||||
- 화면 편집기로 지정한 로고가 깨져 보이던 문제를 수정했습니다. 이미지 주소 자리에 알 수 없는 값이 저장된 사이트에서도 이제 그림이 정상 표시되며, 주소를 해석할 수 없으면 이미지 대신 사이트 이름이 표시됩니다. (#135 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
|
||||
## [1.1.3] - 2026-09-06
|
||||
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
@@ -2,18 +2,50 @@ import React from 'react';
|
||||
|
||||
export interface ImgProps extends React.ImgHTMLAttributes<HTMLImageElement> {}
|
||||
|
||||
/**
|
||||
* `image` 위젯 값 객체의 키 집합 — 레이아웃 편집기가 만들던 `{url,size,repeat,position}`.
|
||||
*
|
||||
* 편집기는 이제 단일 값 슬롯에 url 문자열만 기록하고(코어 변환 계층), 이미 잘못 저장된
|
||||
* 레이아웃은 업그레이드 스텝이 정정한다. 이 상수는 **업그레이드 전 화면**을 위한
|
||||
* 런타임 방어이며, 판정 강도를 백필과 **완전히 같게** 유지한다 — 두 방어선의 기준이
|
||||
* 어긋나면 한쪽만 통과하는 값이 생긴다.
|
||||
*/
|
||||
const IMAGE_VALUE_KEYS = ['url', 'size', 'repeat', 'position'] as const;
|
||||
|
||||
/**
|
||||
* `src` 로 들어온 값에서 실제 이미지 URL 을 해석한다.
|
||||
*
|
||||
* 문자열이면 그대로(회귀 0). 이미지 값 객체(키 집합 ⊆ 4키 **AND** `url` 보유)면 그 url.
|
||||
* 그 외(배열·숫자·불리언·4키 밖 키를 가진 객체·비문자열 url)는 `undefined` 를 돌려주어
|
||||
* `src` 속성을 **아예 붙이지 않는다** — `src="[object Object]"`(엑박)도
|
||||
* `src=""`(현재 문서 재요청)도 만들지 않는다.
|
||||
*
|
||||
* `ImgProps` 를 넓히지 않는다(`src?: string` 계약 유지) — export 하면 그 자체가 공개
|
||||
* 계약이 되어 느슨한 타입이 다시 새어 나간다.
|
||||
*/
|
||||
function resolveImageSrc(src: unknown): string | undefined {
|
||||
if (typeof src === 'string') return src === '' ? undefined : src;
|
||||
if (!src || typeof src !== 'object' || Array.isArray(src)) return undefined;
|
||||
const keys = Object.keys(src as Record<string, unknown>);
|
||||
if (!keys.every((k) => (IMAGE_VALUE_KEYS as readonly string[]).includes(k))) return undefined;
|
||||
const url = (src as { url?: unknown }).url;
|
||||
return typeof url === 'string' && url !== '' ? url : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 이미지 컴포넌트
|
||||
*/
|
||||
export const Img: React.FC<ImgProps> = ({
|
||||
className = '',
|
||||
alt = '',
|
||||
src,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
alt={alt}
|
||||
src={resolveImageSrc(src as unknown)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// @scenario apply_type=propValue, consumer=img_src, storage_scope=template_layouts, stored_shape=string, widget_output=scalar_string
|
||||
// @scenario apply_type=propValue, consumer=mobilenav_logo, storage_scope=template_layouts, stored_shape=absent, widget_output=image_object
|
||||
/**
|
||||
* Img.test.tsx — `src` 값 형태 방어 (공개 #135 런타임 방어선)
|
||||
*
|
||||
* 레이아웃 편집기의 `image` 위젯은 `{url,size,repeat,position}` 객체를 내보내는데,
|
||||
* 그 값이 `propValue` 로 컴포넌트 prop 에 그대로 기록되던 시기가 있었다. 그렇게 저장된
|
||||
* 레이아웃은 `<Img src={객체}>` 가 되어 브라우저가 `[object Object]` 를 URL 로 해석했고,
|
||||
* SPA catch-all 때문에 404 조차 아니라 200(HTML)을 받아 **엑박만 남고 아무 오류도
|
||||
* 남지 않았다.**
|
||||
*
|
||||
* 편집기·백필이 고쳐졌어도 업그레이드 전 화면은 그대로이므로 이 방어가 필요하다.
|
||||
* 판정 강도는 백필 마이그레이션과 **완전히 같다**(키 집합 ⊆ 4키 AND url 문자열) —
|
||||
* 두 방어선의 기준이 어긋나면 한쪽만 통과하는 값이 생긴다.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Img } from '../Img';
|
||||
|
||||
/** 렌더된 <img> 의 src 속성 원문(미부착이면 null) */
|
||||
function srcAttr(container: HTMLElement): string | null {
|
||||
const img = container.querySelector('img');
|
||||
expect(img).toBeTruthy();
|
||||
return img!.getAttribute('src');
|
||||
}
|
||||
|
||||
describe('Img — src 값 형태 방어', () => {
|
||||
/** @effects img_string_src_passes_through_unchanged */
|
||||
it('1 정상 문자열은 그대로 통과한다 (회귀 0)', () => {
|
||||
const { container } = render(<Img src="/img/logo.png" />);
|
||||
expect(srcAttr(container)).toBe('/img/logo.png');
|
||||
});
|
||||
|
||||
/** @effects img_corrupted_value_attaches_no_src_neither_object_object_nor_empty_string */
|
||||
it('2 빈 문자열은 src 를 붙이지 않는다 (현재 문서 재요청 차단)', () => {
|
||||
const { container } = render(<Img src="" />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('3 src 미전달도 예외 없이 미부착', () => {
|
||||
const { container } = render(<Img alt="x" />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
/** @effects img_image_value_object_resolves_to_its_url */
|
||||
it('4 이미지 값 객체(4키 전부) → url 만 채택', () => {
|
||||
const value = {
|
||||
url: '/api/attachment/X',
|
||||
size: 'cover',
|
||||
repeat: 'no-repeat',
|
||||
position: 'center',
|
||||
};
|
||||
const { container } = render(<Img src={value as never} />);
|
||||
expect(srcAttr(container)).toBe('/api/attachment/X');
|
||||
});
|
||||
|
||||
it('5 부분 키(url 만) 객체도 채택', () => {
|
||||
const { container } = render(<Img src={{ url: '/a.png' } as never} />);
|
||||
expect(srcAttr(container)).toBe('/a.png');
|
||||
});
|
||||
|
||||
it('6 url 이 빈 문자열이면 미부착 — [object Object] 도 빈 문자열도 아니다', () => {
|
||||
const { container } = render(
|
||||
<Img src={{ url: '', size: 'cover', repeat: 'no-repeat', position: 'center' } as never} />,
|
||||
);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('7 url 키가 없는 객체는 미부착 (정상 props 와 정적 구분 불가 → 건드리지 않는다)', () => {
|
||||
const { container } = render(
|
||||
<Img src={{ size: 'cover', repeat: 'no-repeat', position: 'center' } as never} />,
|
||||
);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('8 url 이 비문자열이면 미부착', () => {
|
||||
const { container } = render(<Img src={{ url: { deep: 1 }, size: 'cover' } as never} />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
/** @effects img_object_with_key_outside_the_four_is_rejected_same_strictness_as_backfill */
|
||||
it('9 4키 밖 키가 섞이면 미부착 — 백필과 동일 엄격도', () => {
|
||||
const { container } = render(<Img src={{ url: '/a.png', label: 'x' } as never} />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('10 배열은 미부착', () => {
|
||||
const { container } = render(<Img src={['/a.png'] as never} />);
|
||||
expect(srcAttr(container)).toBeNull();
|
||||
});
|
||||
|
||||
it('11 숫자·불리언은 예외 없이 미부착', () => {
|
||||
expect(srcAttr(render(<Img src={42 as never} />).container)).toBeNull();
|
||||
expect(srcAttr(render(<Img src={true as never} />).container)).toBeNull();
|
||||
});
|
||||
|
||||
/** @effects img_other_attributes_pass_through_spread_regression_zero */
|
||||
it('12 다른 속성은 그대로 통과한다 (스프레드 회귀 0)', () => {
|
||||
const { container } = render(
|
||||
<Img src="/a.png" alt="로고" className="h-8" loading="lazy" width={32} />,
|
||||
);
|
||||
const img = container.querySelector('img')!;
|
||||
expect(img.getAttribute('src')).toBe('/a.png');
|
||||
expect(img.getAttribute('alt')).toBe('로고');
|
||||
expect(img.getAttribute('class')).toBe('h-8');
|
||||
expect(img.getAttribute('loading')).toBe('lazy');
|
||||
expect(img.getAttribute('width')).toBe('32');
|
||||
});
|
||||
|
||||
it('13 alt 기본값은 빈 문자열로 유지된다', () => {
|
||||
const { container } = render(<Img src="/a.png" />);
|
||||
expect(container.querySelector('img')!.getAttribute('alt')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,698 @@
|
||||
<?php
|
||||
|
||||
// @scenario apply_type=propValue, consumer=header_logo, storage_scope=layout_previews_untouched, stored_shape=binding_expression, widget_output=scalar_string
|
||||
// @scenario apply_type=propValue, consumer=img_src, storage_scope=layout_previews_untouched, stored_shape=object, widget_output=image_object_nonstring_url
|
||||
// @scenario apply_type=propValue, consumer=none, storage_scope=layout_previews_untouched, stored_shape=string, widget_output=image_object
|
||||
|
||||
namespace Tests\Feature\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Upgrades\Data\V7_0_11\Migrations\NarrowImageObjectPropsInLayouts;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery\MockInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 7.0.11 업그레이드 스텝 — prop 자리 이미지 값 객체 축약 백필 검증.
|
||||
*
|
||||
* 레이아웃 편집기의 `image` 위젯이 내보내는 `{url,size,repeat,position}` 객체가
|
||||
* `propValue` 경로로 `props[key]` 에 그대로 기록되어 소비 컴포넌트가
|
||||
* `<Img src={객체}>` 를 받던 결함(공개 #135)의 기존 데이터 보정.
|
||||
*
|
||||
* 검증 축은 셋이다:
|
||||
* ① **판정식 엄격도** — 느슨한 판정식(4키 중 하나라도)을 쓰면 정상 props 를 파괴한다.
|
||||
* 케이스 9·10·11·14 가 그 반증 가드다.
|
||||
* ② **순회 범위** — prop 자리는 키 allowlist 로 정의 불가하므로 모드 플래그 전역 재귀.
|
||||
* 중첩 노드·responsive 분기·확장 injections 가 포섭되고 style 자리는 배제된다.
|
||||
* ③ **안전성** — 멱등 / 무변경 행 UPDATE 금지 / chunkById / lock_version /
|
||||
* original_content_hash 불변 / 버전·미리보기 스냅샷 미개입.
|
||||
*/
|
||||
class Upgrade7011ImageObjectPropBackfillTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** 청크 크기(100) 를 넘겨야 OFFSET 누락이 드러난다 — Beta2LayoutHashBackfillTest 관례 계승 */
|
||||
private const SEED_COUNT = 250;
|
||||
|
||||
private NarrowImageObjectPropsInLayouts $migration;
|
||||
|
||||
private UpgradeContext $context;
|
||||
|
||||
private int $templateId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// upgrade data 파일은 composer autoload 대상이 아니며 AbstractUpgradeStep 이
|
||||
// 실행 시점에 require_once 로 수동 로드한다. 테스트에서도 동일하게 수동 로드.
|
||||
require_once base_path('upgrades/data/7.0.11/migrations/01_NarrowImageObjectPropsInLayouts.php');
|
||||
|
||||
$this->migration = new NarrowImageObjectPropsInLayouts;
|
||||
$this->context = new UpgradeContext(
|
||||
fromVersion: '7.0.10',
|
||||
toVersion: '7.0.11',
|
||||
currentStep: '7.0.11',
|
||||
);
|
||||
|
||||
$this->templateId = DB::table('templates')->insertGetId([
|
||||
'identifier' => 'test-imgobj_template',
|
||||
'vendor' => 'test',
|
||||
'name' => json_encode(['ko' => '이미지 축약 테스트', 'en' => 'Image Narrow Test']),
|
||||
'version' => '1.0.0',
|
||||
'type' => 'user',
|
||||
'status' => 'active',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 완전한 4키 이미지 값 객체 */
|
||||
private function imageValue(string $url = '/api/attachment/X'): array
|
||||
{
|
||||
return ['url' => $url, 'size' => 'cover', 'repeat' => 'no-repeat', 'position' => 'center'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 1건을 시드하고 id 를 돌려줍니다.
|
||||
*
|
||||
* @param array $content 레이아웃 content 배열
|
||||
* @param array $extra 추가 컬럼 오버라이드
|
||||
* @return int 생성된 레이아웃 id
|
||||
*/
|
||||
private function seedLayout(array $content, array $extra = []): int
|
||||
{
|
||||
static $seq = 0;
|
||||
$seq++;
|
||||
|
||||
return DB::table('template_layouts')->insertGetId(array_merge([
|
||||
'template_id' => $this->templateId,
|
||||
'name' => 'imgobj/layout_'.$seq,
|
||||
'content' => json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'source_type' => 'template',
|
||||
'source_identifier' => 'test-imgobj_template',
|
||||
'lock_version' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], $extra));
|
||||
}
|
||||
|
||||
/** 저장된 content 를 배열로 되읽는다 */
|
||||
private function readContent(int $id, string $table = 'template_layouts'): array
|
||||
{
|
||||
return json_decode(DB::table($table)->where('id', $id)->value('content'), true);
|
||||
}
|
||||
|
||||
private function runMigration(): void
|
||||
{
|
||||
$this->migration->run($this->context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로거를 감시 대역으로 바꾼 컨텍스트로 마이그레이션을 실행합니다.
|
||||
*
|
||||
* 파괴적 동작(prop 키 제거)의 유일한 흔적은 `upgrade` 채널 로그다 — 그 호출이
|
||||
* 사라져도 데이터 단언만으로는 드러나지 않으므로 로그 발생 자체를 계약으로 고정한다.
|
||||
* `UpgradeContext` 는 생성자에서 `Log::channel()` 을 1회 해석하므로 페이크를
|
||||
* **컨텍스트 생성 전에** 심어야 한다.
|
||||
*
|
||||
* @return MockInterface 감시 대역 로거
|
||||
*/
|
||||
private function runMigrationWithLogSpy(): MockInterface
|
||||
{
|
||||
$spy = \Mockery::spy(LoggerInterface::class);
|
||||
Log::shouldReceive('channel')
|
||||
->with('upgrade')
|
||||
->andReturn($spy);
|
||||
|
||||
$this->migration->run(new UpgradeContext(
|
||||
fromVersion: '7.0.10',
|
||||
toVersion: '7.0.11',
|
||||
currentStep: '7.0.11',
|
||||
));
|
||||
|
||||
return $spy;
|
||||
}
|
||||
|
||||
// ── ① 기본 축약 ────────────────────────────────────────────────────────
|
||||
|
||||
/** @effects backfill_narrows_prop_slot_objects_in_layouts_and_extensions */
|
||||
public function test_1_prop_slot_image_object_is_narrowed_to_url(): void
|
||||
{
|
||||
$id = $this->seedLayout([
|
||||
'components' => [
|
||||
['name' => 'Header', 'props' => ['logo' => $this->imageValue(), 'siteName' => '샘플']],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$content = $this->readContent($id);
|
||||
$this->assertSame('/api/attachment/X', $content['components'][0]['props']['logo']);
|
||||
$this->assertSame('샘플', $content['components'][0]['props']['siteName'], '나머지 props 는 불변이어야 합니다.');
|
||||
}
|
||||
|
||||
public function test_2_string_value_is_untouched_and_row_is_not_updated(): void
|
||||
{
|
||||
$id = $this->seedLayout([
|
||||
'components' => [['name' => 'Header', 'props' => ['logo' => '/img/a.png']]],
|
||||
]);
|
||||
$before = DB::table('template_layouts')->where('id', $id)->first();
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$after = DB::table('template_layouts')->where('id', $id)->first();
|
||||
$this->assertSame($before->content, $after->content);
|
||||
$this->assertSame($before->updated_at, $after->updated_at, '무변경 행은 UPDATE 되지 않아야 합니다.');
|
||||
$this->assertSame($before->lock_version, $after->lock_version);
|
||||
}
|
||||
|
||||
public function test_3_binding_expression_string_is_untouched(): void
|
||||
{
|
||||
$expr = '{{_global.settings?.general?.site_logo_url}}';
|
||||
$id = $this->seedLayout([
|
||||
'components' => [['name' => 'Header', 'props' => ['logo' => $expr]]],
|
||||
]);
|
||||
$before = DB::table('template_layouts')->where('id', $id)->first();
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$after = DB::table('template_layouts')->where('id', $id)->first();
|
||||
$this->assertSame($expr, $this->readContent($id)['components'][0]['props']['logo']);
|
||||
$this->assertSame($before->updated_at, $after->updated_at);
|
||||
}
|
||||
|
||||
// ── ② style 자리는 대상이 아니다 ───────────────────────────────────────
|
||||
|
||||
/** @effects backfill_excludes_style_slot_where_background_decomposition_lives */
|
||||
public function test_4_style_background_four_props_are_untouched(): void
|
||||
{
|
||||
$style = [
|
||||
'backgroundImage' => 'url(/a.png)',
|
||||
'backgroundSize' => 'cover',
|
||||
'backgroundRepeat' => 'no-repeat',
|
||||
'backgroundPosition' => 'center',
|
||||
];
|
||||
$id = $this->seedLayout([
|
||||
'components' => [['name' => 'Div', 'props' => ['style' => $style]]],
|
||||
]);
|
||||
$before = DB::table('template_layouts')->where('id', $id)->first();
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame($style, $this->readContent($id)['components'][0]['props']['style']);
|
||||
$this->assertSame($before->updated_at, DB::table('template_layouts')->where('id', $id)->first()->updated_at);
|
||||
}
|
||||
|
||||
public function test_5_corrupted_value_inside_style_slot_is_untouched(): void
|
||||
{
|
||||
// style 자리의 손상값 복구는 엔진 `sanitizeBgScalar` 소관 — 백필은 건드리지 않는다.
|
||||
$style = ['backgroundPosition' => $this->imageValue()];
|
||||
$id = $this->seedLayout([
|
||||
'components' => [['name' => 'Div', 'props' => ['style' => $style]]],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame($style, $this->readContent($id)['components'][0]['props']['style']);
|
||||
}
|
||||
|
||||
// ── ③ 순회 범위 ────────────────────────────────────────────────────────
|
||||
|
||||
/** @effects backfill_traverses_responsive_branches_nested_nodes_and_injections */
|
||||
public function test_6_responsive_branch_props_are_narrowed(): void
|
||||
{
|
||||
$id = $this->seedLayout([
|
||||
'components' => [
|
||||
[
|
||||
'name' => 'Header',
|
||||
'responsive' => ['md' => ['props' => ['logo' => $this->imageValue('/md.png')]]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame('/md.png', $this->readContent($id)['components'][0]['responsive']['md']['props']['logo']);
|
||||
}
|
||||
|
||||
public function test_7_nested_node_inside_props_is_narrowed(): void
|
||||
{
|
||||
$id = $this->seedLayout([
|
||||
'components' => [
|
||||
[
|
||||
'name' => 'DataGrid',
|
||||
'props' => [
|
||||
'cardColumns' => [
|
||||
['cellChildren' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/nested.png')]]]],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$content = $this->readContent($id);
|
||||
$this->assertSame(
|
||||
'/nested.png',
|
||||
$content['components'][0]['props']['cardColumns'][0]['cellChildren'][0]['props']['logo']
|
||||
);
|
||||
}
|
||||
|
||||
public function test_8_style_inside_nested_node_is_still_excluded(): void
|
||||
{
|
||||
$style = ['backgroundImage' => 'url(/n.png)', 'backgroundSize' => 'cover'];
|
||||
$id = $this->seedLayout([
|
||||
'components' => [
|
||||
[
|
||||
'name' => 'Div',
|
||||
'props' => [
|
||||
'expandChildren' => [['name' => 'Div', 'props' => ['style' => $style]]],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$content = $this->readContent($id);
|
||||
$this->assertSame($style, $content['components'][0]['props']['expandChildren'][0]['props']['style']);
|
||||
}
|
||||
|
||||
// ── ④ 판정식 엄격도 (느슨한 판정식이면 여기서 파괴된다) ───────────────
|
||||
|
||||
/** @effects backfill_strict_predicate_leaves_normal_props_untouched */
|
||||
public function test_9_normal_props_with_size_key_are_untouched(): void
|
||||
{
|
||||
// 실측 674건 패턴. 코어 엔진의 느슨한 판정식(4키 중 하나라도)이면 이 props 가
|
||||
// 이미지 값 객체로 오인되어 파괴된다.
|
||||
$props = ['className' => 'x', 'name' => 'user', 'size' => 'lg'];
|
||||
$id = $this->seedLayout(['components' => [['name' => 'Icon', 'props' => $props]]]);
|
||||
$before = DB::table('template_layouts')->where('id', $id)->first();
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame($props, $this->readContent($id)['components'][0]['props']);
|
||||
$this->assertSame($before->updated_at, DB::table('template_layouts')->where('id', $id)->first()->updated_at);
|
||||
}
|
||||
|
||||
public function test_10_normal_props_with_position_key_are_untouched(): void
|
||||
{
|
||||
// 실측 26건 패턴.
|
||||
$props = ['items' => [['label' => 'a']], 'position' => 'bottom'];
|
||||
$id = $this->seedLayout(['components' => [['name' => 'Toast', 'props' => $props]]]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame($props, $this->readContent($id)['components'][0]['props']);
|
||||
}
|
||||
|
||||
public function test_11_object_with_key_outside_the_four_is_untouched(): void
|
||||
{
|
||||
$value = ['url' => '/x', 'label' => 'y'];
|
||||
$id = $this->seedLayout(['components' => [['name' => 'X', 'props' => ['foo' => $value]]]]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame($value, $this->readContent($id)['components'][0]['props']['foo']);
|
||||
}
|
||||
|
||||
public function test_14_image_object_without_url_key_is_untouched(): void
|
||||
{
|
||||
// `{size,repeat,position}` 은 정상 props 와 정적으로 구분 불가 — 건드리면 오탐.
|
||||
$value = ['size' => 'cover', 'repeat' => 'no-repeat', 'position' => 'center'];
|
||||
$id = $this->seedLayout(['components' => [['name' => 'X', 'props' => ['bg' => $value]]]]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame($value, $this->readContent($id)['components'][0]['props']['bg']);
|
||||
}
|
||||
|
||||
public function test_27_props_container_itself_is_never_treated_as_an_image_value(): void
|
||||
{
|
||||
// `props` 는 값이 아니라 **컨테이너**다. `url` prop 을 받는 컴포넌트(임베드·영상 등)는
|
||||
// props 맵 자신이 「4키 부분집합 + url 보유」 형태가 되는데, 그것을 값으로 오인하면
|
||||
// props 가 통째로 문자열로 붕괴해 그 노드의 설정이 전부 사라진다.
|
||||
$props = ['url' => '/embed/x', 'size' => 'lg'];
|
||||
$id = $this->seedLayout(['components' => [['name' => 'Iframe', 'props' => $props]]]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame($props, $this->readContent($id)['components'][0]['props']);
|
||||
}
|
||||
|
||||
public function test_28_props_container_with_corrupted_url_is_never_dropped(): void
|
||||
{
|
||||
// 같은 오인이 손상 url 분기로 가면 props 키 자체가 unset 되어 노드가 빈 껍데기가 된다.
|
||||
$props = ['url' => ['nested' => 1], 'size' => 'lg'];
|
||||
$id = $this->seedLayout(['components' => [['name' => 'Video', 'props' => $props]]]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$node = $this->readContent($id)['components'][0];
|
||||
$this->assertArrayHasKey('props', $node, 'props 컨테이너는 손상 url 분기의 대상이 아니다.');
|
||||
$this->assertSame($props, $node['props']);
|
||||
}
|
||||
|
||||
public function test_29_style_container_itself_is_never_treated_as_an_image_value(): void
|
||||
{
|
||||
// `style` 도 같은 컨테이너 키다 — prop 자리 안에 있어도 값으로 평가하지 않는다.
|
||||
$style = ['url' => '/legacy.png', 'position' => 'center'];
|
||||
$id = $this->seedLayout(['components' => [['name' => 'X', 'props' => ['style' => $style]]]]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame($style, $this->readContent($id)['components'][0]['props']['style']);
|
||||
}
|
||||
|
||||
// ── ⑤ 손상 url ─────────────────────────────────────────────────────────
|
||||
|
||||
/** @effects backfill_drops_prop_key_on_corrupted_url_with_warning_log */
|
||||
public function test_12_empty_url_drops_the_prop_key(): void
|
||||
{
|
||||
$id = $this->seedLayout([
|
||||
'components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue(''), 'siteName' => '샘플']]],
|
||||
]);
|
||||
|
||||
$spy = $this->runMigrationWithLogSpy();
|
||||
|
||||
$props = $this->readContent($id)['components'][0]['props'];
|
||||
$this->assertArrayNotHasKey('logo', $props, 'src="" 는 현재 문서 재요청이므로 키를 제거해 폴백을 살린다.');
|
||||
$this->assertSame('샘플', $props['siteName']);
|
||||
|
||||
// 파괴적 동작이므로 원본이 로그에 남아야 한다 — 행 id·JSON 경로·원본 값 3요소.
|
||||
$spy->shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $m) => str_contains($m, "id={$id}")
|
||||
&& str_contains($m, 'components[0].props.logo')
|
||||
&& str_contains($m, '"url":""'))
|
||||
->once();
|
||||
}
|
||||
|
||||
public function test_13_non_string_url_drops_the_prop_key(): void
|
||||
{
|
||||
$id = $this->seedLayout([
|
||||
'components' => [['name' => 'Header', 'props' => ['logo' => ['url' => ['nested' => 1], 'size' => 'cover']]]],
|
||||
]);
|
||||
|
||||
$spy = $this->runMigrationWithLogSpy();
|
||||
|
||||
$this->assertArrayNotHasKey('logo', $this->readContent($id)['components'][0]['props']);
|
||||
|
||||
$spy->shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $m) => str_contains($m, "id={$id}")
|
||||
&& str_contains($m, 'components[0].props.logo')
|
||||
&& str_contains($m, '"nested":1'))
|
||||
->once();
|
||||
}
|
||||
|
||||
// ── ⑥ 확장 테이블 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 확장 레이아웃 1건을 시드합니다.
|
||||
*
|
||||
* @param array $content content 배열
|
||||
* @return int 생성된 행 id
|
||||
*/
|
||||
private function seedExtension(array $content): int
|
||||
{
|
||||
static $seq = 0;
|
||||
$seq++;
|
||||
|
||||
return DB::table('template_layout_extensions')->insertGetId([
|
||||
'template_id' => $this->templateId,
|
||||
'extension_type' => 'module',
|
||||
'target_name' => 'home_'.$seq,
|
||||
'source_type' => 'module',
|
||||
'source_identifier' => 'test-module',
|
||||
'content' => json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'priority' => 10,
|
||||
'is_active' => true,
|
||||
'lock_version' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_15_extension_injection_component_props_are_narrowed(): void
|
||||
{
|
||||
$id = $this->seedExtension([
|
||||
'injections' => [
|
||||
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/ext.png')]]]],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$content = $this->readContent($id, 'template_layout_extensions');
|
||||
$this->assertSame('/ext.png', $content['injections'][0]['components'][0]['props']['logo']);
|
||||
}
|
||||
|
||||
public function test_16_extension_inject_props_slot_is_narrowed(): void
|
||||
{
|
||||
$id = $this->seedExtension([
|
||||
'injections' => [['props' => ['logo' => $this->imageValue('/inject.png')]]],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$content = $this->readContent($id, 'template_layout_extensions');
|
||||
$this->assertSame('/inject.png', $content['injections'][0]['props']['logo']);
|
||||
}
|
||||
|
||||
/** @effects backfill_traverses_modals_in_both_list_and_map_shape */
|
||||
public function test_17_modals_in_both_list_and_map_shape_are_narrowed(): void
|
||||
{
|
||||
$listId = $this->seedLayout([
|
||||
'modals' => [['id' => 'm1', 'components' => [['name' => 'Img', 'props' => ['src' => $this->imageValue('/list.png')]]]]],
|
||||
]);
|
||||
$mapId = $this->seedLayout([
|
||||
'modals' => [
|
||||
'channel_edit_modal' => [
|
||||
'components' => [['name' => 'Img', 'props' => ['src' => $this->imageValue('/map.png')]]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame('/list.png', $this->readContent($listId)['modals'][0]['components'][0]['props']['src']);
|
||||
$this->assertSame(
|
||||
'/map.png',
|
||||
$this->readContent($mapId)['modals']['channel_edit_modal']['components'][0]['props']['src']
|
||||
);
|
||||
}
|
||||
|
||||
// ── ⑦ 안전성 ───────────────────────────────────────────────────────────
|
||||
|
||||
/** @effects backfill_is_idempotent_and_performs_no_update_on_unchanged_rows */
|
||||
public function test_18_second_run_is_idempotent_and_performs_no_update(): void
|
||||
{
|
||||
$id = $this->seedLayout([
|
||||
'components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue()]]],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
$afterFirst = DB::table('template_layouts')->where('id', $id)->first();
|
||||
|
||||
$this->runMigration();
|
||||
$afterSecond = DB::table('template_layouts')->where('id', $id)->first();
|
||||
|
||||
$this->assertSame($afterFirst->content, $afterSecond->content);
|
||||
$this->assertSame($afterFirst->lock_version, $afterSecond->lock_version, '2회차는 UPDATE 가 없어야 합니다.');
|
||||
$this->assertSame($afterFirst->updated_at, $afterSecond->updated_at);
|
||||
}
|
||||
|
||||
/** @effects backfill_increments_lock_version_only_on_changed_rows */
|
||||
public function test_19_lock_version_is_incremented_only_on_changed_rows(): void
|
||||
{
|
||||
$dirty = $this->seedLayout(
|
||||
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue()]]]],
|
||||
['lock_version' => 3],
|
||||
);
|
||||
$clean = $this->seedLayout(
|
||||
['components' => [['name' => 'Header', 'props' => ['logo' => '/ok.png']]]],
|
||||
['lock_version' => 3],
|
||||
);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame(4, (int) DB::table('template_layouts')->where('id', $dirty)->value('lock_version'));
|
||||
$this->assertSame(3, (int) DB::table('template_layouts')->where('id', $clean)->value('lock_version'));
|
||||
}
|
||||
|
||||
/** @effects backfill_preserves_original_content_hash_so_modified_judgement_holds */
|
||||
public function test_20_original_content_hash_is_preserved(): void
|
||||
{
|
||||
$id = $this->seedLayout(
|
||||
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue()]]]],
|
||||
['original_content_hash' => str_repeat('a', 64), 'original_content_size' => 123],
|
||||
);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$row = DB::table('template_layouts')->where('id', $id)->first();
|
||||
$this->assertSame(str_repeat('a', 64), $row->original_content_hash, '재계산하면 사용자 편집본이 「원본 그대로」로 위장된다.');
|
||||
$this->assertSame(123, (int) $row->original_content_size);
|
||||
// 백필 후에도 content 는 원본 파일과 다르므로 「수정됨」 판정이 유지된다.
|
||||
$this->assertNotSame(hash('sha256', $row->content), $row->original_content_hash);
|
||||
}
|
||||
|
||||
/** @effects backfill_uses_keyset_chunking_so_no_row_is_skipped_past_the_boundary */
|
||||
public function test_21_all_rows_are_converted_beyond_the_chunk_boundary(): void
|
||||
{
|
||||
$ids = [];
|
||||
for ($i = 0; $i < self::SEED_COUNT; $i++) {
|
||||
$ids[] = $this->seedLayout([
|
||||
'components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/a'.$i.'.png')]]],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$remaining = 0;
|
||||
foreach ($ids as $i => $id) {
|
||||
$logo = $this->readContent($id)['components'][0]['props']['logo'];
|
||||
if (! is_string($logo)) {
|
||||
$remaining++;
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->assertSame('/a'.$i.'.png', $logo);
|
||||
}
|
||||
|
||||
$this->assertSame(0, $remaining, 'OFFSET 순회면 청크 경계 이후 행이 조용히 누락된다 (chunkById 필요).');
|
||||
}
|
||||
|
||||
/** @effects backfill_skips_broken_json_rows_without_aborting_the_run */
|
||||
public function test_22_broken_json_row_is_skipped_without_aborting_the_run(): void
|
||||
{
|
||||
$broken = $this->seedLayout(['components' => []]);
|
||||
DB::table('template_layouts')->where('id', $broken)->update(['content' => '{not json']);
|
||||
$good = $this->seedLayout([
|
||||
'components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/good.png')]]],
|
||||
]);
|
||||
|
||||
$spy = $this->runMigrationWithLogSpy();
|
||||
|
||||
$this->assertSame('{not json', DB::table('template_layouts')->where('id', $broken)->value('content'));
|
||||
$this->assertSame('/good.png', $this->readContent($good)['components'][0]['props']['logo']);
|
||||
|
||||
// 건너뛴 행은 조용히 사라지면 안 된다 — 그 행 id 가 로그에 남아야 운영자가 찾아간다.
|
||||
$spy->shouldHaveReceived('warning')
|
||||
->withArgs(fn (string $m) => str_contains($m, "id={$broken}"))
|
||||
->atLeast()->once();
|
||||
}
|
||||
|
||||
/** @effects backfill_leaves_version_and_preview_snapshots_untouched */
|
||||
public function test_23_version_and_preview_snapshots_are_untouched(): void
|
||||
{
|
||||
$layoutId = $this->seedLayout(['components' => []]);
|
||||
$poisoned = json_encode(
|
||||
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/snap.png')]]]],
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
||||
);
|
||||
|
||||
$versionId = DB::table('template_layout_versions')->insertGetId([
|
||||
'layout_id' => $layoutId,
|
||||
'version' => 1,
|
||||
'content' => $poisoned,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$previewId = DB::table('template_layout_previews')->insertGetId([
|
||||
'token' => 'imgobj-preview-token',
|
||||
'template_id' => $this->templateId,
|
||||
'layout_name' => 'imgobj/preview',
|
||||
'preview_type' => 'layout',
|
||||
'content' => $poisoned,
|
||||
'admin_id' => 1,
|
||||
'expires_at' => now()->addHour(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame(
|
||||
$poisoned,
|
||||
DB::table('template_layout_versions')->where('id', $versionId)->value('content'),
|
||||
'과거 스냅샷을 고치면 「이전 버전으로 되돌리기」가 원본과 달라진다.'
|
||||
);
|
||||
$this->assertSame(
|
||||
$poisoned,
|
||||
DB::table('template_layout_previews')->where('id', $previewId)->value('content'),
|
||||
'미리보기 스냅샷도 백필 대상이 아니다.'
|
||||
);
|
||||
}
|
||||
|
||||
/** @effects backfill_converts_soft_deleted_rows_for_restore_safety */
|
||||
public function test_24_soft_deleted_rows_are_converted(): void
|
||||
{
|
||||
$id = $this->seedLayout(
|
||||
['components' => [['name' => 'Header', 'props' => ['logo' => $this->imageValue('/deleted.png')]]]],
|
||||
['deleted_at' => now()],
|
||||
);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame(
|
||||
'/deleted.png',
|
||||
$this->readContent($id)['components'][0]['props']['logo'],
|
||||
'삭제된 레이아웃은 복원 가능하므로 고치지 않으면 복원 시 결함이 되살아난다.'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_25_missing_table_is_skipped_without_error(): void
|
||||
{
|
||||
// 실제 `Schema::drop` 은 쓰지 않는다 — 이 테이블은 versions 테이블의 FK 대상이라
|
||||
// 드롭 자체가 실패하고, DDL 은 RefreshDatabase 트랜잭션을 암묵 커밋시켜 뒤따르는
|
||||
// 테스트까지 오염시킨다. 검증 대상은 `Schema::hasTable` 게이트이므로 존재하지 않는
|
||||
// 테이블명으로 같은 코드 경로를 직접 태운다.
|
||||
$ref = new ReflectionClass($this->migration);
|
||||
$method = $ref->getMethod('processTable');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$this->assertFalse(Schema::hasTable('g7_no_such_layout_table'));
|
||||
$method->invoke($this->migration, $this->context, 'g7_no_such_layout_table');
|
||||
|
||||
// 예외 없이 통과 + 실제 테이블은 그대로다.
|
||||
$this->assertTrue(Schema::hasTable('template_layout_extensions'));
|
||||
}
|
||||
|
||||
// ── ⑧ 변환 함수 직접 호출 (DB 없이 판정식만) ──────────────────────────
|
||||
|
||||
public function test_26_transform_is_callable_directly_and_matches_db_path_results(): void
|
||||
{
|
||||
$ref = new ReflectionClass($this->migration);
|
||||
$method = $ref->getMethod('transform');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$call = function (array $input) use ($method) {
|
||||
$changed = 0;
|
||||
$args = [$input, false, &$changed, $this->context, 't', '1', ''];
|
||||
|
||||
return [$method->invokeArgs($this->migration, $args), $changed];
|
||||
};
|
||||
|
||||
// 케이스 1 — 축약
|
||||
[$out, $changed] = $call(['components' => [['props' => ['logo' => $this->imageValue()]]]]);
|
||||
$this->assertSame('/api/attachment/X', $out['components'][0]['props']['logo']);
|
||||
$this->assertSame(1, $changed);
|
||||
|
||||
// 케이스 4 — style 자리 불변
|
||||
$style = ['backgroundImage' => 'url(/a.png)', 'backgroundSize' => 'cover'];
|
||||
[$out, $changed] = $call(['components' => [['props' => ['style' => $style]]]]);
|
||||
$this->assertSame($style, $out['components'][0]['props']['style']);
|
||||
$this->assertSame(0, $changed);
|
||||
|
||||
// 케이스 9 — 정상 props 불변
|
||||
$props = ['className' => 'x', 'name' => 'user', 'size' => 'lg'];
|
||||
[$out, $changed] = $call(['components' => [['props' => $props]]]);
|
||||
$this->assertSame($props, $out['components'][0]['props']);
|
||||
$this->assertSame(0, $changed);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,14 @@
|
||||
"className": "py-6 px-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "e2e_sandbox_header",
|
||||
"type": "composite",
|
||||
"name": "Header",
|
||||
"props": {
|
||||
"siteName": "E2E 샌드박스"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "e2e_sandbox_root",
|
||||
"type": "basic",
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Layout Editor — image 위젯 값 형태(단일 값 슬롯 축약 / 표시모드 억제 / 표현식 보호).
|
||||
*
|
||||
* 결함(공개 #135): 코어 `image` 위젯이 내보내는 `{url,size,repeat,position}` 객체를
|
||||
* `propValue` 경로가 그대로 `props[key]` 에 기록해, 소비 컴포넌트가 `<Img src={객체}>` 를
|
||||
* 받고 브라우저가 `[object Object]` 를 URL 로 해석했다. 이 결함은 **브라우저에서만**
|
||||
* 드러난다 — 깨진 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)을 받고, 편집기
|
||||
* 위젯의 미리보기는 정상이며, 서버 로그에도 흔적이 없다. 화면의 엑박이 유일한 증상이다.
|
||||
*
|
||||
* 단위(Vitest)는 `applyRecipe` 반환 노드까지만 볼 수 있어 "실제 `<img>` 의 src 가 문자열이고
|
||||
* naturalWidth > 0" 을 포착하지 못한다. 그래서 캔버스 DOM 을 직접 재는 spec 을 둔다.
|
||||
*
|
||||
* 축 요약(마커 아님 — 평문): image_propvalue_narrowing, display_mode_suppression, binding_expression_guard, styleprop_bundle_regression.
|
||||
* 효과 요약(마커 아님 — 평문): image_object_narrows_to_url_string_in_propvalue_slot, single_value_slot_control_hides_display_mode_buttons_entirely, bound_value_shows_expression_badge_and_locks_destructive_controls, styleprop_bundle_four_property_decomposition_is_unchanged.
|
||||
*/
|
||||
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
||||
import { SANDBOX_ROUTE, sandboxRouteParam } from '../../fixtures/seed-layout';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const EDITOR_URL = '/admin/layout-editor/sirsoft-basic?route=%2F';
|
||||
|
||||
/**
|
||||
* 공통 레이아웃 편집 모드 — `Header` 의 「로고 이미지」는 `_user_base` 소유다.
|
||||
*
|
||||
* 라우트 모드에서 그 노드는 **상속(base) 출처**라 ⓘ 대신 「🔒 공통 레이아웃 편집」이 뜬다.
|
||||
* 편집해도 저장 시 마스킹이 통째로 폐기하므로 편집 표면을 열지 않는 것이 계약이다
|
||||
* (계획서 D9~D11). 따라서 그 컨트롤을 여는 정상 경로는 이 모드다.
|
||||
*/
|
||||
const BASE_EDITOR_URL = '/admin/layout-editor/sirsoft-basic?edit=__base__%2F_user_base';
|
||||
|
||||
/**
|
||||
* 편집기에 진입해 캔버스 편집 노드가 렌더될 때까지 기다린다.
|
||||
*/
|
||||
async function openEditor(page: Page, url: string = EDITOR_URL): Promise<void> {
|
||||
const token = issueToken('core.templates.layouts.edit');
|
||||
await authenticatePage(page, token);
|
||||
await page.goto(url);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll('[data-editor-path]').length > 0,
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캔버스 위임 click 핸들러로 노드를 선택한다. 패널/오버레이가 좌표를 가로채는 것을 피해
|
||||
* 편집기가 실제로 받는 pointer/click 시퀀스를 노드 중앙에 직접 발사한다.
|
||||
*/
|
||||
async function selectNode(page: Page, path: string): Promise<void> {
|
||||
await page.evaluate((p) => {
|
||||
const el = document.querySelector(`[data-editor-path="${p}"]`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
const r = el.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2;
|
||||
const cy = r.top + r.height / 2;
|
||||
for (const type of ['pointerover', 'pointermove', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: cx, clientY: cy }));
|
||||
}
|
||||
}, path);
|
||||
}
|
||||
|
||||
/** ⓘ → 「속성 설정」 → 속성 편집 모달. 지정한 탭을 연다. */
|
||||
async function openPropertyTab(page: Page, tab: 'props' | 'style'): Promise<void> {
|
||||
await page.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout: 10_000 });
|
||||
await page.getByTestId('g7le-overlay-info-button').click();
|
||||
await page.waitForSelector('[data-testid="g7le-context-menu-edit-props"]', { timeout: 5_000 });
|
||||
await page.getByTestId('g7le-context-menu-edit-props').click();
|
||||
await page.waitForSelector('[data-testid="g7le-property-modal"]', { timeout: 10_000 });
|
||||
await page.getByTestId(`g7le-property-tab-${tab}`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* `image` 위젯이 열린 상태에서 **단일 값 슬롯 컨트롤**을 찾을 수 있는 노드를 고른다.
|
||||
*
|
||||
* 리터럴 `data-editor-path` 를 쓰지 않는다 — 베이스 루트 인덱스가 밀리면 통째로 깨진다.
|
||||
* 대신 캔버스에서 `Header` 컴포넌트를 이름으로 찾는다(로고 컨트롤의 소유 컴포넌트).
|
||||
*/
|
||||
async function findHeaderPath(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('[data-editor-path][data-editor-name="Header"]');
|
||||
return el?.getAttribute('data-editor-path') ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 시드 화면이 소유한 `Header` 의 편집 경로를 id 로 지목한다.
|
||||
*
|
||||
* 시드 화면은 `_user_base` 를 extends 하므로 캔버스에 `Header` 가 **둘** 뜬다 — 상속받은
|
||||
* 베이스 헤더(잠금: ⓘ 대신 「공통 레이아웃 편집」)와 시드가 직접 둔 라우트 소유 헤더.
|
||||
* 이름으로만 찾으면 앞의 잠긴 쪽이 잡혀 속성 모달에 도달하지 못한다.
|
||||
*/
|
||||
async function findSandboxHeaderPath(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const el = document.querySelector('[data-editor-path][data-editor-id="e2e_sandbox_header"]');
|
||||
return el?.getAttribute('data-editor-path') ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** 배경이 보일 만한 박스형 Div — styleProp 묶음(회귀 가드)의 대상 */
|
||||
async function pickBoxAreaPath(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const cands = Array.from(
|
||||
document.querySelectorAll('[data-editor-path][data-editor-name="Div"]'),
|
||||
).filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 200 && r.width < 800 && r.height > 80 && r.height < 320 && r.left > 360;
|
||||
});
|
||||
return cands[0]?.getAttribute('data-editor-path') ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 페이로드에서 `logo` prop 값을 찾아 돌려준다 (트리 어디에 있든).
|
||||
*
|
||||
* 노드 경로를 리터럴로 박으면 베이스 레이아웃 구조가 한 칸만 바뀌어도 통째로 깨지므로
|
||||
* 키 이름으로 전역 탐색한다.
|
||||
*
|
||||
* @param value 저장 요청 body (JSON)
|
||||
* @returns 찾은 `logo` 값, 없으면 undefined
|
||||
*/
|
||||
function findLogoProp(value: unknown): unknown {
|
||||
// 저장 페이로드의 `content` 는 **JSON 문자열**로 실린다 — 파싱하지 않으면 트리를 볼 수 없다.
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim().startsWith('{')) return undefined;
|
||||
try {
|
||||
return findLogoProp(JSON.parse(value));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const hit = findLogoProp(item);
|
||||
if (hit !== undefined) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const props = record.props;
|
||||
if (props && typeof props === 'object' && 'logo' in (props as Record<string, unknown>)) {
|
||||
return (props as Record<string, unknown>).logo;
|
||||
}
|
||||
for (const child of Object.values(record)) {
|
||||
const hit = findLogoProp(child);
|
||||
if (hit !== undefined) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 1x1 PNG — 첨부 업로드용 최소 실물 이미지(naturalWidth>0 을 재려면 실제 파일이어야 한다) */
|
||||
const PNG_1PX = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
test.describe('@layout-editor image 위젯 값 형태 (공개 #135)', () => {
|
||||
/** @effects image_object_narrows_to_url_string_in_propvalue_slot, single_value_slot_control_hides_display_mode_buttons_entirely */
|
||||
test('로고 이미지 URL 입력 → 캔버스 img src 가 문자열, [object Object] 0건, 모드 버튼 미노출', async ({ page }) => {
|
||||
await openEditor(page, BASE_EDITOR_URL);
|
||||
|
||||
const headerPath = await findHeaderPath(page);
|
||||
test.skip(headerPath === null, '캔버스에서 Header 노드를 찾지 못했습니다');
|
||||
await selectNode(page, headerPath as string);
|
||||
await openPropertyTab(page, 'props');
|
||||
|
||||
const urlInput = page.getByTestId('g7le-image-url');
|
||||
test.skip(!(await urlInput.isVisible().catch(() => false)), '로고 이미지 컨트롤이 노출되지 않았습니다');
|
||||
|
||||
// 저장값이 데이터 연결 표현식이면 컨트롤은 보호 상태로 뜬다(입력칸 readOnly).
|
||||
// 값을 바꾸려면 「직접 지정으로 바꾸기」로 명시적으로 열어야 한다 — 그 한 번의
|
||||
// 의사표시 없이는 환경설정과의 연결이 덮이지 않는다는 것이 계약이다.
|
||||
const replace = page.getByTestId('g7le-image-expression-replace');
|
||||
if (await replace.isVisible().catch(() => false)) {
|
||||
await replace.click();
|
||||
// 해제되면 되돌리기 경로가 함께 열린다(편도 금지).
|
||||
await expect(page.getByTestId('g7le-image-expression-restore')).toBeVisible();
|
||||
}
|
||||
|
||||
// 단일 값 슬롯(propValue) 이므로 표시모드 버튼은 **컨테이너째** 미렌더되어야 한다.
|
||||
// `disabled` 로 남기면 "URL 을 넣으면 살아나겠지" 라는 거짓 정보를 준다.
|
||||
await expect(page.getByTestId('g7le-image-modes')).toHaveCount(0);
|
||||
await expect(page.getByTestId('g7le-image-mode-fill')).toHaveCount(0);
|
||||
|
||||
const url = '/api/templates/sirsoft-basic/layout-attachments/5/file';
|
||||
await urlInput.fill(url);
|
||||
await urlInput.blur();
|
||||
|
||||
// 핵심 검증 — 캔버스의 실제 <img> 가 문자열 src 를 받아야 한다.
|
||||
// 종전에는 여기에 "[object Object]" 가 들어가 엑박이 됐고, SPA catch-all 때문에
|
||||
// 그 요청은 404 조차 아니라 200(HTML) 이라 네트워크 탭으로도 드러나지 않았다.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate((p) => {
|
||||
const host = document.querySelector(`[data-editor-path="${p}"]`);
|
||||
const img = host?.querySelector('img');
|
||||
return img?.getAttribute('src') ?? '';
|
||||
}, headerPath),
|
||||
{ timeout: 8_000 },
|
||||
)
|
||||
.toContain('layout-attachments/5/file');
|
||||
|
||||
const objectObjectCount = await page.evaluate(() =>
|
||||
Array.from(document.images).filter((i) => (i.getAttribute('src') ?? '').includes('[object Object]')).length,
|
||||
);
|
||||
expect(objectObjectCount, '문서 어디에도 [object Object] src 가 없어야 합니다').toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* 저장 → 사용자 화면 3단 — 이 결함의 **최종 소비 지점**이다.
|
||||
*
|
||||
* 위 케이스는 편집기 캔버스까지만 본다. 그런데 #135 가 실제로 드러난 자리는 저장 이후의
|
||||
* 사용자 화면이고, 깨진 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML) 이라
|
||||
* 네트워크 탭·서버 로그 어디에도 흔적이 없다. 그래서 「저장 페이로드가 문자열인가」와
|
||||
* 「그 문자열이 실제로 그려지는가(naturalWidth>0)」를 여기서 함께 잰다.
|
||||
*
|
||||
* 대상은 **시드 화면(`e2e_sandbox`)** 이다. 이 spec 은 저장(PUT)까지 수행하므로 제품
|
||||
* 레이아웃(`_user_base`)을 쓰면 편집 결과가 그대로 영속돼 개발 사이트가 오염된다
|
||||
* (`fixtures/seed-layout.ts` 규약). 시드 화면에 라우트 소유 `Header` 를 두어 로고 컨트롤을
|
||||
* 라우트 모드에서 열 수 있게 했고, 시드는 globalTeardown 이 통째로 제거한다.
|
||||
*
|
||||
* @effects image_object_narrows_to_url_string_in_propvalue_slot, img_string_src_passes_through_unchanged
|
||||
*/
|
||||
test('저장 → 사용자 화면에서 문자열 src 로 실제 렌더된다', async ({ page }) => {
|
||||
// 편집기 진입 + 저장 왕복 + 사용자 화면 재방문 3단이라 기본 30s 로는 부족하다.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// 실제로 그려지는지(naturalWidth>0)를 재려면 실존하는 이미지가 필요하다. 고정 id 를
|
||||
// 박으면 그 첨부가 지워진 환경에서 조용히 0 이 되므로, 이 spec 이 직접 올리고 지운다.
|
||||
const token = issueToken('core.templates.layouts.edit');
|
||||
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
|
||||
const upload = await page.request.post('/api/admin/templates/sirsoft-basic/layout-attachments', {
|
||||
headers,
|
||||
multipart: {
|
||||
file: { name: 'e2e-logo.png', mimeType: 'image/png', buffer: PNG_1PX },
|
||||
},
|
||||
});
|
||||
test.skip(!upload.ok(), `첨부 업로드 실패(${upload.status()})`);
|
||||
const attachmentId = (await upload.json())?.data?.id;
|
||||
test.skip(!attachmentId, '첨부 id 를 받지 못했습니다');
|
||||
const url = `/api/templates/sirsoft-basic/layout-attachments/${attachmentId}/file`;
|
||||
|
||||
try {
|
||||
await openEditor(page, `/admin/layout-editor/sirsoft-basic?route=${sandboxRouteParam()}`);
|
||||
|
||||
const headerPath = await findSandboxHeaderPath(page);
|
||||
test.skip(headerPath === null, '시드 화면에서 Header 노드를 찾지 못했습니다');
|
||||
await selectNode(page, headerPath as string);
|
||||
await openPropertyTab(page, 'props');
|
||||
|
||||
const urlInput = page.getByTestId('g7le-image-url');
|
||||
test.skip(!(await urlInput.isVisible().catch(() => false)), '로고 이미지 컨트롤이 노출되지 않았습니다');
|
||||
|
||||
const replace = page.getByTestId('g7le-image-expression-replace');
|
||||
if (await replace.isVisible().catch(() => false)) await replace.click();
|
||||
|
||||
await urlInput.fill(url);
|
||||
await urlInput.blur();
|
||||
|
||||
// 속성 모달을 닫고 툴바에서 저장한다.
|
||||
await page.getByTestId('g7le-property-modal-done').click();
|
||||
|
||||
// 저장 페이로드의 `logo` 가 **문자열**이어야 한다 — 객체면 수정 전 상태다.
|
||||
const [put] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/layouts/e2e_sandbox') && r.request().method() === 'PUT',
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
page.getByTestId('g7le-toolbar-save').click(),
|
||||
]);
|
||||
expect(put.status(), '저장이 200 이어야 합니다').toBe(200);
|
||||
|
||||
const logo = findLogoProp(put.request().postDataJSON());
|
||||
expect(typeof logo, `저장 페이로드의 logo 가 문자열이어야 합니다 (실제: ${JSON.stringify(logo)})`).toBe('string');
|
||||
expect(logo).toContain('layout-attachments/5/file');
|
||||
|
||||
// 3단 — 사용자 화면에서 실제로 그려지는가.
|
||||
await page.goto(SANDBOX_ROUTE['sirsoft-basic']);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const measured = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const imgs = Array.from(document.images);
|
||||
const hit = imgs.find((i) => (i.getAttribute('src') ?? '').includes('layout-attachments/5/file'));
|
||||
return {
|
||||
src: hit?.getAttribute('src') ?? null,
|
||||
natural: hit?.naturalWidth ?? 0,
|
||||
objectObject: imgs.filter((i) => (i.getAttribute('src') ?? '').includes('[object Object]')).length,
|
||||
};
|
||||
});
|
||||
|
||||
expect(measured.objectObject, '사용자 화면에 [object Object] src 가 없어야 합니다').toBe(0);
|
||||
expect(measured.src, '사용자 화면의 로고 src 가 저장한 문자열이어야 합니다').toContain(
|
||||
'layout-attachments/5/file',
|
||||
);
|
||||
expect(measured.natural, '로고가 실제로 로드되어야 합니다(naturalWidth>0)').toBeGreaterThan(0);
|
||||
} finally {
|
||||
// 올린 첨부는 반드시 지운다 — 실패로 중단돼도 서버에 잔여물을 남기지 않는다.
|
||||
await page.request
|
||||
.delete(`/api/admin/templates/layout-attachments/${attachmentId}`, { headers })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
/** @effects bound_value_shows_expression_badge_and_locks_destructive_controls */
|
||||
test('저장값이 데이터 연결 표현식이면 원문 배지 + 파괴적 조작 잠금', async ({ page }) => {
|
||||
await openEditor(page, BASE_EDITOR_URL);
|
||||
|
||||
const headerPath = await findHeaderPath(page);
|
||||
test.skip(headerPath === null, '캔버스에서 Header 노드를 찾지 못했습니다');
|
||||
await selectNode(page, headerPath as string);
|
||||
await openPropertyTab(page, 'props');
|
||||
|
||||
const badge = page.getByTestId('g7le-image-expression');
|
||||
// 번들 레이아웃의 기본 저장값은 사이트 로고 설정 표현식이다. 표현식 상태가 아니면
|
||||
// (운영자가 이미 고정 URL 을 넣은 사이트) 이 축은 검증 대상이 아니다.
|
||||
test.skip(!(await badge.isVisible().catch(() => false)), '로고가 표현식 상태가 아닙니다');
|
||||
|
||||
// 원문이 그대로 보여야 한다 — 빈 피커로 보이면 업로드 1클릭에 표현식이 소리 없이 사라진다.
|
||||
await expect(badge).toContainText('{{');
|
||||
// 미리보기는 깨지므로 렌더하지 않는다(거짓 미리보기 금지).
|
||||
await expect(page.getByTestId('g7le-image-preview')).toHaveCount(0);
|
||||
// 파괴적 조작은 잠긴다 — 단, 「직접 지정으로 바꾸기」로 즉시 해제되는 일시적 상태다.
|
||||
await expect(page.getByTestId('g7le-image-clear')).toBeDisabled();
|
||||
await expect(page.getByTestId('g7le-image-url')).toHaveAttribute('readonly', /.*/);
|
||||
|
||||
await page.getByTestId('g7le-image-expression-replace').click();
|
||||
await expect(page.getByTestId('g7le-image-expression')).toHaveCount(0);
|
||||
await expect(page.getByTestId('g7le-image-clear')).toBeEnabled();
|
||||
});
|
||||
|
||||
/** @effects styleprop_bundle_four_property_decomposition_is_unchanged */
|
||||
test('회귀 가드 — 배경 이미지(styleProp 묶음) 컨트롤은 모드 버튼과 4속성 분해를 유지', async ({ page }) => {
|
||||
await openEditor(page);
|
||||
|
||||
const targetPath = await pickBoxAreaPath(page);
|
||||
test.skip(targetPath === null, '배경을 넣을 박스형 Div 를 찾지 못했습니다');
|
||||
await selectNode(page, targetPath as string);
|
||||
await openPropertyTab(page, 'style');
|
||||
|
||||
const urlInput = page.getByTestId('g7le-image-url');
|
||||
test.skip(!(await urlInput.isVisible().catch(() => false)), '배경 이미지 컨트롤이 노출되지 않았습니다');
|
||||
|
||||
// 다중 props 묶음이라 표시모드 버튼은 그대로 있어야 한다(단일 슬롯 억제가 여기까지
|
||||
// 번지면 배경 편집 기능이 통째로 죽는다).
|
||||
await expect(page.getByTestId('g7le-image-modes')).toBeVisible();
|
||||
|
||||
const url = 'https://example.com/api/templates/sirsoft-basic/layout-attachments/5/file';
|
||||
await urlInput.fill(url);
|
||||
await urlInput.blur();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate((p) => {
|
||||
const el = document.querySelector(`[data-editor-path="${p}"]`);
|
||||
return el ? getComputedStyle(el).backgroundImage : '';
|
||||
}, targetPath),
|
||||
{ timeout: 8_000 },
|
||||
)
|
||||
.toContain('layout-attachments/5/file');
|
||||
|
||||
const snap = await page.evaluate((p) => {
|
||||
const el = document.querySelector(`[data-editor-path="${p}"]`) as HTMLElement | null;
|
||||
const cs = el ? getComputedStyle(el) : null;
|
||||
return { size: cs?.backgroundSize, repeat: cs?.backgroundRepeat, image: cs?.backgroundImage };
|
||||
}, targetPath);
|
||||
expect(snap.size).toBe('cover');
|
||||
expect(snap.repeat).toBe('no-repeat');
|
||||
expect(snap.image, 'CSS 값 문맥이므로 url(...) 로 감싸져야 합니다').toContain('url(');
|
||||
expect(snap.image).not.toContain('[object Object]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
feature: 레이아웃 편집기 image 위젯 값 형태 — 단일 값 슬롯 축약 / 런타임 방어 / 저장 데이터 백필
|
||||
|
||||
# 입력 axis 는 5축 265조합이다. 전수 마킹은 실효가 없어(테스트 1건이 조합 1개만
|
||||
# 덮으므로 265건이 필요하다) **pairwise** 로 좁힌다 — 어떤 두 축의 조합도 최소 한 번은
|
||||
# 지나가므로 축 간 상호작용 결함은 그대로 잡히고, 마킹 대상은 실측 9건으로 줄어든다.
|
||||
# effects 축은 면제하지 않는다 — 모든 effect 가 테스트 마킹으로 도달해야 한다.
|
||||
coverage_strategy: pairwise
|
||||
|
||||
description: |
|
||||
코어 `image` 위젯은 배경 이미지용으로 설계되어 `{url,size,repeat,position}` **객체**를
|
||||
내보낸다. 그 값을 노드에 기록하는 apply 4종 중 `styleProp` 의 다중 props(배경 묶음)만
|
||||
객체를 CSS 4속성으로 분해했고, `propValue`·`cssVar`·단일 `styleProp` 은 객체를 그대로
|
||||
기록했다. 소비 컴포넌트가 `<Img src={객체}>` 로 받으면 브라우저가 `[object Object]` 를
|
||||
URL 로 해석해 이미지가 깨진다(공개 #135, `sirsoft-basic` 헤더 「로고 이미지」).
|
||||
|
||||
이 결함은 예외도 콘솔 오류도 서버 로그도 남기지 않는다 — 깨진 이미지 요청은 SPA
|
||||
catch-all 때문에 404 조차 아니라 200(HTML)을 받고, 편집기 위젯의 미리보기는 정상이라
|
||||
조작 중에는 이상이 보이지 않는다. 유일한 증상은 화면의 엑박이다.
|
||||
|
||||
방어선은 넷이다:
|
||||
1. **쓰기 축약** — 단일 값 슬롯 writer 3종이 공용 헬퍼 `scalarizeImageValue` 를 경유해
|
||||
url 문자열만 남긴다. 게이트는 `widget === 'image'` 이며 값 형태 sniffing 이 아니다
|
||||
(`isImageValueObject` 는 4키 중 하나만 있어도 참이라, 값만 보면 `{position:'left'}`
|
||||
같은 정당한 객체 prop 을 이미지로 오인해 삭제한다).
|
||||
2. **읽기 역조립** — 저장된 문자열을 `{url}` 로 되감아 위젯이 현재 값을 읽게 한다.
|
||||
표현식 문자열도 감싼다 — 감싸지 않으면 빈 피커로 보이고 업로드 1클릭에 그 표현식이
|
||||
소리 없이 소실된다. 깨진 미리보기 억제와 실수 덮어쓰기 방지는 위젯이 맡는다.
|
||||
3. **런타임 방어** — 업그레이드 전 화면을 위해 템플릿 `Img` 가 객체값에서 url 을
|
||||
해석하고, 손상값이면 `src` 를 아예 붙이지 않는다(엑박도 빈 문자열도 아니다).
|
||||
4. **저장 데이터 백필** — 업그레이드 스텝이 기설치본의 prop 자리 객체를 축약한다.
|
||||
판정식은 런타임 방어와 **완전히 같은 엄격도**(키 집합 ⊆ 4키 AND url 문자열)를 쓴다 —
|
||||
두 방어선의 기준이 어긋나면 한쪽만 통과하는 값이 생긴다.
|
||||
|
||||
단일 값 슬롯 컨트롤에서는 표시모드(채움/맞춤/타일) 버튼을 **컨테이너째 미렌더**한다.
|
||||
`size`/`repeat`/`position` 을 저장할 자리가 없어 눌러도 저장되지 않는 죽은 컨트롤이기
|
||||
때문이다. `disabled` 로 두면 "URL 을 넣으면 살아나겠지" 라는 거짓 정보를 준다.
|
||||
|
||||
axes:
|
||||
widget_output:
|
||||
- scalar_string # 이미 문자열 (회귀 0)
|
||||
- image_object # {url,size,repeat,position} 전체
|
||||
- image_object_empty_url # url 이 빈 문자열
|
||||
- image_object_nonstring_url # url 이 객체/배열/숫자
|
||||
- undefined_cleared # 「제거」 → undefined
|
||||
apply_type: [propValue, styleProp, cssVar, classToken]
|
||||
stored_shape: [string, object, binding_expression, absent]
|
||||
consumer: [img_src, header_logo, mobilenav_logo, div_background, none]
|
||||
storage_scope:
|
||||
- template_layouts
|
||||
- template_layout_extensions
|
||||
- layout_versions_untouched
|
||||
- layout_previews_untouched
|
||||
|
||||
exclusions:
|
||||
- { apply_type: styleProp, consumer: [img_src, header_logo, mobilenav_logo], reason: "styleProp 은 인라인 style 로 흐른다 — <Img src> sink 에 도달하지 않는다" }
|
||||
- { apply_type: propValue, consumer: div_background, reason: "배경은 style 자리 — propValue 로 흐르지 않는다" }
|
||||
- { apply_type: cssVar, consumer: [img_src, header_logo, mobilenav_logo, div_background], reason: "CSS 변수는 소비 sink 가 선언되지 않았다 — 축약만 검증(정적 룰이 신규 선언을 차단)" }
|
||||
- { apply_type: classToken, widget_output: [image_object, image_object_empty_url, image_object_nonstring_url], reason: "image × classToken 은 정적 룰이 저장 이전에 차단해 런타임 도달 불가" }
|
||||
- { widget_output: undefined_cleared, consumer: [img_src, div_background], reason: "해제는 폴백 축 — header_logo 에서 대표 검증(사이트명 텍스트 폴백)" }
|
||||
- { widget_output: undefined_cleared, stored_shape: [object, string, binding_expression], reason: "해제 결과는 absent 하나뿐" }
|
||||
- { stored_shape: binding_expression, widget_output: [image_object, image_object_empty_url, image_object_nonstring_url], reason: "표현식은 문자열이라 객체 형태와 배타" }
|
||||
- { storage_scope: [layout_versions_untouched, layout_previews_untouched], apply_type: [styleProp, cssVar, classToken], reason: "스냅샷 미보존 단언은 apply 무관 단일 축 — propValue 에서 대표 검증" }
|
||||
- { storage_scope: template_layout_extensions, consumer: [mobilenav_logo, div_background, none], reason: "확장 주입 경로는 injections[].props 순회 포섭만 검증 — 소비 sink 는 코어 경로와 동일" }
|
||||
|
||||
effects:
|
||||
# ① 쓰기 축약 (코어 엔진)
|
||||
- image_object_narrows_to_url_string_in_propvalue_slot
|
||||
- image_object_narrows_to_wrapped_css_url_in_cssvar_and_single_styleprop
|
||||
- legacy_css_url_wrapping_is_stripped_for_component_prop_sink
|
||||
- image_object_without_url_or_with_empty_url_deletes_the_prop
|
||||
- non_image_widget_object_and_array_props_are_preserved_intact
|
||||
- styleprop_bundle_four_property_decomposition_is_unchanged
|
||||
- apply_fixed_value_wins_over_control_value_then_gets_narrowed
|
||||
# ② 읽기 역조립
|
||||
- stored_string_is_rewrapped_as_url_object_for_the_widget
|
||||
- binding_expression_is_rewrapped_so_one_click_upload_cannot_silently_lose_it
|
||||
- legacy_object_stored_value_is_returned_losslessly
|
||||
- non_image_widget_values_are_not_rewrapped
|
||||
- node_axis_roundtrip_is_a_fixed_point_after_first_pass
|
||||
- dark_scope_stays_readonly_for_inline_apply
|
||||
# ③ 위젯 표면
|
||||
- single_value_slot_control_hides_display_mode_buttons_entirely
|
||||
- single_value_slot_control_emits_url_only_object_without_size_repeat_position
|
||||
- single_value_slot_preview_is_pinned_to_contain_not_a_false_preview
|
||||
- bundle_control_keeps_display_mode_buttons_and_active_state
|
||||
- apply_undeclared_control_keeps_display_mode_buttons_blacklist_not_whitelist
|
||||
- bound_value_shows_expression_badge_and_locks_destructive_controls
|
||||
- replace_affordance_reopens_editing_without_emitting_a_change
|
||||
- bound_value_protection_is_a_single_shared_gate_for_every_widget
|
||||
- restore_affordance_recovers_the_original_binding_after_replacement
|
||||
# ④ 런타임 방어 (템플릿 Img)
|
||||
- img_string_src_passes_through_unchanged
|
||||
- img_image_value_object_resolves_to_its_url
|
||||
- img_corrupted_value_attaches_no_src_neither_object_object_nor_empty_string
|
||||
- img_object_with_key_outside_the_four_is_rejected_same_strictness_as_backfill
|
||||
- img_other_attributes_pass_through_spread_regression_zero
|
||||
# ⑤ 백필 (업그레이드 스텝)
|
||||
- backfill_narrows_prop_slot_objects_in_layouts_and_extensions
|
||||
- backfill_strict_predicate_leaves_normal_props_untouched
|
||||
- backfill_excludes_style_slot_where_background_decomposition_lives
|
||||
- backfill_traverses_responsive_branches_nested_nodes_and_injections
|
||||
- backfill_traverses_modals_in_both_list_and_map_shape
|
||||
- backfill_drops_prop_key_on_corrupted_url_with_warning_log
|
||||
- backfill_is_idempotent_and_performs_no_update_on_unchanged_rows
|
||||
- backfill_increments_lock_version_only_on_changed_rows
|
||||
- backfill_preserves_original_content_hash_so_modified_judgement_holds
|
||||
- backfill_leaves_version_and_preview_snapshots_untouched
|
||||
- backfill_converts_soft_deleted_rows_for_restore_safety
|
||||
- backfill_uses_keyset_chunking_so_no_row_is_skipped_past_the_boundary
|
||||
- backfill_skips_broken_json_rows_without_aborting_the_run
|
||||
|
||||
test_files:
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/spec/recipeEngine.test.ts
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/components/property-controls.test.tsx
|
||||
- resources/js/core/template-engine/layout-editor/__tests__/components/ControlRenderer.boundValueGuard.test.tsx
|
||||
- templates/_bundled/sirsoft-basic/src/components/basic/__tests__/Img.test.tsx
|
||||
- tests/Feature/Upgrades/Upgrade7011ImageObjectPropBackfillTest.php
|
||||
- tests/Playwright/specs/layout-editor/image-widget-value-shape.spec.ts
|
||||
|
||||
validation:
|
||||
note: |
|
||||
백필 축(⑤)은 DB 데이터 상태라 브라우저 E2E 로 재현할 수 없다. PHPUnit 26케이스가
|
||||
판정식 엄격도·순회 범위·멱등·lock_version·hash 불변·스냅샷 미개입을 전수 잠근다.
|
||||
|
||||
`image` 위젯을 값이 스칼라로 축약되지 않는 apply 경로(classToken/cssVar/apply 미선언)에
|
||||
새로 연결하는 것은 정적 검사가 저장 이전에 차단한다 — 그래서 그 조합은 런타임에 도달
|
||||
불가이며 exclusions 에 명시했다.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* 코어 7.0.11 업그레이드 스텝
|
||||
*
|
||||
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.0.11/` 안에 격리된다:
|
||||
*
|
||||
* - migrations/
|
||||
* 01_NarrowImageObjectPropsInLayouts.php
|
||||
* 저장된 레이아웃의 **prop 자리**에 통째로 들어간 이미지 값 객체
|
||||
* (`{url,size,repeat,position}`)를 그 `url` 문자열로 축약한다. 레이아웃 편집기의
|
||||
* `image` 위젯이 배경용 객체를 내보내는데 `propValue` 경로가 그것을 그대로
|
||||
* 기록해, 소비 컴포넌트가 `<Img src={객체}>` 로 받아 `[object Object]` 를 URL 로
|
||||
* 해석하던 결함(공개 #135)의 **기존 데이터 보정**이다. 편집기 쪽 소스 교정만으로는
|
||||
* 이미 저장된 설치본이 낫지 않는다.
|
||||
*
|
||||
* 실행 순서는 파일명 정렬(`sort()`)을 따른다.
|
||||
*
|
||||
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음
|
||||
* (`run()` 은 final 이라 override 자체가 불가능하다).
|
||||
*
|
||||
* @upgrade-path 7.0.x → 7.0.11
|
||||
*
|
||||
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.0.11/migrations/` 의 버전 namespace
|
||||
* 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작
|
||||
* 100% 동일 보장" invariant 가 성립.
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md §13 "버전별 데이터 스냅샷"
|
||||
*/
|
||||
class Upgrade_7_0_11 extends AbstractUpgradeStep
|
||||
{
|
||||
// 모든 로직 위임 — data/7.0.11/ 가 SSoT.
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\V7_0_11\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 저장된 레이아웃의 prop 자리에 통째로 들어간 이미지 값 객체를 url 문자열로 축약합니다.
|
||||
*
|
||||
* 레이아웃 편집기의 `image` 위젯은 배경 이미지용으로 설계되어
|
||||
* `{url, size, repeat, position}` **객체**를 내보낸다. 그 값을 노드에 기록하는 경로 중
|
||||
* `styleProp` 의 다중 속성 묶음만 객체를 CSS 4속성으로 분해했고, `propValue` 는 객체를
|
||||
* 그대로 `props[key]` 에 기록했다. 소비 컴포넌트가 `<Img src={객체}>` 로 받으면 브라우저가
|
||||
* `[object Object]` 를 URL 로 해석해 이미지가 깨진다(공개 #135).
|
||||
*
|
||||
* 이 결함은 예외도 콘솔 오류도 서버 로그도 남기지 않는다 — 깨진 이미지 요청은 SPA
|
||||
* catch-all 때문에 404 조차 아니라 200(HTML)을 받고, 편집기 위젯의 미리보기는 정상이라
|
||||
* 조작 중에는 이상이 보이지 않는다. 소스 교정만으로는 이미 저장된 설치본이 낫지 않으므로
|
||||
* 여기서 보정한다.
|
||||
*
|
||||
* ── 판정식 엄격도 (이 마이그레이션에서 가장 중요한 사실) ─────────────────────────
|
||||
*
|
||||
* 코어 편집기 엔진의 `isImageValueObject` 는 4키 중 **하나라도** 있으면 참이다. 그것을
|
||||
* 여기에 이식하면 저장된 레이아웃이 대량 파괴된다 — 레이아웃 JSON 전수 실측에서 느슨한
|
||||
* 판정식은 2,219건을 매치했고 그중 정상 props 가 대부분이었다(`{className,name,size}`
|
||||
* 674건 · `{name,size}` 552건 · `{items,position}` 26건). 엄격 판정식(키 집합 ⊆ 4키
|
||||
* **AND** `url` 키 존재)의 매치는 0건이었다.
|
||||
*
|
||||
* 두 판정식이 다른 것은 실수가 아니라 의도다 — 엔진은 "방금 위젯이 만든 값"을 보므로
|
||||
* 느슨해도 안전하고, 백필은 "임의 사용자 레이아웃 전수"를 보므로 정밀도 요구가 다르다.
|
||||
*
|
||||
* ── 순회 범위 ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* "prop 자리" 는 키 이름 allowlist 로 정의할 수 없다 — 실측상 `props` 안에 컴포넌트 노드가
|
||||
* 1,150건 산다(`props.cardColumns[0].cellChildren[0]`, `props.itemTemplate` …). 임의의
|
||||
* prop 키가 노드를 품을 수 있으므로 **모드 플래그 전역 재귀**를 쓴다: `props` 키를 만나면
|
||||
* 모드 ON, `style` 키를 만나면 OFF(배경 4속성 분해값은 정상), 그 외는 현재 모드 승계.
|
||||
* 이 한 규칙으로 `node.props.*` · `responsive.{bp}.props.*` · 중첩 노드의 props ·
|
||||
* 확장 content 의 `injections[].props` 가 전부 포섭되고, `children`/`slots`/`modals`
|
||||
* 등은 그냥 통과 지점이라 열거할 필요가 없다.
|
||||
*
|
||||
* ── 안전성 ───────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* - **멱등**: 이미 문자열인 값은 `is_array` 에서 탈락하므로 2회차 변경 건수는 구조적으로 0.
|
||||
* - **무변경 행 UPDATE 금지**: 변경 카운터로 판정한다. 재직렬화 결과 비교로 판정하면
|
||||
* 키 순서·escape 차이로 전 행을 오탐 UPDATE 시킨다.
|
||||
* - **`chunkById`**: 콜백이 `content` 를 update 하므로 OFFSET 방식은 커서가 밀려
|
||||
* 미처리 행을 조용히 건너뛴다.
|
||||
* - **사전 필터 없음**: `content LIKE '%"repeat"%'` 는 인덱스를 못 타 어차피 풀스캔이고,
|
||||
* escape·공백 변형에 따라 오탐이 아니라 **누락**을 만든다 — 누락은 흔적이 없다.
|
||||
* - **soft delete 행 포함**: 삭제된 레이아웃은 복원 가능하므로 고치지 않으면 복원 시
|
||||
* 결함이 되살아난다. `DB::table()` 은 SoftDeletes 스코프를 타지 않아 기본 동작이 곧
|
||||
* 원하는 동작이다.
|
||||
* - **`lock_version` 은 변경된 행만 +1**: 업데이트 직전에 편집기 탭을 열어 둔 운영자가
|
||||
* 업데이트 후 저장하면, 올리지 않은 경우 그의 stale content(= 객체값 그대로)가 조용히
|
||||
* 백필을 되돌린다. 올리면 "다른 곳에서 수정됨" 으로 정확히 거부된다.
|
||||
* - **`updated_by` 미변경**: 시스템 보정이지 사람의 편집이 아니다.
|
||||
* - **`original_content_hash` 미변경**: 재계산하면 사용자 편집본이 "원본 그대로" 로
|
||||
* 위장되어 다음 `template:update --preserve-modified` 가 그 레이아웃을 덮어쓴다.
|
||||
* - **버전·미리보기 스냅샷 미개입**: `template_layout_versions` ·
|
||||
* `template_layout_previews` 는 대상이 아니다. 과거 스냅샷을 고치면 「이전 버전으로
|
||||
* 되돌리기」가 원본과 달라진다. 그리고 그 미개입이 곧 본 마이그레이션의 안전망이다 —
|
||||
* 되돌리기는 스텝 규격에 없으므로(`DataMigration` 은 `name()`+`run()` 뿐) 운영자의
|
||||
* 개별 복구 경로는 관리자 UI 의 레이아웃 이력이다.
|
||||
*
|
||||
* V-1 안전: `Illuminate\Support\Facades\{DB,Schema}` + 로컬 private 헬퍼만 사용한다
|
||||
* (Service/Manager/Repository 컨테이너 해석 금지 — 그 클래스들은 스텝 실행 시점에 이전
|
||||
* 버전 표면일 수 있다).
|
||||
*/
|
||||
class NarrowImageObjectPropsInLayouts implements DataMigration
|
||||
{
|
||||
/** 이미지 값 객체가 가질 수 있는 키의 전부 */
|
||||
private const IMAGE_VALUE_KEYS = ['url', 'size', 'repeat', 'position'];
|
||||
|
||||
/**
|
||||
* 모드 전이 키 → 그 아래의 prop 자리 여부.
|
||||
*
|
||||
* 이 목록에 있는 키는 **컨테이너지 값이 아니다** — 두 사실(모드를 어떻게 바꾸는가,
|
||||
* 값 판정에서 제외해야 하는가)이 같은 목록에서 나와야 한다. 따로 적어 두면 나중에
|
||||
* 전이 키가 하나 늘 때 한쪽만 갱신되어, 그 컨테이너가 값으로 평가된다.
|
||||
*/
|
||||
private const CONTAINER_MODE = ['props' => true, 'style' => false];
|
||||
|
||||
/** 대상 테이블 — 버전/미리보기 스냅샷은 의도적으로 제외한다 */
|
||||
private const TARGET_TABLES = ['template_layouts', 'template_layout_extensions'];
|
||||
|
||||
/** 한 청크의 행 수 */
|
||||
private const CHUNK_SIZE = 100;
|
||||
|
||||
/** 손상 url 로 prop 키를 제거한 건수(로그용 누적) */
|
||||
private int $droppedCount = 0;
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자를 반환합니다.
|
||||
*
|
||||
* @return string 마이그레이션 이름
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'NarrowImageObjectPropsInLayouts';
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 테이블의 `content` JSON 을 순회하며 prop 자리의 이미지 값 객체를 축약합니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
* @return void
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
foreach (self::TARGET_TABLES as $table) {
|
||||
$this->processTable($context, $table);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 테이블을 청크 단위로 순회하며 보정합니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
* @param string $table 대상 테이블명
|
||||
* @return void
|
||||
*/
|
||||
private function processTable(UpgradeContext $context, string $table): void
|
||||
{
|
||||
if (! Schema::hasTable($table)) {
|
||||
$context->logger->info("[core:7.0.11] {$table} 테이블 부재 — 이미지 값 객체 축약 스킵");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn($table, 'content')) {
|
||||
$context->logger->info("[core:7.0.11] {$table}.content 컬럼 부재 — 이미지 값 객체 축약 스킵");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hasLockVersion = Schema::hasColumn($table, 'lock_version');
|
||||
$updatedRows = 0;
|
||||
$changedProps = 0;
|
||||
$failedRows = 0;
|
||||
$this->droppedCount = 0;
|
||||
|
||||
DB::table($table)
|
||||
->select(['id', 'content'])
|
||||
->orderBy('id')
|
||||
->chunkById(self::CHUNK_SIZE, function ($rows) use (
|
||||
$context,
|
||||
$table,
|
||||
$hasLockVersion,
|
||||
&$updatedRows,
|
||||
&$changedProps,
|
||||
&$failedRows
|
||||
) {
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$changed = $this->processRow($context, $table, $row, $hasLockVersion);
|
||||
if ($changed > 0) {
|
||||
$updatedRows++;
|
||||
$changedProps += $changed;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// 행 단위 격리 — 예외를 전파하면 CoreUpdateCommand 가 백업 복원(롤백)을
|
||||
// 트리거한다. 손상 레이아웃 1건 때문에 코어 업데이트 전체를 되돌리는 것은
|
||||
// 비례하지 않는다.
|
||||
$failedRows++;
|
||||
$context->logger->warning(
|
||||
"[core:7.0.11] {$table} id={$row->id} 이미지 값 객체 축약 실패(건너뜀) — ".$e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($failedRows > 0) {
|
||||
$context->logger->warning(
|
||||
"[core:7.0.11] {$table} 처리 실패 행 {$failedRows}건 — 위 경고의 id 를 확인하세요"
|
||||
);
|
||||
}
|
||||
|
||||
if ($updatedRows === 0) {
|
||||
$context->logger->info("[core:7.0.11] {$table} 보정 대상 없음 — 이미 정상");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$context->logger->info(
|
||||
"[core:7.0.11] {$table} 이미지 값 객체 축약 완료 — 행 {$updatedRows}건 / prop {$changedProps}건"
|
||||
.($this->droppedCount > 0 ? " / 손상 url 로 제거한 prop {$this->droppedCount}건" : '')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 행의 content JSON 을 보정하고, 변경이 있을 때만 UPDATE 합니다.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
* @param string $table 대상 테이블명
|
||||
* @param object $row `id` + `content` 를 가진 행
|
||||
* @param bool $hasLockVersion lock_version 컬럼 보유 여부
|
||||
* @return int 변경된 prop 개수 (0이면 UPDATE 미수행)
|
||||
*/
|
||||
private function processRow(UpgradeContext $context, string $table, object $row, bool $hasLockVersion): int
|
||||
{
|
||||
if (! is_string($row->content) || $row->content === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$decoded = json_decode($row->content, true);
|
||||
if (! is_array($decoded)) {
|
||||
$context->logger->warning(
|
||||
"[core:7.0.11] {$table} id={$row->id} content JSON 파싱 실패(건너뜀)"
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
$next = $this->transform($decoded, false, $changed, $context, $table, (string) $row->id, '');
|
||||
|
||||
if ($changed === 0) {
|
||||
// 무변경 행은 UPDATE 하지 않는다 — updated_at·lock_version 을 건드리면
|
||||
// 「수정됨」 판정과 낙관적 잠금이 이유 없이 흔들린다.
|
||||
return 0;
|
||||
}
|
||||
|
||||
$encoded = json_encode($next, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (! is_string($encoded)) {
|
||||
$context->logger->warning(
|
||||
"[core:7.0.11] {$table} id={$row->id} content 재직렬화 실패(건너뜀)"
|
||||
);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$update = ['content' => $encoded];
|
||||
if ($hasLockVersion) {
|
||||
// 편집기 탭을 열어 둔 운영자의 stale 저장이 백필을 되돌리지 않도록 올린다.
|
||||
$update['lock_version'] = DB::raw('lock_version + 1');
|
||||
}
|
||||
|
||||
DB::table($table)->where('id', $row->id)->update($update);
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* content 트리를 재귀 순회하며 prop 자리의 이미지 값 객체를 축약합니다.
|
||||
*
|
||||
* `props` 키 아래로 들어가면 모드 ON, `style` 키 아래로 들어가면 OFF, 그 외는 현재
|
||||
* 모드를 승계한다. 이 한 규칙이 임의 깊이의 중첩 노드까지 포섭한다.
|
||||
*
|
||||
* @param mixed $value 현재 노드(맵/리스트/스칼라)
|
||||
* @param bool $inPropSlot 현재 위치가 prop 자리인지
|
||||
* @param int $changed 변경 카운터 (참조 누적)
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트
|
||||
* @param string $table 대상 테이블명 (로그용)
|
||||
* @param string $rowId 행 id (로그용)
|
||||
* @param string $path JSON 경로 (로그용)
|
||||
* @return mixed 보정된 값
|
||||
*/
|
||||
private function transform(
|
||||
mixed $value,
|
||||
bool $inPropSlot,
|
||||
int &$changed,
|
||||
UpgradeContext $context,
|
||||
string $table,
|
||||
string $rowId,
|
||||
string $path
|
||||
): mixed {
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// 리스트 — 모드를 그대로 승계하며 각 원소를 내려간다.
|
||||
if (array_is_list($value)) {
|
||||
foreach ($value as $i => $item) {
|
||||
$value[$i] = $this->transform(
|
||||
$item, $inPropSlot, $changed, $context, $table, $rowId, $path.'['.$i.']'
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $child) {
|
||||
$childPath = $path === '' ? (string) $key : $path.'.'.$key;
|
||||
|
||||
// 모드 전이 — `props` 는 prop 자리 진입, `style` 은 CSS 자리(배경 4속성 분해값이
|
||||
// 정상적으로 사는 곳)라 이탈. 그 외 키는 현재 모드를 그대로 승계한다.
|
||||
$isContainerKey = array_key_exists($key, self::CONTAINER_MODE);
|
||||
$childMode = $isContainerKey ? self::CONTAINER_MODE[$key] : $inPropSlot;
|
||||
|
||||
// 값 판정은 **자식이 놓인 슬롯**($inPropSlot) 기준이다. $childMode 는 자식 *안으로*
|
||||
// 내려갈 때의 모드라, 그것으로 게이트하면 `props` 컨테이너 자신이 값으로 평가된다 —
|
||||
// `url` prop 을 받는 컴포넌트(`{"url":"/embed/x","size":"lg"}`)는 props 맵 자체가
|
||||
// 「4키 부분집합 + url 보유」라 판정식을 통과해, props 가 통째로 문자열로 붕괴하거나
|
||||
// (손상 url 이면) 키째 삭제된다. 컨테이너 키는 값이 아니므로 판정에서 제외한다.
|
||||
if ($inPropSlot && ! $isContainerKey && $this->isImageValueObject($child)) {
|
||||
$url = $child['url'] ?? null;
|
||||
|
||||
if (is_string($url) && $url !== '') {
|
||||
$value[$key] = $url;
|
||||
$changed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 손상값 — 스칼라가 아니거나 빈 문자열이면 어떤 렌더 경로에서도 유효한 src 가
|
||||
// 될 수 없다. prop 키를 제거해 컴포넌트의 폴백(사이트명 텍스트 등)을 살린다.
|
||||
// 파괴적 동작이므로 원본을 로그에 남긴다.
|
||||
$context->logger->warning(
|
||||
"[core:7.0.11] {$table} id={$rowId} {$childPath} — url 이 유효하지 않아 prop 제거: "
|
||||
.json_encode($child, JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
unset($value[$key]);
|
||||
$changed++;
|
||||
$this->droppedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$value[$key] = $this->transform(
|
||||
$child, $childMode, $changed, $context, $table, $rowId, $childPath
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값이 이미지 값 객체인지 **엄격하게** 판정합니다.
|
||||
*
|
||||
* 키 집합이 4키의 부분집합이고 `url` 키를 실제로 보유해야 한다. 코어 엔진의 느슨한
|
||||
* 판정식(4키 중 하나라도)을 여기에 쓰면 정상 props 를 대량 파괴한다 — 클래스 docblock
|
||||
* 의 실측 수치 참조.
|
||||
*
|
||||
* @param mixed $v 판정 대상
|
||||
* @return bool 이미지 값 객체이면 true
|
||||
*/
|
||||
private function isImageValueObject(mixed $v): bool
|
||||
{
|
||||
if (! is_array($v) || $v === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_is_list($v)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (array_keys($v) as $k) {
|
||||
if (! in_array($k, self::IMAGE_VALUE_KEYS, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return array_key_exists('url', $v);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user