fix(core,extensions): 창 크기 변경 후 저장 시 편집기 본문이 사라지던 문제
저장소 B(_global._local)를 동기화하는 두 지점이 저장소 A 계열 스냅샷을 그대로
넘겨 왔다. setGlobalState 는 _local 을 얕게 병합하므로 그 쓰기는 patch 가 아니라
통째 교체이며, A 가 아직 받지 못한 값 — CKEditor 가 setLocal({render:false,
selfManaged:true}) 로 B 에만 쓴 본문 — 이 예외도 로그도 없이 사라진다.
handleSetState COMPONENT 분기의 B 쓰기와 sequence 반환값을 함께 live B 기반으로
바꾼다. 요청 body 는 B 가 아니라 그 반환값에서 오므로, B 쓰기만 고치면 저장은
여전히 422 가 난다. 같은 형태의 setParentLocal 도 함께 정정했다.
트리거는 브레이크포인트 교차가 아니라 19px 폭 변경이다 — 모바일 URL바 숨김·
가상키보드·화면 회전이 모두 해당한다. 작성 화면은 422 로, 수정 화면은 성공
토스트와 함께 조용한 데이터 손실로 나타났다.
함께 담은 것:
- ckeditor5: 미리보기 입력이 설정으로 저장되던 문제, 본문만 고치면 저장 버튼이
비활성으로 남던 문제
- board: 일괄 적용 확인 창의 항목 목록이 통째로 렌더되지 않던 문제(표현식
평가기가 콜백 안 멤버 대입을 거부), 「금지어」 라벨 누락
- 확장 문서의 Playwright 명령이 코어 config 를 잡아 "No tests found" 로 조용히
0건이 지나가던 문제
This commit is contained in:
@@ -313,6 +313,21 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
|||||||
| await 후 캡처된 상태 사용 | await 후 `G7Core.state.getLocal()` 재조회 |
|
| await 후 캡처된 상태 사용 | await 후 `G7Core.state.getLocal()` 재조회 |
|
||||||
| setState params 키에 `{{}}` 사용 | 키는 정적 경로만, 배열 조작은 `.map()`/`.filter()` |
|
| setState params 키에 `{{}}` 사용 | 키는 정적 경로만, 배열 조작은 `.map()`/`.filter()` |
|
||||||
|
|
||||||
|
### 저장소 B 통째 교체 금지
|
||||||
|
|
||||||
|
엔진은 폼 상태를 React `localDynamicState`(저장소 A)와 `globalState._local`(저장소 B)에 이중 저장한다. `TemplateApp.setGlobalState` 는 최상위 키를 **얕게** 병합하므로 `setGlobalState({ _local: X })` 는 B 를 patch 가 아니라 **통째 교체**한다. X 가 A 계열 스냅샷이면, A 가 아직 받지 못한 값이 조용히 사라진다.
|
||||||
|
|
||||||
|
| 금지 | 올바른 사용 |
|
||||||
|
|------|------------|
|
||||||
|
| `globalStateUpdater({ _local: <A 계열 스냅샷> })` (저장소 B 통째 교체) | live B(`getGlobalState()._local`)를 base 로 변경 키만 얹기 |
|
||||||
|
| sequence 반환값을 stale base 로 구성 | 반환값도 live B 기반 + `addMissingLeafKeys` 로 A 전용 키 보충 |
|
||||||
|
| 두 쓰기 경로(B 쓰기 / 반환값)에 서로 다른 병합 규칙 | 같은 규칙 — 갈라지면 나중에 소비자가 생길 때 어느 경로를 탔느냐로 결과가 달라진다 |
|
||||||
|
| `__g7ForcedLocalFields` 오버레이가 있으니 `context.state` 도 최신이라고 가정 | 그 오버레이는 `extendedDataContext` **useMemo 안에서 읽는 window 전역**이라 deps 가 아니다 — memo 가 재계산되지 않으면 실리지 않는다 |
|
||||||
|
|
||||||
|
A 가 값을 못 받는 대표 경로는 `setLocal({ render: false, selfManaged: true })`(CKEditor 등 자체 DOM 관리 플러그인)다. `render:false` 는 `updateTemplateData` 앞에서 조기 return 하고 액션 밖이라 `__g7ActionContext` 도 없으므로 **React 렌더가 0회** — memo 가 재계산되지 않아 `context.state` 가 입력 이전 스냅샷으로 고정된다. 여기에 폭 변경 리렌더가 `__g7PendingLocalState` 를 null 로 지우면(의존성 배열 없는 `useLayoutEffect`) base 가 stale A 로 떨어진다.
|
||||||
|
|
||||||
|
이 결함군은 예외도 콘솔 에러도 남기지 않는다 — 화면에는 본문이 그대로 보이는데 요청 body 만 비어 나가고(작성 화면 422), 수정 화면에서는 성공 토스트와 함께 **직전 본문이 저장되어 편집분이 사라진다**. 정적 검사가 `_local` 동기화 호출의 base 를 검사한다.
|
||||||
|
|
||||||
### 핸들러 정의
|
### 핸들러 정의
|
||||||
|
|
||||||
| 금지 | 올바른 사용 |
|
| 금지 | 올바른 사용 |
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
- 실제 화면 동작에 쓰이는 라이브러리(axios·laravel-echo·pusher-js)가 개발용으로 분류돼 있어 보안 점검에서 빠지던 문제를 수정했습니다. 이제 점검 대상에 포함되며, 함께 확인된 axios 취약점도 1.20.0 으로 올려 해소했습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||||
|
- 글을 쓰다 브라우저 창 크기가 바뀌면 저장 시 본문이 사라지던 문제를 수정했습니다. 새 글은 「내용은 필수입니다」로 저장에 실패했고, 글 수정에서는 저장에 성공한 것처럼 보이면서 그때까지 고친 내용이 사라졌습니다. 창 크기를 조금만 바꿔도(20픽셀 이내) 발생했으므로, 휴대폰에서 주소창이 숨겨지거나 키보드가 올라오거나 화면을 돌리는 것도 같은 상황입니다. 게시판 글쓰기(사용자·관리자), 페이지 본문, 상품 상세설명, 상품 공통정보 화면이 대상입니다. (#130 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||||
- 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
- 서버측 라이브러리 guzzle·commonmark 의 알려진 취약점 12건을 해소했습니다. 결제·본인인증·알림 발송처럼 외부와 통신하는 경로가 이 라이브러리를 사용합니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||||
- 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS).
|
- 초기 화면 파일을 명령줄과 웹이 번갈아 만들 때, 나중에 생기는 하위 폴더가 한쪽 계정 전용으로 남아 다른 쪽이 쓰지 못하던 문제를 수정했습니다. 게시 폴더가 그룹 권한을 하위 폴더에 물려주도록 정리합니다(Linux·macOS).
|
||||||
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
|
- 확장 설치가 의존성·버전 검사에서 실패해도 복사된 파일이 남아, 목록에도 보이지 않는 디렉토리가 쌓이던 문제를 수정했습니다. 실패한 설치는 이번에 만든 파일을 되돌립니다(이미 설치돼 있던 확장을 다시 설치하다 실패한 경우에는 기존 파일을 건드리지 않습니다).
|
||||||
|
|||||||
@@ -160,9 +160,13 @@ class TestPathCollector
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($playwright['count'] > 0) {
|
if ($playwright['count'] > 0) {
|
||||||
|
// 확장은 자기 config 를 `tests/Playwright/` 아래 두므로 저장소 루트에서 부르면
|
||||||
|
// 코어 config(testDir: tests/Playwright/specs)가 잡혀 그 spec 이 모집단 밖이 된다.
|
||||||
|
// 결과는 실패가 아니라 "No tests found" — 돌렸다고 착각한 채 0건이 지나가고,
|
||||||
|
// 코어 globalSetup 이 개발 사이트에 시드 화면을 설치·제거하는 부작용만 남는다.
|
||||||
$commands[] = [
|
$commands[] = [
|
||||||
'label' => 'Playwright E2E',
|
'label' => 'Playwright E2E (확장 디렉토리에서)',
|
||||||
'command' => "npx playwright test {$rel}/tests/Playwright/specs/<대상>.spec.ts",
|
'command' => "cd {$rel} && npm run test:e2e -- specs/<대상>.spec.ts",
|
||||||
'shell' => 'Bash',
|
'shell' => 'Bash',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-09-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 게시판 환경설정 > 일괄 적용 확인 창의 「금지어」 항목 이름 일본어 번역을 추가했습니다 (`admin/settings.bulk_apply.field_labels.blocked_keywords`).
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-19
|
## [1.0.3] - 2026-08-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -310,6 +310,7 @@
|
|||||||
"notify_admin_on_post": "管理者通知",
|
"notify_admin_on_post": "管理者通知",
|
||||||
"notify_author": "投稿者通知",
|
"notify_author": "投稿者通知",
|
||||||
"new_display_hours": "新着表示時間",
|
"new_display_hours": "新着表示時間",
|
||||||
|
"blocked_keywords": "禁止ワード",
|
||||||
"default_board_permissions": "デフォルト権限",
|
"default_board_permissions": "デフォルト権限",
|
||||||
"manager": "掲示板管理"
|
"manager": "掲示板管理"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"en": "G7 module (sirsoft-board) Japanese language pack (bundled)",
|
"en": "G7 module (sirsoft-board) Japanese language pack (bundled)",
|
||||||
"ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)"
|
"ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)"
|
||||||
},
|
},
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scope": "module",
|
"scope": "module",
|
||||||
"target_identifier": "sirsoft-board",
|
"target_identifier": "sirsoft-board",
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ CRUD 흐름 자체를 **자기 도메인으로 대체**하는 가장 무거운
|
|||||||
| 게시글/댓글 삭제·복원 시 `posts_count`/`comments_count` 를 Service 에서 `increment()`/`decrement()` | 훅(`after_create`/`after_delete`/`after_restore`) 리스너의 count 동기화에 맡긴다 | 직접 증감은 훅 기반 동기화와 이중 집계되어 카운트가 어긋난다 |
|
| 게시글/댓글 삭제·복원 시 `posts_count`/`comments_count` 를 Service 에서 `increment()`/`decrement()` | 훅(`after_create`/`after_delete`/`after_restore`) 리스너의 count 동기화에 맡긴다 | 직접 증감은 훅 기반 동기화와 이중 집계되어 카운트가 어긋난다 |
|
||||||
| 게시판별 동적 권한/역할을 board 삭제와 별도 시점에 정리 | `BoardService` 삭제 흐름 안에서 즉시 정리(또는 stale cleanup 이 `getDynamicPermissionIdentifiers()` 로 정확히 판정하게 유지) | 정리가 늦으면 존재하지 않는 게시판의 권한이 역할에 남아 관리 화면에 유령 항목이 뜬다 |
|
| 게시판별 동적 권한/역할을 board 삭제와 별도 시점에 정리 | `BoardService` 삭제 흐름 안에서 즉시 정리(또는 stale cleanup 이 `getDynamicPermissionIdentifiers()` 로 정확히 판정하게 유지) | 정리가 늦으면 존재하지 않는 게시판의 권한이 역할에 남아 관리 화면에 유령 항목이 뜬다 |
|
||||||
| 새 콘텐츠 타입을 board 코드에 `if ($type === 'inquiry')` 로 직접 분기 | `EcommerceInquiryHookListener` 처럼 필터 훅으로 CRUD 를 위임 | board 코드가 알지 못하는 도메인이 늘어날수록 분기가 무한 증식한다 |
|
| 새 콘텐츠 타입을 board 코드에 `if ($type === 'inquiry')` 로 직접 분기 | `EcommerceInquiryHookListener` 처럼 필터 훅으로 CRUD 를 위임 | board 코드가 알지 못하는 도메인이 늘어날수록 분기가 무한 증식한다 |
|
||||||
|
| 레이아웃 표현식에서 누산기에 멤버 대입 (`grouped[k] = []` · `.push()`) 하거나 rest 구조분해 (`const { a, ...rest } = obj`) 사용 | `map` / `filter` / `reduce(Object.assign)` / `Object.fromEntries` 로 누산 없이 구성 | 표현식 평가기가 그 구문을 거부해 **식 전체가 평가되지 않는다.** `iteration.source` 면 배열이 아니게 되어 그 목록이 통째로 렌더되지 않고, `apiCall` body 면 원문 문자열이 그대로 전송된다 — 둘 다 오류도 경고도 남지 않는다 (일괄 적용 확인 창에서 실제 발생) |
|
||||||
|
| 일괄 적용 대상 설정 키를 `sectionMap` 에만 추가 | 같은 키의 `bulk_apply.field_labels` 라벨을 ko/en(+번들 ja)에 함께 추가 | 라벨이 없으면 확인 창에 원시 다국어 키가 그대로 노출된다 |
|
||||||
<!-- @intent END -->
|
<!-- @intent END -->
|
||||||
|
|
||||||
## 7. 테스트 실행
|
## 7. 테스트 실행
|
||||||
@@ -168,8 +170,8 @@ php vendor/bin/phpunit modules/_bundled/sirsoft-board/tests --filter='<대상클
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd modules/_bundled/sirsoft-board && powershell -Command "npm run test:run -- <대상>"
|
cd modules/_bundled/sirsoft-board && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test modules/_bundled/sirsoft-board/tests/Playwright/specs/<대상>.spec.ts
|
cd modules/_bundled/sirsoft-board && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
|
||||||
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 게시판 환경설정 > 게시판 설정 > 일괄 적용에서 [일괄 적용] 확인 창의 「적용할 항목」 목록이 아무것도 표시되지 않던 문제를 고쳤습니다. 이제 선택한 항목이 기본/목록/게시글/댓글/첨부파일/알림/권한 설정별로 묶여 표시되므로, 무엇이 적용되는지 확인한 뒤 실행할 수 있습니다.
|
||||||
|
- 위 목록에서 「금지어」 항목만 이름 대신 내부 식별자가 표시되던 문제를 고쳤습니다.
|
||||||
|
|
||||||
## [1.1.0] - 2026-08-24
|
## [1.1.0] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+80
@@ -30,6 +30,8 @@ import tabSeo from '../../../layouts/admin/partials/admin_board_settings/_tab_se
|
|||||||
|
|
||||||
// 허용 확장자 안내 문구 회귀 검증용 ko lang
|
// 허용 확장자 안내 문구 회귀 검증용 ko lang
|
||||||
import koSettingsLang from '../../../lang/partial/ko/admin/settings.json';
|
import koSettingsLang from '../../../lang/partial/ko/admin/settings.json';
|
||||||
|
// 일괄 적용 라벨 누락 회귀 검증용 en lang
|
||||||
|
import enSettingsLang from '../../../lang/partial/en/admin/settings.json';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
|
* JSON 트리에서 특정 ID를 가진 노드를 재귀적으로 찾습니다.
|
||||||
@@ -1890,3 +1892,81 @@ describe('_tab_seo.json - SEO 설정 탭', () => {
|
|||||||
expect(body).toContain('seo:');
|
expect(body).toContain('seo:');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('일괄 적용 확인 창의 섹션 그루핑 (회귀)', () => {
|
||||||
|
/**
|
||||||
|
* 그루핑 iteration source 에서 sectionMap 키 목록을 뽑습니다.
|
||||||
|
*
|
||||||
|
* @return sectionMap 에 선언된 설정 키 배열
|
||||||
|
*/
|
||||||
|
function sectionMapKeys(): string[] {
|
||||||
|
const source = findGroupingSource();
|
||||||
|
const block = /const sectionMap = \{([\s\S]*?)\};/.exec(source);
|
||||||
|
expect(block, 'iteration source 에 sectionMap 선언이 있어야 한다').not.toBeNull();
|
||||||
|
return [...block![1].matchAll(/([A-Za-z_][\w]*)\s*:\s*'/g)].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 섹션 그루핑 iteration 의 source 표현식을 찾습니다.
|
||||||
|
*
|
||||||
|
* @return iteration source 문자열
|
||||||
|
*/
|
||||||
|
function findGroupingSource(): string {
|
||||||
|
let found: string | null = null;
|
||||||
|
const walk = (node: any): void => {
|
||||||
|
if (!node || typeof node !== 'object') return;
|
||||||
|
if (typeof node?.iteration?.source === 'string' && node.iteration.source.includes('sectionMap')) {
|
||||||
|
found = node.iteration.source;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const v of Object.values(node)) {
|
||||||
|
if (Array.isArray(v)) v.forEach(walk);
|
||||||
|
else if (v && typeof v === 'object') walk(v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(bulkApplyModal);
|
||||||
|
expect(found, '섹션 그루핑 iteration source 를 찾아야 한다').not.toBeNull();
|
||||||
|
return found!;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('표현식이 평가기가 지원하지 않는 구문을 쓰지 않는다 (콜백 안 멤버 대입 / rest 구조분해)', () => {
|
||||||
|
const source = findGroupingSource();
|
||||||
|
|
||||||
|
// 평가기는 콜백 안의 멤버 대입(o[k] = v)과 rest 구조분해를 거부한다.
|
||||||
|
// 쓰면 식 전체가 평가에 실패해 iteration source 가 배열이 아니게 되고,
|
||||||
|
// 이 목록이 **오류 없이 통째로 렌더되지 않는다**. 그래서 구문 자체를 잠근다.
|
||||||
|
expect(source, '콜백 안 멤버 대입 금지').not.toMatch(/\w+\[[^\]]+\]\s*=\s*/);
|
||||||
|
expect(source, '누산기 push 금지').not.toMatch(/\.push\(/);
|
||||||
|
expect(source, 'rest 구조분해 금지').not.toMatch(/\.\.\.\w+\s*\}\s*=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('그루핑 결과가 실제로 배열이다 (죽은 표현식이면 여기서 잡힌다)', () => {
|
||||||
|
const source = findGroupingSource();
|
||||||
|
// 평가기와 동일한 입력 형태로 실행해 배열이 나오는지 본다.
|
||||||
|
const fields = ['secret_mode', 'order_by', 'blocked_keywords', 'manager', 'admin.posts.write'];
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const run = new Function('_global', `return ${source};`);
|
||||||
|
const out = run({ bulkApplySnapshot: { fields } });
|
||||||
|
|
||||||
|
expect(Array.isArray(out), 'iteration source 는 배열을 반환해야 한다').toBe(true);
|
||||||
|
expect(out.length, '선택한 필드가 섹션별로 묶여야 한다').toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const grouped = out.flatMap((g: any) => g.items);
|
||||||
|
expect(grouped.sort(), '선택한 필드가 하나도 빠지지 않아야 한다').toEqual([...fields].sort());
|
||||||
|
for (const g of out) {
|
||||||
|
expect(g.sectionKey, '섹션 키가 있어야 한다').toBeTruthy();
|
||||||
|
expect(g.items.length, '빈 섹션은 나오지 않아야 한다').toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sectionMap 의 모든 키에 ko/en 라벨이 있다 (없으면 화면에 원시 키가 노출된다)', () => {
|
||||||
|
const labelsKo = (koSettingsLang as any).bulk_apply.field_labels;
|
||||||
|
const labelsEn = (enSettingsLang as any).bulk_apply.field_labels;
|
||||||
|
|
||||||
|
const missingKo = sectionMapKeys().filter((k) => !(k in labelsKo));
|
||||||
|
const missingEn = sectionMapKeys().filter((k) => !(k in labelsEn));
|
||||||
|
|
||||||
|
expect(missingKo, `ko 라벨 누락: ${missingKo.join(', ')}`).toEqual([]);
|
||||||
|
expect(missingEn, `en 라벨 누락: ${missingEn.join(', ')}`).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -311,6 +311,7 @@
|
|||||||
"notify_admin_on_post": "Admin Notification",
|
"notify_admin_on_post": "Admin Notification",
|
||||||
"notify_author": "Author Notification",
|
"notify_author": "Author Notification",
|
||||||
"new_display_hours": "New Post Display Hours",
|
"new_display_hours": "New Post Display Hours",
|
||||||
|
"blocked_keywords": "Blocked Keywords",
|
||||||
"default_board_permissions": "Default Permissions",
|
"default_board_permissions": "Default Permissions",
|
||||||
"manager": "Manage Board"
|
"manager": "Manage Board"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,6 +311,7 @@
|
|||||||
"notify_admin_on_post": "관리자 알림",
|
"notify_admin_on_post": "관리자 알림",
|
||||||
"notify_author": "작성자 알림",
|
"notify_author": "작성자 알림",
|
||||||
"new_display_hours": "새글 표시 시간",
|
"new_display_hours": "새글 표시 시간",
|
||||||
|
"blocked_keywords": "금지어",
|
||||||
"default_board_permissions": "기본 권한",
|
"default_board_permissions": "기본 권한",
|
||||||
"manager": "게시판 관리"
|
"manager": "게시판 관리"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -129,11 +129,11 @@
|
|||||||
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.no_fields"
|
"text": "$t:sirsoft-board.admin.settings.bulk_apply_modal.no_fields"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"comment": "섹션별 그루핑 iteration",
|
"comment": "섹션별 그루핑 iteration. 표현식 평가기는 콜백 안의 멤버 대입(grouped[sk] = [] / .push())을 허용하지 않는다 — 쓰면 식 전체가 평가에 실패해 iteration source 가 배열이 아니게 되고, 이 목록이 오류 없이 통째로 렌더되지 않는다. 누산기 없이 map + filter 로만 구성한다.",
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Div",
|
"name": "Div",
|
||||||
"iteration": {
|
"iteration": {
|
||||||
"source": "(function() { const fields = _global.bulkApplySnapshot?.fields ?? []; const sectionMap = { type:'basic', show_view_count:'basic', secret_mode:'basic', use_comment:'basic', use_reply:'basic', use_report:'basic', per_page:'list', per_page_mobile:'list', order_by:'list', order_direction:'list', min_title_length:'post', max_title_length:'post', min_content_length:'post', max_content_length:'post', max_reply_depth:'post', blocked_keywords:'post', new_display_hours:'post', comment_order:'comment', min_comment_length:'comment', max_comment_length:'comment', max_comment_depth:'comment', use_file_upload:'attachment', max_file_size:'attachment', max_file_count:'attachment', allowed_extensions:'attachment', notify_admin_on_post:'notification', notify_author:'notification', manager:'permissions' }; const sectionOrder = ['basic','list','post','comment','attachment','notification','permissions']; const grouped = {}; fields.forEach(k => { const sk = k.includes('.') ? 'permissions' : (sectionMap[k] ?? 'basic'); if (!grouped[sk]) grouped[sk] = []; grouped[sk].push(k); }); return sectionOrder.filter(sk => grouped[sk]).map(sk => ({ sectionKey: sk, items: grouped[sk] })); })()",
|
"source": "(function() { const fields = _global.bulkApplySnapshot?.fields ?? []; const sectionMap = { type:'basic', show_view_count:'basic', secret_mode:'basic', use_comment:'basic', use_reply:'basic', use_report:'basic', per_page:'list', per_page_mobile:'list', order_by:'list', order_direction:'list', min_title_length:'post', max_title_length:'post', min_content_length:'post', max_content_length:'post', max_reply_depth:'post', blocked_keywords:'post', new_display_hours:'post', comment_order:'comment', min_comment_length:'comment', max_comment_length:'comment', max_comment_depth:'comment', use_file_upload:'attachment', max_file_size:'attachment', max_file_count:'attachment', allowed_extensions:'attachment', notify_admin_on_post:'notification', notify_author:'notification', manager:'permissions' }; const sectionOrder = ['basic','list','post','comment','attachment','notification','permissions']; return sectionOrder.map(sk => ({ sectionKey: sk, items: fields.filter(k => (k.includes('.') ? 'permissions' : (sectionMap[k] ?? 'basic')) === sk) })).filter(g => g.items.length > 0); })()",
|
||||||
"item_var": "sectionGroup"
|
"item_var": "sectionGroup"
|
||||||
},
|
},
|
||||||
"props": {
|
"props": {
|
||||||
|
|||||||
@@ -209,8 +209,8 @@ php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests --filter='<대
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd modules/_bundled/sirsoft-ecommerce && powershell -Command "npm run test:run -- <대상>"
|
cd modules/_bundled/sirsoft-ecommerce && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/<대상>.spec.ts
|
cd modules/_bundled/sirsoft-ecommerce && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ php vendor/bin/phpunit modules/_bundled/sirsoft-page/tests --filter='<대상클
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd modules/_bundled/sirsoft-page && powershell -Command "npm run test:run -- <대상>"
|
cd modules/_bundled/sirsoft-page && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test modules/_bundled/sirsoft-page/tests/Playwright/specs/<대상>.spec.ts
|
cd modules/_bundled/sirsoft-page && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -171,8 +171,8 @@
|
|||||||
| 종류 | 개수 | 위치 |
|
| 종류 | 개수 | 위치 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| PHPUnit | 15개 | `plugins/_bundled/sirsoft-ckeditor5/tests` |
|
| PHPUnit | 15개 | `plugins/_bundled/sirsoft-ckeditor5/tests` |
|
||||||
| Vitest | 9개 | `vitest.config.ts` |
|
| Vitest | 10개 | `vitest.config.ts` |
|
||||||
| Playwright | 3개 | `tests/Playwright` |
|
| Playwright | 4개 | `tests/Playwright` |
|
||||||
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
|
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
|
||||||
|
|
||||||
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
기저 TestCase: `tests/PluginTestCase.php` — 확장 테스트는 이 클래스를 상속합니다 (`Tests\TestCase` 직접 상속 금지).
|
||||||
@@ -184,8 +184,8 @@ php vendor/bin/phpunit plugins/_bundled/sirsoft-ckeditor5/tests --filter='<대
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd plugins/_bundled/sirsoft-ckeditor5 && powershell -Command "npm run test:run -- <대상>"
|
cd plugins/_bundled/sirsoft-ckeditor5 && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/<대상>.spec.ts
|
cd plugins/_bundled/sirsoft-ckeditor5 && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 관리자 화면에서 글을 수정하다 다른 글의 수정 화면으로 곧바로 옮기면 편집기에 이전 글의 본문이 그대로 남아 있던 문제를 고쳤습니다. 그 상태에서 글자를 입력하면 저장 시 본문이 이전 글의 것으로 덮어써질 수 있었습니다.
|
- 관리자 화면에서 글을 수정하다 다른 글의 수정 화면으로 곧바로 옮기면 편집기에 이전 글의 본문이 그대로 남아 있던 문제를 고쳤습니다. 그 상태에서 글자를 입력하면 저장 시 본문이 이전 글의 것으로 덮어써질 수 있었습니다.
|
||||||
|
- 플러그인 설정 화면에서 미리보기 편집기에 입력한 내용이 설정과 함께 저장되던 문제를 고쳤습니다. 미리보기는 설정을 시험해 보는 자리이므로 저장 대상에서 제외됩니다.
|
||||||
|
- 글을 수정할 때 본문만 고치면 [저장] 버튼이 계속 비활성으로 남아 저장할 수 없던 문제를 고쳤습니다. 제목 등 다른 항목을 함께 건드리면 버튼이 켜졌기 때문에 화면에 따라 드러나기도 하고 아니기도 했습니다.
|
||||||
- 에디터 스타일과 본문 스타일을 불러오지 못했을 때 아무 표시 없이 서식만 깨지던 문제를 고쳤습니다. 이제 다시 시도한 뒤에도 실패하면 화면에 알립니다.
|
- 에디터 스타일과 본문 스타일을 불러오지 못했을 때 아무 표시 없이 서식만 깨지던 문제를 고쳤습니다. 이제 다시 시도한 뒤에도 실패하면 화면에 알립니다.
|
||||||
- 번역 파일을 불러오지 못하면 아무 기록 없이 영어로 표시되던 동작에 경고 기록을 남기고, 일시적인 실패는 다시 시도합니다.
|
- 번역 파일을 불러오지 못하면 아무 기록 없이 영어로 표시되던 동작에 경고 기록을 남기고, 일시적인 실패는 다시 시도합니다.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+121
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 회귀: 본문만 고쳐도 저장 버튼이 활성화된다 (`hasChanges` 를 본문 배치에서 분리)
|
||||||
|
*
|
||||||
|
* 결함: `syncToForm` 이 `hasChanges: true` 를 본문과 **같은**
|
||||||
|
* `setLocal({ render:false, selfManaged:true })` 배치에 넣었다. 그 배치는 React 렌더를
|
||||||
|
* 일으키지 않으므로(성능 — 37,000+ 바인딩 재평가 회피) 저장소 A 는 플래그를 받지 못하고,
|
||||||
|
* 저장 버튼의 활성 조건 `{{!_local.hasChanges || _local.isSaving}}` 이 재평가되지 않는다.
|
||||||
|
* 결과적으로 **본문만 고친 운영자는 저장 자체를 할 수 없다** — 오류도 안내도 없이 버튼이
|
||||||
|
* 계속 비활성으로 남는다.
|
||||||
|
*
|
||||||
|
* 제목 등 다른 입력을 함께 건드리면 그 입력의 자동바인딩이 렌더를 일으켜 증상이 가려지므로,
|
||||||
|
* 화면·게시판에 따라 드러나기도 하고 아니기도 한다(관리자 게시글 수정 화면에서 실측 재현).
|
||||||
|
*
|
||||||
|
* 계약:
|
||||||
|
* 1. `hasChanges` 는 렌더를 일으키는 별도 setLocal 로 보낸다 (render:false 배치에 넣지 않는다)
|
||||||
|
* 2. 이미 true 면 보내지 않는다 — 편집 세션당 추가 렌더는 최대 1회 (성능 회귀 차단)
|
||||||
|
* 3. 본문은 종전대로 debounce + render:false + selfManaged:true 를 유지한다
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { syncToForm } from '../../handlers/initEditor';
|
||||||
|
|
||||||
|
type Call = { updates: Record<string, any>; options?: Record<string, any> };
|
||||||
|
|
||||||
|
let calls: Call[] = [];
|
||||||
|
let localState: Record<string, any> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* G7Core 전역 스텁을 설치합니다.
|
||||||
|
*
|
||||||
|
* @param initialLocal 초기 로컬 상태 (`hasChanges` 포함 가능)
|
||||||
|
*/
|
||||||
|
function stubG7Core(initialLocal: Record<string, any> = {}): void {
|
||||||
|
localState = { ...initialLocal };
|
||||||
|
calls = [];
|
||||||
|
(window as any).G7Core = {
|
||||||
|
state: {
|
||||||
|
getLocal: () => localState,
|
||||||
|
setLocal: (updates: Record<string, any>, options?: Record<string, any>) => {
|
||||||
|
calls.push({ updates, options });
|
||||||
|
localState = { ...localState, ...updates };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `hasChanges` 를 보낸 호출만 고른다. */
|
||||||
|
const hasChangesCalls = () => calls.filter((c) => 'hasChanges' in c.updates);
|
||||||
|
/** 본문(form.*)을 보낸 호출만 고른다. */
|
||||||
|
const contentCalls = () => calls.filter((c) => Object.keys(c.updates).some((k) => k.startsWith('form.')));
|
||||||
|
|
||||||
|
describe('편집기 본문 동기화 — hasChanges 렌더 분리 (저장 버튼 비활성 회귀)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubG7Core();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as any).G7Core;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasChanges 를 render:false 배치에 넣지 않는다 (넣으면 저장 버튼이 안 켜진다)', () => {
|
||||||
|
syncToForm('content', 'ko', '<p>본문</p>', false);
|
||||||
|
|
||||||
|
for (const c of contentCalls()) {
|
||||||
|
expect(
|
||||||
|
'hasChanges' in c.updates,
|
||||||
|
'hasChanges 가 본문 배치에 섞이면 저장소 A 가 못 받아 버튼이 비활성으로 남는다',
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasChanges 는 렌더를 일으키는 setLocal 로 보낸다', () => {
|
||||||
|
syncToForm('content', 'ko', '<p>본문</p>', false);
|
||||||
|
|
||||||
|
const flag = hasChangesCalls();
|
||||||
|
expect(flag, 'hasChanges 를 보내는 호출이 있어야 한다').toHaveLength(1);
|
||||||
|
expect(flag[0].updates.hasChanges).toBe(true);
|
||||||
|
// render:false / selfManaged 가 붙으면 React 가 다시 그리지 않아 버튼이 갱신되지 않는다
|
||||||
|
expect(flag[0].options?.render, 'render:false 면 안 된다').not.toBe(false);
|
||||||
|
expect(flag[0].options?.selfManaged, 'selfManaged 면 안 된다').not.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('이미 hasChanges 가 true 면 다시 보내지 않는다 (세션당 추가 렌더 1회)', () => {
|
||||||
|
stubG7Core({ hasChanges: true });
|
||||||
|
|
||||||
|
syncToForm('content', 'ko', '<p>1</p>', false);
|
||||||
|
syncToForm('content', 'ko', '<p>12</p>', false);
|
||||||
|
syncToForm('content', 'ko', '<p>123</p>', false);
|
||||||
|
|
||||||
|
expect(hasChangesCalls(), '이미 true 면 추가 렌더를 만들지 않는다').toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('연속 입력에서도 hasChanges 렌더는 첫 1회뿐이다', () => {
|
||||||
|
syncToForm('content', 'ko', '<p>a</p>', false);
|
||||||
|
syncToForm('content', 'ko', '<p>ab</p>', false);
|
||||||
|
syncToForm('content', 'ko', '<p>abc</p>', false);
|
||||||
|
|
||||||
|
expect(hasChangesCalls(), '첫 입력에서만 플래그를 올린다').toHaveLength(1);
|
||||||
|
expect(contentCalls(), '본문은 매번 보낸다 (디바운스는 엔진이 처리)').toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('본문 배치는 종전 성능 옵션을 유지한다 (debounce + render:false + selfManaged)', () => {
|
||||||
|
syncToForm('content', 'ko', '<p>본문</p>', false);
|
||||||
|
|
||||||
|
const body = contentCalls();
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].options?.render).toBe(false);
|
||||||
|
expect(body[0].options?.selfManaged).toBe(true);
|
||||||
|
expect(body[0].options?.debounce).toBe(300);
|
||||||
|
expect(body[0].updates['form.content']).toBe('<p>본문</p>');
|
||||||
|
expect(body[0].updates['form.content_mode']).toBe('html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('다국어 모드는 로케일 경로로 보낸다', () => {
|
||||||
|
syncToForm('content', 'ja', '<p>本文</p>', true);
|
||||||
|
|
||||||
|
const body = contentCalls();
|
||||||
|
expect(body[0].updates['form.content.ja']).toBe('<p>本文</p>');
|
||||||
|
expect(hasChangesCalls()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1096,8 +1096,12 @@ function createMultilingualTabs(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 업데이트를 위해 G7Core setState를 호출합니다.
|
* 폼 데이터 업데이트를 위해 G7Core setState를 호출합니다.
|
||||||
|
*
|
||||||
|
* 테스트를 위해 export 한다 — `hasChanges` 를 본문과 분리해 보내는 계약은 화면에서
|
||||||
|
* "저장 버튼이 계속 비활성" 으로만 드러나므로 단위 테스트로 잠근다
|
||||||
|
* (`resolveSingleContent` 와 같은 선례).
|
||||||
*/
|
*/
|
||||||
function syncToForm(
|
export function syncToForm(
|
||||||
name: string,
|
name: string,
|
||||||
locale: string,
|
locale: string,
|
||||||
value: string,
|
value: string,
|
||||||
@@ -1111,9 +1115,24 @@ function syncToForm(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `hasChanges` 는 아래 본문 배치와 함께 보내면 안 된다.
|
||||||
|
//
|
||||||
|
// 그 배치는 `render: false` + `selfManaged: true` 라 React 렌더를 일으키지 않는다(성능 —
|
||||||
|
// 37,000+ 바인딩 재평가 회피). 그런데 저장 버튼의 활성 조건이 `{{!_local.hasChanges || ...}}`
|
||||||
|
// 처럼 이 플래그를 읽는 화면에서는, 플래그가 저장소 B 에만 들어가고 React 가 다시 그리지
|
||||||
|
// 않아 **버튼이 계속 비활성으로 남는다** — 본문만 고친 운영자는 저장 자체를 할 수 없다.
|
||||||
|
// (관리자 게시글 수정 화면에서 실측 재현. 제목 등 다른 입력을 함께 건드리면 그 입력의
|
||||||
|
// 자동바인딩이 렌더를 일으켜 가려지므로, 화면·보드에 따라 드러나기도 하고 아니기도 한다.)
|
||||||
|
//
|
||||||
|
// 이 플래그는 스칼라 한 개라 본문과 달리 성능 사유가 없다. 그래서 렌더를 일으키는 일반
|
||||||
|
// setLocal 로 분리하되, **false → true 로 처음 넘어갈 때만** 보낸다. 이미 true 면 건너뛰므로
|
||||||
|
// 편집 세션당 추가 렌더는 최대 1회다.
|
||||||
|
if (G7Core.state.getLocal?.()?.hasChanges !== true) {
|
||||||
|
G7Core.state.setLocal({ hasChanges: true });
|
||||||
|
}
|
||||||
|
|
||||||
const updates: Record<string, any> = {
|
const updates: Record<string, any> = {
|
||||||
[`form.${name}_mode`]: 'html',
|
[`form.${name}_mode`]: 'html',
|
||||||
hasChanges: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isMultilingual) {
|
if (isMultilingual) {
|
||||||
|
|||||||
@@ -779,11 +779,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"handler": "apiCall",
|
"handler": "apiCall",
|
||||||
|
"comment": "미리보기 편집기(preview_editor)는 props.name 이 preview_content 라 폼 자동바인딩으로 _local.form 에 쌓인다. 저장 대상이 아니므로 body 에서 제외한다 — 그대로 두면 운영자가 미리보기에 입력한 내용이 플러그인 설정으로 저장된다. 표현식 평가기는 구조분해 rest(const { a, ...rest } = obj)와 콜백 안의 멤버 대입(out[k] = v)을 지원하지 않는다 — 쓰면 식이 평가되지 않고 원문 문자열이 그대로 body 로 전송된다(오류 없이 200 이 떨어져 드러나지 않는다). Object.entries/filter/fromEntries 조합만 사용할 것(브라우저 실측으로 확인).",
|
||||||
"auth_required": true,
|
"auth_required": true,
|
||||||
"target": "/api/admin/plugins/{{route.identifier}}/settings",
|
"target": "/api/admin/plugins/{{route.identifier}}/settings",
|
||||||
"params": {
|
"params": {
|
||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"body": "{{_local.form}}"
|
"body": "{{(function() { const form = _local.form || {}; return Object.fromEntries(Object.entries(form).filter(entry => entry[0] !== 'preview_content' && entry[0] !== 'preview_content_mode')); })()}}"
|
||||||
},
|
},
|
||||||
"onSuccess": [
|
"onSuccess": [
|
||||||
{
|
{
|
||||||
|
|||||||
+351
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* E2E: 브라우저 폭이 바뀐 뒤 저장해도 편집기 본문이 사라지지 않는다 (공개 이슈 #130)
|
||||||
|
*
|
||||||
|
* 배경: 엔진은 폼 상태를 React `localDynamicState`(저장소 A)와 `globalState._local`(저장소 B)에
|
||||||
|
* 이중 저장한다. CKEditor 는 `setLocal({ render:false, selfManaged:true })` 로 **B 에만** 본문을
|
||||||
|
* 쓴다 — 그 호출은 React 렌더를 한 번도 일으키지 않으므로 `context.state` 는 입력 이전 스냅샷에
|
||||||
|
* 머문다. 여기에 **브레이크포인트를 넘지 않는 폭 변경**(19px 로 충분)이 겹치면 의존성 배열 없는
|
||||||
|
* `useLayoutEffect` 가 `__g7PendingLocalState` 를 null 로 지우고, 저장 시 setState 의 base 가
|
||||||
|
* stale A 로 떨어져 B 와 sequence 반환값이 함께 오염된다.
|
||||||
|
*
|
||||||
|
* 증상은 화면에 드러나지 않는다:
|
||||||
|
* - 작성 화면: `내용은 필수입니다` 422
|
||||||
|
* - 수정 화면: **성공 토스트가 뜨고 직전 본문이 저장되어 편집분이 사라진다**
|
||||||
|
* - 콘솔 에러 0건
|
||||||
|
*
|
||||||
|
* 그래서 확인해야 하는 것은 "저장됐다" 가 아니라 **요청 body 의 content** 다.
|
||||||
|
*
|
||||||
|
* 결정화(없으면 간헐적 false green):
|
||||||
|
* 1. 타이핑 후 저장소 B 에 마커가 실릴 때까지 poll (CKEditor 디바운스 발화 확정)
|
||||||
|
* 2. 폭 변경
|
||||||
|
* 3. `__g7PendingLocalState === null` 이 될 때까지 poll (리렌더 완료 확정)
|
||||||
|
* 4. 저장 클릭
|
||||||
|
*
|
||||||
|
* 준수 사항:
|
||||||
|
* - 본문 입력은 `pressSequentially` — `fill`/`editor.setData()` 는 `change:data` → `syncToForm`
|
||||||
|
* 경로를 타지 않아 재현 조건 자체가 성립하지 않는다
|
||||||
|
* - 화면 진입 후에는 `page.goto` 금지 (전체 새로고침이 모든 렌더러를 재마운트해 조건이 사라진다)
|
||||||
|
* - 입력 순서는 **제목 먼저 → 본문 나중** 고정 (본문 뒤에 다른 입력이 오면 그 렌더가 memo 를
|
||||||
|
* 재계산시켜 조건이 사라진다)
|
||||||
|
*
|
||||||
|
* @see 트러블슈팅 사례 40 (저장소 B 통째 교체 + stale 반환값)
|
||||||
|
*/
|
||||||
|
import { test, expect, authenticatePage } from '../../fixtures/ckeditor5-auth';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const BOARD = 'notice';
|
||||||
|
const API = `/api/modules/sirsoft-board/admin/board/${BOARD}/posts`;
|
||||||
|
|
||||||
|
/** 브레이크포인트를 넘지 않는 폭 변경 (desktop 구간 안에서만 움직인다) */
|
||||||
|
const SAME_BREAKPOINT = { from: { width: 1440, height: 900 }, to: { width: 1421, height: 900 } };
|
||||||
|
/** 브레이크포인트를 넘는 폭 변경 (desktop → tablet) */
|
||||||
|
const CROSSED_BREAKPOINT = { from: { width: 1034, height: 900 }, to: { width: 1015, height: 900 } };
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장소 B(`globalState._local`) 에 마커가 실릴 때까지 기다립니다 — 디바운스 발화 확정.
|
||||||
|
*
|
||||||
|
* @param page Playwright 페이지
|
||||||
|
* @param marker 본문에 넣은 마커 문자열
|
||||||
|
*/
|
||||||
|
async function waitForDebounceFlush(page: Page, marker: string): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const w = window as any;
|
||||||
|
const c = w.__templateApp?.getGlobalState?.()?._local?.form?.content;
|
||||||
|
return typeof c === 'string' ? c : JSON.stringify(c ?? '');
|
||||||
|
}),
|
||||||
|
{ timeout: 15_000, message: 'CKEditor 디바운스가 저장소 B 에 본문을 기록해야 한다' },
|
||||||
|
)
|
||||||
|
.toContain(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폭 변경이 `__g7PendingLocalState` 를 비울 때까지 기다립니다 — 리렌더 완료 확정.
|
||||||
|
*
|
||||||
|
* @param page Playwright 페이지
|
||||||
|
*/
|
||||||
|
async function waitForPendingCleared(page: Page): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => (window as any).__g7PendingLocalState === null), {
|
||||||
|
timeout: 15_000,
|
||||||
|
message: '폭 변경 리렌더가 __g7PendingLocalState 를 비워야 한다 (재현 조건의 절반)',
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시글을 API 로 만들고 id 를 돌려줍니다.
|
||||||
|
*
|
||||||
|
* @param request Playwright 요청 컨텍스트
|
||||||
|
* @param token 권한 토큰
|
||||||
|
* @param title 제목
|
||||||
|
* @param content 본문 (HTML)
|
||||||
|
* @return 생성된 게시글 id
|
||||||
|
*/
|
||||||
|
async function createPost(
|
||||||
|
request: import('@playwright/test').APIRequestContext,
|
||||||
|
token: string,
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const res = await request.post(API, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||||
|
data: { title, content, content_mode: 'html' },
|
||||||
|
});
|
||||||
|
expect(res.ok(), `게시글 생성이 성공해야 한다 (status ${res.status()})`).toBeTruthy();
|
||||||
|
return (await res.json()).data.id as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('폭 변경 후 저장 — 편집기 본문 보존', () => {
|
||||||
|
test.beforeEach(({}, testInfo) => {
|
||||||
|
testInfo.setTimeout(180_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 화면 본문: 폭 변경 후 저장하고 요청 body 를 검사합니다.
|
||||||
|
*
|
||||||
|
* `@scenario` 마커는 축 값을 담으므로 각 `test` 안에 **리터럴로** 둔다 —
|
||||||
|
* 템플릿 리터럴로 만들면 주석 안에서 보간되지 않아 죽은 마커가 되고,
|
||||||
|
* 게이트는 그것을 조용히 통과시킨다.
|
||||||
|
*
|
||||||
|
* @param page Playwright 페이지
|
||||||
|
* @param editorToken 권한 토큰
|
||||||
|
* @param viewport 폭 변경 전/후 뷰포트
|
||||||
|
*/
|
||||||
|
async function runCreateFlow(
|
||||||
|
page: Page,
|
||||||
|
editorToken: string,
|
||||||
|
viewport: { from: { width: number; height: number }; to: { width: number; height: number } },
|
||||||
|
): Promise<void> {
|
||||||
|
const marker = `RESIZE-CREATE-${Date.now()}`;
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (m.type() === 'error') consoleErrors.push(m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.setViewportSize(viewport.from);
|
||||||
|
await authenticatePage(page, editorToken);
|
||||||
|
await page.goto(`/admin/board/${BOARD}/create`);
|
||||||
|
|
||||||
|
const editor = page.locator('.ck-editor__editable').first();
|
||||||
|
await expect(editor).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// 제목 먼저 → 본문 나중 (순서 고정)
|
||||||
|
await page.locator('input[name="title"]').pressSequentially(`제목 ${marker}`);
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially(marker);
|
||||||
|
|
||||||
|
await waitForDebounceFlush(page, marker); // ① 디바운스 발화 확정
|
||||||
|
|
||||||
|
// 주장하는 것은 "이 앱에 콘솔 에러가 하나도 없다" 가 아니라 **폭 변경 → 저장 구간이
|
||||||
|
// 조용하다** 는 것이다. 진입 시점의 에러(환경별 자산 404 등)까지 싸잡으면 이 축이
|
||||||
|
// 결함과 무관한 이유로 red 가 되어, 정작 지켜야 할 "조용함" 이 신호를 잃는다.
|
||||||
|
consoleErrors.length = 0;
|
||||||
|
|
||||||
|
await page.setViewportSize(viewport.to); // ② 폭 변경
|
||||||
|
await waitForPendingCleared(page); // ③ 리렌더 완료 확정
|
||||||
|
|
||||||
|
const [request, response] = await Promise.all([
|
||||||
|
page.waitForRequest((r) => r.url().includes('/posts') && r.method() === 'POST'),
|
||||||
|
page.waitForResponse((r) => r.url().includes('/posts') && r.request().method() === 'POST'),
|
||||||
|
page.locator('#footer_save_button').click(), // ④ 저장
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(String(request.postDataJSON().content ?? ''), '요청 body 의 본문에 입력한 마커가 있어야 한다').toContain(marker);
|
||||||
|
expect(response.status(), '저장이 422 가 아니어야 한다').toBeLessThan(400);
|
||||||
|
expect(consoleErrors, '폭 변경 → 저장 구간은 조용해야 한다 (이 결함은 콘솔에 아무것도 남기지 않으므로 조용함 자체를 고정한다)').toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수정 화면 본문: 폭 변경 후 저장하고 요청 body 와 DB 반영을 함께 검사합니다.
|
||||||
|
*
|
||||||
|
* @param page Playwright 페이지
|
||||||
|
* @param request Playwright 요청 컨텍스트
|
||||||
|
* @param editorToken 권한 토큰
|
||||||
|
* @param viewport 폭 변경 전/후 뷰포트
|
||||||
|
*/
|
||||||
|
async function runEditFlow(
|
||||||
|
page: Page,
|
||||||
|
request: import('@playwright/test').APIRequestContext,
|
||||||
|
editorToken: string,
|
||||||
|
viewport: { from: { width: number; height: number }; to: { width: number; height: number } },
|
||||||
|
): Promise<void> {
|
||||||
|
const original = `ORIGINAL-${Date.now()}`;
|
||||||
|
const added = `ADDED-${Date.now()}`;
|
||||||
|
const id = await createPost(request, editorToken, `수정대상 ${original}`, `<p>${original}</p>`);
|
||||||
|
|
||||||
|
await page.setViewportSize(viewport.from);
|
||||||
|
await authenticatePage(page, editorToken);
|
||||||
|
await page.goto(`/admin/board/${BOARD}/${id}/edit`);
|
||||||
|
|
||||||
|
const editor = page.locator('.ck-editor__editable').first();
|
||||||
|
await expect(editor).toContainText(original, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// 제목 먼저 → 본문 나중 (순서 고정).
|
||||||
|
//
|
||||||
|
// 제목을 함께 건드리는 것은 편의가 아니라 필수다 — 본문만 고치면 저장 버튼이 계속
|
||||||
|
// disabled 로 남아 클릭이 무반응이 된다. CKEditor 의 setLocal({render:false}) 은 저장소 B
|
||||||
|
// 의 hasChanges 만 true 로 만들고 React 렌더를 일으키지 않아, 버튼의 disabled 바인딩
|
||||||
|
// (저장소 A 기준)이 재평가되지 않기 때문이다. 이 결함은 #130 수정 범위 밖의 **표시 축**
|
||||||
|
// 이며(저장소 A 를 건드리는 경로는 2026-04-22 철회 이력이 있는 영역이다), 여기서는
|
||||||
|
// 재현 조건을 성립시키기 위해 제목을 먼저 건드린다.
|
||||||
|
await page.locator('input[name="title"]').pressSequentially('!');
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially(added);
|
||||||
|
|
||||||
|
await waitForDebounceFlush(page, added);
|
||||||
|
await page.setViewportSize(viewport.to);
|
||||||
|
await waitForPendingCleared(page);
|
||||||
|
|
||||||
|
const [saveRequest, saveResponse] = await Promise.all([
|
||||||
|
page.waitForRequest((r) => r.url().includes('/posts') && r.method() === 'PUT'),
|
||||||
|
page.waitForResponse((r) => r.url().includes('/posts') && r.request().method() === 'PUT'),
|
||||||
|
page.locator('#footer_save_button').click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sent = String(saveRequest.postDataJSON().content ?? '');
|
||||||
|
expect(sent, '원본 본문이 남아 있어야 한다').toContain(original);
|
||||||
|
expect(sent, '방금 입력한 편집분이 실려야 한다 — 이것이 조용한 손실 축이다').toContain(added);
|
||||||
|
expect(saveResponse.status(), '수정 저장이 성공해야 한다').toBeLessThan(400);
|
||||||
|
|
||||||
|
// 서버에 실제로 반영됐는지까지 본다 — 이 화면은 실패해도 성공 토스트가 뜨므로
|
||||||
|
// 화면 피드백으로는 판정할 수 없다. 응답을 기다린 뒤 재조회한다.
|
||||||
|
const check = await request.get(`${API}/${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${editorToken}`, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
expect(String((await check.json()).data.content ?? ''), 'DB 에 편집분이 남아야 한다').toContain(added);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('작성: 같은 브레이크포인트 안에서 폭을 바꿔도 본문이 그대로 전송된다', async ({ page, editorToken }) => {
|
||||||
|
// @scenario save_flow=create, resize_kind=same_breakpoint
|
||||||
|
// @effects request_body_content_matches_editor_getdata, create_save_returns_2xx_not_422, canonical_local_keeps_editor_content_after_resize, resize_save_emits_no_console_errors
|
||||||
|
await runCreateFlow(page, editorToken, SAME_BREAKPOINT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('작성: 브레이크포인트를 넘겨 폭을 바꿔도 본문이 그대로 전송된다', async ({ page, editorToken }) => {
|
||||||
|
// @scenario save_flow=create, resize_kind=crossed_breakpoint
|
||||||
|
// @effects request_body_content_matches_editor_getdata, create_save_returns_2xx_not_422, canonical_local_keeps_editor_content_after_resize, resize_save_emits_no_console_errors
|
||||||
|
await runCreateFlow(page, editorToken, CROSSED_BREAKPOINT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('수정: 같은 브레이크포인트 안에서 폭을 바꿔도 편집분이 유실되지 않는다', async ({ page, request, editorToken }) => {
|
||||||
|
// @scenario save_flow=edit, resize_kind=same_breakpoint
|
||||||
|
// @effects request_body_content_matches_editor_getdata, edit_save_persists_typed_addition, canonical_local_keeps_editor_content_after_resize, success_toast_only_when_content_actually_saved
|
||||||
|
await runEditFlow(page, request, editorToken, SAME_BREAKPOINT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('수정: 브레이크포인트를 넘겨 폭을 바꿔도 편집분이 유실되지 않는다', async ({ page, request, editorToken }) => {
|
||||||
|
// @scenario save_flow=edit, resize_kind=crossed_breakpoint
|
||||||
|
// @effects request_body_content_matches_editor_getdata, edit_save_persists_typed_addition, canonical_local_keeps_editor_content_after_resize, success_toast_only_when_content_actually_saved
|
||||||
|
await runEditFlow(page, request, editorToken, CROSSED_BREAKPOINT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('대조군(작성): 폭을 바꾸지 않으면 수정 전에도 정상이다', async ({ page, editorToken }) => {
|
||||||
|
// @scenario save_flow=create, resize_kind=none
|
||||||
|
// @effects save_without_resize_unaffected, create_save_returns_2xx_not_422
|
||||||
|
const marker = `NORESIZE-CREATE-${Date.now()}`;
|
||||||
|
|
||||||
|
await page.setViewportSize(SAME_BREAKPOINT.from);
|
||||||
|
await authenticatePage(page, editorToken);
|
||||||
|
await page.goto(`/admin/board/${BOARD}/create`);
|
||||||
|
|
||||||
|
const editor = page.locator('.ck-editor__editable').first();
|
||||||
|
await expect(editor).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.locator('input[name="title"]').pressSequentially(`제목 ${marker}`);
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially(marker);
|
||||||
|
await waitForDebounceFlush(page, marker);
|
||||||
|
|
||||||
|
// 폭을 바꾸지 않으므로 pending 이 살아 있어야 한다 — 결함 모델의 전제
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => (window as any).__g7PendingLocalState !== null),
|
||||||
|
'폭을 바꾸지 않았으므로 pending 이 살아 있어야 한다',
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const [request, response] = await Promise.all([
|
||||||
|
page.waitForRequest((r) => r.url().includes('/posts') && r.method() === 'POST'),
|
||||||
|
page.waitForResponse((r) => r.url().includes('/posts') && r.request().method() === 'POST'),
|
||||||
|
page.locator('#footer_save_button').click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(String(request.postDataJSON().content ?? '')).toContain(marker);
|
||||||
|
expect(response.status()).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('대조군(수정): 폭을 바꾸지 않으면 편집분이 그대로 저장된다', async ({ page, request, editorToken }) => {
|
||||||
|
// @scenario save_flow=edit, resize_kind=none
|
||||||
|
// @effects save_without_resize_unaffected, edit_save_persists_typed_addition
|
||||||
|
const original = `NORESIZE-ORIG-${Date.now()}`;
|
||||||
|
const added = `NORESIZE-ADD-${Date.now()}`;
|
||||||
|
const id = await createPost(request, editorToken, `대조군 ${original}`, `<p>${original}</p>`);
|
||||||
|
|
||||||
|
await page.setViewportSize(SAME_BREAKPOINT.from);
|
||||||
|
await authenticatePage(page, editorToken);
|
||||||
|
await page.goto(`/admin/board/${BOARD}/${id}/edit`);
|
||||||
|
|
||||||
|
const editor = page.locator('.ck-editor__editable').first();
|
||||||
|
await expect(editor).toContainText(original, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// 제목 먼저 → 본문 나중 (순서 고정).
|
||||||
|
//
|
||||||
|
// 제목을 함께 건드리는 것은 편의가 아니라 필수다 — 본문만 고치면 저장 버튼이 계속
|
||||||
|
// disabled 로 남아 클릭이 무반응이 된다. CKEditor 의 setLocal({render:false}) 은 저장소 B
|
||||||
|
// 의 hasChanges 만 true 로 만들고 React 렌더를 일으키지 않아, 버튼의 disabled 바인딩
|
||||||
|
// (저장소 A 기준)이 재평가되지 않기 때문이다. 이 결함은 #130 수정 범위 밖의 **표시 축**
|
||||||
|
// 이며(저장소 A 를 건드리는 경로는 2026-04-22 철회 이력이 있는 영역이다), 여기서는
|
||||||
|
// 재현 조건을 성립시키기 위해 제목을 먼저 건드린다.
|
||||||
|
await page.locator('input[name="title"]').pressSequentially('!');
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially(added);
|
||||||
|
await waitForDebounceFlush(page, added);
|
||||||
|
|
||||||
|
const [request2] = await Promise.all([
|
||||||
|
page.waitForRequest((r) => r.url().includes('/posts') && r.method() === 'PUT'),
|
||||||
|
page.locator('#footer_save_button').click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sent = String(request2.postDataJSON().content ?? '');
|
||||||
|
expect(sent).toContain(original);
|
||||||
|
expect(sent).toContain(added);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('폭 변경 뒤에도 저장소 B 는 본문을 들고 있다 (setState 가 덮지 않는다)', async ({ page, editorToken }) => {
|
||||||
|
// @scenario save_flow=create, resize_kind=same_breakpoint
|
||||||
|
// @effects setstate_local_preserves_canonical_keys_when_pending_is_null, canonical_local_keeps_editor_content_after_resize
|
||||||
|
const marker = `CANONICAL-${Date.now()}`;
|
||||||
|
|
||||||
|
await page.setViewportSize(SAME_BREAKPOINT.from);
|
||||||
|
await authenticatePage(page, editorToken);
|
||||||
|
await page.goto(`/admin/board/${BOARD}/create`);
|
||||||
|
|
||||||
|
const editor = page.locator('.ck-editor__editable').first();
|
||||||
|
await expect(editor).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.locator('input[name="title"]').pressSequentially(`제목 ${marker}`);
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially(marker);
|
||||||
|
|
||||||
|
await waitForDebounceFlush(page, marker);
|
||||||
|
await page.setViewportSize(SAME_BREAKPOINT.to);
|
||||||
|
await waitForPendingCleared(page);
|
||||||
|
|
||||||
|
// pending 이 null 인 상태에서 setState(local) 을 한 번 태운다 — 저장 sequence 의 첫 액션과 동형
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as any).G7Core.dispatch({
|
||||||
|
handler: 'setState',
|
||||||
|
params: { target: 'local', isSaving: true, errors: null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canonical = await page.evaluate(() => {
|
||||||
|
const c = (window as any).__templateApp?.getGlobalState?.()?._local?.form?.content;
|
||||||
|
return typeof c === 'string' ? c : JSON.stringify(c ?? '');
|
||||||
|
});
|
||||||
|
expect(canonical, 'setState 가 저장소 B 의 본문을 지우지 않아야 한다').toContain(marker);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -162,4 +162,77 @@ test.describe('편집기 자산 실패 폴백', () => {
|
|||||||
await expect(page.locator('[data-ckeditor5-fallback]')).toHaveCount(0, { timeout: 20_000 });
|
await expect(page.locator('[data-ckeditor5-fallback]')).toHaveCount(0, { timeout: 20_000 });
|
||||||
await expect(page.locator('#g7-asset-failure-notice')).toHaveCount(0, { timeout: 20_000 });
|
await expect(page.locator('#g7-asset-failure-notice')).toHaveCount(0, { timeout: 20_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('폭을 바꾼 뒤 저장해도 폴백 입력창의 본문이 그대로 전송된다', async ({ page, editorToken }) => {
|
||||||
|
// @scenario asset_class=vendored, outcome=failed
|
||||||
|
// @effects failed_asset_falls_back_to_plain_input
|
||||||
|
//
|
||||||
|
// 공개 #130(engine-v1.63.3) 인접 축. 평문 폴백도 본문을 `setLocal({ render:false,
|
||||||
|
// selfManaged:true })` 로 저장소 B 에만 쓰므로, 편집기 경로와 **같은 조건**이 성립한다.
|
||||||
|
// 다만 폴백은 디바운스를 쓰지 않아 입력 즉시 B 에 실린다 — 그래서 편집기 spec 의
|
||||||
|
// 결정화 4단계 중 ①(디바운스 발화 대기)이 필요 없고, ②폭 변경 → ③pending 클리어
|
||||||
|
// 확정 → ④저장만 밟는다.
|
||||||
|
//
|
||||||
|
// 판정은 화면이 아니라 **요청 body** 로 한다. 이 결함은 작성 화면에서 422 로,
|
||||||
|
// 수정 화면에서는 성공 토스트와 함께 조용한 손실로 나타나므로 화면 피드백은 근거가
|
||||||
|
// 되지 못한다.
|
||||||
|
await page.route(EDITOR_BUNDLE, (route) => route.abort());
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await authenticatePage(page, editorToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/board/notice/create');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const textarea = page.locator('[data-ckeditor5-fallback]');
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
const stamp = Date.now();
|
||||||
|
const marker = `FALLBACK-RESIZE-${stamp}`;
|
||||||
|
await page.locator('input[name="title"]').fill(`E2E 폴백 폭변경 ${stamp}`);
|
||||||
|
await textarea.fill(marker);
|
||||||
|
|
||||||
|
// 본문이 저장소 B 에 실렸는지 먼저 확정한다 (폴백은 디바운스가 없어 즉시 실린다)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
await page.evaluate(
|
||||||
|
() => (window as any).G7Core?.state?.getLocal?.()?.form?.content ?? ''
|
||||||
|
),
|
||||||
|
{ timeout: 20_000 }
|
||||||
|
)
|
||||||
|
.toContain(marker);
|
||||||
|
|
||||||
|
// 브레이크포인트를 넘지 않는 19px 변경 — 이것만으로 pending 이 비워진다
|
||||||
|
await page.setViewportSize({ width: 1421, height: 900 });
|
||||||
|
await expect
|
||||||
|
.poll(async () => await page.evaluate(() => (window as any).__g7PendingLocalState === null), {
|
||||||
|
timeout: 20_000,
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
let postId: number | null = null;
|
||||||
|
try {
|
||||||
|
const [request, response] = await Promise.all([
|
||||||
|
page.waitForRequest((r) => r.url().includes('/posts') && r.method() === 'POST'),
|
||||||
|
page.waitForResponse((r) => r.url().includes('/posts') && r.request().method() === 'POST'),
|
||||||
|
page.locator('#footer_save_button').click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const body = request.postDataJSON();
|
||||||
|
expect(String(body.content ?? ''), '폭 변경 뒤에도 폴백 본문이 전송돼야 한다').toContain(marker);
|
||||||
|
expect(body.content_mode, '폴백의 평문 계약은 폭 변경과 무관하게 유지돼야 한다').toBe('text');
|
||||||
|
expect(response.status(), '저장이 422 가 아니어야 한다').toBeLessThan(400);
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
postId = json?.data?.id ?? null;
|
||||||
|
} finally {
|
||||||
|
if (postId !== null) {
|
||||||
|
const del = await page.request.delete(
|
||||||
|
`/api/modules/sirsoft-board/admin/board/notice/posts/${postId}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${editorToken}`, Accept: 'application/json' } },
|
||||||
|
);
|
||||||
|
expect(del.ok(), `정리 삭제가 성공해야 한다 (status ${del.status()})`).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ php vendor/bin/phpunit plugins/_bundled/sirsoft-gdpr/tests --filter='<대상클
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd plugins/_bundled/sirsoft-gdpr && powershell -Command "npm run test:run -- <대상>"
|
cd plugins/_bundled/sirsoft-gdpr && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test plugins/_bundled/sirsoft-gdpr/tests/Playwright/specs/<대상>.spec.ts
|
cd plugins/_bundled/sirsoft-gdpr && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -198,8 +198,8 @@ php vendor/bin/phpunit plugins/_bundled/sirsoft-message_bizppurio/tests --filter
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd plugins/_bundled/sirsoft-message_bizppurio && powershell -Command "npm run test:run -- <대상>"
|
cd plugins/_bundled/sirsoft-message_bizppurio && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/<대상>.spec.ts
|
cd plugins/_bundled/sirsoft-message_bizppurio && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ php vendor/bin/phpunit plugins/_bundled/sirsoft-verification_nhnkcp/tests --filt
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd plugins/_bundled/sirsoft-verification_nhnkcp && powershell -Command "npm run test:run -- <대상>"
|
cd plugins/_bundled/sirsoft-verification_nhnkcp && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test plugins/_bundled/sirsoft-verification_nhnkcp/tests/Playwright/specs/<대상>.spec.ts
|
cd plugins/_bundled/sirsoft-verification_nhnkcp && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+22
-22
File diff suppressed because one or more lines are too long
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* G7CoreGlobals.selfManagedPromotion.test.ts
|
||||||
|
*
|
||||||
|
* 사례 40(engine-v1.63.3) L5 — 사례 32 의 자동 승격/`selfManaged` opt-out 을 **실물로** 검증한다.
|
||||||
|
*
|
||||||
|
* 왜 별도 파일인가:
|
||||||
|
* 기존 사례 32 회귀 테스트(`troubleshooting-state-setstate.test.ts`)는 판정 함수
|
||||||
|
* `shouldPromoteRender`/`flattenLeafPaths` 를 테스트 파일 안에 **재구현**해 검증한다.
|
||||||
|
* 그래서 엔진의 판정 블록(G7CoreGlobals.ts:1834~1843)을 통째로 지워도 그 테스트는 초록이다.
|
||||||
|
* 이 파일은 `initializeG7CoreGlobals()` 로 실제 `G7Core.state.setLocal` 을 세워
|
||||||
|
* **엔진이 실제로 승격하는지**를 `setGlobalState` 의 `render` 인자로 관측한다.
|
||||||
|
*
|
||||||
|
* 그리고 troubleshooting 파일과 공존할 수 없다 — 그 파일은 실물 `ActionDispatcher` 를 쓰는데
|
||||||
|
* 여기서는 `vi.mock('../template-engine/ActionDispatcher')` 가 필요하다.
|
||||||
|
*
|
||||||
|
* 사례 40 과의 관계:
|
||||||
|
* `selfManaged: true` 는 **의도된 opt-out** 이다(37,000+ 바인딩 재평가 회피).
|
||||||
|
* 그 opt-out 이 안전한 전제는 "저장소 B 를 통째로 교체하는 경로가 없을 것" 이며,
|
||||||
|
* 그 전제는 사례 40 의 `handleSetState` COMPONENT path 수정이 잠근다.
|
||||||
|
* 따라서 이 파일은 opt-out 이 **살아 있음**을 단언한다 — 없애는 것이 사례 40 의 수정 방향이 아니다.
|
||||||
|
*
|
||||||
|
* @see 트러블슈팅 사례 32(자동 승격 도입) · 사례 40(저장소 B 통째 교체)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { initializeG7CoreGlobals, G7CoreDependencies } from '../template-engine/G7CoreGlobals';
|
||||||
|
|
||||||
|
vi.mock('../template-engine/ComponentRegistry', () => ({
|
||||||
|
ComponentRegistry: {
|
||||||
|
getInstance: vi.fn(() => ({ getComponentMap: vi.fn(() => ({})) })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/TranslationEngine', () => ({
|
||||||
|
TranslationEngine: vi.fn(),
|
||||||
|
TranslationContext: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/ActionDispatcher', () => ({ ActionDispatcher: vi.fn() }));
|
||||||
|
vi.mock('../template-engine/DataBindingEngine', () => ({
|
||||||
|
DataBindingEngine: vi.fn(),
|
||||||
|
dataBindingEngine: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/DataSourceManager', () => ({
|
||||||
|
DataSourceManager: vi.fn(),
|
||||||
|
dataSourceManager: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/DynamicRenderer', () => ({ default: () => null }));
|
||||||
|
vi.mock('../template-engine/ResponsiveManager', () => ({
|
||||||
|
responsiveManager: {},
|
||||||
|
BREAKPOINT_PRESETS: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/TransitionContext', () => ({
|
||||||
|
useTransitionState: vi.fn(() => ({ isPending: false })),
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/TranslationContext', () => ({
|
||||||
|
useTranslation: vi.fn(() => ({ t: (key: string) => key })),
|
||||||
|
TranslationProvider: () => null,
|
||||||
|
TranslationReactContext: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/ResponsiveContext', () => ({
|
||||||
|
useResponsive: vi.fn(() => ({ width: 1024, isMobile: false })),
|
||||||
|
ResponsiveProvider: () => null,
|
||||||
|
ResponsiveContext: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../auth/AuthManager', () => ({ AuthManager: { getInstance: vi.fn() } }));
|
||||||
|
vi.mock('../api/ApiClient', () => ({ getApiClient: vi.fn(() => ({ get: vi.fn(), post: vi.fn() })) }));
|
||||||
|
vi.mock('../websocket/WebSocketManager', () => ({ WebSocketManager: vi.fn() }));
|
||||||
|
vi.mock('../template-engine/helpers', () => ({
|
||||||
|
renderItemChildren: vi.fn(() => []),
|
||||||
|
createChangeEvent: vi.fn(),
|
||||||
|
createClickEvent: vi.fn(),
|
||||||
|
createSubmitEvent: vi.fn(),
|
||||||
|
createKeyboardEvent: vi.fn(),
|
||||||
|
mergeClasses: vi.fn((...args: string[]) => args.join(' ')),
|
||||||
|
conditionalClass: vi.fn(() => ''),
|
||||||
|
joinClasses: vi.fn((...args: (string | false | undefined)[]) => args.filter(Boolean).join(' ')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createMockDependencies(): G7CoreDependencies {
|
||||||
|
return {
|
||||||
|
getState: vi.fn(() => ({
|
||||||
|
translationEngine: { translate: vi.fn((k: string) => k) } as any,
|
||||||
|
translationContext: { templateId: 'test', locale: 'ko' },
|
||||||
|
bindingEngine: {} as any,
|
||||||
|
actionDispatcher: {} as any,
|
||||||
|
templateMetadata: { locales: ['ko'] },
|
||||||
|
})),
|
||||||
|
transitionManager: { getIsPending: vi.fn(() => false), subscribe: vi.fn(() => () => {}) },
|
||||||
|
responsiveManager: {},
|
||||||
|
webSocketManager: {
|
||||||
|
subscribe: vi.fn(() => 'sub'),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
leaveChannel: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
isInitialized: vi.fn(() => true),
|
||||||
|
getSubscriptionCount: vi.fn(() => 0),
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('[사례 32 실물화] setLocal 자동 승격과 selfManaged opt-out (사례 40 L5)', () => {
|
||||||
|
let globalState: Record<string, any>;
|
||||||
|
let setGlobalState: ReturnType<typeof vi.fn>;
|
||||||
|
let originalG7Core: any;
|
||||||
|
|
||||||
|
/** 마지막 setGlobalState 호출의 render 옵션 (undefined = 미지정 = 기본 true) */
|
||||||
|
const lastRenderOption = () => {
|
||||||
|
const calls = setGlobalState.mock.calls;
|
||||||
|
return calls.length ? calls[calls.length - 1][1]?.render : 'NOT_CALLED';
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalG7Core = (window as any).G7Core;
|
||||||
|
delete (window as any).G7Core;
|
||||||
|
|
||||||
|
globalState = { _local: {} };
|
||||||
|
setGlobalState = vi.fn((updates: any) => {
|
||||||
|
globalState = { ...globalState, ...updates };
|
||||||
|
});
|
||||||
|
|
||||||
|
(window as any).__templateApp = {
|
||||||
|
getGlobalState: () => globalState,
|
||||||
|
setGlobalState,
|
||||||
|
// debounce 경로로 새지 않도록 ActionDispatcher 를 노출하지 않는다
|
||||||
|
getActionDispatcher: undefined,
|
||||||
|
};
|
||||||
|
(window as any).__g7AutoBindingPaths = new Map<string, number>();
|
||||||
|
(window as any).__g7PendingLocalState = undefined;
|
||||||
|
(window as any).__g7ForcedLocalFields = undefined;
|
||||||
|
(window as any).__g7SetLocalOverrideKeys = undefined;
|
||||||
|
(window as any).__g7ActionContext = undefined;
|
||||||
|
(window as any).__g7LayoutContextStack = [];
|
||||||
|
|
||||||
|
initializeG7CoreGlobals(createMockDependencies());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
delete (window as any).__templateApp;
|
||||||
|
delete (window as any).__g7AutoBindingPaths;
|
||||||
|
(window as any).__g7PendingLocalState = undefined;
|
||||||
|
(window as any).__g7ForcedLocalFields = undefined;
|
||||||
|
(window as any).__g7SetLocalOverrideKeys = undefined;
|
||||||
|
(window as any).__g7LayoutContextStack = [];
|
||||||
|
if (originalG7Core === undefined) delete (window as any).G7Core;
|
||||||
|
else (window as any).G7Core = originalG7Core;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setLocal = (updates: any, options?: any) =>
|
||||||
|
(window as any).G7Core.state.setLocal(updates, options);
|
||||||
|
|
||||||
|
it('레지스트리에 겹치는 경로를 render:false 로 쓰면 엔진이 render:true 로 승격한다', () => {
|
||||||
|
(window as any).__g7AutoBindingPaths.set('form.title', 1);
|
||||||
|
|
||||||
|
setLocal({ 'form.title': 'X' }, { render: false });
|
||||||
|
|
||||||
|
expect(lastRenderOption()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selfManaged:true 는 겹쳐도 승격하지 않는다 (CKEditor 성능 opt-out 보존)', () => {
|
||||||
|
// @scenario save_flow=edit, resize_kind=none
|
||||||
|
// @effects selfmanaged_setlocal_writes_canonical_without_render
|
||||||
|
(window as any).__g7AutoBindingPaths.set('form.content', 1);
|
||||||
|
|
||||||
|
setLocal({ 'form.content': '<p>내용</p>' }, { render: false, selfManaged: true });
|
||||||
|
|
||||||
|
expect(lastRenderOption()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('레지스트리가 비면 승격하지 않는다 (SPA 네비게이션 직후)', () => {
|
||||||
|
setLocal({ 'form.title': 'X' }, { render: false });
|
||||||
|
|
||||||
|
expect(lastRenderOption()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('겹치지 않는 경로는 승격하지 않는다', () => {
|
||||||
|
(window as any).__g7AutoBindingPaths.set('form.title', 1);
|
||||||
|
|
||||||
|
setLocal({ 'form.content': 'Y' }, { render: false });
|
||||||
|
|
||||||
|
expect(lastRenderOption()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render 옵션을 생략하면 판정 분기에 들어가지 않는다 (기본 render:true)', () => {
|
||||||
|
(window as any).__g7AutoBindingPaths.set('form.title', 1);
|
||||||
|
|
||||||
|
setLocal({ 'form.title': 'X' });
|
||||||
|
|
||||||
|
expect(lastRenderOption()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selfManaged 경로도 저장소 B 에는 값을 남긴다 (사례 40 의 재현 전제)', () => {
|
||||||
|
// @scenario save_flow=create, resize_kind=none
|
||||||
|
// @effects selfmanaged_setlocal_writes_canonical_without_render
|
||||||
|
(window as any).__g7AutoBindingPaths.set('form.content', 1);
|
||||||
|
|
||||||
|
setLocal({ 'form.content': '<p>본문</p>' }, { render: false, selfManaged: true });
|
||||||
|
|
||||||
|
expect(globalState._local.form.content).toBe('<p>본문</p>');
|
||||||
|
// 오버레이/pending 도 함께 세워진다 — 리사이즈가 pending 만 지우는 것이 결함의 절반이다
|
||||||
|
expect((window as any).__g7PendingLocalState.form.content).toBe('<p>본문</p>');
|
||||||
|
expect((window as any).__g7ForcedLocalFields.form.content).toBe('<p>본문</p>');
|
||||||
|
expect((window as any).__g7SetLocalOverrideKeys.form.content).toBe('<p>본문</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* 회귀: `setParentLocal` 의 저장소 B 쓰기가 live B 를 base 로 한다 (engine-v1.63.3 / 공개 이슈 #130 계열)
|
||||||
|
*
|
||||||
|
* 결함: `setParentLocal` 은 부모 컨텍스트의 **저장소 A**(`parentEntry.state._local`)를 base 로
|
||||||
|
* `merged` 를 만든 뒤 그것을 그대로 `setGlobalState({ _local: merged })` 로 보냈다.
|
||||||
|
* `setGlobalState` 는 `_local` 을 얕게 병합하므로 이 쓰기는 patch 가 아니라 **통째 교체**이며,
|
||||||
|
* A 가 아직 받지 못한 값(예: `selfManaged` 플러그인이 B 에만 쓴 편집기 본문)이 조용히 사라진다.
|
||||||
|
* 부모가 페이지 루트일 때 그 A 스냅샷은 B 보다 뒤처져 있을 수 있다(사례 21).
|
||||||
|
*
|
||||||
|
* 계약:
|
||||||
|
* 1. B 쓰기는 live B + 변경 키 (A 에만 있던 키는 `addMissingLeafKeys` 로 보충)
|
||||||
|
* 2. 저장소 A 경로(`parentEntry.setState`)와 `__g7PendingLocalState` 는 종전 그대로 —
|
||||||
|
* 그쪽 base 를 바꾸면 React 전용 배열이 B 초기값으로 덮이는 사례 22 위험이 생긴다
|
||||||
|
* 3. `merge: 'replace'` 는 의도적 리셋이므로 live B 를 base 로 쓰지 않는다 (사례 17)
|
||||||
|
*
|
||||||
|
* 이 파일은 **실물 엔진**을 세운다 — `template-engine/__tests__/G7CoreGlobals.test.ts` 의
|
||||||
|
* `setParentLocal` 테스트는 G7Core 를 테스트 파일 안에 재구현해 검증하므로 엔진을 지워도 초록이다.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { initializeG7CoreGlobals, G7CoreDependencies } from '../template-engine/G7CoreGlobals';
|
||||||
|
|
||||||
|
vi.mock('../template-engine/ComponentRegistry', () => ({
|
||||||
|
ComponentRegistry: { getInstance: vi.fn(() => ({ getComponentMap: vi.fn(() => ({})) })) },
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/TranslationEngine', () => ({ TranslationEngine: vi.fn(), TranslationContext: {} }));
|
||||||
|
vi.mock('../template-engine/ActionDispatcher', () => ({ ActionDispatcher: vi.fn() }));
|
||||||
|
vi.mock('../template-engine/DataBindingEngine', () => ({ DataBindingEngine: vi.fn(), dataBindingEngine: {} }));
|
||||||
|
vi.mock('../template-engine/DataSourceManager', () => ({ DataSourceManager: vi.fn(), dataSourceManager: {} }));
|
||||||
|
vi.mock('../template-engine/DynamicRenderer', () => ({ default: () => null }));
|
||||||
|
vi.mock('../template-engine/ResponsiveManager', () => ({ responsiveManager: {}, BREAKPOINT_PRESETS: {} }));
|
||||||
|
vi.mock('../template-engine/TransitionContext', () => ({ useTransitionState: vi.fn(() => ({ isPending: false })) }));
|
||||||
|
vi.mock('../template-engine/TranslationContext', () => ({
|
||||||
|
useTranslation: vi.fn(() => ({ t: (k: string) => k })),
|
||||||
|
TranslationProvider: () => null,
|
||||||
|
TranslationReactContext: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../template-engine/ResponsiveContext', () => ({
|
||||||
|
useResponsive: vi.fn(() => ({ width: 1024, isMobile: false })),
|
||||||
|
ResponsiveProvider: () => null,
|
||||||
|
ResponsiveContext: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../auth/AuthManager', () => ({ AuthManager: { getInstance: vi.fn() } }));
|
||||||
|
vi.mock('../api/ApiClient', () => ({ getApiClient: vi.fn(() => ({ get: vi.fn(), post: vi.fn() })) }));
|
||||||
|
vi.mock('../websocket/WebSocketManager', () => ({ WebSocketManager: vi.fn() }));
|
||||||
|
vi.mock('../template-engine/helpers', () => ({
|
||||||
|
renderItemChildren: vi.fn(() => []),
|
||||||
|
createChangeEvent: vi.fn(),
|
||||||
|
createClickEvent: vi.fn(),
|
||||||
|
createSubmitEvent: vi.fn(),
|
||||||
|
createKeyboardEvent: vi.fn(),
|
||||||
|
mergeClasses: vi.fn((...a: string[]) => a.join(' ')),
|
||||||
|
conditionalClass: vi.fn(() => ''),
|
||||||
|
joinClasses: vi.fn((...a: (string | false | undefined)[]) => a.filter(Boolean).join(' ')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createMockDependencies(): G7CoreDependencies {
|
||||||
|
return {
|
||||||
|
getState: vi.fn(() => ({
|
||||||
|
translationEngine: { translate: vi.fn((k: string) => k) } as any,
|
||||||
|
translationContext: { templateId: 'test', locale: 'ko' },
|
||||||
|
bindingEngine: {} as any,
|
||||||
|
actionDispatcher: {} as any,
|
||||||
|
templateMetadata: { locales: ['ko'] },
|
||||||
|
})),
|
||||||
|
transitionManager: { getIsPending: vi.fn(() => false), subscribe: vi.fn(() => () => {}) },
|
||||||
|
responsiveManager: {},
|
||||||
|
webSocketManager: {
|
||||||
|
subscribe: vi.fn(() => 'sub'),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
leaveChannel: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
isInitialized: vi.fn(() => true),
|
||||||
|
getSubscriptionCount: vi.fn(() => 0),
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setParentLocal — 저장소 B 쓰기는 live B 를 base 로 한다', () => {
|
||||||
|
let globalState: Record<string, any>;
|
||||||
|
let setGlobalState: ReturnType<typeof vi.fn>;
|
||||||
|
let parentSetState: ReturnType<typeof vi.fn>;
|
||||||
|
let originalG7Core: any;
|
||||||
|
|
||||||
|
/** setGlobalState 로 B 에 실제로 쓰인 _local */
|
||||||
|
const writtenLocal = () => {
|
||||||
|
const calls = setGlobalState.mock.calls;
|
||||||
|
return calls.length ? calls[calls.length - 1][0]._local : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부모 컨텍스트를 스택에 세웁니다.
|
||||||
|
*
|
||||||
|
* @param parentLocalA 부모 컨텍스트의 저장소 A 스냅샷
|
||||||
|
*/
|
||||||
|
const pushParent = (parentLocalA: Record<string, any>): void => {
|
||||||
|
(window as any).__g7LayoutContextStack = [{ state: { _local: parentLocalA }, setState: parentSetState }];
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalG7Core = (window as any).G7Core;
|
||||||
|
delete (window as any).G7Core;
|
||||||
|
|
||||||
|
globalState = { _local: {} };
|
||||||
|
setGlobalState = vi.fn((updates: any) => {
|
||||||
|
globalState = { ...globalState, ...updates };
|
||||||
|
});
|
||||||
|
parentSetState = vi.fn();
|
||||||
|
|
||||||
|
(window as any).__templateApp = { getGlobalState: () => globalState, setGlobalState };
|
||||||
|
(window as any).__g7PendingLocalState = undefined;
|
||||||
|
(window as any).__g7LayoutContextStack = [];
|
||||||
|
|
||||||
|
initializeG7CoreGlobals(createMockDependencies());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
delete (window as any).__templateApp;
|
||||||
|
(window as any).__g7PendingLocalState = undefined;
|
||||||
|
(window as any).__g7LayoutContextStack = [];
|
||||||
|
if (originalG7Core === undefined) delete (window as any).G7Core;
|
||||||
|
else (window as any).G7Core = originalG7Core;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('결함 재현 핀: B 에만 있던 편집기 본문이 stale 부모 A 스냅샷으로 덮이지 않는다', () => {
|
||||||
|
// CKEditor 가 setLocal({render:false, selfManaged:true}) 로 B 에만 기록한 본문
|
||||||
|
globalState._local = { form: { title: '제목', content: '<p>본문</p>' } };
|
||||||
|
// 부모 컨텍스트의 저장소 A 는 그 본문을 아직 못 받았다
|
||||||
|
pushParent({ form: { title: '제목', content: '' } });
|
||||||
|
|
||||||
|
(window as any).G7Core.state.setParentLocal({ 'form.title': '새 제목' });
|
||||||
|
|
||||||
|
expect(writtenLocal().form.content, 'B 의 본문이 살아 있어야 한다').toBe('<p>본문</p>');
|
||||||
|
expect(writtenLocal().form.title).toBe('새 제목');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('B 에만 있던 최상위 키도 잃지 않는다', () => {
|
||||||
|
globalState._local = { couponId: 7, orderer: { name: '관리자' } };
|
||||||
|
pushParent({ selectedItems: [1, 2] });
|
||||||
|
|
||||||
|
(window as any).G7Core.state.setParentLocal({ memo: 'x' });
|
||||||
|
|
||||||
|
expect(writtenLocal().couponId).toBe(7);
|
||||||
|
expect(writtenLocal().orderer).toEqual({ name: '관리자' });
|
||||||
|
expect(writtenLocal().memo).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('부모 A 에만 있던 키는 addMissingLeafKeys 로 보충된다', () => {
|
||||||
|
globalState._local = { form: { content: '<p>본문</p>' } };
|
||||||
|
pushParent({ form: { content: '' }, loadingActions: { save: true } });
|
||||||
|
|
||||||
|
(window as any).G7Core.state.setParentLocal({ memo: 'y' });
|
||||||
|
|
||||||
|
expect(writtenLocal().loadingActions, 'A 전용 키가 B 에 보충되어야 한다').toEqual({ save: true });
|
||||||
|
expect(writtenLocal().form.content).toBe('<p>본문</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('사례 22 핀: 저장소 A 경로는 종전대로 부모 A 기반이다', () => {
|
||||||
|
globalState._local = { expandedRows: [] };
|
||||||
|
pushParent({ expandedRows: [301] });
|
||||||
|
|
||||||
|
(window as any).G7Core.state.setParentLocal({ memo: 'z' });
|
||||||
|
|
||||||
|
// parentEntry.setState 는 부모 A 기반 merged 를 받아야 한다 (B 기반으로 바꾸면 배열이 초기값으로 덮인다)
|
||||||
|
expect(parentSetState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(parentSetState.mock.calls[0][0].expandedRows).toEqual([301]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('사례 17 핀: merge replace 는 live B 를 base 로 쓰지 않는다', () => {
|
||||||
|
globalState._local = { form: { content: '<p>본문</p>' }, keep: 1 };
|
||||||
|
pushParent({ form: { content: '' } });
|
||||||
|
|
||||||
|
(window as any).G7Core.state.setParentLocal({ only: 'this' }, { merge: 'replace' });
|
||||||
|
|
||||||
|
expect(writtenLocal().keep, 'replace 는 의도적 리셋이다').toBeUndefined();
|
||||||
|
expect(writtenLocal().only).toBe('this');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merge shallow 도 live B 를 base 로 한다', () => {
|
||||||
|
globalState._local = { a: 1, b: 2, form: { content: '<p>본문</p>' } };
|
||||||
|
pushParent({ a: 1 });
|
||||||
|
|
||||||
|
(window as any).G7Core.state.setParentLocal({ b: 99 }, { merge: 'shallow' });
|
||||||
|
|
||||||
|
expect(writtenLocal().b).toBe(99);
|
||||||
|
expect(writtenLocal().a).toBe(1);
|
||||||
|
expect(writtenLocal().form.content).toBe('<p>본문</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('컨텍스트 스택이 비면 아무것도 쓰지 않는다', () => {
|
||||||
|
(window as any).__g7LayoutContextStack = [];
|
||||||
|
|
||||||
|
(window as any).G7Core.state.setParentLocal({ x: 1 });
|
||||||
|
|
||||||
|
expect(setGlobalState).not.toHaveBeenCalled();
|
||||||
|
expect(parentSetState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ import type { ErrorHandlerConfig, ErrorContext } from '../types/ErrorHandling';
|
|||||||
import { createLogger } from '../utils/Logger';
|
import { createLogger } from '../utils/Logger';
|
||||||
import type { G7DevToolsInterface } from './G7CoreGlobals';
|
import type { G7DevToolsInterface } from './G7CoreGlobals';
|
||||||
import { evaluateConditionBranches } from './helpers/ConditionEvaluator';
|
import { evaluateConditionBranches } from './helpers/ConditionEvaluator';
|
||||||
|
import { addMissingLeafKeys } from './helpers/StateMerge';
|
||||||
import { triggerModalParentUpdate } from './ParentContextProvider';
|
import { triggerModalParentUpdate } from './ParentContextProvider';
|
||||||
import type { GlobalHeaderRule } from './LayoutLoader';
|
import type { GlobalHeaderRule } from './LayoutLoader';
|
||||||
import { IdentityGuardInterceptor } from '../identity/IdentityGuardInterceptor';
|
import { IdentityGuardInterceptor } from '../identity/IdentityGuardInterceptor';
|
||||||
@@ -4141,9 +4142,69 @@ export class ActionDispatcher {
|
|||||||
// (_localInit 미반영 시점)에 __g7PendingLocalState 를 fresh B baseline 으로 미리 채워두므로,
|
// (_localInit 미반영 시점)에 __g7PendingLocalState 를 fresh B baseline 으로 미리 채워두므로,
|
||||||
// currentState 가 pending(정상)을 우선 사용하여 stale context.state 를 건너뛴다.
|
// currentState 가 pending(정상)을 우선 사용하여 stale context.state 를 건너뛴다.
|
||||||
// 그 사전설정 경로를 변경/제거하면 사례 13/22(stale 배열의 globalLocal 통째 교체 오염) 재발 가능성을 함께 점검해야 한다.
|
// 그 사전설정 경로를 변경/제거하면 사례 13/22(stale 배열의 globalLocal 통째 교체 오염) 재발 가능성을 함께 점검해야 한다.
|
||||||
|
// engine-v1.63.3 (공개 이슈 #130): B 동기화의 base 를 A 계열 전체 스냅샷에서
|
||||||
|
// live B(_global._local) + 변경 키로 바꾼다.
|
||||||
|
//
|
||||||
|
// 위 [안전성 의존 관계] 주석이 전제한 "currentState 가 정합한 _local 전체" 는 성립하지
|
||||||
|
// 않는 구간이 있다. CKEditor 등 selfManaged 플러그인이 setLocal({render:false}) 로
|
||||||
|
// B 에만 본문을 쓰면 React 렌더가 일어나지 않아 extendedDataContext useMemo 가
|
||||||
|
// 재계산되지 않고(deps 에 __g7ForcedLocalFields 가 없다 — window 전역이라 deps 가 될 수 없다),
|
||||||
|
// context.state 는 본문 타이핑 이전 스냅샷으로 고정된다. 그 상태에서 폭 변경 리렌더가
|
||||||
|
// __g7PendingLocalState 를 null 로 지우면(DynamicRenderer.tsx:1110) currentState 가
|
||||||
|
// stale A 로 떨어지고, 그것을 B 에 통째로 덮어써 본문이 사라진다.
|
||||||
|
//
|
||||||
|
// 정답 선례는 같은 파일의 dot-notation path(engine-v1.58.2, :4235~)다. 그것이 이미
|
||||||
|
// "live B base + 변경 키" 로 같은 문제를 풀었고 주석에서 이 COMPONENT path 를 위험으로
|
||||||
|
// 지목했다. 이번 수정은 그 정책을 이 경로에 맞춘다.
|
||||||
|
//
|
||||||
|
// 제외 조건:
|
||||||
|
// - merge:"replace" — 의도적 리셋이므로 live B 를 base 로 삼지 않는다 (사례 17)
|
||||||
|
// - 모달 컨텍스트 스택이 있음 — 모달의 setState 가 페이지 _local 을 흡수하는 것을 막는다
|
||||||
|
// (사례 29, setLocal v1.24.7 가드와 동형)
|
||||||
|
// 한계: 스택은 openModal/closeModal 핸들러에서만 push/pop 되므로(:4836·:4911)
|
||||||
|
// setState 플래그로 여는 모달과 G7Core.modal.open() 은 depth 0 으로 보인다. 이는
|
||||||
|
// v1.24.7 가드가 이미 가진 사각과 동일하며, 그 경우에도 새 동작은 "B 통째 교체" 가
|
||||||
|
// 아니라 "B 에 병합" 이라 사례 29 대비 악화되지 않는다.
|
||||||
|
// - __templateApp 부재 — 종전 전체 스냅샷 폴백 (v1.50.4 호환)
|
||||||
|
//
|
||||||
|
// merge:"shallow" 는 제외하지 않는다. 현행 shallow 는 pending 을 무시하고 context.state
|
||||||
|
// 만 base 로 쓰므로(:4110) 리프 컴포넌트의 부분 상태가 base 가 되어 오히려 이 결함에
|
||||||
|
// 더 노출돼 있다.
|
||||||
|
const modalDepth = ((window as any).__g7LayoutContextStack || []).length;
|
||||||
|
let canonicalMerged: Record<string, any> | undefined;
|
||||||
|
|
||||||
if (this.globalStateUpdater) {
|
if (this.globalStateUpdater) {
|
||||||
this.globalStateUpdater({ _local: pendingExpected }, { render: false });
|
const canonicalLocal = modalDepth === 0
|
||||||
logger.log('[handleSetState] _global._local synced (render:false):', pendingExpected);
|
? (window as any).__templateApp?.getGlobalState?.()?._local
|
||||||
|
: undefined;
|
||||||
|
const canUseCanonical = mergeMode !== 'replace'
|
||||||
|
&& !!canonicalLocal && typeof canonicalLocal === 'object' && !Array.isArray(canonicalLocal);
|
||||||
|
|
||||||
|
if (canUseCanonical) {
|
||||||
|
// convertedPayload 를 쓰면 안 된다 — 그것은 빈 base 위의 변환이라
|
||||||
|
// {"form.title":"X"} → {form:{title:"X"}} 가 되고, 얕게 얹으면 B.form 이 통째
|
||||||
|
// 교체되어 고치려던 결함을 재생산한다. deepMergeWithState 는 createNestedUpdate 로
|
||||||
|
// 형제 키를 유지한 채 leaf 만 바꾼다. result={...currentState}(:4521) 로 시작하고
|
||||||
|
// deepMergeInto 가 참조 동일성 가드(:4608)로 매 레벨 방어 복사하므로 live B 를
|
||||||
|
// 변이하지 않는다.
|
||||||
|
const merged = mergeMode === 'deep'
|
||||||
|
? this.deepMergeWithState(resolvedPayload, canonicalLocal as Record<string, any>)
|
||||||
|
: { ...(canonicalLocal as Record<string, any>), ...resolvedPayload };
|
||||||
|
const { __mergeMode: _cmm, __setStateId: _cssid, ...rest } = merged as any;
|
||||||
|
canonicalMerged = rest;
|
||||||
|
} else if (mergeMode !== 'replace') {
|
||||||
|
logger.log('[handleSetState] canonical _local 미사용 → 전체 스냅샷 폴백',
|
||||||
|
{ modalDepth, hasTemplateApp: !!(window as any).__templateApp });
|
||||||
|
}
|
||||||
|
|
||||||
|
// B 쓰기에도 A 전용 키를 보충한다 — setLocal 선례(G7CoreGlobals 의
|
||||||
|
// addMissingLeafKeys(globalLocal, dynamicLocal))와 대칭.
|
||||||
|
// addMissingLeafKeys 는 base 에 이미 있는 값을 절대 덮지 않으므로 사례 13/22 위험이 없다.
|
||||||
|
const syncedLocal = canonicalMerged
|
||||||
|
? addMissingLeafKeys(canonicalMerged, pendingExpected)
|
||||||
|
: pendingExpected;
|
||||||
|
this.globalStateUpdater({ _local: syncedLocal }, { render: false });
|
||||||
|
logger.log('[handleSetState] _global._local synced (render:false):', syncedLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// engine-v1.17.5: dataKey 자동 바인딩이 있는 컴포넌트에서 setState 핸들러 호출 시
|
// engine-v1.17.5: dataKey 자동 바인딩이 있는 컴포넌트에서 setState 핸들러 호출 시
|
||||||
@@ -4170,7 +4231,24 @@ export class ActionDispatcher {
|
|||||||
// DevTools: 렌더링 완료 후 상태 변경 완료 (DynamicRenderer에서 처리되지만 fallback으로 setTimeout 사용)
|
// DevTools: 렌더링 완료 후 상태 변경 완료 (DynamicRenderer에서 처리되지만 fallback으로 setTimeout 사용)
|
||||||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||||||
// sequence에서 _local 동기화에 사용하기 위해 __target 마커 추가
|
// sequence에서 _local 동기화에 사용하기 위해 __target 마커 추가
|
||||||
return { __target: 'local', ...fullMergedState };
|
//
|
||||||
|
// engine-v1.63.3 (공개 이슈 #130): 반환값도 live B 기반으로 신선화한다.
|
||||||
|
// 요청 body 는 저장소 B 를 읽지 않는다 — handleApiCall 의 getLocal() 은
|
||||||
|
// getMatchingGlobalHeaders 전용이고, body 는 sequence 의 currentState(= 이 반환값)에서
|
||||||
|
// 온다(handleSequence:5342·5379·5428, 트러블슈팅 사례 15 계약). 따라서 B 쓰기만 고치면
|
||||||
|
// B 는 지켜지지만 422 는 그대로 난다.
|
||||||
|
//
|
||||||
|
// base 는 live B(충돌 leaf 는 B 승), extra 는 A 기반 스냅샷에서 B 에 없는 키만 보충
|
||||||
|
// — 사례 13(engine-v1.41.0) 정책 재사용. React 전용 키(loadingActions, DataGrid 선택 등)를
|
||||||
|
// 잃지 않는다. __mergeMode 재부착은 handleSequence:5431~5438 계약 보존용.
|
||||||
|
const returnedState = canonicalMerged
|
||||||
|
? addMissingLeafKeys(canonicalMerged, pendingExpected)
|
||||||
|
: fullMergedState;
|
||||||
|
return {
|
||||||
|
__target: 'local',
|
||||||
|
...returnedState,
|
||||||
|
...(mergeMode !== 'deep' ? { __mergeMode: mergeMode } : {}),
|
||||||
|
};
|
||||||
} else if (this.globalStateUpdater) {
|
} else if (this.globalStateUpdater) {
|
||||||
// init_actions 등에서 componentContext가 없는 경우 globalStateUpdater를 통해 _local 업데이트
|
// init_actions 등에서 componentContext가 없는 경우 globalStateUpdater를 통해 _local 업데이트
|
||||||
logger.log('[handleSetState] Using GLOBAL STATE UPDATER path for _local');
|
logger.log('[handleSetState] Using GLOBAL STATE UPDATER path for _local');
|
||||||
|
|||||||
@@ -5,6 +5,28 @@
|
|||||||
>
|
>
|
||||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||||
|
|
||||||
|
## [engine-v1.63.3] - 2026-09-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### 리사이즈 후 저장 시 편집기 본문이 저장소 B 와 sequence 반환값에서 함께 사라지던 문제 (#130)
|
||||||
|
|
||||||
|
- `handleSetState` 의 COMPONENT 분기가 `_global._local`(저장소 B)을 동기화할 때, base 를 저장소 A 계열 전체 스냅샷 대신 **live B + 변경 키**로 삼는다. `setGlobalState` 는 `_local` 을 얕게 병합하므로 이 동기화는 patch 가 아니라 **통째 교체**였고, A 가 아직 받지 못한 값이 조용히 사라졌다. 정답 선례는 같은 파일의 dot-notation 경로(engine-v1.58.2)이며 그 주석이 이 경로를 위험으로 지목하고 있었다.
|
||||||
|
- **같은 분기의 반환값도 live B 기반으로 신선화한다.** 요청 body 는 저장소 B 를 읽지 않는다 — sequence 의 `currentState`(= 이 반환값)에서 온다. B 쓰기만 고치면 B 는 지켜지지만 저장은 여전히 422 가 났다.
|
||||||
|
- A 가 값을 못 받는 대표 경로는 `setLocal({ render:false, selfManaged:true })`(CKEditor5 등 자체 DOM 관리 플러그인)다. 이 호출은 React 렌더를 한 번도 일으키지 않아, `extendedDataContext` useMemo 가 재계산되지 않고 `context.state` 가 입력 이전 스냅샷으로 고정된다. 여기에 **브레이크포인트를 넘지 않는 폭 변경**(19px 로 재현)이 겹쳐 `__g7PendingLocalState` 가 null 이 되면 base 가 stale A 로 떨어졌다.
|
||||||
|
- 예외도 콘솔 에러도 남지 않는 결함이었다 — 작성 화면은 422, **수정 화면은 성공 토스트와 함께 직전 본문이 저장되어 편집분이 사라졌다.**
|
||||||
|
- 제외 조건은 종전 동작을 유지한다: `merge:"replace"`(의도적 리셋 · 사례 17), 모달 컨텍스트 스택이 있는 경우(사례 29), `__templateApp` 부재(v1.50.4 호환 폴백). `merge:"shallow"` 는 제외하지 않는다 — 현행 shallow 는 리프 컴포넌트의 부분 상태를 base 로 써서 오히려 이 결함에 더 노출돼 있었다.
|
||||||
|
- 두 쓰기 경로(B 쓰기 · 반환값)가 같은 규칙으로 저장소 A 전용 키(`loadingActions` 등)를 보충한다. 규칙이 갈리면 나중에 소비자가 생길 때 어느 경로를 탔느냐로 결과가 달라진다.
|
||||||
|
|
||||||
|
#### `setParentLocal` 이 모달에서 부모로 값을 올릴 때 저장소 B 를 통째 교체하던 문제
|
||||||
|
|
||||||
|
- `setParentLocal` 은 부모 컨텍스트의 **저장소 A**(`parentEntry.state._local`)를 base 로 만든 값을 그대로 `setGlobalState({ _local: ... })` 에 넘겼다. 위와 같은 얕은 병합 특성 때문에 이 쓰기도 patch 가 아니라 통째 교체였고, 부모가 페이지 루트일 때 그 A 스냅샷은 B 보다 뒤처져 있을 수 있다(사례 21). 이제 B 에는 live B + 변경 키만 얹고 A 전용 키는 보충한다.
|
||||||
|
- 저장소 A 경로(`parentEntry.setState`)와 `__g7PendingLocalState` 는 종전 그대로다 — 그쪽 base 까지 B 기반으로 바꾸면 React 전용 배열이 B 초기값으로 덮이는 사례 22 위험이 생긴다. `merge:"replace"` 는 여기서도 제외한다(사례 17).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `addMissingLeafKeys` 를 `helpers/StateMerge.ts` 로 옮겨 `G7CoreGlobals` 와 `ActionDispatcher` 가 공유한다. `G7CoreGlobals` 가 이미 `ActionDispatcher` 를 import 하고 있어 역방향 값 import 가 런타임 순환이 되기 때문이며, 동작은 원문 그대로다.
|
||||||
|
|
||||||
## [engine-v1.63.2] - 2026-08-27
|
## [engine-v1.63.2] - 2026-08-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { DataBindingEngine, dataBindingEngine } from './DataBindingEngine';
|
|||||||
import { hasPipes } from './PipeRegistry';
|
import { hasPipes } from './PipeRegistry';
|
||||||
import { extractSingleBinding } from './BindingShape';
|
import { extractSingleBinding } from './BindingShape';
|
||||||
import { evaluateStringCondition } from './helpers/ConditionEvaluator';
|
import { evaluateStringCondition } from './helpers/ConditionEvaluator';
|
||||||
|
import { addMissingLeafKeys } from './helpers/StateMerge';
|
||||||
import { DataSourceManager, dataSourceManager } from './DataSourceManager';
|
import { DataSourceManager, dataSourceManager } from './DataSourceManager';
|
||||||
import DynamicRenderer from './DynamicRenderer';
|
import DynamicRenderer from './DynamicRenderer';
|
||||||
import { useTransitionState } from './TransitionContext';
|
import { useTransitionState } from './TransitionContext';
|
||||||
@@ -1627,48 +1628,10 @@ function hasOnlyNumericKeys(obj: Record<string, any>): boolean {
|
|||||||
* @param source 병합할 소스 객체
|
* @param source 병합할 소스 객체
|
||||||
* @returns 병합된 결과 객체
|
* @returns 병합된 결과 객체
|
||||||
*/
|
*/
|
||||||
/**
|
// addMissingLeafKeys 는 engine-v1.63.3 에서 helpers/StateMerge 로 이동했다.
|
||||||
* base 객체에 없는 leaf 키만 extra에서 추가합니다.
|
// ActionDispatcher 의 handleSetState COMPONENT path 가 같은 보충 규칙을 써야 하는데
|
||||||
*
|
// G7CoreGlobals → ActionDispatcher import 가 이미 있어 역방향 값 import 가 순환이 되기 때문이다.
|
||||||
* deepMerge와 달리 base에 이미 존재하는 값(배열 포함)은 절대 덮어쓰지 않습니다.
|
// 동작은 원문 그대로다 (사례 13 회귀 테스트가 잠금 역할).
|
||||||
* extra에만 존재하는 키는 재귀적으로 추가됩니다.
|
|
||||||
*
|
|
||||||
* 용도: setLocal에서 dynamicLocal(actionContext.state)의 setState 전용 키를 globalLocal에
|
|
||||||
* 안전하게 추가할 때 사용. dynamicLocal의 stale 배열(init_actions 기본값)이 globalLocal의
|
|
||||||
* 정상 API 데이터를 덮어쓰는 것을 방지합니다.
|
|
||||||
*
|
|
||||||
* @since engine-v1.41.0
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const base = { form: { category_ids: [381, 384], name: 'A' } };
|
|
||||||
* const extra = { form: { category_ids: [], options: [] }, selectedProducts: [1] };
|
|
||||||
* addMissingLeafKeys(base, extra);
|
|
||||||
* // → { form: { category_ids: [381, 384], name: 'A', options: [] }, selectedProducts: [1] }
|
|
||||||
* // base의 category_ids는 보존, extra의 selectedProducts와 options는 추가
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
function addMissingLeafKeys(base: Record<string, any>, extra: Record<string, any>): Record<string, any> {
|
|
||||||
const result = { ...base };
|
|
||||||
for (const key of Object.keys(extra)) {
|
|
||||||
if (!(key in result)) {
|
|
||||||
// base에 없는 키: extra 값 그대로 추가
|
|
||||||
result[key] = extra[key];
|
|
||||||
} else if (
|
|
||||||
result[key] !== null &&
|
|
||||||
typeof result[key] === 'object' &&
|
|
||||||
!Array.isArray(result[key]) &&
|
|
||||||
extra[key] !== null &&
|
|
||||||
typeof extra[key] === 'object' &&
|
|
||||||
!Array.isArray(extra[key])
|
|
||||||
) {
|
|
||||||
// 양쪽 모두 plain object: 재귀적으로 처리
|
|
||||||
result[key] = addMissingLeafKeys(result[key], extra[key]);
|
|
||||||
}
|
|
||||||
// base에 이미 존재하는 leaf 값(배열, 문자열, 숫자 등): 건너뜀 (base 값 보존)
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
|
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
|
||||||
// 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우
|
// 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우
|
||||||
@@ -2363,7 +2326,31 @@ function initStateAPI(G7Core: any): void {
|
|||||||
// globalState._local도 함께 업데이트해야 후속 getLocal() 호출이 최신 값을 반환함.
|
// globalState._local도 함께 업데이트해야 후속 getLocal() 호출이 최신 값을 반환함.
|
||||||
const templateApp = (window as any).__templateApp;
|
const templateApp = (window as any).__templateApp;
|
||||||
if (templateApp?.setGlobalState) {
|
if (templateApp?.setGlobalState) {
|
||||||
templateApp.setGlobalState({ _local: merged });
|
// 저장소 B 쓰기의 base 는 live B 다 (engine-v1.63.3 / 공개 이슈 #130 과 같은 정책).
|
||||||
|
//
|
||||||
|
// `merged` 는 부모 컨텍스트의 **저장소 A**(`parentEntry.state._local`)를 base 로 만든 값이다.
|
||||||
|
// `setGlobalState` 는 `_local` 을 얕게 병합하므로 그것을 그대로 쓰면 B 가 통째 교체되고,
|
||||||
|
// A 가 아직 받지 못한 값(예: selfManaged 플러그인이 B 에만 쓴 편집기 본문)이 사라진다.
|
||||||
|
// 부모 컨텍스트가 페이지 루트일 때 그 A 스냅샷은 B 보다 뒤처져 있을 수 있다(사례 21).
|
||||||
|
//
|
||||||
|
// 그래서 B 에는 live B + 변경 키만 얹고, A 전용 키는 `addMissingLeafKeys` 로 보충한다.
|
||||||
|
// 저장소 A 경로(`parentEntry.setState`)와 pending 은 종전 그대로 둔다 — 그쪽 base 를
|
||||||
|
// 바꾸면 React 전용 배열이 B 초기값으로 덮이는 사례 22 위험이 생긴다.
|
||||||
|
//
|
||||||
|
// `merge: 'replace'` 는 의도적 리셋이므로 제외한다(사례 17).
|
||||||
|
const canonicalLocal = templateApp.getGlobalState?.()?._local;
|
||||||
|
const canUseCanonical = mergeMode !== 'replace'
|
||||||
|
&& !!canonicalLocal && typeof canonicalLocal === 'object' && !Array.isArray(canonicalLocal);
|
||||||
|
|
||||||
|
const canonicalMerged = canUseCanonical
|
||||||
|
? (mergeMode === 'shallow'
|
||||||
|
? { ...(canonicalLocal as Record<string, any>), ...converted }
|
||||||
|
: deepMerge(canonicalLocal as Record<string, any>, converted))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
templateApp.setGlobalState({
|
||||||
|
_local: canonicalMerged ? addMissingLeafKeys(canonicalMerged, merged) : merged,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
parentEntry.setState(merged);
|
parentEntry.setState(merged);
|
||||||
|
|||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* DynamicRenderer.resizePendingClear.test.tsx
|
||||||
|
*
|
||||||
|
* 사례 40(engine-v1.63.3) 위험 핀 — 트리거의 절반을 실물 렌더로 잠근다.
|
||||||
|
*
|
||||||
|
* 공개 이슈 #130 의 재현 조건은 두 조각으로 이루어진다:
|
||||||
|
* ① CKEditor 가 `setLocal({ render:false, selfManaged:true })` 로 저장소 B 에만 본문을 기록
|
||||||
|
* ② **브레이크포인트를 넘지 않는 폭 변경만으로도** 루트 렌더러가 리렌더되어
|
||||||
|
* `__g7PendingLocalState` 가 null 이 된다
|
||||||
|
*
|
||||||
|
* 이 파일은 ②만 잠근다. `useLayoutEffect` 에 의존성 배열이 없어(DynamicRenderer.tsx:1082~1116)
|
||||||
|
* ResponsiveContext 의 width 값이 바뀌어 리렌더가 일어나기만 하면 클리어가 실행된다는 사실이
|
||||||
|
* 코드 주석이 아니라 실물 렌더로 고정된다. 이 줄에 의존성 배열이 붙거나 조건이 생기면
|
||||||
|
* 여기서 red 가 된다.
|
||||||
|
*
|
||||||
|
* 소실 자체(저장소 B 통째 교체)는 이 계층에서 재현할 수 없다 — TemplateApp 재구현이 필요하다.
|
||||||
|
* 그 축은 troubleshooting-state-setstate.test.ts 의 `[사례 40]` 이 담당한다.
|
||||||
|
* 따라서 이 파일은 fail-first 가 아니다.
|
||||||
|
*
|
||||||
|
* @see 트러블슈팅 사례 40 (저장소 B 통째 교체 + stale 반환값)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { render, cleanup } from '@testing-library/react';
|
||||||
|
import DynamicRenderer, { ComponentDefinition } from '../DynamicRenderer';
|
||||||
|
import { ComponentRegistry } from '../ComponentRegistry';
|
||||||
|
import { DataBindingEngine } from '../DataBindingEngine';
|
||||||
|
import { TranslationEngine, TranslationContext } from '../TranslationEngine';
|
||||||
|
import { ActionDispatcher } from '../ActionDispatcher';
|
||||||
|
import * as ResponsiveContextModule from '../ResponsiveContext';
|
||||||
|
|
||||||
|
vi.mock('../ResponsiveManager', () => ({
|
||||||
|
responsiveManager: {
|
||||||
|
getWidth: vi.fn(() => 1440),
|
||||||
|
subscribe: vi.fn(() => () => {}),
|
||||||
|
getMatchingKey: vi.fn(() => null),
|
||||||
|
parseRange: vi.fn(() => null),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TestDiv: React.FC<{ className?: string; children?: React.ReactNode }> = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<div data-testid="test-div" className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('DynamicRenderer — 폭 변경만으로 __g7PendingLocalState 가 클리어된다 (사례 40 위험 핀)', () => {
|
||||||
|
let registry: ComponentRegistry;
|
||||||
|
let bindingEngine: DataBindingEngine;
|
||||||
|
let translationEngine: TranslationEngine;
|
||||||
|
let actionDispatcher: ActionDispatcher;
|
||||||
|
let translationContext: TranslationContext;
|
||||||
|
|
||||||
|
const setWidth = (width: number) => {
|
||||||
|
vi.spyOn(ResponsiveContextModule, 'useResponsive').mockReturnValue({
|
||||||
|
width,
|
||||||
|
isMobile: width < 768,
|
||||||
|
isTablet: width >= 768 && width < 1024,
|
||||||
|
isDesktop: width >= 1024,
|
||||||
|
matchedPreset: width < 768 ? 'mobile' : width < 1024 ? 'tablet' : 'desktop',
|
||||||
|
} as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const componentDef: ComponentDefinition = {
|
||||||
|
id: 'resize-pending-root',
|
||||||
|
type: 'basic',
|
||||||
|
name: 'Div',
|
||||||
|
props: { className: 'root' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTree = () => (
|
||||||
|
<DynamicRenderer
|
||||||
|
componentDef={componentDef}
|
||||||
|
dataContext={{ _local: { form: { title: '제목' } } }}
|
||||||
|
translationContext={translationContext}
|
||||||
|
registry={registry}
|
||||||
|
bindingEngine={bindingEngine}
|
||||||
|
translationEngine={translationEngine}
|
||||||
|
actionDispatcher={actionDispatcher}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = ComponentRegistry.getInstance();
|
||||||
|
(registry as any).registry = {
|
||||||
|
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
bindingEngine = new DataBindingEngine();
|
||||||
|
translationEngine = new TranslationEngine();
|
||||||
|
actionDispatcher = new ActionDispatcher({ navigate: vi.fn() });
|
||||||
|
translationContext = { templateId: 'test-template', locale: 'ko' };
|
||||||
|
|
||||||
|
setWidth(1440);
|
||||||
|
delete (window as any).__g7PendingLocalState;
|
||||||
|
delete (window as any).__g7SetLocalOverrideKeys;
|
||||||
|
delete (window as any).__g7ForcedLocalFields;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete (window as any).__g7PendingLocalState;
|
||||||
|
delete (window as any).__g7SetLocalOverrideKeys;
|
||||||
|
delete (window as any).__g7ForcedLocalFields;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('브레이크포인트를 넘지 않는 19px 변경만으로 pending 이 null 이 된다', () => {
|
||||||
|
// @scenario save_flow=create, resize_kind=same_breakpoint
|
||||||
|
// @effects resize_clears_pending_local_state
|
||||||
|
const { rerender } = render(renderTree());
|
||||||
|
|
||||||
|
// CKEditor 의 setLocal(render:false) 이 남긴 상태를 모사한다
|
||||||
|
(window as any).__g7PendingLocalState = { form: { content: '<p>본문</p>' } };
|
||||||
|
(window as any).__g7SetLocalOverrideKeys = { form: { content: '<p>본문</p>' } };
|
||||||
|
(window as any).__g7ForcedLocalFields = { form: { content: '<p>본문</p>' } };
|
||||||
|
|
||||||
|
// 1440 → 1421: desktop 프리셋 안에서만 움직인다 (브레이크포인트 미교차)
|
||||||
|
setWidth(1421);
|
||||||
|
rerender(renderTree());
|
||||||
|
|
||||||
|
expect((window as any).__g7PendingLocalState).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('같은 렌더에서 __g7ForcedLocalFields 는 살아남는다 (__g7SetLocalOverrideKeys 가 있으므로)', () => {
|
||||||
|
// @scenario save_flow=create, resize_kind=crossed_breakpoint
|
||||||
|
// @effects resize_clears_pending_local_state
|
||||||
|
const { rerender } = render(renderTree());
|
||||||
|
|
||||||
|
(window as any).__g7PendingLocalState = { form: { content: '<p>본문</p>' } };
|
||||||
|
(window as any).__g7SetLocalOverrideKeys = { form: { content: '<p>본문</p>' } };
|
||||||
|
(window as any).__g7ForcedLocalFields = { form: { content: '<p>본문</p>' } };
|
||||||
|
|
||||||
|
setWidth(1421);
|
||||||
|
rerender(renderTree());
|
||||||
|
|
||||||
|
// 오버레이는 조건부 클리어라 남는다 — 그럼에도 결함이 나는 이유는
|
||||||
|
// extendedDataContext useMemo 의 deps 가 이 전역을 포함하지 않아
|
||||||
|
// 리사이즈 렌더에서 오버레이가 context.state 로 합성되지 않기 때문이다.
|
||||||
|
expect((window as any).__g7ForcedLocalFields).toEqual({ form: { content: '<p>본문</p>' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('__g7SetLocalOverrideKeys 가 없으면 오버레이도 함께 클리어된다 (조건부 클리어 계약)', () => {
|
||||||
|
const { rerender } = render(renderTree());
|
||||||
|
|
||||||
|
(window as any).__g7PendingLocalState = { form: { content: '<p>본문</p>' } };
|
||||||
|
(window as any).__g7ForcedLocalFields = { form: { content: '<p>본문</p>' } };
|
||||||
|
delete (window as any).__g7SetLocalOverrideKeys;
|
||||||
|
|
||||||
|
setWidth(1421);
|
||||||
|
rerender(renderTree());
|
||||||
|
|
||||||
|
expect((window as any).__g7PendingLocalState).toBeNull();
|
||||||
|
expect((window as any).__g7ForcedLocalFields).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5269,3 +5269,221 @@ describe('[사례 39] dot notation setState 의 canonical source 동기화 (engi
|
|||||||
expect(globalState._local.form).toEqual({ title: '변경', body: '유지' });
|
expect(globalState._local.form).toEqual({ title: '변경', body: '유지' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('[사례 40] 리사이즈 후 저장 시 편집기 본문이 저장소 B 와 반환값에서 함께 사라짐 (engine-v1.63.3)', () => {
|
||||||
|
let dispatcher: ActionDispatcher;
|
||||||
|
let globalState: Record<string, any>;
|
||||||
|
let componentState: Record<string, any>;
|
||||||
|
let setStateCalls: any[];
|
||||||
|
|
||||||
|
/** TemplateApp.setGlobalState 와 동일한 계약: 최상위 키 얕은 병합 + 동기 대입 */
|
||||||
|
const globalStateUpdater = vi.fn((updates: any) => {
|
||||||
|
globalState = { ...globalState, ...updates };
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setStateCalls = [];
|
||||||
|
componentState = {};
|
||||||
|
globalState = { _local: {} };
|
||||||
|
|
||||||
|
dispatcher = new ActionDispatcher({ navigate: vi.fn() });
|
||||||
|
dispatcher.setGlobalStateUpdater(globalStateUpdater);
|
||||||
|
Logger.getInstance().setDebug(false);
|
||||||
|
|
||||||
|
(window as any).__templateApp = { getGlobalState: () => globalState };
|
||||||
|
// 리사이즈 렌더가 이미 pending 을 비운 상태를 결정화한다
|
||||||
|
// (관측 가능한 결과가 이 값 하나뿐이므로 타이밍 모사가 불필요)
|
||||||
|
(window as any).__g7PendingLocalState = null;
|
||||||
|
(window as any).__g7ForcedLocalFields = undefined;
|
||||||
|
(window as any).__g7LayoutContextStack = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
delete (window as any).__templateApp;
|
||||||
|
(window as any).__g7PendingLocalState = undefined;
|
||||||
|
(window as any).__g7ForcedLocalFields = undefined;
|
||||||
|
(window as any).__g7LayoutContextStack = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = () => ({
|
||||||
|
state: componentState,
|
||||||
|
setState: vi.fn((updates: any) => {
|
||||||
|
setStateCalls.push(updates);
|
||||||
|
componentState = { ...componentState, ...updates };
|
||||||
|
}),
|
||||||
|
actionId: 'case-40',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('1. 결함 재현 핀: B 의 편집기 본문이 stale A 스냅샷으로 덮이지 않는다', async () => {
|
||||||
|
// @scenario save_flow=create, resize_kind=same_breakpoint
|
||||||
|
// @effects canonical_local_keeps_editor_content_after_resize, setstate_local_preserves_canonical_keys_when_pending_is_null
|
||||||
|
// CKEditor 가 setLocal({ render:false, selfManaged:true }) 로 B 에만 기록한 본문
|
||||||
|
globalState._local = { form: { title: '제목', content: '<p>본문</p>' }, isSaving: false };
|
||||||
|
// 리사이즈 렌더로 memo 가 재계산되지 않아 context.state 는 본문 타이핑 이전 스냅샷
|
||||||
|
componentState = { form: { title: '제목', content: '' }, isSaving: false };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { isSaving: true, errors: null } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalState._local.form.content).toBe('<p>본문</p>');
|
||||||
|
expect(globalState._local.isSaving).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2. body 회귀 핀: 반환값에도 원문 본문이 실린다 (없으면 422 가 남는다)', async () => {
|
||||||
|
// @scenario save_flow=edit, resize_kind=crossed_breakpoint
|
||||||
|
// @effects request_body_content_matches_editor_getdata, edit_save_persists_typed_addition
|
||||||
|
globalState._local = { form: { title: '제목', content: '<p>본문</p>' } };
|
||||||
|
componentState = { form: { title: '제목', content: '' } };
|
||||||
|
|
||||||
|
const result = await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { isSaving: true } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.data.form.content).toBe('<p>본문</p>');
|
||||||
|
expect(result.data.isSaving).toBe(true);
|
||||||
|
expect(result.data.__target).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('3. 사례 22 핀: __g7PendingLocalState 는 A 계열 기반으로 유지된다', async () => {
|
||||||
|
globalState._local = { form: { content: '<p>본문</p>' }, expandedRows: [] };
|
||||||
|
componentState = { form: { content: '' }, expandedRows: [301] };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { isSaving: true } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
// pending 은 A(context.state) 기반이어야 한다 — B 기반으로 바꾸면 사례 22 재발
|
||||||
|
expect((window as any).__g7PendingLocalState.expandedRows).toEqual([301]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('4. 중첩 경로 핀: 다국어 form.{name}.{locale} 에서 형제 로케일/필드가 보존된다', async () => {
|
||||||
|
globalState._local = { form: { content: { ko: '<p>본문</p>', en: '<p>body</p>' }, title: { ko: '제목' } } };
|
||||||
|
componentState = { form: { content: { ko: '', en: '' }, title: { ko: '제목' } } };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { 'form.title.ko': '새 제목' } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalState._local.form.content).toEqual({ ko: '<p>본문</p>', en: '<p>body</p>' });
|
||||||
|
expect(globalState._local.form.title.ko).toBe('새 제목');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('5. 사례 17 핀: merge replace 는 live B 를 base 로 쓰지 않는다', async () => {
|
||||||
|
globalState._local = { form: { content: '<p>본문</p>' }, keep: 1 };
|
||||||
|
componentState = { form: { content: '' } };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { merge: 'replace', only: 'this' } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalState._local.keep).toBeUndefined();
|
||||||
|
expect(globalState._local.only).toBe('this');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('6. 사례 29 핀: 모달 컨텍스트 스택이 있으면 종전 동작(전체 스냅샷)을 유지한다', async () => {
|
||||||
|
(window as any).__g7LayoutContextStack = [{ state: {}, setState: vi.fn() }];
|
||||||
|
globalState._local = { form: { content: '<p>페이지 본문</p>' } };
|
||||||
|
componentState = { modalField: 'x' };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { modalField: 'y' } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달 안에서는 live B 를 base 로 삼지 않는다 (페이지 _local 흡수 방지)
|
||||||
|
expect(globalState._local.modalField).toBe('y');
|
||||||
|
expect(globalState._local.form).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('7. 폴백 핀: __templateApp 이 없으면 종전 전체 스냅샷 경로를 탄다', async () => {
|
||||||
|
delete (window as any).__templateApp;
|
||||||
|
componentState = { form: { content: '' }, a: 1 };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { b: 2 } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalState._local).toEqual({ form: { content: '' }, a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('8. A 전용 키 핀: loadingActions 가 반환값에 남는다', async () => {
|
||||||
|
globalState._local = { form: { content: '<p>본문</p>' } };
|
||||||
|
componentState = { form: { content: '' }, loadingActions: { save: true } };
|
||||||
|
|
||||||
|
const result = await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { isSaving: true } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.data.loadingActions).toEqual({ save: true });
|
||||||
|
expect(result.data.form.content).toBe('<p>본문</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('9. B 의 중첩 객체가 in-place 변이되지 않는다', async () => {
|
||||||
|
const liveForm = { title: '제목', content: '<p>본문</p>' };
|
||||||
|
globalState._local = { form: liveForm };
|
||||||
|
componentState = { form: { title: '제목', content: '' } };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { 'form.title': '변경' } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 원본 참조는 변이되지 않아야 한다
|
||||||
|
expect(liveForm.title).toBe('제목');
|
||||||
|
expect(globalState._local.form.title).toBe('변경');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('10. merge shallow 핀(F3): payload 밖 키를 live B 값으로 보존한다', async () => {
|
||||||
|
// 모집단: admin_ecommerce_{order,product}_list 필터 (#421 회귀 핀 대상)
|
||||||
|
globalState._local = { filters: { status: 'paid' }, search: '주문번호', page: 3 };
|
||||||
|
componentState = { filters: { status: 'paid' } };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { merge: 'shallow', page: 1 } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalState._local.page).toBe(1);
|
||||||
|
expect(globalState._local.search).toBe('주문번호');
|
||||||
|
expect(globalState._local.filters).toEqual({ status: 'paid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('11. A 전용 키 핀(F2): loadingActions 가 B 에도 보충된다 (setLocal 선례와 대칭)', async () => {
|
||||||
|
globalState._local = { form: { content: '<p>본문</p>' } };
|
||||||
|
componentState = { form: { content: '' }, loadingActions: { save: true }, apiError: null };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { isSaving: true } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalState._local.loadingActions).toEqual({ save: true });
|
||||||
|
expect(globalState._local.form.content).toBe('<p>본문</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('12. 편집기 프리뷰 핀(F5): 격리 façade 에서도 canonical 경로를 타고 폴백이 발생하지 않는다', async () => {
|
||||||
|
// PreviewCanvas 는 _local 을 항상 객체로 보장하므로 폴백이 발생하지 않는다
|
||||||
|
const isolatedStore: Record<string, any> = { _local: { previewOnly: true } };
|
||||||
|
(window as any).__templateApp = { getGlobalState: () => isolatedStore };
|
||||||
|
componentState = { form: { content: '' } };
|
||||||
|
|
||||||
|
await dispatcher.executeAction(
|
||||||
|
{ handler: 'setState' as const, params: { isSaving: true } },
|
||||||
|
context()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalState._local.previewOnly).toBe(true);
|
||||||
|
expect(globalState._local.isSaving).toBe(true);
|
||||||
|
// 격리 store 자체는 globalStateUpdater 를 통하지 않으므로 그대로다
|
||||||
|
expect(isolatedStore._local).toEqual({ previewOnly: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 상태 병합 헬퍼
|
||||||
|
*
|
||||||
|
* 이중 저장소(React `localDynamicState` = 저장소 A, `globalState._local` = 저장소 B)를
|
||||||
|
* 동기화하는 두 쓰기 경로가 공유하는 병합 프리미티브를 담는다.
|
||||||
|
*
|
||||||
|
* 왜 `helpers/` 인가:
|
||||||
|
* `addMissingLeafKeys` 는 `G7CoreGlobals.ts` 의 모듈 프라이빗 함수였다. 그런데
|
||||||
|
* `G7CoreGlobals.ts` 가 `ActionDispatcher` 를 import 하므로 `ActionDispatcher` 에서
|
||||||
|
* 역방향 값 import 를 하면 런타임 순환이 된다. `helpers/` 는 엔진 모듈을 import 하지
|
||||||
|
* 않으므로 양쪽이 안전하게 공유할 수 있다.
|
||||||
|
*
|
||||||
|
* @since engine-v1.63.3
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base 객체에 없는 leaf 키만 extra에서 추가합니다.
|
||||||
|
*
|
||||||
|
* deepMerge와 달리 base에 이미 존재하는 값(배열 포함)은 절대 덮어쓰지 않습니다.
|
||||||
|
* extra에만 존재하는 키는 재귀적으로 추가됩니다.
|
||||||
|
*
|
||||||
|
* 용도:
|
||||||
|
* - `setLocal`(G7CoreGlobals)에서 dynamicLocal(actionContext.state)의 setState 전용 키를
|
||||||
|
* globalLocal에 안전하게 추가할 때. dynamicLocal의 stale 배열(init_actions 기본값)이
|
||||||
|
* globalLocal의 정상 API 데이터를 덮어쓰는 것을 방지합니다.
|
||||||
|
* - `handleSetState`(ActionDispatcher) COMPONENT path 에서 저장소 B 를 base 로 삼을 때
|
||||||
|
* 저장소 A 전용 키(`loadingActions`, `apiError`, `*_result` 등)를 보충할 때.
|
||||||
|
*
|
||||||
|
* @param base 우선하는 기준 객체 (충돌 시 이 값이 이긴다)
|
||||||
|
* @param extra base 에 없는 키만 가져올 보충 객체
|
||||||
|
* @return base 를 얕게 복사한 뒤 누락 키를 채운 새 객체 (base/extra 모두 변이하지 않음)
|
||||||
|
*
|
||||||
|
* @since engine-v1.41.0 (G7CoreGlobals 내부 함수로 최초 도입)
|
||||||
|
* @since engine-v1.63.3 helpers/StateMerge 로 이동해 ActionDispatcher 와 공유
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const base = { form: { category_ids: [381, 384], name: 'A' } };
|
||||||
|
* const extra = { form: { category_ids: [], options: [] }, selectedProducts: [1] };
|
||||||
|
* addMissingLeafKeys(base, extra);
|
||||||
|
* // → { form: { category_ids: [381, 384], name: 'A', options: [] }, selectedProducts: [1] }
|
||||||
|
* // base의 category_ids는 보존, extra의 selectedProducts와 options는 추가
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function addMissingLeafKeys(
|
||||||
|
base: Record<string, any>,
|
||||||
|
extra: Record<string, any>
|
||||||
|
): Record<string, any> {
|
||||||
|
const result = { ...base };
|
||||||
|
for (const key of Object.keys(extra)) {
|
||||||
|
if (!(key in result)) {
|
||||||
|
// base에 없는 키: extra 값 그대로 추가
|
||||||
|
result[key] = extra[key];
|
||||||
|
} else if (
|
||||||
|
result[key] !== null &&
|
||||||
|
typeof result[key] === 'object' &&
|
||||||
|
!Array.isArray(result[key]) &&
|
||||||
|
extra[key] !== null &&
|
||||||
|
typeof extra[key] === 'object' &&
|
||||||
|
!Array.isArray(extra[key])
|
||||||
|
) {
|
||||||
|
// 양쪽 모두 plain object: 재귀적으로 처리
|
||||||
|
result[key] = addMissingLeafKeys(result[key], extra[key]);
|
||||||
|
}
|
||||||
|
// base에 이미 존재하는 leaf 값(배열, 문자열, 숫자 등): 건너뜀 (base 값 보존)
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -90,6 +90,14 @@ export {
|
|||||||
type ActionDefinition,
|
type ActionDefinition,
|
||||||
} from './ConditionEvaluator';
|
} from './ConditionEvaluator';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 상태 병합 헬퍼
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
addMissingLeafKeys,
|
||||||
|
} from './StateMerge';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 향후 추가 예정 헬퍼 모듈 예시
|
// 향후 추가 예정 헬퍼 모듈 예시
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd templates/_bundled/sirsoft-admin_basic && powershell -Command "npm run test:run -- <대상>"
|
cd templates/_bundled/sirsoft-admin_basic && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/<대상>.spec.ts
|
cd templates/_bundled/sirsoft-admin_basic && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| 스타일 시스템 | `tailwind` |
|
| 스타일 시스템 | `tailwind` |
|
||||||
| 다크 모드 전략 | `ancestor-class` |
|
| 다크 모드 전략 | `ancestor-class` |
|
||||||
|
|
||||||
> 분할 13블록 · 팔레트 79 · 스타일 컨트롤 303 · 편집 역량 86 · 중첩 컨테이너 19 · 프리뷰 샘플 70 · 페이지 상태 10 · 액션 레시피 14
|
> 분할 13블록 · 팔레트 79 · 스타일 컨트롤 303 · 편집 역량 86 · 중첩 컨테이너 19 · 프리뷰 샘플 71 · 페이지 상태 10 · 액션 레시피 14
|
||||||
<!-- @generated:editor-spec-summary END -->
|
<!-- @generated:editor-spec-summary END -->
|
||||||
|
|
||||||
<!-- @intent START -->
|
<!-- @intent START -->
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
| `componentCapabilities` | 컴포넌트별 편집 역량(어떤 속성을 편집기가 다루는가) | 86 | `editor-spec/componentCapabilities.json` |
|
| `componentCapabilities` | 컴포넌트별 편집 역량(어떤 속성을 편집기가 다루는가) | 86 | `editor-spec/componentCapabilities.json` |
|
||||||
| `nesting.draggable` | 캔버스에서 끌어 옮길 수 있는 컴포넌트 | 84 | `editor-spec/nesting.json` |
|
| `nesting.draggable` | 캔버스에서 끌어 옮길 수 있는 컴포넌트 | 84 | `editor-spec/nesting.json` |
|
||||||
| `nesting.containers` | 자식을 담을 수 있는 컴포넌트와 그 허용 규칙 | 19 | `editor-spec/nesting.json` |
|
| `nesting.containers` | 자식을 담을 수 있는 컴포넌트와 그 허용 규칙 | 19 | `editor-spec/nesting.json` |
|
||||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 70 | `editor-spec/sampleData.json` |
|
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 71 | `editor-spec/sampleData.json` |
|
||||||
| `sampleGlobal` | `_global.*` 프리뷰 baseline 시드 | 5 | `editor-spec/sampleGlobal.json` |
|
| `sampleGlobal` | `_global.*` 프리뷰 baseline 시드 | 5 | `editor-spec/sampleGlobal.json` |
|
||||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `editor-spec/states.json` |
|
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `editor-spec/states.json` |
|
||||||
| `stateLabels` | 상태값 친화 명칭 카탈로그 | 8 | `editor-spec/stateLabels.json` |
|
| `stateLabels` | 상태값 친화 명칭 카탈로그 | 8 | `editor-spec/stateLabels.json` |
|
||||||
@@ -91,13 +91,11 @@
|
|||||||
<!-- @generated:editor-spec-samples START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
<!-- @generated:editor-spec-samples START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||||
| 자리 | 역할 | 개수 | ID |
|
| 자리 | 역할 | 개수 | ID |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 70 | `sitemap_status` · `sitemap_progress_ws` · `locales` · `me` · `installed_modules` · `active_plugins` · `users` · `roles` · `availableChannels` · `identityProviders` · `identityPurposes` · `identityPolicies` … 외 58개 |
|
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 71 | `trustedProxy` · `sitemap_status` · `sitemap_progress_ws` · `locales` · `me` · `installed_modules` · `active_plugins` · `users` · `roles` · `availableChannels` · `identityProviders` · `identityPurposes` … 외 59개 |
|
||||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - |
|
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - |
|
||||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `_admin_base` · `*/admin/users` · `*/admin/users/:id/edit` · `*/admin/settings` · `*/admin/reset-password` · `*/admin/roles/:id/edit` · `*/admin/identity/challenge` · `*/admin/templates/:type` · `*/admin/login` · `*/admin/forgot-password` |
|
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `_admin_base` · `*/admin/users` · `*/admin/users/:id/edit` · `*/admin/settings` · `*/admin/reset-password` · `*/admin/roles/:id/edit` · `*/admin/identity/challenge` · `*/admin/templates/:type` · `*/admin/login` · `*/admin/forgot-password` |
|
||||||
|
|
||||||
**프리뷰 샘플이 없는 `data_source` 1개** — 편집기 캔버스에서 이 자리만 빈 화면이 됩니다. 실제 화면은 정상이라 오류도 경고도 남지 않습니다.
|
_이 확장 레이아웃의 `data_source` 는 전부 프리뷰 샘플이 붙습니다 (이 확장 또는 번들 템플릿 스펙이 커버)._
|
||||||
|
|
||||||
`trustedProxy`
|
|
||||||
<!-- @generated:editor-spec-samples END -->
|
<!-- @generated:editor-spec-samples END -->
|
||||||
|
|
||||||
<!-- @intent START -->
|
<!-- @intent START -->
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"comment": "admin 자체 + 코어 SSoT 도메인. locales/admin_menus/me/installed_modules/active_plugins/users 등. 실제 데이터소스 ID 는 착수 시점 admin 레이아웃 data_sources 로 재검증 — 본 블록은 코어 SSoT/공통 폴백.",
|
"comment": "admin 자체 + 코어 SSoT 도메인. locales/admin_menus/me/installed_modules/active_plugins/users 등. 실제 데이터소스 ID 는 착수 시점 admin 레이아웃 data_sources 로 재검증 — 본 블록은 코어 SSoT/공통 폴백.",
|
||||||
"byDataSourceId": {
|
"byDataSourceId": {
|
||||||
|
"trustedProxy": {
|
||||||
|
"data": {
|
||||||
|
"status": "warning",
|
||||||
|
"trusted_configured": false,
|
||||||
|
"configured_proxies": null,
|
||||||
|
"forwarded_headers": ["X-Forwarded-For", "X-Forwarded-Proto"],
|
||||||
|
"is_secure": false,
|
||||||
|
"client_ip": "10.0.2.31",
|
||||||
|
"remote_addr": "203.0.113.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sitemap_status": {
|
"sitemap_status": {
|
||||||
"data": {
|
"data": {
|
||||||
"last_updated_at": "2026-06-21T15:40:00+09:00",
|
"last_updated_at": "2026-06-21T15:40:00+09:00",
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
|||||||
# Vitest (확장 디렉토리에서) (PowerShell)
|
# Vitest (확장 디렉토리에서) (PowerShell)
|
||||||
cd templates/_bundled/sirsoft-basic && powershell -Command "npm run test:run -- <대상>"
|
cd templates/_bundled/sirsoft-basic && powershell -Command "npm run test:run -- <대상>"
|
||||||
|
|
||||||
# Playwright E2E (Bash)
|
# Playwright E2E (확장 디렉토리에서) (Bash)
|
||||||
npx playwright test templates/_bundled/sirsoft-basic/tests/Playwright/specs/<대상>.spec.ts
|
cd templates/_bundled/sirsoft-basic && npm run test:e2e -- specs/<대상>.spec.ts
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,10 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Documentation;
|
namespace Tests\Feature\Documentation;
|
||||||
|
|
||||||
use App\Support\ExtensionDoc\DataModelCollector;
|
|
||||||
use App\Support\ExtensionDoc\DeclarativeSurfaceCollector;
|
|
||||||
use App\Support\ExtensionDoc\DependencyGraphCollector;
|
|
||||||
use App\Support\ExtensionDoc\ExtensionDocContext;
|
use App\Support\ExtensionDoc\ExtensionDocContext;
|
||||||
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
|
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
|
||||||
use App\Support\ExtensionDoc\ExtensionInventory;
|
use App\Support\ExtensionDoc\ExtensionInventory;
|
||||||
use App\Support\ExtensionDoc\FrontendInventory;
|
use App\Support\ExtensionDoc\FrontendInventory;
|
||||||
use App\Support\ExtensionDoc\HookInventory;
|
|
||||||
use App\Support\ExtensionDoc\TestPathCollector;
|
use App\Support\ExtensionDoc\TestPathCollector;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -436,19 +432,19 @@ class ExtensionDocContractTest extends TestCase
|
|||||||
*/
|
*/
|
||||||
public function test_empty_intent_axis_agrees_across_tooling(): void
|
public function test_empty_intent_axis_agrees_across_tooling(): void
|
||||||
{
|
{
|
||||||
$filled = "<!-- @intent START -->
|
$filled = '<!-- @intent START -->
|
||||||
서술이 있다.
|
서술이 있다.
|
||||||
<!-- @intent END -->";
|
<!-- @intent END -->';
|
||||||
$empty = "<!-- @intent START -->
|
$empty = '<!-- @intent START -->
|
||||||
|
|
||||||
<!-- @intent END -->";
|
<!-- @intent END -->';
|
||||||
|
|
||||||
$this->assertSame(0, ExtensionDocScaffolder::emptyIntentBlocks($filled));
|
$this->assertSame(0, ExtensionDocScaffolder::emptyIntentBlocks($filled));
|
||||||
$this->assertSame(1, ExtensionDocScaffolder::emptyIntentBlocks($empty));
|
$this->assertSame(1, ExtensionDocScaffolder::emptyIntentBlocks($empty));
|
||||||
$this->assertSame(2, ExtensionDocScaffolder::emptyIntentBlocks($empty."
|
$this->assertSame(2, ExtensionDocScaffolder::emptyIntentBlocks($empty.'
|
||||||
".$empty));
|
'.$empty));
|
||||||
$this->assertSame(1, ExtensionDocScaffolder::emptyIntentBlocks($filled."
|
$this->assertSame(1, ExtensionDocScaffolder::emptyIntentBlocks($filled.'
|
||||||
".$empty));
|
'.$empty));
|
||||||
|
|
||||||
$script = (string) file_get_contents(base_path('.claude/scripts/check-extension-docs.cjs'));
|
$script = (string) file_get_contents(base_path('.claude/scripts/check-extension-docs.cjs'));
|
||||||
|
|
||||||
@@ -1341,4 +1337,80 @@ class ExtensionDocContractTest extends TestCase
|
|||||||
// 결과는 "생성기를 다시 돌려라" 인데 아무리 돌려도 사라지지 않는 드리프트다.
|
// 결과는 "생성기를 다시 돌려라" 인데 아무리 돌려도 사라지지 않는 드리프트다.
|
||||||
return app(ExtensionDocContext::class)->build($record);
|
return app(ExtensionDocContext::class)->build($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서가 싣는 Playwright 명령이 그 확장의 config 를 잡는지 단언합니다.
|
||||||
|
*
|
||||||
|
* 확장은 자기 config 를 `tests/Playwright/playwright.config.ts` 에 두므로, 저장소
|
||||||
|
* 루트에서 spec 경로만 넘기면 코어 config(`testDir: tests/Playwright/specs`)가 잡혀
|
||||||
|
* 그 spec 이 모집단 밖이 됩니다. 결과는 실패가 아니라 **"No tests found"** 입니다 —
|
||||||
|
* 문서대로 따른 사람은 테스트를 돌렸다고 믿는데 0건이 지나가고, 코어 globalSetup 이
|
||||||
|
* 개발 사이트에 시드 화면을 설치·제거하는 부작용만 남습니다.
|
||||||
|
*
|
||||||
|
* 모집단은 열거하지 않고 실측에서 파생합니다 — Playwright 테스트를 새로 갖추는 확장이
|
||||||
|
* 생기면 자동으로 편입됩니다.
|
||||||
|
*/
|
||||||
|
public function test_playwright_command_resolves_the_extension_config(): void
|
||||||
|
{
|
||||||
|
$collector = new TestPathCollector;
|
||||||
|
$checked = [];
|
||||||
|
|
||||||
|
foreach ((new ExtensionInventory)->collect('all') as $record) {
|
||||||
|
$collected = $collector->collect($record);
|
||||||
|
|
||||||
|
// 명령 문자열이 아니라 **라벨**로 고른다 — 명령은 `npm run test:e2e` 처럼
|
||||||
|
// 도구 이름을 담지 않을 수 있고, 그러면 이 검사가 대상을 못 찾은 채 통과한다.
|
||||||
|
$command = null;
|
||||||
|
foreach ($collected['commands'] as $entry) {
|
||||||
|
if (str_contains($entry['label'], 'Playwright')) {
|
||||||
|
$command = $entry['command'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($command === null) {
|
||||||
|
$this->assertSame(
|
||||||
|
0,
|
||||||
|
$collected['playwright']['count'],
|
||||||
|
"{$record['type']}:{$record['id']} — Playwright 테스트가 있는데 실행 명령이 없습니다.",
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rel = $record['relPath'];
|
||||||
|
$config = $record['path'].DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR
|
||||||
|
.'Playwright'.DIRECTORY_SEPARATOR.'playwright.config.ts';
|
||||||
|
|
||||||
|
$this->assertFileExists(
|
||||||
|
$config,
|
||||||
|
"{$rel} — Playwright 명령을 싣는 확장은 자기 config 를 가져야 합니다.",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 확장 디렉토리로 이동하거나(그 자리의 npm 스크립트가 --config 를 품는다),
|
||||||
|
// 명령이 직접 --config 를 지목하거나. 둘 중 하나여야 코어 config 를 피한다.
|
||||||
|
$entersExtensionDir = str_contains($command, "cd {$rel} &&");
|
||||||
|
$namesConfig = str_contains($command, '--config=');
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$entersExtensionDir || $namesConfig,
|
||||||
|
"{$rel} — 명령이 코어 config 를 잡습니다(실행 결과가 \"No tests found\"). "
|
||||||
|
."확장 디렉토리로 이동하거나 --config 를 지목해야 합니다: {$command}",
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString(
|
||||||
|
"npx playwright test {$rel}/tests/Playwright/specs/",
|
||||||
|
$command,
|
||||||
|
"{$rel} — 루트 기준 spec 경로 형태는 코어 config 로 해석됩니다.",
|
||||||
|
);
|
||||||
|
|
||||||
|
$checked[] = $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하한 — 모집단이 비면 위 루프가 통째로 돌지 않고도 통과한다.
|
||||||
|
$this->assertNotEmpty(
|
||||||
|
$checked,
|
||||||
|
'Playwright 명령을 싣는 확장을 하나도 찾지 못했습니다 — 모집단 파생이 죽었습니다.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
feature: 폭 변경 후 저장 시 편집기 본문 보존 (공개 이슈 #130 / engine-v1.63.3)
|
||||||
|
|
||||||
|
description: |
|
||||||
|
엔진은 폼 상태를 React localDynamicState(저장소 A)와 globalState._local(저장소 B)에 이중
|
||||||
|
저장한다. setGlobalState 는 _local 을 얕게 병합하므로 `{ _local: X }` 는 B 를 통째 교체한다.
|
||||||
|
|
||||||
|
CKEditor 는 setLocal({ render:false, selfManaged:true }) 로 B 에만 본문을 쓴다 — 그 호출은
|
||||||
|
React 렌더를 한 번도 일으키지 않아 extendedDataContext useMemo 가 재계산되지 않고
|
||||||
|
context.state 가 입력 이전 스냅샷에 머문다. 여기에 브레이크포인트를 넘지 않는 폭 변경(19px)
|
||||||
|
이 겹치면 의존성 배열 없는 useLayoutEffect 가 __g7PendingLocalState 를 null 로 지우고,
|
||||||
|
저장 시 setState 의 base 가 stale A 로 떨어져 B 와 sequence 반환값이 함께 오염된다.
|
||||||
|
|
||||||
|
수정은 두 지점을 함께 고친다:
|
||||||
|
- B 동기화 base 를 live B(getGlobalState()._local) + 변경 키로
|
||||||
|
- 같은 분기의 반환값도 같은 canonical 결과로 (요청 body 는 B 가 아니라 이 반환값에서 온다)
|
||||||
|
두 경로 모두 addMissingLeafKeys 로 저장소 A 전용 키(loadingActions 등)를 보충한다.
|
||||||
|
|
||||||
|
증상이 조용하다는 점이 이 기능의 핵심이다 — 작성은 422, 수정은 성공 토스트와 함께 편집분
|
||||||
|
소실이며 콘솔 에러는 0건이다. 그래서 "저장됐다" 가 아니라 요청 body 의 content 를 본다.
|
||||||
|
|
||||||
|
test_files:
|
||||||
|
- resources/js/core/template-engine/__tests__/troubleshooting-state-setstate.test.ts
|
||||||
|
- resources/js/core/template-engine/__tests__/DynamicRenderer.resizePendingClear.test.tsx
|
||||||
|
- resources/js/core/__tests__/G7CoreGlobals.selfManagedPromotion.test.ts
|
||||||
|
- plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/editor-resize-save-body-integrity.spec.ts
|
||||||
|
|
||||||
|
axes:
|
||||||
|
save_flow: [create, edit]
|
||||||
|
resize_kind: [same_breakpoint, crossed_breakpoint, none]
|
||||||
|
|
||||||
|
effects:
|
||||||
|
- request_body_content_matches_editor_getdata
|
||||||
|
- create_save_returns_2xx_not_422
|
||||||
|
- edit_save_persists_typed_addition
|
||||||
|
- canonical_local_keeps_editor_content_after_resize
|
||||||
|
- setstate_local_preserves_canonical_keys_when_pending_is_null
|
||||||
|
- save_without_resize_unaffected
|
||||||
|
- resize_save_emits_no_console_errors
|
||||||
|
- success_toast_only_when_content_actually_saved
|
||||||
|
- resize_clears_pending_local_state
|
||||||
|
- selfmanaged_setlocal_writes_canonical_without_render
|
||||||
|
|
||||||
|
sub_flows:
|
||||||
|
- id: control_no_resize
|
||||||
|
description: |
|
||||||
|
리사이즈 없이 저장하면 수정 전에도 정상이다 — 재현 조건이 폭 변경임을 고정한다.
|
||||||
|
이 축이 수정 전에 실패하면 결함 모델이 틀린 것이므로, 자동바인딩 → pending → setLocal
|
||||||
|
두 번째 소실 경로(사례 40 "인접 벡터")를 먼저 조사해야 한다.
|
||||||
|
effects: [save_without_resize_unaffected]
|
||||||
|
|
||||||
|
- id: silent_failure_surface
|
||||||
|
description: |
|
||||||
|
콘솔에도 토스트에도 드러나지 않던 결함이라 조용함 자체를 증거로 고정한다.
|
||||||
|
수정 화면은 성공 토스트가 뜨므로 화면 피드백으로는 판정할 수 없다 — DB 재조회로 본다.
|
||||||
|
effects: [resize_save_emits_no_console_errors, success_toast_only_when_content_actually_saved]
|
||||||
|
|
||||||
|
- id: canonical_store_integrity
|
||||||
|
description: |
|
||||||
|
pending 이 null 인 상태에서 setState(local) 을 태워도 저장소 B 의 본문이 남는지를
|
||||||
|
직접 본다. 요청 body 축과 별개로 B 쓰기 축을 독립 고정한다.
|
||||||
|
effects:
|
||||||
|
- canonical_local_keeps_editor_content_after_resize
|
||||||
|
- setstate_local_preserves_canonical_keys_when_pending_is_null
|
||||||
|
|
||||||
|
- id: trigger_halves
|
||||||
|
description: |
|
||||||
|
결함은 두 조각이 겹쳐야 성립한다. 각 조각을 단위 계층에서 따로 잠근다 —
|
||||||
|
① 폭 변경 리렌더가 __g7PendingLocalState 를 비운다(실물 렌더)
|
||||||
|
② selfManaged setLocal 이 렌더 없이 저장소 B 에만 쓴다(엔진 실물)
|
||||||
|
단위 계층에서는 소실 자체를 재현할 수 없으므로(TemplateApp 재구현 필요) 조각만 고정하고,
|
||||||
|
합류 지점은 사례 40 회귀 테스트와 E2E 가 담당한다.
|
||||||
|
effects:
|
||||||
|
- resize_clears_pending_local_state
|
||||||
|
- selfmanaged_setlocal_writes_canonical_without_render
|
||||||
Reference in New Issue
Block a user