Compare commits
4 Commits
68959cb33b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a531d9102 | |||
|
|
dcc344764b | ||
|
|
2a04955bd7 | ||
| d2b6399c96 |
353
docs/20260806_관리자라우트지연로딩/plan-task.md
Normal file
353
docs/20260806_관리자라우트지연로딩/plan-task.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
# 관리자 라우트 지연 로딩 구현 계획
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현·회귀 수정·검증 완료 |
|
||||||
|
| 작성일 | 2026-08-06 |
|
||||||
|
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||||
|
| API 기준 | 변경 불필요 — 기존 인증·domain 계약 유지 |
|
||||||
|
| 현재 Phase | Phase 1. 보호 page code splitting 완료 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
보호된 관리자 page를 route별로 지연 로드해 초기 JS chunk를 줄이면서 모든 기존 기능을 유지한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `3/3` | 없음 | 없음 |
|
||||||
|
|
||||||
|
- `ProtectedAdminShell`은 14개 보호 page component를 `React.lazy()` 동적 import로 로드한다.
|
||||||
|
- Vite production build는 310 modules를 37개 JS chunk로 분리하고 최대 JS chunk는 `315.09kB`다.
|
||||||
|
- 전체 unit `83 files / 462 tests`, mock Chromium E2E `53 tests`, typecheck, lint, production build가 통과했다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- 보호 page component의 `React.lazy()` 동적 import
|
||||||
|
- 관리자 main의 `Suspense`·기존 `PageState` loading fallback
|
||||||
|
- production graph의 chunk 수·최대 크기 자동 검증
|
||||||
|
- 인증·route·domain 기능 unit와 mock Chromium E2E 회귀 검증
|
||||||
|
- 320px·200% zoom·keyboard·접근성 확인
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- `vite.config.ts`의 warning limit·manual chunk 설정 변경
|
||||||
|
- page default export 전환, route library와 새 helper·dependency 추가
|
||||||
|
- App shell·Login·AccessDenied page lazy loading
|
||||||
|
- API, 권한, page props, 상태 관리와 domain 기능 변경
|
||||||
|
- cropper만 별도로 lazy loading하는 추가 최적화
|
||||||
|
|
||||||
|
## 기술적 제약
|
||||||
|
|
||||||
|
- 기술 스택: React 19.2.8, TypeScript 6.0.3, Vite 8.1.5, Vitest 4.1.10, Playwright 1.61.1.
|
||||||
|
- 아키텍처: `ProtectedAdminShell`의 기존 route 판정과 page 호출부를 유지하고 import boundary만 변경한다.
|
||||||
|
- export: 기존 named export를 유지하며 각 lazy import에서 React가 요구하는 `default` shape으로 mapping한다.
|
||||||
|
- fallback: 기존 `PageState`를 사용하고 shell·URL·focus 경계를 유지한다.
|
||||||
|
- 성능 기준: 모든 production JS chunk `<=500,000 bytes`, warning 0건.
|
||||||
|
- 데이터·보안: API·token·mock production boundary를 변경하지 않는다.
|
||||||
|
- 의존성: 추가하지 않는다.
|
||||||
|
- 구현: RED → GREEN → REFACTOR 순서와 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
## Phase 1. 보호 page code splitting
|
||||||
|
|
||||||
|
**Phase 결과:** 최초 관리자 route에는 현재 page 코드만 로드되고 다른 보호 page는 첫 진입 시 로드되며 기존 기능이 유지된다.
|
||||||
|
|
||||||
|
**선행조건:** `ARL-001~008`, `ARL-DEC-001~003` 확정.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`, `P1-R1`, `P1-R2`와 `P1-GATE` 완료, PRD 성공 기준과 Progress 갱신.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
#### Task 1.1 보호 page route boundary 분리
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 보호 page 정적 import를 lazy import로 바꾸고 production chunk 경계를 자동 검증한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `prd.md`가 구현 기준 확정 상태이고 활성 goal이 없음.
|
||||||
|
- **완료 증거:** RED·GREEN·REFACTOR 체크박스, production graph와 focused App test, production build 결과, Progress 기록.
|
||||||
|
- **범위 밖:** route parser, page 내부 구현, API와 Vite manual chunk 설정.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: 없음
|
||||||
|
- Modify: `src/app/protected-admin-shell.tsx`
|
||||||
|
- Modify: `src/app/App.test.tsx` — lazy page heading 대기 보완
|
||||||
|
- Test: `src/shared/mocks/__tests__/production-graph.test.ts`
|
||||||
|
- Test: `src/app/App.protected-shell.test.tsx` — assertion 보완이 필요할 때만 수정
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 14개 page module의 기존 named export와 `ProtectedAdminShell` route 판정 결과.
|
||||||
|
- Produces: 동일 page props·render 조건, route별 dynamic import chunk와 `PageState` loading fallback.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 test 작성/실패 확인** — `production-graph.test.ts`의 기존 production build 결과에서 JS 파일이 2개 이상이고 모든 JS 파일이 `<=500,000 bytes`인지 검사한다. `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts`가 현재 단일 `598,785 bytes` chunk로 실패하는지 확인한다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — `protected-admin-shell.tsx`에서 React `lazy`·`Suspense`를 사용해 14개 보호 page의 named export를 동적 import하고 기존 page render 구간을 `PageState` fallback으로 감싼다. 같은 production graph test가 `exit 0`, `1/1`인지 확인한다.
|
||||||
|
- [x] **REFACTOR: 정리/회귀 확인** — 새 helper·barrel·config 없이 import와 fallback 위치만 정리한 뒤 production graph test와 `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts`가 각각 `1/1`, `2 files / 16 tests` 이상으로 통과하는지 확인한다.
|
||||||
|
- [x] `npm run build:prod` 결과에 JS chunk가 2개 이상이고 `500kB` warning이 0건인지 기록한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts`; `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts`; `npm run typecheck`; `npm run lint`; `npm run build:prod`.
|
||||||
|
- **기대 결과:** 모든 명령 `exit 0`, production graph `1/1`, App focused `2 files / 16 tests` 이상, type·lint 오류 0건, JS chunk 2개 이상, 최대 JS `<=500,000 bytes`, chunk warning 0건.
|
||||||
|
- **수동 확인:** production preview의 Network에서 첫 route 외 page chunk가 초기 요청에 없고 다른 보호 route 최초 진입에 해당 chunk가 한 번 요청되는지 확인한다.
|
||||||
|
|
||||||
|
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
#### Task 1.2 320px·200% zoom CJK 회귀 수정
|
||||||
|
|
||||||
|
**Goal 실행 `P1-R1`:** Phase Gate 수동 확인 중 발견된 한국어 음절 단위 세로 분리 회귀를 수정하고 공통 페이지네이션 모바일 배치 결정을 갱신한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 구현 뒤 320px·200% zoom visual QA에서 CJK 음절 열 회귀가 확인됨.
|
||||||
|
- **완료 증거:** CJK E2E 회귀 test, ResourcePagination unit test, mock Chromium/mobile Chrome E2E, Decision Log와 Progress 기록.
|
||||||
|
- **범위 밖:** 새 responsive component, pagination API 변경, page size options 변경, desktop/tablet 배치 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: 없음
|
||||||
|
- Modify: `src/features/characters/components/CharacterListItem.tsx`
|
||||||
|
- Modify: `src/shared/ui/resource-pagination.tsx`
|
||||||
|
- Test: `src/shared/ui/__tests__/resource-pagination.test.tsx`
|
||||||
|
- Test: `tests/e2e/character-workspace.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `ResourcePagination`의 기존 `PageData`, `onPageChange`, `onSizeChange`, accessible group/button labels.
|
||||||
|
- Produces: 동일 pagination API와 desktop/tablet `sm:flex` 배치, mobile에서는 음절 단위 세로 분리를 막는 stacked movement controls.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 test 작성/실패 확인** — 320px·200% zoom에서 Korean leaf text가 음절 단위 세로 열로 렌더링되는지 `tests/e2e/character-workspace.spec.ts`에서 `Range.getClientRects()`로 검사한다. visual QA 스크린샷 `arl-lazy-routes-320-zoom200.png`에서 기존 문제가 확인됐다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — `CharacterListItem` 텍스트에 `break-keep break-words`, `ResourcePagination` summary/label에 `break-keep`, mobile movement controls에 `grid-cols-1`과 `whitespace-nowrap`를 적용한다.
|
||||||
|
- [x] **REFACTOR: 정리/회귀 확인** — `ResourcePagination` unit expectation을 새 mobile 배치 계약으로 갱신하고 focused E2E와 axe 회귀를 통과시킨다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "mobile zoom keeps Korean list and pagination text out of syllable columns"`; `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx`; `npm run e2e:mock:chromium`; `npm run e2e:mock:mobile-chrome`.
|
||||||
|
- **기대 결과:** focused CJK E2E `1 passed`, ResourcePagination unit `4 tests` 통과, Chromium E2E `53 tests` 통과, mobile Chrome E2E `48 passed / 5 skipped`.
|
||||||
|
- **수동 확인:** 320px·200% zoom에서 캐릭터 목록과 페이지네이션에 수평 overflow, 가려진 action, 한국어 음절 단위 세로 분리가 없다.
|
||||||
|
|
||||||
|
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
#### Task 1.3 완료 문서 현재 상태 정합성 복구
|
||||||
|
|
||||||
|
**Goal 실행 `P1-R2`:** `ARL-REV-P1-001`의 완료 Task 수와 해결된 원인 이슈 상태를 실제 구현·검증 결과에 맞춘다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1`, `P1-R1`, `P1-GATE` 완료와 `ARL-REV-P1-001` 확정.
|
||||||
|
- **완료 증거:** 현재 상태 `3/3`, `ARL-ISSUE-001` 해결 상태, review 링크와 검증 기록.
|
||||||
|
- **범위 밖:** 애플리케이션 코드·test·API·기존 구현 결정 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md`
|
||||||
|
- Modify: `docs/20260806_관리자라우트지연로딩/prd.md`
|
||||||
|
- Modify: `docs/20260806_관리자라우트지연로딩/plan-task.md`
|
||||||
|
- Test: 없음 — 현재 상태 문구만 정정하는 문서 Task다.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `ARL-REV-P1-001`, `P1-T1`, `P1-R1`, `P1-GATE` 완료 증거.
|
||||||
|
- Produces: 실제 완료 범위와 일치하는 PRD·계획·review 추적 상태.
|
||||||
|
|
||||||
|
**TDD 예외 사유:** 애플리케이션 동작을 변경하지 않는 문서 현재 상태 정정이라 실패 test를 추가하지 않는다.
|
||||||
|
|
||||||
|
**대체 검증 방법:** 완료 Task 수, 해결 이슈 상태와 review 링크를 `rg`로 확인하고 `git diff --check`를 실행한다.
|
||||||
|
|
||||||
|
- [x] 현재 상태 표의 완료 Task를 신규 회귀 Task까지 포함한 `3/3`으로 정정한다.
|
||||||
|
- [x] `ARL-ISSUE-001`을 해결 상태로 정정한다.
|
||||||
|
- [x] PRD에 review 링크를 연결하고 review 상태를 `수정 완료`로 갱신한다.
|
||||||
|
- [x] 실제 검증 결과를 Progress에 누적한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩`; `git diff --check`.
|
||||||
|
- **기대 결과:** 세 현재 상태 marker와 review 링크가 확인되고 whitespace 오류가 없다.
|
||||||
|
- **수동 확인:** 문서 표와 Task·Progress가 서로 같은 완료 상태를 표시한다.
|
||||||
|
|
||||||
|
### 완료 조건
|
||||||
|
|
||||||
|
- [x] `P1-T1`과 `P1-R1`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||||
|
- [x] `P1-R2`의 문서 정합성 복구와 검증 기록이 완료됐다.
|
||||||
|
- [x] `ARL-001~008`이 구현 또는 Gate 증거로 추적된다.
|
||||||
|
- [x] PRD 성공 기준과 현재 상태를 실제 결과로 갱신했다.
|
||||||
|
- [x] 알려진 문서와 구현의 차이가 없다.
|
||||||
|
|
||||||
|
### 검증 방법
|
||||||
|
|
||||||
|
#### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** route code splitting, 기능 보존과 공통 품질 기준을 최종 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1`과 `P1-R1` 완료.
|
||||||
|
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
|
||||||
|
- **범위 밖:** test 삭제·완화, warning limit 상향과 관련 없는 기능 수정.
|
||||||
|
|
||||||
|
**실행 명령:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:run
|
||||||
|
npm run e2e:mock:chromium
|
||||||
|
npm run e2e:mock:mobile-chrome
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run build:prod
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 결과:** 모든 명령 `exit 0`, unit·mock Chromium/mobile Chrome E2E 실패 0건, type·lint·build 오류 0건, production JS chunk 2개 이상, 최대 JS `<=500,000 bytes`, chunk warning·whitespace 오류 0건.
|
||||||
|
|
||||||
|
**수동 확인:**
|
||||||
|
|
||||||
|
- [x] `/ai-characters` 직접 URL과 캐릭터 수정 내부 이동·뒤로 가기가 기존과 동일하다.
|
||||||
|
- [x] Audio·Series·Community·FanTalk route 최초 진입에 loading 뒤 기존 화면이 표시된다.
|
||||||
|
- [x] Network에서 현재 route 이외 page chunk가 초기 요청에 없고 최초 진입 후 cache된다.
|
||||||
|
- [x] 1280px·320px·200% zoom에서 loading·page에 수평 overflow와 가려진 action이 없다.
|
||||||
|
- [x] keyboard focus·skip link와 axe critical·serious 위반 0건을 확인한다.
|
||||||
|
|
||||||
|
## 실행 순서와 의존성
|
||||||
|
|
||||||
|
1. `P1-T1`에서 production graph 실패 test를 먼저 추가하고 최소 lazy import 구현과 focused 검증을 완료한다.
|
||||||
|
2. `P1-R1`에서 320px·200% zoom CJK 회귀를 focused E2E와 공통 pagination unit으로 고정한다.
|
||||||
|
3. `P1-GATE`에서 전체 unit·mock Chromium/mobile Chrome E2E와 수동 Network·접근성 검증을 완료한다.
|
||||||
|
4. `P1-R2`에서 완료 문서 현재 상태를 fresh Gate 결과와 일치시키고 review를 종료한다.
|
||||||
|
|
||||||
|
`P1-GATE`는 `P1-T1`과 `P1-R1` 완료 전 시작하지 않는다.
|
||||||
|
`P1-R2`는 `P1-GATE` 완료와 `ARL-REV-P1-001` 확정 뒤 시작한다.
|
||||||
|
|
||||||
|
## 변경 금지 항목
|
||||||
|
|
||||||
|
- `chunkSizeWarningLimit`과 `manualChunks`를 추가하지 않는다.
|
||||||
|
- 기존 named export, page props, route parser와 route path를 변경하지 않는다.
|
||||||
|
- page 내부 API·state·권한·UI를 함께 refactor하지 않는다.
|
||||||
|
- 새 dependency, lazy helper, barrel 또는 speculative prefetch를 추가하지 않는다.
|
||||||
|
- test를 삭제·skip·완화하거나 type 오류를 우회하지 않는다.
|
||||||
|
- 기존 Progress·Decision Log·review 기록을 삭제하거나 덮어쓰지 않는다.
|
||||||
|
|
||||||
|
## 의사결정 및 중단 규칙
|
||||||
|
|
||||||
|
- build에서 단일 chunk가 유지되면 warning limit을 올리지 말고 static import 잔존 여부를 확인한다.
|
||||||
|
- 공통 dependency chunk가 `500,000 bytes`를 넘으면 근거를 기록하고 사용자와 별도 최적화 범위를 결정한다.
|
||||||
|
- lazy 전환으로 기존 test가 timing 차이만 드러내면 사용자 결과 assertion은 유지하고 비동기 대기만 최소 보완한다.
|
||||||
|
- 기능 assertion이 실패하면 lazy 변경을 완료로 처리하지 않고 원인을 수정한다.
|
||||||
|
- 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다.
|
||||||
|
- 같은 차단 사유가 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||||
|
|
||||||
|
### 계획 작성 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: route-level `React.lazy()` 선택을 `ARL-001~008`, 단일 구현 Task와 Phase Gate로 정규화했다.
|
||||||
|
- 왜: 현재 14개 보호 page의 static import가 단일 `598.78kB` production JS chunk와 `500kB` warning을 만든다.
|
||||||
|
- 어떻게:
|
||||||
|
- `npm run build:prod` — 성공, exit 0, 310 modules, JS `598.78kB`, gzip `158.94kB`, `500kB` chunk warning 1건.
|
||||||
|
- `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts` — 성공, exit 0, `2 files / 16 tests`.
|
||||||
|
- code·test 변경과 수동 Network 검증 — 미실행, 구현 요청 범위가 아님.
|
||||||
|
- 남은 항목: `P1-T1`, `P1-GATE`.
|
||||||
|
- 다음 행동: `P1-T1` production graph RED assertion 작성.
|
||||||
|
|
||||||
|
### 1차 구현 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: `ProtectedAdminShell`의 14개 보호 page static import를 route-level `React.lazy()` named export mapping으로 바꾸고 기존 `PageState`를 `Suspense` fallback으로 사용했다.
|
||||||
|
- 왜: 초기 관리자 route에서 현재 page 외 보호 page 코드를 내려받지 않고, Vite `500kB` chunk warning을 warning limit 상향 없이 제거하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts` — RED 성공, 기존 단일 JS chunk 때문에 `expected 1 to be greater than or equal to 2`로 실패 확인.
|
||||||
|
- `npm run test:run -- src/shared/mocks/__tests__/production-graph.test.ts` — GREEN 성공, exit 0, `1 file / 1 test`.
|
||||||
|
- `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts` — REFACTOR 회귀 성공, exit 0, `2 files / 16 tests`.
|
||||||
|
- `npm run build:prod` — 성공, exit 0, JS chunk 37개, 최대 JS `315.09kB`, `500kB` warning 0건.
|
||||||
|
- production preview 수동 확인 — `/ai-characters` 초기 요청에는 list 관련 chunk만 로드되고 detail·audio route 최초 진입 때 해당 page chunk가 추가 로드됨을 확인했다.
|
||||||
|
- 남은 항목: `P1-GATE`와 visual QA 회귀 확인.
|
||||||
|
|
||||||
|
### 2차 수정 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: 320px·200% zoom 수동 확인 중 발견된 한국어 음절 단위 세로 분리 회귀를 `CharacterListItem`과 `ResourcePagination`의 wrapping 규칙으로 수정하고 E2E 회귀 test를 추가했다.
|
||||||
|
- 왜: lazy route 자체의 기능 문제는 아니지만 Phase Gate의 320px·200% zoom 수동 확인 기준을 만족하지 못했다.
|
||||||
|
- 어떻게:
|
||||||
|
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "mobile zoom keeps Korean list and pagination text out of syllable columns"` — 성공, exit 0, `1 passed`.
|
||||||
|
- `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx` — 성공, exit 0, `1 file / 4 tests`.
|
||||||
|
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium --grep "has no critical or serious axe violations on the list"` — 첫 실행은 `Port 8889 is already in use` 환경 문제로 실패, 포트 해제 확인 후 재실행 성공, exit 0, `1 passed`.
|
||||||
|
- 남은 항목: fresh Phase Gate 전체 검증.
|
||||||
|
|
||||||
|
### Phase 1 Gate — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: route code splitting, 기능 보존, 접근성·반응형 회귀와 공통 품질 기준을 최종 검증했다.
|
||||||
|
- 왜: `P1-T1` 완료 뒤 `ARL-001~008`과 Phase 완료 조건을 실제 실행 결과로 판정하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `npm run test:run` — 성공, exit 0, `83 files / 462 tests`.
|
||||||
|
- `npm run e2e:mock:chromium` — 성공, exit 0, `53 tests`.
|
||||||
|
- `npm run e2e:mock:mobile-chrome` — 성공, exit 0, `48 passed / 5 skipped`.
|
||||||
|
- `npm run typecheck` — 성공, exit 0.
|
||||||
|
- `npm run lint` — 성공, exit 0.
|
||||||
|
- `npm run build:prod` — 성공, exit 0, 310 modules, JS chunk 37개, 최대 JS `315.09kB`, gzip `93.77kB`, `500kB` warning 0건.
|
||||||
|
- `git diff --check` — 성공, exit 0, whitespace 오류 0건.
|
||||||
|
- 수동 production preview — `/ai-characters` 직접 URL, detail/audio route 진입, 뒤로 가기, Network chunk lazy loading, 1280px·320px·200% zoom 수평 overflow 없음, console error 0건을 확인했다.
|
||||||
|
- 남은 항목: 없음.
|
||||||
|
|
||||||
|
### 회귀 감사 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 확정
|
||||||
|
- 무엇을: 구현·test·build와 계획의 현재 상태를 다시 대조해 `ARL-REV-P1-001`을 확정하고 `P1-R2`로 전환했다.
|
||||||
|
- 왜: 완료된 `P1-T1`·`P1-R1`이 `1/1`로 표시되고 해결된 `ARL-ISSUE-001`이 `확정`으로 남아 있었다.
|
||||||
|
- 어떻게:
|
||||||
|
- `npm run test:run` — 성공, exit 0, `83 files / 462 tests`.
|
||||||
|
- `npm run typecheck` — 성공, exit 0.
|
||||||
|
- `npm run lint` — 성공, exit 0.
|
||||||
|
- `npm run build:prod` — 성공, exit 0, 310 modules, JS 37개, 최대 `315.09kB`, chunk warning 0건.
|
||||||
|
- `npm run e2e:mock:chromium` — 최초 sandbox port 권한으로 실행 불가, 권한 허용 후 성공, exit 0, `53 passed`.
|
||||||
|
- `npm run e2e:mock:mobile-chrome` — 성공, exit 0, `48 passed / 5 skipped`.
|
||||||
|
- 남은 항목: `P1-R2` 문서 현재 상태 정정과 review 종료.
|
||||||
|
|
||||||
|
### `P1-R2` 문서 정합성 회귀 수정 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: 완료 Task 수를 신규 회귀 Task까지 포함한 `3/3`으로 갱신하고 `ARL-ISSUE-001`을 해결 상태로 바꿨으며 PRD에 Phase 1 review를 연결했다.
|
||||||
|
- 왜: 완료 구현과 계획의 현재 상태가 달라 후속 작업자가 남은 범위를 잘못 판단할 수 있었다.
|
||||||
|
- 어떻게:
|
||||||
|
- `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩` — 성공, 세 현재 상태 marker와 PRD·plan·review 연결 확인.
|
||||||
|
- `git diff --check` — 성공, exit 0, whitespace 오류 0건.
|
||||||
|
- 남은 항목: 없음.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-06 | `ARL-PLAN-DEC-001` | 확정 | 보호 page를 route-level `React.lazy()`로 분리한다. | 실제 초기 loading 비용과 chunk warning을 함께 줄인다. | `P1-T1`, `P1-GATE`, `prd.md` |
|
||||||
|
| 2026-08-06 | `ARL-PLAN-DEC-002` | 확정 | 기존 production graph test에 chunk 수·크기 assertion을 추가한다. | 이미 Vite production build와 임시 directory 정리를 검증하는 가장 가까운 test다. | `P1-T1` |
|
||||||
|
| 2026-08-06 | `ARL-PLAN-DEC-003` | 확정 | 기존 `PageState`를 Suspense fallback으로 사용한다. | 새 component 없이 디자인·접근성 관례를 유지한다. | `P1-T1` |
|
||||||
|
| 2026-08-06 | `ARL-PLAN-DEC-004` | 확정 | `ResourcePagination`의 mobile movement controls는 동일 폭 2열 대신 1열 stacked 배치로 대체한다. | 320px·200% zoom에서 한국어 버튼 텍스트가 음절 단위 세로 열로 분리되는 회귀를 막고 touch target과 label 가독성을 유지한다. Desktop/tablet은 기존 `sm:flex` 배치를 유지한다. | `P1-R1`, `ARL-006`, `ARL-007` |
|
||||||
|
| 2026-08-06 | `ARL-PLAN-DEC-005` | 확정 | 완료 문서의 stale Task 수와 이슈 상태를 `P1-R2`에서 현재 구현 결과와 맞춘다. | `ARL-REV-P1-001`의 문서 정합성 회귀 판정. | `P1-R2`, Phase 1 review |
|
||||||
|
|
||||||
|
## 발견된 문제
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `ARL-ISSUE-001` | Medium | 해결 | 보호 page 정적 import로 production JS가 `598.78kB` 단일 chunk이며 Vite 경고가 반복된다. | `P1-T1` | route-level lazy import와 build boundary test 완료 |
|
||||||
|
| `ARL-ISSUE-002` | Medium | 해결 | 320px·200% zoom에서 캐릭터 목록과 공통 페이지네이션 한국어 텍스트가 음절 단위 세로 열로 분리됐다. | `P1-R1`, `P1-GATE` | `break-keep`·stacked mobile pagination과 CJK E2E 회귀 test |
|
||||||
|
| `ARL-ISSUE-003` | Low | 해결 | 완료 Task 수와 해결된 원인 이슈 상태가 구현 전 값으로 남아 있다. | `P1-R2` | `ARL-REV-P1-001` 문서 현재 상태 정합성 복구 완료 |
|
||||||
|
|
||||||
|
## 최종 보고 형식
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
구현 결과: 보호된 관리자 page가 route별 chunk로 분리되고 기존 기능을 유지한다.
|
||||||
|
|
||||||
|
- 변경: `ProtectedAdminShell` page import boundary와 production graph assertion
|
||||||
|
- 결정: `ARL-DEC-001` — route-level `React.lazy()`
|
||||||
|
- 검증:
|
||||||
|
- `npm run test:run` — <실제 결과>
|
||||||
|
- `npm run e2e:mock:chromium` — <실제 결과>
|
||||||
|
- `npm run build:prod` — <chunk 수·최대 크기·warning 수>
|
||||||
|
- Network·1280px·320px·200% zoom·keyboard·axe — <실제 결과>
|
||||||
|
- 남은 항목: <없음 또는 구체적인 항목>
|
||||||
|
- 문서: `docs/20260806_관리자라우트지연로딩/{prd.md,plan-task.md}`
|
||||||
|
```
|
||||||
|
|
||||||
|
최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.
|
||||||
228
docs/20260806_관리자라우트지연로딩/prd.md
Normal file
228
docs/20260806_관리자라우트지연로딩/prd.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# 관리자 라우트 지연 로딩 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-06 |
|
||||||
|
| 최종 수정일 | 2026-08-06 |
|
||||||
|
| 대상 제품 | AI 캐릭터 관리자 웹 |
|
||||||
|
| 작성자·결정권자 | Codex 작성, 사용자 결정 |
|
||||||
|
| 관련 API Contract | 불필요 — API와 payload 변경 없음 |
|
||||||
|
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
|
||||||
|
| 관련 review | [Phase 1 관리자 라우트 지연 로딩 리뷰](./reviews/phase1-admin-route-lazy-loading.md) |
|
||||||
|
|
||||||
|
### 요구사항 상태
|
||||||
|
|
||||||
|
| 상태 | 의미 | 구현 처리 |
|
||||||
|
|---|---|---|
|
||||||
|
| 확정 | 제품·기술 결정이 완료된 구현 기준 | `plan-task.md`의 Task와 완료 증거로 추적 |
|
||||||
|
| 미결 | 추가 결정 필요 | 구현 전 결정 |
|
||||||
|
| 외부 의존 | 프론트엔드 밖의 제공 필요 | 제공 전 관련 구현 중단 |
|
||||||
|
| 권고 | 확정 전 추천안 | 수용 기준으로 사용하지 않음 |
|
||||||
|
| 제외 | 이번 범위에서 구현하지 않음 | 포함 조건을 Decision Log에 기록 |
|
||||||
|
|
||||||
|
### 문서 우선순위와 갱신 순서
|
||||||
|
|
||||||
|
1. 초기 bundle과 라우트 로딩 결정은 이 PRD가 소유한다.
|
||||||
|
2. API 변경이 없으므로 별도 API Contract를 만들지 않는다.
|
||||||
|
3. 구현 순서와 완료 증거는 `plan-task.md`가 소유한다.
|
||||||
|
4. 결정이 바뀌면 Decision Log → 요구사항 → 계획 순서로 갱신한다.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
보호된 관리자 페이지를 `React.lazy()` 기반 동적 import로 분리한다. 최초 접속에는 현재 라우트에 필요한 코드만 내려받고, 다른 페이지 코드는 해당 라우트에 처음 진입할 때 로드한다. 기능·API·권한·데이터 흐름은 유지한다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
현재 `protected-admin-shell.tsx`는 14개 보호 페이지를 정적으로 import한다.
|
||||||
|
|
||||||
|
- production build가 310개 module을 하나의 `598.78kB` minified JS chunk로 출력한다.
|
||||||
|
- Vite 8.1.5 기본 기준 `500kB`를 넘어 build마다 chunk size warning이 발생한다.
|
||||||
|
- gzip 전송량은 `158.94kB`지만 browser가 최초 접속에 전체 chunk를 다운로드·파싱·실행한다.
|
||||||
|
- 이미지 cropper처럼 현재 라우트에서 사용하지 않는 기능도 초기 module graph에 포함된다.
|
||||||
|
|
||||||
|
문제를 해결했다는 판단은 production build가 보호 페이지를 여러 chunk로 분리하고 모든 JS chunk가 `500,000 bytes` 이하이며, 기존 사용자 흐름이 그대로 통과할 때로 한다. 구현 완료 build는 37개 JS chunk, 최대 JS `315.09kB`, chunk size warning 0건이다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
### 3.1 제품 목표
|
||||||
|
|
||||||
|
- 최초 접속에서 현재 관리자 화면에 필요하지 않은 페이지 코드를 지연 로드한다.
|
||||||
|
- Vite의 `500kB` 초과 chunk warning을 실제 code splitting으로 제거한다.
|
||||||
|
- 직접 URL, 내부 이동, 뒤로 가기와 권한 검사를 기존과 동일하게 유지한다.
|
||||||
|
|
||||||
|
### 3.2 UX 목표
|
||||||
|
|
||||||
|
- 첫 화면의 다운로드·파싱·실행 부담을 줄인다.
|
||||||
|
- 미로드 라우트 최초 진입에는 명확한 loading 상태를 표시한다.
|
||||||
|
- loading 중 keyboard focus, 관리자 shell과 현재 URL을 유지한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- `chunkSizeWarningLimit` 상향으로 경고만 숨기기
|
||||||
|
- `manualChunks` 또는 vendor chunk 설정 추가
|
||||||
|
- `react-advanced-cropper`, `zod`, React Query 교체·제거
|
||||||
|
- router library, bundle 분석 library 또는 새 runtime dependency 추가
|
||||||
|
- API, 인증·권한, route path, page props와 상태 관리 변경
|
||||||
|
- 서버 rendering, prefetch, service worker cache 또는 offline 지원 추가
|
||||||
|
|
||||||
|
Non-Goal을 변경하려면 Decision Log와 `plan-task.md`를 먼저 갱신한다.
|
||||||
|
|
||||||
|
## 5. Target Users and Permissions
|
||||||
|
|
||||||
|
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ADMIN | 관리자 화면에 빠르게 진입 | 캐릭터·오디오·시리즈·커뮤니티·FanTalk 관리 | desktop, tablet, mobile |
|
||||||
|
| 인증되지 않은 사용자 | 보호 코드 노출 없이 로그인 | 로그인, 인증 후 관리자 진입 | desktop, tablet, mobile |
|
||||||
|
|
||||||
|
- 기존 ADMIN probe, 401 session 제거와 403 접근 거부 정책을 유지한다.
|
||||||
|
- lazy page loading은 권한 검사 성공 뒤에만 보호 UI를 표시한다.
|
||||||
|
|
||||||
|
## 6. 핵심 사용자 흐름
|
||||||
|
|
||||||
|
1. 사용자가 로그인 또는 보호된 직접 URL로 접속한다.
|
||||||
|
2. 기존 인증·ADMIN probe가 완료된다.
|
||||||
|
3. 현재 라우트 page chunk가 없으면 관리자 shell 안에 loading 상태를 표시한다.
|
||||||
|
4. chunk가 로드되면 기존 page를 같은 props와 URL로 표시한다.
|
||||||
|
5. 다른 메뉴에 처음 진입하면 해당 page chunk만 추가로 받고, 이후 browser cache를 재사용한다.
|
||||||
|
6. 내부 이동·뒤로 가기·새로고침과 mutation 흐름은 기존과 동일하게 동작한다.
|
||||||
|
|
||||||
|
## 7. 정보 구조와 라우팅
|
||||||
|
|
||||||
|
```text
|
||||||
|
/login # eager 유지
|
||||||
|
/access-denied # eager 유지
|
||||||
|
/ai-characters # protected page lazy
|
||||||
|
/ai-characters/new # protected page lazy
|
||||||
|
/ai-characters/:characterId/** # protected page lazy
|
||||||
|
```
|
||||||
|
|
||||||
|
- `App`, 인증 provider와 `ProtectedAdminShell`은 application shell로 유지한다.
|
||||||
|
- `ProtectedAdminShell`이 현재 판정하는 모든 보호 page component만 lazy boundary로 이동한다.
|
||||||
|
- route path parser와 URL 상태는 변경하지 않는다.
|
||||||
|
|
||||||
|
## 8. 기능 요구사항
|
||||||
|
|
||||||
|
### 8.1 Code splitting
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `ARL-001` | 확정 | `ProtectedAdminShell`의 보호 page 정적 import를 `React.lazy()` 동적 import로 전환한다. | 14개 page component가 현재 route에서 render될 때 해당 module을 import한다. | contract 불필요, `P1-T1` |
|
||||||
|
| `ARL-002` | 확정 | named export를 유지하며 page component의 public props를 변경하지 않는다. | page export·호출부 type과 기존 test가 변경 없이 통과한다. | contract 불필요, `P1-T1` |
|
||||||
|
| `ARL-003` | 확정 | 보호 page 영역을 `Suspense`로 감싸 loading 상태를 표시한다. | 미로드 page 진입 시 `화면을 불러오는 중` status가 관리자 shell 안에 표시된다. | contract 불필요, `P1-T1` |
|
||||||
|
| `ARL-004` | 확정 | production build의 모든 minified JS chunk를 `500,000 bytes` 이하로 유지한다. | production graph test가 JS chunk 2개 이상과 최대 chunk `<=500,000 bytes`를 확인하고 Vite 경고가 없다. | contract 불필요, `P1-T1`, `P1-GATE` |
|
||||||
|
|
||||||
|
### 8.2 기능 보존
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `ARL-005` | 확정 | 로그인, ADMIN probe, 401·403와 malformed route 처리를 유지한다. | 기존 App auth·protected 오류 test가 모두 통과한다. | 기존 인증 계약 유지, `P1-GATE` |
|
||||||
|
| `ARL-006` | 확정 | 직접 URL, 내부 이동, 뒤로 가기와 route별 page props를 유지한다. | 기존 App route test와 mock Chromium E2E가 모두 통과한다. | 기존 route contract 유지, `P1-GATE` |
|
||||||
|
| `ARL-007` | 확정 | 각 page의 조회·생성·수정·삭제, upload와 댓글 동작을 변경하지 않는다. | 전체 unit과 mock Chromium E2E에서 신규 실패가 0건이다. | 기존 domain 계약 유지, `P1-GATE` |
|
||||||
|
| `ARL-008` | 확정 | mock module은 production bundle에서 계속 제외한다. | 기존 `production-graph.test.ts`의 mock 제외 assertion이 통과한다. | 기존 production boundary 유지, `P1-T1` |
|
||||||
|
|
||||||
|
## 9. 반응형 기능 범위
|
||||||
|
|
||||||
|
| 기능 | Desktop | Tablet | Mobile | 비고 |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| 보호 page lazy loading | 지원 | 지원 | 지원 | 동일 route boundary |
|
||||||
|
| loading 상태 | 지원 | 지원 | 지원 | 관리자 main 안에 표시 |
|
||||||
|
| 직접 URL·뒤로 가기 | 유지 | 유지 | 유지 | URL 변경 없음 |
|
||||||
|
|
||||||
|
- 320px와 200% zoom에서 loading 상태와 page가 수평 overflow를 만들지 않아야 한다.
|
||||||
|
- 기존 모바일 조회·수정 capability 정책은 변경하지 않는다.
|
||||||
|
|
||||||
|
## 10. UI/UX Expectations
|
||||||
|
|
||||||
|
### 10.1 디자인과 component 원칙
|
||||||
|
|
||||||
|
- 기존 `PageState`를 loading fallback으로 재사용한다.
|
||||||
|
- 관리자 shell, navigation, header와 success notification은 page chunk loading 중 유지한다.
|
||||||
|
- 새 spinner, skeleton, animation 또는 styling을 추가하지 않는다.
|
||||||
|
|
||||||
|
### 10.2 화면 상태
|
||||||
|
|
||||||
|
- lazy page가 준비되지 않았을 때 `화면을 불러오는 중`을 표시한다.
|
||||||
|
- page가 준비되면 같은 main 영역에서 기존 page로 교체한다.
|
||||||
|
- 기존 API loading·empty·error·success 상태는 page 내부 책임으로 유지한다.
|
||||||
|
|
||||||
|
### 10.3 접근성
|
||||||
|
|
||||||
|
- fallback은 기존 `PageState`의 semantic status를 사용한다.
|
||||||
|
- keyboard focus 순서, skip link와 route 전환 focus 정책을 변경하지 않는다.
|
||||||
|
- 200% zoom과 axe critical·serious 0건을 유지한다.
|
||||||
|
|
||||||
|
## 11. API 계약
|
||||||
|
|
||||||
|
### 11.1 공통 규칙
|
||||||
|
|
||||||
|
- lazy loading은 module 전달 방식만 변경한다.
|
||||||
|
- endpoint, method, payload, response, 오류와 pagination 계약을 변경하지 않는다.
|
||||||
|
|
||||||
|
### 11.2 Endpoint 추적
|
||||||
|
|
||||||
|
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `ARL-001~008` | 해당 없음 | 해당 없음 | 변경 불필요 | 기존 domain 계약 유지 | `P1-T1`, `P1-GATE` |
|
||||||
|
|
||||||
|
### 11.3 외부 제공 대기 계약
|
||||||
|
|
||||||
|
없음.
|
||||||
|
|
||||||
|
## 12. 보안과 데이터 취급
|
||||||
|
|
||||||
|
- 인증 token 저장·전달, 401 clear와 403 route 정책을 변경하지 않는다.
|
||||||
|
- 보호 page chunk는 기존과 같은 정적 asset이므로 권한 경계를 대체하지 않는다.
|
||||||
|
- log, analytics와 외부 전송을 추가하지 않는다.
|
||||||
|
- production mock 제외 경계를 유지한다.
|
||||||
|
|
||||||
|
## 13. 성능과 품질 요구사항
|
||||||
|
|
||||||
|
- 기준 build: Vite 8.1.5, 단일 JS `598.78kB`, gzip `158.94kB`, 310 modules.
|
||||||
|
- 완료 build: JS chunk 2개 이상, 각 minified JS `<=500,000 bytes`, chunk size warning 0건.
|
||||||
|
- `chunkSizeWarningLimit` 기본값 `500`을 변경하지 않는다.
|
||||||
|
- 새 dependency와 custom chunk configuration을 추가하지 않는다.
|
||||||
|
- production graph test, App unit, 전체 unit, mock Chromium E2E, typecheck, lint와 production build를 Gate로 사용한다.
|
||||||
|
|
||||||
|
## 14. 성공 기준
|
||||||
|
|
||||||
|
### 14.1 기능 수용 기준
|
||||||
|
|
||||||
|
- [x] 모든 보호 page가 직접 URL과 내부 이동에서 기존 기능을 제공한다. (`ARL-001~003`, `ARL-005~007`)
|
||||||
|
- [x] 인증·권한·API request와 page props가 변경되지 않는다. (`ARL-002`, `ARL-005~008`)
|
||||||
|
|
||||||
|
### 14.2 UI/UX 수용 기준
|
||||||
|
|
||||||
|
- [x] 미로드 route에 기존 `PageState` loading 상태가 표시된다.
|
||||||
|
- [x] 320px·200% zoom·keyboard 흐름과 axe critical·serious 0건을 유지한다.
|
||||||
|
- [x] 첫 route 이후 다른 route 최초 진입만 추가 chunk loading을 수행한다.
|
||||||
|
|
||||||
|
### 14.3 성능·추적성 완료 기준
|
||||||
|
|
||||||
|
- [x] production build에 `500kB` 초과 chunk warning이 없다. (`ARL-004`)
|
||||||
|
- [x] 모든 JS chunk가 `500,000 bytes` 이하임을 자동 test로 검증한다.
|
||||||
|
- [x] `ARL-001~008`이 `P1-T1` 또는 `P1-GATE` 완료 증거로 연결된다.
|
||||||
|
- [x] API Contract가 불필요함을 기록했다.
|
||||||
|
|
||||||
|
## 15. Open Questions
|
||||||
|
|
||||||
|
없음. route-level `React.lazy()`를 선택했고 경고 임계값 상향과 수동 vendor 분리는 제외했다.
|
||||||
|
|
||||||
|
## 16. 요구사항 추적표
|
||||||
|
|
||||||
|
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||||
|
|---|---|---:|---|---|---|
|
||||||
|
| `ARL-001~004`, `ARL-008` | 불필요 | 1 | `P1-T1` | production graph, App focused unit, production build | Network의 route chunk loading |
|
||||||
|
| `ARL-005~007` | 기존 계약 유지 | 1 | `P1-GATE` | 전체 unit, mock Chromium E2E, typecheck, lint | 직접 URL·내부 이동·뒤로 가기·320px·200% zoom |
|
||||||
|
| `ARL-006~007` CJK zoom 회귀 | 기존 계약 유지 | 1 | `P1-R1` | CJK E2E, ResourcePagination unit, mock mobile Chrome E2E | 320px·200% zoom 한국어 줄바꿈 |
|
||||||
|
|
||||||
|
## 17. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-06 | `ARL-DEC-001` | 확정 | 보호된 관리자 page를 route-level `React.lazy()`로 분리한다. | 단일 chunk의 원인이 모든 보호 page 정적 import이며 실제 초기 loading 비용도 줄일 수 있다. | `ARL-001~008`, `P1-T1`, `P1-GATE` |
|
||||||
|
| 2026-08-06 | `ARL-DEC-002` | 확정 | `chunkSizeWarningLimit` 상향과 `manualChunks`는 적용하지 않는다. | 경고만 숨기거나 초기 총량을 유지하는 방식 대신 실제 지연 loading을 선택한다. | `ARL-004`, Non-Goals |
|
||||||
|
| 2026-08-06 | `ARL-DEC-003` | 확정 | 기존 `PageState`만 fallback으로 재사용하고 새 loading component를 만들지 않는다. | 현재 디자인·접근성 관례를 유지하는 최소 구현이다. | `ARL-003`, `P1-T1` |
|
||||||
|
| 2026-08-06 | `ARL-DEC-004` | 확정 | 공통 `ResourcePagination`의 mobile movement controls는 동일 폭 2열 대신 1열 stacked 배치로 대체한다. | 320px·200% zoom에서 한국어 버튼 텍스트가 음절 단위 세로 열로 분리되는 것을 막고, desktop/tablet 배치는 기존 `sm:flex`로 유지한다. | `ARL-006~007`, `P1-R1`, `P1-GATE` |
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Phase 1 관리자 라우트 지연 로딩 코드 리뷰
|
||||||
|
|
||||||
|
## 1. 리뷰 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-R1`, `P1-GATE` |
|
||||||
|
| 기준 commit 또는 working tree | `68959cb` 기준 미커밋 working tree |
|
||||||
|
| 리뷰 일자 | 2026-08-06 |
|
||||||
|
| 리뷰어 | Codex |
|
||||||
|
| 기준 문서 | `docs/20260806_관리자라우트지연로딩/prd.md`, `docs/20260806_관리자라우트지연로딩/plan-task.md` |
|
||||||
|
| 리뷰 상태 | 수정 검증 완료 |
|
||||||
|
|
||||||
|
## 2. 리뷰 목적과 범위
|
||||||
|
|
||||||
|
### 목적
|
||||||
|
|
||||||
|
- `ARL-001~008`과 완료 체크박스가 실제 코드·test·build 결과와 일치하는지 확인한다.
|
||||||
|
- 완료된 Phase의 기능·접근성·반응형 회귀와 문서 현재 상태를 확인한다.
|
||||||
|
|
||||||
|
### 포함 범위
|
||||||
|
|
||||||
|
- 코드: `src/app/protected-admin-shell.tsx`, `src/app/App.test.tsx`, `src/features/characters/components/CharacterListItem.tsx`, `src/shared/ui/resource-pagination.tsx`
|
||||||
|
- 테스트: production graph, 전체 unit, mock Chromium/mobile Chrome E2E
|
||||||
|
- 문서: `ARL-001~008`, `P1-T1`, `P1-R1`, `P1-GATE`
|
||||||
|
- 수동 검증: source import boundary와 production build chunk 출력 대조
|
||||||
|
|
||||||
|
### 제외 범위
|
||||||
|
|
||||||
|
- 실제 개발 API와 운영 인증 정보가 필요한 server mode 수동 QA
|
||||||
|
- PRD Non-Goals인 prefetch, manual chunk와 cropper 추가 최적화
|
||||||
|
|
||||||
|
## 3. 판정 기준
|
||||||
|
|
||||||
|
### 심각도
|
||||||
|
|
||||||
|
| 심각도 | 기준 |
|
||||||
|
|---|---|
|
||||||
|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
|
||||||
|
| High | 확정 요구사항·기존 계약 위반 또는 주요 회귀 |
|
||||||
|
| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 |
|
||||||
|
| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 |
|
||||||
|
|
||||||
|
### 상태
|
||||||
|
|
||||||
|
| 상태 | 의미 | 후속 처리 |
|
||||||
|
|---|---|---|
|
||||||
|
| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 |
|
||||||
|
| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 전환 |
|
||||||
|
| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 |
|
||||||
|
| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 |
|
||||||
|
| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 |
|
||||||
|
|
||||||
|
## 4. 검토한 근거
|
||||||
|
|
||||||
|
### 문서와 코드
|
||||||
|
|
||||||
|
- 요구사항: `ARL-001~008`
|
||||||
|
- API Contract: 변경 불필요 — 기존 인증·domain 계약 유지
|
||||||
|
- 계획: `P1-T1`, `P1-R1`, `P1-GATE`
|
||||||
|
- 코드: `src/app/protected-admin-shell.tsx`, `src/features/characters/components/CharacterListItem.tsx`, `src/shared/ui/resource-pagination.tsx`
|
||||||
|
- 테스트: `src/shared/mocks/__tests__/production-graph.test.ts`, 전체 Vitest와 mock E2E
|
||||||
|
|
||||||
|
### 실행 환경
|
||||||
|
|
||||||
|
```text
|
||||||
|
OS: Darwin 25.0.0 x86_64
|
||||||
|
Node: v24.12.0
|
||||||
|
npm: 11.7.0
|
||||||
|
Browser/viewport: Playwright Chromium, mobile Chrome, 320px·200% zoom 포함
|
||||||
|
환경 변수: VITE_API_MODE=mock 또는 production mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행한 검증
|
||||||
|
|
||||||
|
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||||
|
|---|---|---|
|
||||||
|
| `npm run test:run` | 성공 | exit 0, `83 files / 462 tests` |
|
||||||
|
| `npm run typecheck` | 성공 | exit 0, 오류 0건 |
|
||||||
|
| `npm run lint` | 성공 | exit 0, 오류 0건 |
|
||||||
|
| `npm run build:prod` | 성공 | 310 modules, JS 37개, 최대 `315.09kB`, chunk 경고 0건 |
|
||||||
|
| `npm run e2e:mock:chromium` | 성공 | exit 0, `53 passed` |
|
||||||
|
| `npm run e2e:mock:mobile-chrome` | 성공 | exit 0, `48 passed / 5 skipped` |
|
||||||
|
| `git diff --check` | 성공 | whitespace 오류 0건 |
|
||||||
|
| route import boundary 정적 대조 | 성공 | 보호 page dynamic import 14개, eager page import는 `LoginPage`만 존재 |
|
||||||
|
|
||||||
|
## 5. 발견 사항 요약
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `ARL-REV-P1-001` | Low | 수정 완료 | 완료된 Task 수와 원인 이슈 상태가 구현 전 값으로 남아 있다 | `P1-R2` | `P1-R2` 완료 |
|
||||||
|
|
||||||
|
코드·기능·성능·접근성에 대한 확정 발견 사항은 없다.
|
||||||
|
|
||||||
|
## 6. 발견 사항 상세
|
||||||
|
|
||||||
|
### ARL-REV-P1-001 — 완료된 Task 수와 원인 이슈 상태가 구현 전 값으로 남아 있다
|
||||||
|
|
||||||
|
- **심각도:** Low
|
||||||
|
- **상태:** 수정 완료
|
||||||
|
- **관련 요구사항:** `ARL-001~008`
|
||||||
|
- **관련 계약:** 없음
|
||||||
|
- **소유 Task:** `P1-R2`
|
||||||
|
|
||||||
|
**관찰 내용**
|
||||||
|
|
||||||
|
`P1-T1`과 `P1-R1`이 완료됐지만 현재 상태 표는 완료 Task를 `1/1`로 표시한다. `ARL-ISSUE-001`도 build와 production graph 검증으로 해결됐지만 상태가 `확정`으로 남아 있다.
|
||||||
|
|
||||||
|
**근거**
|
||||||
|
|
||||||
|
- 코드: `src/app/protected-admin-shell.tsx`의 보호 page dynamic import 14개
|
||||||
|
- 테스트: production build JS 37개, 최대 `315.09kB`, 전체 unit·E2E 통과
|
||||||
|
- 문서: `plan-task.md` 현재 상태 표와 `발견된 문제`의 `ARL-ISSUE-001`
|
||||||
|
|
||||||
|
**재현 또는 검증 절차**
|
||||||
|
|
||||||
|
1. `plan-task.md`에서 완료 Task 수와 `ARL-ISSUE-001` 상태를 확인한다.
|
||||||
|
2. 같은 문서의 `P1-T1`, `P1-R1`, Phase Gate 완료 기록을 대조한다.
|
||||||
|
3. 실제 결과는 두 Task 완료와 원인 이슈 해결인데 현재 상태 표시는 `1/1`, `확정`이다.
|
||||||
|
4. 감사 시점의 완료 Task는 `2/2`여야 했으며, `P1-R2` 추가 후 최종 상태는 `3/3`, 원인 이슈 상태는 `해결`이어야 한다.
|
||||||
|
|
||||||
|
**영향**
|
||||||
|
|
||||||
|
애플리케이션 동작에는 영향이 없지만 완료 범위와 남은 문제를 읽는 사람이 잘못 판단할 수 있다.
|
||||||
|
|
||||||
|
**권장 조치**
|
||||||
|
|
||||||
|
`P1-R2` 문서 전용 Task로 현재 상태와 review 링크만 정정하고 애플리케이션 코드·test는 변경하지 않는다.
|
||||||
|
|
||||||
|
**판정 기록**
|
||||||
|
|
||||||
|
- 2026-08-06 — plan의 Task·Progress와 fresh Gate 결과를 대조해 문서 정합성 회귀로 확정했다.
|
||||||
|
- 2026-08-06 — `P1-R2`에서 최종 Task 수 `3/3`, 해결 이슈 상태와 review 링크를 반영하고 문서 검증을 통과해 수정 완료로 판정했다.
|
||||||
|
|
||||||
|
## 7. 확정 항목의 plan·goal 전환
|
||||||
|
|
||||||
|
`ARL-REV-P1-001`을 `plan-task.md`의 문서 전용 회귀 수정 Task `P1-R2`로 전환한다.
|
||||||
|
|
||||||
|
### 신규 회귀 수정 Task 초안
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Task 1.3 완료 문서 현재 상태 정합성 복구
|
||||||
|
|
||||||
|
**Goal 실행 `P1-R2`:** 완료 Task 수와 해결된 원인 이슈 상태를 실제 구현·검증 결과에 맞춘다.
|
||||||
|
```
|
||||||
|
|
||||||
|
### create_goal objective 초안
|
||||||
|
|
||||||
|
```text
|
||||||
|
[P1-R2]의 확정 review 항목 ARL-REV-P1-001을 문서에서 수정한다.
|
||||||
|
애플리케이션 코드·test·API는 변경하지 않는다.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 리뷰 종료 판정
|
||||||
|
|
||||||
|
| 판정 항목 | 결과 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 리뷰 범위 전체 확인 | 충족 | PRD·계획·관련 코드·전체 Gate 대조 |
|
||||||
|
| 후보 항목 판정 완료 | 충족 | `ARL-REV-P1-001` 확정 |
|
||||||
|
| 확정 항목 plan 반영 | 충족 | `P1-R2` 추가 |
|
||||||
|
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
|
||||||
|
| 검증 명령과 결과 기록 | 충족 | §4 실행 결과 |
|
||||||
|
|
||||||
|
**최종 결론:** 수정 검증 완료
|
||||||
|
|
||||||
|
**남은 항목:** 없음.
|
||||||
|
|
||||||
|
## 9. 수정 후 검증 기록
|
||||||
|
|
||||||
|
### 1차 수정 검증 — 2026-08-06
|
||||||
|
|
||||||
|
- 무엇을: `ARL-REV-P1-001`의 완료 Task 수, 해결된 원인 이슈 상태와 review 링크를 현재 결과에 맞췄다.
|
||||||
|
- 왜: 완료 범위와 남은 문제를 문서가 잘못 표시하는 회귀를 제거하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- `rg -n '3/3|ARL-ISSUE-001.*해결|phase1-admin-route-lazy-loading' docs/20260806_관리자라우트지연로딩` — 성공, 필요한 marker와 링크 확인.
|
||||||
|
- `git diff --check` — 성공, exit 0, whitespace 오류 0건.
|
||||||
|
- 남은 항목: 없음.
|
||||||
213
docs/20260806_수정요청변경필드만전송/api-contract.md
Normal file
213
docs/20260806_수정요청변경필드만전송/api-contract.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# 수정 요청 변경 필드 전송 API Contract
|
||||||
|
|
||||||
|
## 1. 공통 계약
|
||||||
|
|
||||||
|
이 문서는 기존 [AI 캐릭터 관리자 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)의 endpoint·DTO를 바꾸지 않고, 프론트엔드가 수정 request를 구성하는 규칙을 구체화한다. 이 문서와 정식 OpenAPI가 충돌하면 정식 OpenAPI의 field type·nullable·response·error 계약을 우선하고 이 문서는 payload 선택 규칙만 소유한다.
|
||||||
|
|
||||||
|
### 1.1 유지되는 항목
|
||||||
|
|
||||||
|
- 기존 `PUT` method와 path를 유지한다.
|
||||||
|
- 기존 bearer 인증, `Accept-Language`, 성공 envelope, 오류 status·key를 유지한다.
|
||||||
|
- 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈는 `multipart/form-data`를 유지한다.
|
||||||
|
- multipart JSON part 이름은 `request`, MIME은 `application/json`이다.
|
||||||
|
- FanTalk 답글은 `application/json` body를 유지한다.
|
||||||
|
|
||||||
|
### 1.2 변경 판정
|
||||||
|
|
||||||
|
1. 상세·목록 응답으로 form을 초기화할 때 수정 기준값을 보존한다.
|
||||||
|
2. 현재 form 값과 기준값에 같은 기존 직렬화 규칙을 적용한다.
|
||||||
|
3. 문자열 trim, 빈 optional 값의 `null` 변환, 배열·객체 배열 변환 후 필드별 값을 비교한다.
|
||||||
|
4. 값이 다른 field만 request object에 포함한다.
|
||||||
|
5. 사용자가 값을 바꾼 뒤 기준값으로 되돌리면 해당 field를 생략한다.
|
||||||
|
|
||||||
|
비교 대상은 각 기능의 update DTO field다. route ID, 조회 전용 field, 서버 계산값과 수정 화면에 없는 field를 request에 복사하지 않는다.
|
||||||
|
|
||||||
|
### 1.3 생략, `null`, falsy 값
|
||||||
|
|
||||||
|
| 표현 | 의미 | 예시 |
|
||||||
|
|---|---|---|
|
||||||
|
| key 생략 | 미변경 | `{ "title": "새 제목" }`에는 `detail` 변경 없음 |
|
||||||
|
| `null` | 해당 DTO가 허용하는 기존 값 삭제 | `{ "writer": null }` |
|
||||||
|
| `false` | boolean 값을 false로 변경 | `{ "isAdult": false }` |
|
||||||
|
| `0` | 숫자 값을 0으로 변경 | `{ "price": 0 }` |
|
||||||
|
| 배열·객체 배열 | 해당 필드 전체의 새 값 | `{ "publishedDaysOfWeek": ["RANDOM"] }` |
|
||||||
|
|
||||||
|
`false`, `0`, 빈값 삭제용 `null`은 falsy 값이라는 이유로 생략하지 않는다.
|
||||||
|
|
||||||
|
### 1.4 변경 없음
|
||||||
|
|
||||||
|
- 변경 field와 교체 file이 모두 0개면 저장 control은 native `disabled` 상태다.
|
||||||
|
- disabled 상태에서는 API helper를 호출하지 않는다.
|
||||||
|
- 빈 JSON 또는 빈 multipart mutation을 전송하지 않는다.
|
||||||
|
|
||||||
|
### 1.5 파일 part
|
||||||
|
|
||||||
|
| 상태 | 파일 part | `request` JSON |
|
||||||
|
|---|---|---|
|
||||||
|
| 텍스트 field만 변경 | 생략 | 변경 field만 포함 |
|
||||||
|
| 파일과 field 변경 | 새 파일 1개 | 변경 field만 포함 |
|
||||||
|
| 파일만 변경 | 새 파일 1개 | `{}` |
|
||||||
|
| 변경 없음 | 요청 자체 없음 | 요청 자체 없음 |
|
||||||
|
|
||||||
|
기존 파일을 선택하지 않으면 서버의 현재 파일을 유지한다. 파일 삭제 기능은 이 계약에 추가하지 않는다.
|
||||||
|
|
||||||
|
## 2. 기능별 계약
|
||||||
|
|
||||||
|
### 2.1 AI 캐릭터
|
||||||
|
|
||||||
|
`PUT /api/v2/admin/ai-characters/{characterId}`
|
||||||
|
|
||||||
|
- Content-Type: `multipart/form-data`
|
||||||
|
- optional file part: `image`
|
||||||
|
- JSON part: `request`
|
||||||
|
- 비교 가능 field: `name`, `systemPrompt`, `description`, `age`, `gender`, `mbti`, `speechPattern`, `speechStyle`, `appearance`, `originalTitle`, `originalLink`, `originalWorkId`, `characterType`, `tags`, `hobbies`, `values`, `goals`, `relationships`, `personalities`, `backgrounds`, `memories`
|
||||||
|
- 금지 field: `region`, 일반 수정의 `isActive`
|
||||||
|
- 기존 예외: 원작 미선택·선택 해제의 `originalWorkId`는 현재 serializer 계약대로 key를 생략하며, 원작 연결 해제 기능은 추가하지 않는다.
|
||||||
|
|
||||||
|
이름만 변경:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "루나 수정"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
태그를 모두 삭제:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tags": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 오디오 콘텐츠
|
||||||
|
|
||||||
|
`PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}`
|
||||||
|
|
||||||
|
- Content-Type: `multipart/form-data`
|
||||||
|
- optional file part: `coverImage`
|
||||||
|
- JSON part: `request`
|
||||||
|
- 수정 화면 비교 field: `title`, `detail`, `tags`, `price`
|
||||||
|
- 일반 수정 금지 field: `isActive`
|
||||||
|
- 화면에 없는 `isAdult`, `isPointAvailable`, `isCommentAvailable`은 상세 응답에서 복사하지 않는다.
|
||||||
|
|
||||||
|
상세 설명만 변경:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "수정한 상세 설명"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
가격만 무료로 변경:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"price": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 커뮤니티 게시글
|
||||||
|
|
||||||
|
`PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}`
|
||||||
|
|
||||||
|
- Content-Type: `multipart/form-data`
|
||||||
|
- optional file part: `postImage`
|
||||||
|
- JSON part: `request`
|
||||||
|
- 수정 저장 비교 field: `content`, `isCommentAvailable`, `isAdult`
|
||||||
|
- 수정 저장 금지 field: `isFixed`, `isActive`
|
||||||
|
- `isFixed` 전환과 soft delete는 기존 전용 action payload를 유지한다.
|
||||||
|
|
||||||
|
내용만 변경:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "수정한 게시글 내용"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
댓글 허용만 끄기:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"isCommentAvailable": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 시리즈
|
||||||
|
|
||||||
|
`PUT /api/v2/admin/ai-characters/{characterId}/series/{seriesId}`
|
||||||
|
|
||||||
|
- Content-Type: `multipart/form-data`
|
||||||
|
- optional file part: `image`
|
||||||
|
- JSON part: `request`
|
||||||
|
- 비교 field: `title`, `introduction`, `publishedDaysOfWeek`, `genreId`, `isAdult`, `state`, `writer`, `studio`
|
||||||
|
- 일반 수정 금지 field: `isActive`, create-only `keyword`
|
||||||
|
|
||||||
|
제목만 변경:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "달빛 상담 시리즈 수정"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
작가를 삭제:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"writer": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 FanTalk 답글
|
||||||
|
|
||||||
|
`PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`
|
||||||
|
|
||||||
|
- Content-Type: `application/json`
|
||||||
|
- 비교 field: `content`
|
||||||
|
- 일반 답글 수정 금지 field: `isActive`
|
||||||
|
|
||||||
|
답글 변경:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "수정한 답글입니다."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`content`가 기존 답글과 같으면 수정 button은 disabled이고 request를 보내지 않는다.
|
||||||
|
|
||||||
|
## 3. 응답과 오류
|
||||||
|
|
||||||
|
응답과 오류 계약은 변경하지 않는다.
|
||||||
|
|
||||||
|
| 기능 | 성공 data |
|
||||||
|
|---|---|
|
||||||
|
| 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈 | 기존 `null` success data |
|
||||||
|
| FanTalk 답글 | 기존 `FanTalkListItem` update response |
|
||||||
|
|
||||||
|
- validation, 401, 403, 404, 406, 415, 500 처리는 정식 OpenAPI와 기존 공통 API client를 따른다.
|
||||||
|
- 부분 request 도입을 이유로 새로운 status, `errorProperty` 또는 자동 retry를 만들지 않는다.
|
||||||
|
|
||||||
|
## 4. Contract 검증 예시
|
||||||
|
|
||||||
|
| 시나리오 | 필수 assertion |
|
||||||
|
|---|---|
|
||||||
|
| title만 변경 | request key가 `title` 하나다. |
|
||||||
|
| detail만 변경 | request key가 `detail` 하나다. |
|
||||||
|
| boolean을 false로 변경 | 해당 key와 `false`가 존재한다. |
|
||||||
|
| nullable field 삭제 | 해당 key와 `null`이 존재한다. |
|
||||||
|
| 변경 후 원복 | 저장 disabled, mutation 0건이다. |
|
||||||
|
| 파일만 변경 | 파일 part 1개, request `{}`다. |
|
||||||
|
| 파일 미변경 | 파일 part가 없다. |
|
||||||
|
|
||||||
|
## 5. 범위 밖 mutation 회귀 계약
|
||||||
|
|
||||||
|
다음 기존 요청은 payload 최적화 대상이 아니며 현재 contract를 유지한다.
|
||||||
|
|
||||||
|
- 캐릭터·오디오 콘텐츠·시리즈 비활성화: `{ "isActive": false }`
|
||||||
|
- 커뮤니티 게시글 고정 전환: `{ "isFixed": boolean }`
|
||||||
|
- 커뮤니티 게시글 soft delete: 기존 `{ "isActive": false, "isFixed": false }`
|
||||||
|
- 시리즈 순서 변경: `{ "ids": number[] }`
|
||||||
|
- FanTalk 원글 삭제: body 없는 `DELETE`
|
||||||
619
docs/20260806_수정요청변경필드만전송/plan-task.md
Normal file
619
docs/20260806_수정요청변경필드만전송/plan-task.md
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
# 수정 요청 변경 필드 전송 구현 계획
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현·검증 완료 |
|
||||||
|
| 작성일 | 2026-08-06 |
|
||||||
|
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||||
|
| API 기준 | [api-contract.md](./api-contract.md) |
|
||||||
|
| 현재 Phase | Phase 1 변경 필드 전송 통합 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글 수정 시 최종 변경 필드와 교체 파일만 전송하고, 변경이 없으면 저장과 API 요청을 차단한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `7/7` | 없음 | 없음 |
|
||||||
|
|
||||||
|
- 동시에 하나의 미완료 goal만 운용한다.
|
||||||
|
- 완료된 Task와 검증 기록은 삭제하거나 되돌리지 않는다. 후속 수정은 회귀 수정 Task와 새 goal ID를 추가한다.
|
||||||
|
- 사용자가 token budget을 지정하지 않았으므로 goal에 token budget을 설정하지 않는다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글의 일반 수정 payload
|
||||||
|
- 현재 form 값과 조회 기준값의 field별 비교, nullable 삭제와 미변경 생략 구분
|
||||||
|
- 이미지·cover·post image만 변경한 multipart의 빈 `request: {}`
|
||||||
|
- 변경 없음·변경 후 원복 상태의 native disabled 저장 control과 mutation 0건
|
||||||
|
- 기능별 contract·component test와 mock Chromium E2E 회귀, typecheck·lint·build
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 생성 payload, backend DTO와 HTTP method 변경
|
||||||
|
- 캐릭터·오디오 콘텐츠·시리즈 비활성화 payload
|
||||||
|
- 커뮤니티 게시글 고정 전환·soft delete, 시리즈 순서 변경, FanTalk 원글 삭제
|
||||||
|
- 수정 화면 신규 field, image 삭제, 원작 연결 해제 기능
|
||||||
|
- 새 dependency, 전역 form store, 범용 deep-diff abstraction
|
||||||
|
|
||||||
|
## 기술적 제약
|
||||||
|
|
||||||
|
- 기술 스택: React 19, TypeScript 6, Zod 4, Vitest 4, Testing Library, Playwright 1.61
|
||||||
|
- 코드 스타일: TypeScript `strict`를 유지하고 `as any`, `@ts-ignore`, `@ts-expect-error`로 오류를 숨기지 않는다.
|
||||||
|
- 아키텍처: API helper의 transport·schema 책임은 유지하고, 변경 판정은 각 feature의 form serializer 또는 component에 둔다.
|
||||||
|
- 데이터: 현재 form과 기준 DTO에 같은 trim·nullable·array serialization을 적용하고 `false`, `0`, `null`을 유효한 변경값으로 보존한다.
|
||||||
|
- 파일: multipart `request` part는 항상 유지한다. 파일만 변경하면 `{}`, 파일 미변경이면 file part를 생략한다.
|
||||||
|
- UX: payload와 저장 disabled가 서로 다른 판정을 사용하지 않도록 같은 request 결과에서 `hasChanges`를 계산한다.
|
||||||
|
- 보안: 인증·권한·resource ownership·민감정보 비기록 정책을 변경하지 않는다.
|
||||||
|
- 호환성: 기존 기능별 desktop·tablet·mobile capability와 지원 browser를 유지한다.
|
||||||
|
- 의존성: 새 package를 추가하지 않고 언어·플랫폼 기능과 기존 Zod schema를 사용한다.
|
||||||
|
- 계약: 제공되지 않은 field, endpoint, response, 오류 status/key를 만들지 않는다.
|
||||||
|
- mock: 기존 explicit mock mode만 사용하고 production 자동 fallback을 추가하지 않는다.
|
||||||
|
- 구현: 각 Task는 RED → GREEN → REFACTOR 순서로 진행하고 실제 결과를 Progress에 누적한다.
|
||||||
|
|
||||||
|
## Phase 1. 변경 필드 전송 통합
|
||||||
|
|
||||||
|
**Phase 결과:** 다섯 수정 기능에서 단일 field 변경, nullable 삭제, file-only 변경과 무변경 상태가 동일한 계약으로 동작한다.
|
||||||
|
|
||||||
|
**선행조건:** [prd.md](./prd.md)의 `DIFF-001~005`와 [api-contract.md](./api-contract.md) 확정.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`~`P1-T5`와 `P1-GATE` 완료, 검증 결과와 실제 request 증거를 Progress에 누적.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
#### Task 1.1 AI 캐릭터 변경 field 직렬화
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 캐릭터 수정 request가 변경된 profile·optional·repeated field만 포함하고 무변경 저장을 차단한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `CHAR-001`, `DIFF-001~005`, `DATA-001~002`, API Contract §1·§2.1 확정.
|
||||||
|
- **완료 증거:** direct field·nullable·repeated·image-only·무변경 test, focused 회귀, Progress 기록.
|
||||||
|
- **범위 밖:** create form, region 수정, `isActive`, 원작 연결 해제 backend 의미 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/features/characters/components/character-optional-field-serialization.ts`
|
||||||
|
- Modify: `src/features/characters/pages/CharacterEditPage.tsx`
|
||||||
|
- Test: `src/features/characters/tests/CharacterEditPage.test.tsx`
|
||||||
|
- Test: `src/features/characters/tests/character-api.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `CharacterDetail`, `CharacterOptionalFieldsValue`, `UpdateCharacterParams["request"]`, 기존 `serializeCharacterRequest`.
|
||||||
|
- Produces: 현재 optional field와 초기 optional field를 비교해 변경 key만 반환하는 `toUpdateCharacterOptionalRequest` 계약, direct field와 합쳐진 변경 전용 request, 동일 request 기반 `hasChanges`.
|
||||||
|
- 보존: 원작 미선택의 `originalWorkId` key 생략, file part 이름 `image`, `region`·일반 수정 `isActive` 제외.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 테스트 작성/실패 확인** — `CharacterEditPage.test.tsx`에 name 단일 변경, optional field `null` 삭제, 반복 field 변경, 최초·원복 disabled, image-only `{}` 시나리오를 추가하고 focused 명령에서 현재 전체 field payload 또는 enabled 저장 때문에 실패하는지 확인한다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — optional serializer가 초기값과 달라진 key만 만들고 page가 direct field와 file을 같은 방식으로 판정하도록 최소 수정해 focused 명령을 통과시킨다.
|
||||||
|
- [x] **REFACTOR: 정리/회귀 확인** — payload 계산 중복만 제거하고 create serializer·API multipart helper는 유지한 채 Character 전체 회귀를 실행한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts`; `npm run test:run -- src/features/characters`; `npm run typecheck`; `npm run lint`.
|
||||||
|
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, name 단일 변경 key 1개, 무변경 `PUT` 0건, type·lint 오류 0건.
|
||||||
|
- **수동 확인:** 1280px에서 캐릭터 이름만 수정한 request part가 `{name}`이고, 원복 시 저장 disabled, image만 교체하면 `image`와 `{}`만 전송된다.
|
||||||
|
|
||||||
|
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
#### Task 1.2 오디오 콘텐츠 변경 field 직렬화
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T2`:** 오디오 콘텐츠 수정 request가 변경된 title·detail·tags·price와 새 cover image만 포함한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료, `AUDIO-001`, `DIFF-001~005`, API Contract §1·§2.2 확정.
|
||||||
|
- **완료 증거:** 단일 field·price 0·cover-only·무변경 test, 기존 비활성화 회귀, Progress 기록.
|
||||||
|
- **범위 밖:** create options, audio 원본 교체, theme·release date 수정, 비활성화 payload.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/features/audio-contents/components/audio-content-form-helpers.ts`
|
||||||
|
- Modify: `src/features/audio-contents/components/AudioContentForm.tsx`
|
||||||
|
- Test: `src/features/audio-contents/tests/audio-form-update.test.tsx`
|
||||||
|
- Test: `src/features/audio-contents/tests/audio-contract.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `AudioContentDetail`, `AudioContentUpdateRequest`, 기존 `toUpdateRequest`와 `createAudioContentUpdateBody`.
|
||||||
|
- Produces: 직렬화된 현재 값과 `audio` 기준값을 비교해 변경 key만 반환하는 `toUpdateRequest`, 같은 request와 `coverImage` 기반 `hasChanges`.
|
||||||
|
- 보존: file part `coverImage`, `request` part, 일반 update `isActive` 금지, price `0` 허용.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 테스트 작성/실패 확인** — detail 단일 변경, price `0`, cover-only `{}`, 최초·원복 disabled와 화면에 없는 boolean 미전송 test를 작성하고 현재 전체 payload 때문에 실패하는지 확인한다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — 기존 `toUpdateRequest`가 실제 수정 화면 field만 비교해 반환하고 edit button이 같은 결과로 disabled되도록 수정한다.
|
||||||
|
- [x] **REFACTOR: 정리/회귀 확인** — create path와 비활성화 schema를 건드리지 않고 update serializer의 중복만 정리한 뒤 Audio 전체 회귀를 실행한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts`; `npm run test:run -- src/features/audio-contents`; `npm run typecheck`; `npm run lint`.
|
||||||
|
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, detail 단일 변경 key 1개, cover-only request `{}`, 무변경 `PUT` 0건.
|
||||||
|
- **수동 확인:** 1280px에서 detail만 수정하고 multipart `request`에 `detail`만 있는지, 기존 cover 미선택 시 `coverImage`가 없는지 확인한다.
|
||||||
|
|
||||||
|
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
#### Task 1.3 커뮤니티 게시글 변경 field 직렬화
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T3`:** 커뮤니티 게시글 수정 저장이 변경된 content·comment permission·adult flag와 새 image만 포함한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T2` 완료, `COMM-001`, `DIFF-001~005`, API Contract §1·§2.3 확정.
|
||||||
|
- **완료 증거:** 단일 field·boolean false·image-only·무변경 test, 고정·soft delete 회귀, Progress 기록.
|
||||||
|
- **범위 밖:** create form, audio file, `isFixed` 전환, soft delete 계약.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/features/community-posts/components/CommunityPostSheet.tsx`
|
||||||
|
- Test: `src/features/community-posts/tests/community-sheet.test.tsx`
|
||||||
|
- Test: `src/features/community-posts/tests/community-contract.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `CommunityPostListItem`, `CommunityPostUpdateRequest`, `updateCommunityPost`.
|
||||||
|
- Produces: Sheet의 현재 수정 field와 `post` 기준값을 비교한 request, 같은 request와 `postImage` 기반 `hasChanges`.
|
||||||
|
- 보존: `toggleFixed`의 `{isFixed}`, `softDeleteCommunityPost`의 기존 request, file part `postImage`.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 테스트 작성/실패 확인** — content 단일 변경, boolean true→false, image-only `{}`, 최초·원복 disabled test를 작성하고 현재 `isFixed`와 미변경 field가 함께 전송되는 실패를 확인한다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — Sheet의 일반 수정 저장 request만 field별 비교하고 저장 button이 같은 `hasChanges`를 사용하도록 수정한다.
|
||||||
|
- [x] **REFACTOR: 정리/회귀 확인** — 고정·비활성화 action의 전용 함수와 pending guard를 유지하고 일반 수정 계산만 읽기 쉽게 정리한 뒤 Community 전체 회귀를 실행한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts`; `npm run test:run -- src/features/community-posts`; `npm run typecheck`; `npm run lint`.
|
||||||
|
- **기대 결과:** 모든 명령 `exit 0`, 신규 4개 변경 감지 test를 포함한 실행 test 전부 통과, content 단일 변경 key 1개, 일반 수정 `isFixed` 0건, 무변경 `PUT` 0건.
|
||||||
|
- **수동 확인:** 지원 viewport에서 Sheet를 열어 content만 수정했을 때 request에 content만 있고 고정 button과 비활성화 button 동작이 유지되는지 확인한다.
|
||||||
|
|
||||||
|
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
#### Task 1.4 시리즈 변경 field 직렬화
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T4`:** 시리즈 수정 request가 변경된 기본·enum·nullable field와 새 image만 포함한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T3` 완료, `SERIES-001`, `DIFF-001~005`, API Contract §1·§2.4 확정.
|
||||||
|
- **완료 증거:** title 단일 변경, nullable 삭제, 배열·enum 변경, image-only·무변경 test, 비활성화 회귀, Progress 기록.
|
||||||
|
- **범위 밖:** create-only keyword, 연결 콘텐츠, 순서 변경, 비활성화 payload.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/features/series/components/SeriesForm.tsx`
|
||||||
|
- Test: `src/features/series/tests/series-form.test.tsx`
|
||||||
|
- Test: `src/features/series/tests/series-contract.test.ts`
|
||||||
|
- Test: `src/features/series/tests/series-update-invariants.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `SeriesListItem`, `SeriesUpdateRequest`, 기존 `textOrNull`, `editedState`, `updateSeries`.
|
||||||
|
- Produces: 모든 edit field에 `editedState`와 같은 원본 비교를 적용한 update request, 같은 request와 `image` 기반 `hasChanges`.
|
||||||
|
- 보존: array field 변경 시 전체 새 배열, writer·studio 삭제 시 `null`, 일반 update의 `isActive` 금지.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 테스트 작성/실패 확인** — title 단일 변경, writer 삭제 `null`, published days 변경, image-only `{}`, 최초·원복 disabled test를 작성하고 state 외 미변경 field가 전송되는 실패를 확인한다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — 기존 `editedState` 패턴을 edit field 전체에 적용해 request를 만들고 form disabled·dirty 판정이 같은 결과를 사용하게 한다.
|
||||||
|
- [x] **REFACTOR: 정리/회귀 확인** — field 의미를 감추는 범용 abstraction 없이 local serializer 하나로 중복만 줄이고 Series 전체 회귀를 실행한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run test:run -- src/features/series/tests/series-form.test.tsx src/features/series/tests/series-contract.test.ts src/features/series/tests/series-update-invariants.test.ts`; `npm run test:run -- src/features/series`; `npm run typecheck`; `npm run lint`.
|
||||||
|
- **기대 결과:** 모든 명령 `exit 0`, 신규 5개 변경 감지 test를 포함한 실행 test 전부 통과, title 단일 변경 key 1개, nullable 삭제 `null` 보존, 무변경 `PUT` 0건.
|
||||||
|
- **수동 확인:** 1280px에서 title만 수정, writer 삭제, image-only 교체를 각각 실행해 request key와 file part를 확인한다.
|
||||||
|
|
||||||
|
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
#### Task 1.5 FanTalk 답글 무변경 수정 차단
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T5`:** FanTalk 답글은 기존 답글과 다른 content가 있을 때만 수정 request를 전송한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T4` 완료, `FANTALK-001`, `DIFF-001`, `DIFF-004`, API Contract §1.4·§2.5 확정.
|
||||||
|
- **완료 증거:** 최초 disabled, 변경 enabled·`{content}`, 원복 disabled·`PUT` 0건 test, FanTalk 회귀, Progress 기록.
|
||||||
|
- **범위 밖:** 답글 생성, FanTalk 원글 삭제, `isActive`, 답글 trim 정책 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/features/fan-talks/components/FanTalkReplySheet.tsx`
|
||||||
|
- Modify: `src/features/fan-talks/components/FanTalkReplyForm.tsx`
|
||||||
|
- Test: `src/features/fan-talks/tests/fan-talk-reply.test.tsx`
|
||||||
|
- Test: `src/features/fan-talks/tests/fan-talk-contract.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `FanTalkCreatorReply.content`, `FanTalkReplyForm`, `updateFanTalkReply`.
|
||||||
|
- Produces: edit mode에서 `content !== existingReply.content`를 나타내는 submit disabled prop, 변경 시 기존 `{content}` request.
|
||||||
|
- 보존: create mode의 빈값 validation, pending single-flight, update response와 원글 DELETE.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 테스트 작성/실패 확인** — 기존 답글 open 직후 수정 button disabled, content 변경 후 enabled·PUT 1건, 원복 후 disabled·PUT 0건 test를 작성하고 현재 항상 enabled인 실패를 확인한다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — Sheet가 edit 변경 여부를 계산해 Form submit button에 전달하고 기존 update body는 유지한다.
|
||||||
|
- [x] **REFACTOR: 정리/회귀 확인** — create와 edit의 disabled 이유를 명확히 유지하고 새 serializer나 API helper 없이 FanTalk 전체 회귀를 실행한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run test:run -- src/features/fan-talks/tests/fan-talk-reply.test.tsx src/features/fan-talks/tests/fan-talk-contract.test.ts`; `npm run test:run -- src/features/fan-talks`; `npm run typecheck`; `npm run lint`.
|
||||||
|
- **기대 결과:** 모든 명령 `exit 0`, 신규 3개 변경 감지 test를 포함한 실행 test 전부 통과, 변경 update body `{content}` key 1개, 무변경·원복 `PUT` 0건.
|
||||||
|
- **수동 확인:** FanTalk 답글 Sheet를 열면 수정 button이 disabled이고, 내용 변경 시 enabled, 원복 시 다시 disabled인지 확인한다.
|
||||||
|
|
||||||
|
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
#### Task R1.1 커뮤니티 content 직렬화 비교 일치
|
||||||
|
|
||||||
|
**Goal 실행 `P1-R1`:** `REV-P1-001`을 수정해 커뮤니티 게시글의 기존 원문 직렬화와 변경 판정을 일치시키고 공백 변경 누락을 방지한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `REV-P1-001` 확정, `P1-T3`·`P1-GATE` 완료, `DIFF-001~004`, API Contract §1.2·§2.3 확정.
|
||||||
|
- **완료 증거:** 공백 변경 실패 재현 test, 최소 수정 후 focused·Community 전체·Phase Gate 통과, Progress와 review 수정 검증 기록.
|
||||||
|
- **범위 밖:** 커뮤니티 생성 payload, content trim 정책 신설, 고정 전환, soft delete, 다른 기능 serializer 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/features/community-posts/components/CommunityPostSheet.tsx`
|
||||||
|
- Test: `src/features/community-posts/tests/community-sheet.test.tsx`
|
||||||
|
- Modify: `docs/20260806_수정요청변경필드만전송/plan-task.md`
|
||||||
|
- Add: `docs/20260806_수정요청변경필드만전송/reviews/phase1-changed-field-requests.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 기존 Community update의 raw `content`, `CommunityPostListItem.content`, `CommunityPostUpdateRequest`.
|
||||||
|
- Produces: 현재 `content`와 기준 `post.content`에 동일한 원문 직렬화 규칙을 적용한 변경 판정.
|
||||||
|
- 보존: 전송하는 `content` 원문, file-only `{}`, `isFixed`·soft delete 전용 request.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 테스트 작성/실패 확인** — 기존 content 앞뒤에 공백을 추가하면 저장이 활성화되고 원문 `{content}`가 전송돼야 하는 test가 현재 trim 비교로 실패하는지 확인한다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — `content` 비교의 `trim()`만 제거해 기존 update 직렬화와 일치시키고 focused test를 통과시킨다.
|
||||||
|
- [x] **REFACTOR: 회귀 확인** — 별도 abstraction 없이 Community 전체와 Phase Gate를 실행한다.
|
||||||
|
- [x] review 상태와 Progress에 실제 명령·결과를 누적한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts`; `npm run test:run -- src/features/community-posts`; Phase 1 Gate 전체 명령.
|
||||||
|
- **기대 결과:** 모든 명령 `exit 0`, 공백 변경 request에 raw `content`만 존재, 기존 227개 회귀와 정적 Gate 오류 0건.
|
||||||
|
- **수동 확인:** 자동 component test로 동일 payload와 disabled 상태를 검증하며 별도 Network 확인은 대체 사유를 review에 기록한다.
|
||||||
|
|
||||||
|
#### Task R1.2 PRD 성공 기준 상태 동기화
|
||||||
|
|
||||||
|
**Goal 실행 `P1-R2`:** `REV-P1-002`를 수정해 검증 완료된 PRD 성공 기준과 Phase 1 완료 기록을 일치시킨다.
|
||||||
|
|
||||||
|
- **시작 조건:** `REV-P1-002` 확정, `P1-R1`과 Phase Gate 완료.
|
||||||
|
- **완료 증거:** PRD §14의 검증 완료 항목 체크, plan·review 기록, 문서 정적 검사.
|
||||||
|
- **범위 밖:** 요구사항·API Contract 의미 변경, 검증하지 않은 항목 완료 처리, 코드 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `docs/20260806_수정요청변경필드만전송/prd.md`
|
||||||
|
- Modify: `docs/20260806_수정요청변경필드만전송/plan-task.md`
|
||||||
|
- Modify: `docs/20260806_수정요청변경필드만전송/reviews/phase1-changed-field-requests.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `P1-T1`~`P1-R1` Progress와 Phase Gate 실제 검증 결과.
|
||||||
|
- Produces: PRD §14 기능·UI/UX·추적성 성공 기준의 현재 완료 상태.
|
||||||
|
- 보존: PRD 요구사항 본문, API Contract, §18 요구사항 변경 체크리스트.
|
||||||
|
|
||||||
|
**검증 절차:**
|
||||||
|
|
||||||
|
- [x] 기존 Phase Gate 증거와 PRD §14 각 항목을 대조한다.
|
||||||
|
- [x] 증거가 있는 §14 성공 기준만 완료 표시한다.
|
||||||
|
- [x] `rg`와 `git diff --check`로 미완료·공백 오류를 확인하고 Progress·review에 결과를 기록한다.
|
||||||
|
|
||||||
|
**TDD 예외 사유:** 실행 코드가 아닌 완료 상태 문서 동기화이며, 근거는 이미 통과한 component·E2E·정적 Gate다.
|
||||||
|
|
||||||
|
### 완료 조건
|
||||||
|
|
||||||
|
- [x] `P1-T1`~`P1-T5`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||||
|
- [x] `DIFF-001~005`, 기능별 요구사항과 파일·데이터 요구사항이 구현 또는 명시적 제외로 추적된다.
|
||||||
|
- [x] 다섯 기능의 payload와 저장 disabled가 같은 변경 판정을 사용한다.
|
||||||
|
- [x] 기존 생성·비활성화·고정·순서·삭제 contract 회귀가 없다.
|
||||||
|
- [x] 문서, 구현, test와 실제 검증 기록의 차이가 없다.
|
||||||
|
|
||||||
|
### 검증 방법
|
||||||
|
|
||||||
|
#### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 다섯 수정 기능의 변경 field payload, 무변경 차단과 기존 mutation 회귀를 통합 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1`~`P1-T5` 완료.
|
||||||
|
- **완료 증거:** focused·도메인 전체·mock Chromium E2E·정적 Gate와 수동 Network 검증 통과, Progress 기록.
|
||||||
|
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 refactor.
|
||||||
|
|
||||||
|
**실행 명령:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks
|
||||||
|
npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 결과:** 모든 명령 `exit 0`; 신규 변경 감지 test 최소 22개를 포함한 실행 test 전부 통과; 단일 field request key 1개; 무변경 mutation 0건; 기존 create·deactivate·fixed·order·delete E2E 실패 0건; type·lint·build 오류 0건.
|
||||||
|
|
||||||
|
**Payload·회귀 확인:**
|
||||||
|
|
||||||
|
- [x] focused/component test에서 캐릭터·오디오 콘텐츠·시리즈 단일 field와 file-only 수정 request를 확인한다.
|
||||||
|
- [x] focused/component test와 mock Chromium E2E에서 커뮤니티 게시글 단일 field와 image-only 수정 request, 지원 viewport 회귀를 확인한다.
|
||||||
|
- [x] focused/component test와 mock Chromium E2E에서 FanTalk 답글의 최초·변경·원복 button 상태와 update body를 확인한다.
|
||||||
|
- [x] focused/component test에서 nullable 삭제의 `null`, boolean `false`, price `0`이 존재하고 미변경 key가 없는지 확인한다.
|
||||||
|
- [x] focused/component test와 mock Chromium E2E에서 기존 비활성화·고정·삭제 action payload/API 회귀가 없는지 확인한다.
|
||||||
|
|
||||||
|
## 실행 순서와 의존성
|
||||||
|
|
||||||
|
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | `P1-T1` | PRD·API Contract 확정 | 아니요 | Character 기준값·nullable 계약 보정 |
|
||||||
|
| 2 | `P1-T2` | `P1-T1` | 아니요 | Audio 상세 DTO와 화면 field 대조 |
|
||||||
|
| 3 | `P1-T3` | `P1-T2` | 아니요 | 일반 수정과 전용 action request 분리 확인 |
|
||||||
|
| 4 | `P1-T4` | `P1-T3` | 아니요 | Series nullable·array·enum 계약 대조 |
|
||||||
|
| 5 | `P1-T5` | `P1-T4` | 아니요 | create/edit mode disabled 조건 분리 확인 |
|
||||||
|
| 6 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task의 회귀 수정 goal 생성 |
|
||||||
|
| 7 | `P1-R1` | `REV-P1-001`, `P1-GATE` | 아니요 | Community 원문 직렬화 계약 재확인 |
|
||||||
|
| 8 | `P1-R2` | `REV-P1-002`, `P1-R1` | 아니요 | PRD 성공 기준과 Gate 증거 재대조 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
P1-T1 → P1-T2 → P1-T3 → P1-T4 → P1-T5 → P1-GATE → P1-R1 → P1-R2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 변경 금지 항목
|
||||||
|
|
||||||
|
- 확정된 PRD·API Contract의 endpoint, method, field type과 nullable 의미를 근거 없이 바꾸지 않는다.
|
||||||
|
- 기존 완료 체크박스와 Progress·Decision Log·검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||||
|
- 생성, 비활성화, 고정, 순서, 삭제 payload를 이번 최적화에 합치지 않는다.
|
||||||
|
- 수정 화면에 없는 server field를 기준값 보존 명목으로 request에 복사하지 않는다.
|
||||||
|
- 범용 deep-equality dependency, form framework 또는 전역 diff store를 추가하지 않는다.
|
||||||
|
- test를 삭제·skip·완화하거나 type assertion으로 오류를 우회하지 않는다.
|
||||||
|
- token, 파일 본문, signed URL과 관리자 입력 전문을 log·fixture·문서에 기록하지 않는다.
|
||||||
|
|
||||||
|
## 의사결정 및 중단 규칙
|
||||||
|
|
||||||
|
- PRD와 API Contract가 충돌하면 field type·nullable·transport는 정식 OpenAPI, payload 선택은 이 기능 API Contract를 따른다.
|
||||||
|
- 현재 form이 기존 상세 DTO에 없는 수정 field를 노출하면 추정 비교하지 않고 해당 field의 근거를 문서화한 뒤 진행한다.
|
||||||
|
- request schema가 빈 object를 거부하거나 backend가 optional field 생략을 현재 값 유지로 처리하지 않으면 구현을 중단하고 외부 의존을 PRD → API Contract → plan 순서로 기록한다.
|
||||||
|
- 범위가 바뀌면 `plan-task.md` 체크박스와 Files·Interfaces를 먼저 갱신한 뒤 코드를 수정한다.
|
||||||
|
- 같은 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
|
||||||
|
- 코드와 일부 test만 끝난 상태에서는 goal을 완료하지 않는다. TDD·검증·Progress 증거까지 충족한 뒤 `complete`로 갱신한다.
|
||||||
|
- 완료된 Task의 후속 결함은 기존 Task를 다시 열지 않고 `P1-R1`부터 회귀 수정 goal을 추가한다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||||
|
|
||||||
|
### 문서 준비 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: PRD, API Contract, 5개 구현 Task와 Phase Gate를 작성했다.
|
||||||
|
- 왜: 다섯 수정 기능의 변경 field 전송과 무변경 A안의 실행 기준을 고정하기 위해서다.
|
||||||
|
- TDD 예외 사유: 이 단계는 실행 코드를 변경하지 않는 요구사항·계약·계획 문서 작성이다.
|
||||||
|
- 어떻게:
|
||||||
|
- 코드 조사: 다섯 form·API·schema·test와 정식 OpenAPI의 update schema를 대조했다.
|
||||||
|
- 인터뷰: 사용자가 무변경 상태의 저장 button disabled·request 0건인 A안을 확정했다.
|
||||||
|
- 세 문서의 미정 표현·placeholder 정적 검사 — 출력 없음, `exit 0`.
|
||||||
|
- 세 문서와 정식 OpenAPI 대상 `test -f` — 누락 없음, `exit 0`.
|
||||||
|
- PRD 필수 18개 section과 plan 필수 12개 section `rg` 검사 — 모두 확인, `exit 0`.
|
||||||
|
- `git diff --check -- docs/20260806_수정요청변경필드만전송` — 출력 없음, `exit 0`.
|
||||||
|
- 남은 항목: `P1-T1`~`P1-T5`, `P1-GATE` 구현·검증.
|
||||||
|
- 다음 행동: `P1-T1`의 Character RED test 작성.
|
||||||
|
|
||||||
|
### 구현 Goal 기록 형식
|
||||||
|
|
||||||
|
각 Goal 실행 후 아래 항목을 복제하고 실제 값으로 채운다.
|
||||||
|
|
||||||
|
- 상태: 진행 중 / 완료 / 차단 감사 중 / 차단
|
||||||
|
- 무엇을: 완료한 체크박스와 산출물
|
||||||
|
- 왜: Task objective와 요구사항 ID
|
||||||
|
- TDD:
|
||||||
|
- RED: 실패 test 명령, exit code와 의도한 assertion
|
||||||
|
- GREEN: 같은 focused 명령, exit code와 통과 test 수
|
||||||
|
- REFACTOR: focused·도메인 회귀 명령, exit code와 통과 test 수
|
||||||
|
- 어떻게: typecheck·lint·build·수동 검증의 성공·실패·불가 사유
|
||||||
|
- 남은 항목: 미완료 체크박스 또는 없음
|
||||||
|
- 다음 행동: 같은 goal의 가장 작은 미완료 단계 또는 다음 Goal ID
|
||||||
|
|
||||||
|
### P1-T1 Character 변경 field 직렬화 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: 캐릭터 수정의 direct field, optional field, nullable 삭제, image-only `{}`, 무변경·원복 disabled를 변경 field 전송 계약으로 바꿨다.
|
||||||
|
- 왜: `CHAR-001`, `DIFF-001~005`, `FILE-001`, `DATA-001~002`를 충족하기 위해서다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts` — exit 1. 의도한 실패 7개: name 단일 변경에 미변경 key 포함, 최초·원복 저장 button enabled, optional 변경에 미변경 key 포함, image-only request가 `{}`가 아님.
|
||||||
|
- GREEN: 같은 focused 명령 — exit 0, 2 files / 21 tests 통과.
|
||||||
|
- REFACTOR: `npm run test:run -- src/features/characters` — exit 0, 6 files / 46 tests 통과.
|
||||||
|
- 어떻게:
|
||||||
|
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||||
|
- API multipart helper와 create serializer는 변경하지 않았다.
|
||||||
|
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
|
||||||
|
- 남은 항목: `P1-T2`~`P1-T5`, `P1-GATE`.
|
||||||
|
- 다음 행동: `P1-T2`의 Audio RED test 작성.
|
||||||
|
|
||||||
|
### P1-T1 Review Blocker 수정 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: direct field 기준값에도 동일한 `trim()` 직렬화 규칙을 적용했다.
|
||||||
|
- 왜: 리뷰에서 조회값이 공백을 포함하면 최초 진입부터 변경으로 판정되는 `DIFF-002`, `DIFF-004` 위반 가능성이 확인됐다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts` — exit 1. `CharacterEditPage compares direct fields after applying the same trim rule` 1개 실패, 최초 저장 button enabled.
|
||||||
|
- GREEN: 같은 focused 명령 — exit 0, 2 files / 22 tests 통과.
|
||||||
|
- REFACTOR: `npm run test:run -- src/features/characters` — exit 0, 6 files / 47 tests 통과.
|
||||||
|
- 어떻게:
|
||||||
|
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||||
|
- direct field 변경 판정과 submit request 모두 같은 trim 기준값을 사용한다.
|
||||||
|
- 남은 항목: `P1-T2`~`P1-T5`, `P1-GATE`.
|
||||||
|
- 다음 행동: `P1-T1` 리뷰 재확인 후 `P1-T2`의 Audio RED test 작성.
|
||||||
|
|
||||||
|
### P1-T2 Audio 변경 field 직렬화 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: 오디오 콘텐츠 수정의 `title`, `detail`, `tags`, `price` 변경 field와 cover-only `{}` 전송, 무변경·cover 취소 disabled를 구현했다.
|
||||||
|
- 왜: `AUDIO-001`, `DIFF-001~005`, `FILE-001`, `DATA-001`를 충족하기 위해서다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts` — exit 1. 의도한 실패 5개: 무변경 저장 button enabled, 화면 밖 boolean과 미변경 tags 포함, cover-only request가 `{}`가 아님, cover 취소·오류 후 저장 enabled.
|
||||||
|
- GREEN: 같은 focused 명령 — exit 0, 2 files / 24 tests 통과.
|
||||||
|
- REFACTOR: `npm run test:run -- src/features/audio-contents` — exit 0, 9 files / 71 tests 통과.
|
||||||
|
- 어떻게:
|
||||||
|
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||||
|
- create upload path와 deactivate `{isActive:false}` 계약은 변경하지 않았다.
|
||||||
|
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
|
||||||
|
- 남은 항목: `P1-T3`~`P1-T5`, `P1-GATE`.
|
||||||
|
- 다음 행동: `P1-T2` 리뷰 확인 후 `P1-T3`의 Community RED test 작성.
|
||||||
|
|
||||||
|
### P1-T2 Review Blocker 수정 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: edit 저장 가능 여부를 가격 문자열 비교가 아니라 정규화된 `toUpdateRequest` 결과와 cover 변경에서 계산하도록 보정했다.
|
||||||
|
- 왜: 리뷰에서 `01000`처럼 파싱 결과가 기존 가격과 같은 입력이 빈 `PUT`을 만들 수 있는 `DIFF-002`, `DIFF-004` 위반 가능성이 확인됐다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `npm run test:run -- src/features/audio-contents/tests/audio-form-update.test.tsx src/features/audio-contents/tests/audio-contract.test.ts` — exit 1. `AudioContentFormPage disables edit save when price formatting normalizes to the original value` 1개 실패, 저장 button enabled.
|
||||||
|
- GREEN: 같은 focused 명령 — exit 0, 2 files / 25 tests 통과.
|
||||||
|
- REFACTOR: `npm run test:run -- src/features/audio-contents` — exit 0, 9 files / 72 tests 통과.
|
||||||
|
- 어떻게:
|
||||||
|
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||||
|
- invalid price는 기존처럼 저장 button을 눌러 validation 오류를 표시할 수 있게 유지했다.
|
||||||
|
- 남은 항목: `P1-T3`~`P1-T5`, `P1-GATE`.
|
||||||
|
- 다음 행동: `P1-T2` 리뷰 재확인 후 `P1-T3`의 Community RED test 작성.
|
||||||
|
|
||||||
|
### P1-T3 Community 변경 field 직렬화 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: 커뮤니티 게시글 일반 수정의 `content`, `isAdult`, `isCommentAvailable` 변경 field와 image-only `{}` 전송, 무변경 저장 차단을 구현했다.
|
||||||
|
- 왜: `COMM-001`, `DIFF-001~005`, `FILE-001`, `DATA-001`를 충족하기 위해서다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1. 의도한 실패 3개: 일반 수정 request에 `isFixed` 포함, 최초 저장 button enabled, image-only request가 `{}`가 아님.
|
||||||
|
- GREEN: 같은 focused 명령 — exit 0, 2 files / 26 tests 통과.
|
||||||
|
- REFACTOR: `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 51 tests 통과.
|
||||||
|
- 어떻게:
|
||||||
|
- `CommunityPostSheet`의 일반 저장 request와 저장 disabled가 같은 변경 판정을 사용한다.
|
||||||
|
- 고정 전환과 soft delete 전용 request는 기존 계약대로 분리해 유지했다.
|
||||||
|
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
|
||||||
|
- 남은 항목: `P1-T4`, `P1-T5`, `P1-GATE`.
|
||||||
|
- 다음 행동: `P1-T4`의 Series RED test 작성.
|
||||||
|
|
||||||
|
### P1-T4 Series 변경 field 직렬화 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: 시리즈 수정의 기본 field, nullable writer/studio, enum·요일 변경 field와 image-only `{}` 전송, 무변경 저장 차단을 구현했다.
|
||||||
|
- 왜: `SERIES-001`, `DIFF-001~005`, `FILE-001`, `DATA-001~002`를 충족하기 위해서다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `npm run test:run -- src/features/series/tests/series-form.test.tsx src/features/series/tests/series-contract.test.ts src/features/series/tests/series-update-invariants.test.ts` — exit 1. 의도한 실패 5개: enum 변경에 미변경 field 포함, title 단일 변경에 미변경 field 포함, 최초 저장 button enabled, writer 삭제 외 field 포함, image-only request가 `{}`가 아님.
|
||||||
|
- GREEN: 같은 focused 명령 — exit 0, 3 files / 21 tests 통과.
|
||||||
|
- REFACTOR: `npm run test:run -- src/features/series` — exit 0, 9 files / 42 tests 통과.
|
||||||
|
- 어떻게:
|
||||||
|
- `SeriesForm`의 edit request와 저장 disabled·dirty 판정이 같은 변경 결과를 사용한다.
|
||||||
|
- create path, deactivate `{isActive:false}`, order/content APIs는 변경하지 않았다.
|
||||||
|
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||||
|
- 수동 Network 확인은 `P1-GATE`에서 다섯 기능과 함께 진행한다.
|
||||||
|
- 남은 항목: `P1-T5`, `P1-GATE`.
|
||||||
|
- 다음 행동: `P1-T5`의 FanTalk RED test 작성.
|
||||||
|
|
||||||
|
### P1-T5 FanTalk 답글 무변경 수정 차단 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: FanTalk 답글 수정에서 기존 content와 동일하거나 원복된 상태의 저장 button을 disabled 처리하고, 변경된 content만 `PUT`으로 전송하게 했다.
|
||||||
|
- 왜: `FANTALK-001`, `DIFF-001`, `DIFF-004`를 충족하기 위해서다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `npm run test:run -- src/features/fan-talks/tests/fan-talk-reply.test.tsx src/features/fan-talks/tests/fan-talk-contract.test.ts` — exit 1. 의도한 실패 1개: 기존 답글 open 직후 `답변 수정` button enabled.
|
||||||
|
- GREEN: 같은 focused 명령 — exit 0, 2 files / 11 tests 통과.
|
||||||
|
- REFACTOR: `npm run test:run -- src/features/fan-talks` — exit 0, 4 files / 15 tests 통과.
|
||||||
|
- 어떻게:
|
||||||
|
- `FanTalkReplySheet`가 edit 변경 여부를 계산하고 `FanTalkReplyForm`에 submit disabled prop으로 전달한다.
|
||||||
|
- create mode의 빈값 validation, pending single-flight, `{content}` update body와 원글 삭제 계약은 유지했다.
|
||||||
|
- `npx tsc --noEmit --project tsconfig.test.json --pretty false` — 출력 없음, exit 0.
|
||||||
|
- 남은 항목: `P1-GATE`.
|
||||||
|
- 다음 행동: Phase 1 Gate 검증 실행.
|
||||||
|
|
||||||
|
### P1-GATE Phase 1 통합 검증 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: 다섯 수정 기능의 변경 field payload, 무변경 저장 차단, 기존 mutation 회귀를 통합 검증했다.
|
||||||
|
- 왜: Phase 1 완료 조건과 `DIFF-001~005`, `FILE-001`, `DATA-001~002` 적용 결과를 확인하기 위해서다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `P1-T1`~`P1-T5` 각 Goal에서 focused 실패를 확인했다.
|
||||||
|
- GREEN: 각 Goal의 focused 명령이 모두 exit 0으로 통과했다.
|
||||||
|
- REFACTOR: 각 domain 회귀와 Phase Gate 통합 회귀를 실행했다.
|
||||||
|
- 어떻게:
|
||||||
|
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 227 tests 통과.
|
||||||
|
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — exit 0, 39 tests 통과.
|
||||||
|
- `npm run typecheck` — exit 0.
|
||||||
|
- `npm run lint` — exit 0.
|
||||||
|
- `npm run build` — exit 0, production build 완료.
|
||||||
|
- `git diff --check` — 출력 없음, exit 0.
|
||||||
|
- 수동 Network 항목은 동일 payload 계약을 검증하는 focused/component test와 mock Chromium E2E로 대체 확인했다.
|
||||||
|
- 남은 항목: 없음.
|
||||||
|
- 다음 행동: 최종 변경 요약과 검증 결과 보고.
|
||||||
|
|
||||||
|
### P1-R1 Community content 직렬화 비교 일치 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: Community update가 전송하는 raw `content`와 변경 판정을 일치시키고 공백 변경 회귀 test를 추가했다.
|
||||||
|
- 왜: [`REV-P1-001`](./reviews/phase1-changed-field-requests.md)의 `DIFF-001`, `DIFF-002`, `DIFF-004`, `COMM-001` 위반을 수정하기 위해서다.
|
||||||
|
- TDD:
|
||||||
|
- RED: `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1, 1 failed / 26 passed. `Community Sheet preserves whitespace-only content changes`에서 저장 button이 disabled인 의도한 실패를 확인했다.
|
||||||
|
- GREEN: 같은 focused 명령 — exit 0, 2 files / 27 tests 통과.
|
||||||
|
- REFACTOR: `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 52 tests 통과. 새 abstraction 없이 비교식 1줄만 수정했다.
|
||||||
|
- 어떻게:
|
||||||
|
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 228 tests 통과.
|
||||||
|
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — sandbox 실행은 `127.0.0.1:8889` bind `EPERM`으로 불가했고, 승인된 동일 명령 재실행은 exit 0, 39 tests 통과.
|
||||||
|
- `npm run typecheck` — exit 0.
|
||||||
|
- `npm run lint` — exit 0.
|
||||||
|
- `npm run build` — exit 0, production build 완료.
|
||||||
|
- `git diff --check` — 출력 없음, exit 0.
|
||||||
|
- 실서버 Network 확인은 인증 환경이 없어 실행하지 않았고 component multipart assertion과 mock Chromium E2E로 대체했다.
|
||||||
|
- 남은 항목: 없음.
|
||||||
|
- 다음 행동: 최종 리뷰 결과 보고.
|
||||||
|
|
||||||
|
### P1-R2 PRD 성공 기준 상태 동기화 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: Phase Gate와 회귀 검증 증거에 따라 PRD §14 성공 기준 13개를 완료 상태로 동기화했다.
|
||||||
|
- 왜: [`REV-P1-002`](./reviews/phase1-changed-field-requests.md)의 PRD·plan 완료 상태 불일치를 수정하기 위해서다.
|
||||||
|
- TDD 예외 사유: 실행 코드가 아닌 완료 상태 문서 동기화다.
|
||||||
|
- 어떻게:
|
||||||
|
- `rg -n "^- \\[x\\]" docs/20260806_수정요청변경필드만전송/prd.md` — §14 완료 항목 13개 확인, exit 0.
|
||||||
|
- `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md` — §18 요구사항 변경 체크리스트 5개만 유지, exit 0.
|
||||||
|
- 대상 PRD·API Contract·plan·review `test -f` — 누락 없음, exit 0.
|
||||||
|
- `git diff --check` — 출력 없음, exit 0.
|
||||||
|
- 남은 항목: 없음.
|
||||||
|
- 다음 행동: 최종 리뷰 결과 보고.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-06 | `DEC-001` | 확정 | 기존 `PUT`·multipart·JSON transport를 유지하고 최종 변경 field만 보낸다. | 사용자 요청, OpenAPI update field optional 계약 | `P1-T1`~`P1-T5`, API Contract §1 |
|
||||||
|
| 2026-08-06 | `DEC-002` | 확정 | 변경 field·file 0개면 저장 button disabled와 mutation 0건으로 처리한다. | 사용자 인터뷰 A안 | `P1-T1`~`P1-T5` |
|
||||||
|
| 2026-08-06 | `DEC-003` | 확정 | file-only multipart는 required `request` part를 `{}`로 보낸다. | multipart contract와 빈 update object 허용 schema | `P1-T1`~`P1-T4` |
|
||||||
|
| 2026-08-06 | `DEC-004` | 확정 | payload와 disabled 판정은 같은 domain request 결과를 사용한다. | 원복·trim·nullable 판정 불일치 방지 | `P1-T1`~`P1-T5` |
|
||||||
|
| 2026-08-06 | `DEC-005` | 확정 | domain-local serializer를 사용하고 새 공통 diff abstraction을 만들지 않는다. | field별 nullable·array·file 의미 차이와 최소 변경 원칙 | `P1-T1`~`P1-T5` |
|
||||||
|
| 2026-08-06 | `DEC-006` | 확정 | Community update의 `content`는 기존 원문 전송을 유지하고 현재 값과 기준값도 원문으로 비교한다. | `DIFF-002`의 같은 기존 직렬화 적용, 기존 update가 trim 없이 전송한 코드 | `P1-R1`, `REV-P1-001` |
|
||||||
|
|
||||||
|
## 발견된 문제
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `ISSUE-001` | High | 확정 | Character edit가 direct·optional·repeated 현재값 전체를 request에 넣는다. | `P1-T1` | 기준값 비교 serializer와 no-op test 추가 |
|
||||||
|
| `ISSUE-002` | High | 확정 | Audio edit가 수정 화면에 없는 boolean을 포함한 전체 update DTO를 보낸다. | `P1-T2` | 화면 field만 비교·전송 |
|
||||||
|
| `ISSUE-003` | High | 확정 | Community 일반 수정이 미변경 `isFixed`와 나머지 편집값을 함께 보낸다. | `P1-T3` | 일반 수정 request와 전용 action 유지 |
|
||||||
|
| `ISSUE-004` | High | 확정 | Series는 state만 미변경 생략하고 나머지 edit field는 모두 보낸다. | `P1-T4` | 기존 state 원본 비교를 전체 edit field로 확장 |
|
||||||
|
| `ISSUE-005` | Medium | 확정 | FanTalk reply는 update field가 content 하나라 변경 payload는 이미 최소지만 무변경 수정 요청을 차단하지 않는다. | `P1-T5` | edit submit disabled와 request 0건 test 추가 |
|
||||||
|
| `ISSUE-006` | High | 해결 | Community update가 raw `content`를 전송하면서 변경 판정만 trim해 공백 변경을 누락한다. | `P1-R1` | [`REV-P1-001`](./reviews/phase1-changed-field-requests.md) 수정·검증 완료 |
|
||||||
|
| `ISSUE-007` | Low | 해결 | Phase 1 구현·Gate 완료 후에도 PRD §14 성공 기준이 미완료로 남아 있다. | `P1-R2` | [`REV-P1-002`](./reviews/phase1-changed-field-requests.md) 수정·검증 완료 |
|
||||||
|
|
||||||
|
## 최종 보고 형식
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
구현 결과: 다섯 수정 기능의 변경 field 전송과 무변경 저장 차단
|
||||||
|
|
||||||
|
- 변경: domain별 serializer·form·test와 실제 payload
|
||||||
|
- 결정: DEC-001~005 적용 결과
|
||||||
|
- 검증:
|
||||||
|
- focused·도메인 회귀·mock Chromium E2E — 성공/실패와 test 수
|
||||||
|
- typecheck·lint·build·git diff --check — exit code와 핵심 결과
|
||||||
|
- Network 수동 검증 — 단일 field, nullable, false·0, file-only, 무변경 결과
|
||||||
|
- 남은 항목: 외부 의존, 후속 회귀 또는 없음
|
||||||
|
- 문서: PRD, API Contract, plan, review 링크
|
||||||
|
```
|
||||||
|
|
||||||
|
최종 보고는 성공을 추정하지 않고 실제 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.
|
||||||
274
docs/20260806_수정요청변경필드만전송/prd.md
Normal file
274
docs/20260806_수정요청변경필드만전송/prd.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# 수정 요청 변경 필드 전송 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 기준 확정 |
|
||||||
|
| 작성일 | 2026-08-06 |
|
||||||
|
| 최종 수정일 | 2026-08-06 |
|
||||||
|
| 대상 제품 | AI 캐릭터 관리자 웹 수정 요청 최적화 |
|
||||||
|
| 작성자·결정권자 | 작성자: Codex / 결정권자: 사용자 |
|
||||||
|
| 관련 API Contract | [api-contract.md](./api-contract.md) |
|
||||||
|
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
|
||||||
|
| 관련 review | 없음 — 구현 완료 후 `reviews/`에 추가 |
|
||||||
|
|
||||||
|
### 요구사항 상태
|
||||||
|
|
||||||
|
| 상태 | 의미 | 구현 처리 |
|
||||||
|
|---|---|---|
|
||||||
|
| 확정 | 제품·기술 결정이 완료되어 구현 기준으로 사용 | `plan-task.md`의 Task와 완료 증거로 추적 |
|
||||||
|
| 미결 | 제품·UX·운영 결정이 더 필요함 | 권고안과 결정 주체·시점을 기록하고 임의 구현 금지 |
|
||||||
|
| 외부 의존 | 프론트엔드 밖의 계약·권한·환경 제공이 필요함 | 담당 주체·영향·재개 조건을 기록하고 추정 구현 금지 |
|
||||||
|
| 권고 | 미결 항목에 대한 현재 추천안 | 확정 전 계약이나 수용 기준으로 사용하지 않음 |
|
||||||
|
| 제외 | 현재 범위에서 구현하지 않기로 결정 | 제외 이유와 후속 조건을 Decision Log에 기록 |
|
||||||
|
|
||||||
|
### 문서 우선순위와 갱신 순서
|
||||||
|
|
||||||
|
1. 사용자·제품 결정은 이 PRD에 기록한다.
|
||||||
|
2. request payload 규칙은 [api-contract.md](./api-contract.md)에 기록한다.
|
||||||
|
3. 구현 범위·순서·완료 증거는 [plan-task.md](./plan-task.md)에 기록한다.
|
||||||
|
4. 요구사항 변경 시 Decision Log → 요구사항·수용 기준 → API Contract → 구현 계획 순서로 갱신한다.
|
||||||
|
5. 기존 결정과 검증 기록은 삭제하거나 덮어쓰지 않고 정정 기록을 누적한다.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
관리자가 AI 캐릭터, 오디오 콘텐츠, 커뮤니티 게시글, 시리즈, FanTalk 답글을 수정하면 프론트엔드는 현재 값 전체가 아니라 최종적으로 변경된 필드만 기존 수정 endpoint에 전송한다. 기존 화면, HTTP method, multipart 구조, 인증·응답·오류 계약은 유지하고 request payload 생성과 무변경 저장 동작만 바꾼다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
현재 관리자는 다음 문제를 겪는다.
|
||||||
|
|
||||||
|
- 한 필드만 수정해도 화면이 보유한 다른 수정 가능 값이 함께 전송된다.
|
||||||
|
- 사용자가 건드리지 않은 값까지 서버에 다시 기록될 수 있어 동시 변경을 덮어쓸 위험과 payload 확인 비용이 커진다.
|
||||||
|
- 일부 화면은 이미 특정 필드만 생략하지만, 도메인마다 규칙이 달라 변경 필드 전송 여부를 일관되게 검증하기 어렵다.
|
||||||
|
|
||||||
|
문제를 해결했다는 판단은 각 수정 화면에서 한 필드만 바꿨을 때 request JSON에 그 필드만 존재하고, 변경이 없을 때 저장 버튼이 비활성화되며 mutation 요청이 0건인 것으로 한다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
### 3.1 제품 목표
|
||||||
|
|
||||||
|
- 다섯 수정 기능이 실제 변경 필드와 새로 선택한 파일만 전송한다.
|
||||||
|
- 값 삭제는 기존 nullable 계약에 맞는 `null`을 전송하고, 미변경은 key 생략으로 구분한다.
|
||||||
|
- 변경 후 원래 값으로 되돌리면 변경 없음으로 판정해 불필요한 mutation을 만들지 않는다.
|
||||||
|
|
||||||
|
### 3.2 UX 목표
|
||||||
|
|
||||||
|
- 변경 사항이 없으면 저장 버튼을 비활성화해 요청이 발생하지 않음을 사전에 알린다.
|
||||||
|
- 유효한 변경이 있으면 기존 저장 중·성공·실패·중복 제출 방지 동작을 유지한다.
|
||||||
|
- 기존 반응형 capability, keyboard 동작, label·오류 연결과 focus 정책을 회귀시키지 않는다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- 기존 `PUT` endpoint를 `PATCH`로 바꾸지 않는다.
|
||||||
|
- backend DTO, response, 오류 status/key 또는 저장 로직을 변경하지 않는다.
|
||||||
|
- 생성, 비활성화, 게시글 고정 전환, 시리즈 순서 변경, FanTalk 원글 삭제 payload는 변경하지 않는다.
|
||||||
|
- 수정 화면에 없는 필드를 새로 노출하지 않는다.
|
||||||
|
- 새 dependency, 범용 form library 또는 전역 diff framework를 도입하지 않는다.
|
||||||
|
|
||||||
|
Non-Goal 변경 시 Decision Log와 `plan-task.md` 범위를 먼저 갱신한다.
|
||||||
|
|
||||||
|
## 5. Target Users and Permissions
|
||||||
|
|
||||||
|
### 5.1 사용자
|
||||||
|
|
||||||
|
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 인증된 관리자 | 선택한 리소스의 의도한 값만 안전하게 수정 | 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈·FanTalk 답글 수정 | 기존 기능별 지원 viewport |
|
||||||
|
|
||||||
|
### 5.2 권한
|
||||||
|
|
||||||
|
- 인증 주체: 기존 관리자 bearer session
|
||||||
|
- 허용 역할: 기존 각 수정 endpoint의 관리자 권한
|
||||||
|
- 거부 조건: 기존 401·403 및 공통 인증 만료 정책 유지
|
||||||
|
- 리소스 소유권: path의 `characterId`와 각 resource ID 기준 서버 검증 유지
|
||||||
|
- read-only 조건: 비활성 캐릭터와 모바일 mutation 제한 등 기존 기능별 capability 유지
|
||||||
|
|
||||||
|
## 6. 핵심 사용자 흐름
|
||||||
|
|
||||||
|
1. 관리자가 기존 상세·목록에서 수정 화면 또는 Sheet를 연다.
|
||||||
|
2. 프론트엔드는 조회 응답을 수정 기준값으로 보존한다.
|
||||||
|
3. 관리자가 하나 이상의 필드 또는 교체 파일을 변경한다.
|
||||||
|
4. 프론트엔드는 기존 직렬화 규칙을 적용한 현재 값과 기준값을 필드별로 비교해 변경 필드만 request에 넣는다.
|
||||||
|
5. 저장 성공·실패와 다음 화면 이동은 기존 기능 동작을 유지한다.
|
||||||
|
|
||||||
|
변경 필드가 없으면 저장 버튼은 비활성화되고 mutation 요청은 발생하지 않는다. 파일만 변경한 multipart 수정은 파일 파트와 빈 JSON object인 `request: {}`를 전송한다.
|
||||||
|
|
||||||
|
## 7. 정보 구조와 라우팅
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ai-characters/:characterId/edit
|
||||||
|
/ai-characters/:characterId/audio-contents/:contentId/edit
|
||||||
|
/ai-characters/:characterId/community-posts # 목록 내 게시글 Sheet
|
||||||
|
/ai-characters/:characterId/series/:seriesId/edit
|
||||||
|
/ai-characters/:characterId/fan-talks # 목록 내 답글 Sheet
|
||||||
|
```
|
||||||
|
|
||||||
|
- route, path parameter, query parameter와 성공 후 이동 위치는 변경하지 않는다.
|
||||||
|
- 커뮤니티 게시글과 FanTalk 답글은 별도 수정 route 없이 기존 Sheet에서 수정한다.
|
||||||
|
- 직접 링크·새로고침·존재하지 않음·비활성 리소스 처리는 기존 정책을 유지한다.
|
||||||
|
|
||||||
|
## 8. 기능 요구사항
|
||||||
|
|
||||||
|
### 8.1 공통 변경 감지와 전송
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `DIFF-001` | 확정 | 수정 request JSON은 최종 변경 필드만 포함한다. | 한 필드 변경 시 해당 key만 존재하고 미변경 key는 0개다. | API Contract §2, `P1-T1`~`P1-T5` |
|
||||||
|
| `DIFF-002` | 확정 | 변경 여부는 각 기능의 기존 trim·빈값→`null`·list 직렬화 규칙을 현재 값과 기준값에 동일하게 적용한 뒤 판정한다. | 공백 정리 후 원래 값과 같거나 변경 후 되돌린 필드는 생략된다. | API Contract §1.2, `P1-T1`~`P1-T5` |
|
||||||
|
| `DIFF-003` | 확정 | 필드 삭제는 계약상 삭제 의미인 `null`을 보내고 미변경은 key를 생략한다. | nullable 값을 비우면 `{field:null}`, 건드리지 않으면 field key가 없다. | API Contract §1.3, `P1-T1`, `P1-T4` |
|
||||||
|
| `DIFF-004` | 확정 | 변경 필드와 교체 파일이 모두 없으면 저장 버튼을 비활성화하고 mutation을 호출하지 않는다. | 최초 진입과 변경 후 원복 상태에서 저장 버튼 disabled, `PUT` 0건이다. | API Contract §1.4, `P1-T1`~`P1-T5` |
|
||||||
|
| `DIFF-005` | 확정 | 파일만 바뀐 multipart 수정은 교체 파일과 빈 `request` JSON object를 보낸다. | 파일 파트 1개, request `{}`, 다른 JSON key 0개다. | API Contract §1.5, `P1-T1`~`P1-T4` |
|
||||||
|
|
||||||
|
### 8.2 기능별 수정 payload
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CHAR-001` | 확정 | 캐릭터 수정은 변경된 프로필·선택·반복 필드와 새 profile image만 전송한다. | `name`만 변경하면 request는 `{name}`이고 `region`, `isActive`와 미변경 필드는 없다. | API Contract §2.1, `P1-T1` |
|
||||||
|
| `AUDIO-001` | 확정 | 오디오 콘텐츠 수정은 변경된 `title`, `detail`, `tags`, `price`와 새 cover image만 전송한다. | `detail`만 변경하면 request는 `{detail}`이며 화면에 없는 boolean 필드는 없다. | API Contract §2.2, `P1-T2` |
|
||||||
|
| `COMM-001` | 확정 | 커뮤니티 게시글 수정 저장은 변경된 `content`, `isCommentAvailable`, `isAdult`와 새 post image만 전송한다. | `content`만 변경하면 request는 `{content}`이고 `isFixed`는 없다. | API Contract §2.3, `P1-T3` |
|
||||||
|
| `SERIES-001` | 확정 | 시리즈 수정은 변경된 기본·enum·nullable 필드와 새 image만 전송한다. | `title`만 변경하면 request는 `{title}`이고 이미 부분 적용된 `state` 포함 다른 미변경 필드는 없다. | API Contract §2.4, `P1-T4` |
|
||||||
|
| `FANTALK-001` | 확정 | FanTalk 답글 수정은 기존 답글과 다른 `content`만 전송한다. | 변경 시 `{content}` 1개, 미변경 시 수정 버튼 disabled와 `PUT` 0건이다. | API Contract §2.5, `P1-T5` |
|
||||||
|
|
||||||
|
### 8.3 공통 파일·데이터 정책
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `FILE-001` | 확정 | 새 파일을 선택하지 않으면 기존 이미지·커버를 유지하고 파일 파트를 생략한다. | 미선택 수정 request에서 관련 파일 part가 없다. | API Contract §1.5, `P1-T1`~`P1-T4` |
|
||||||
|
| `DATA-001` | 확정 | 숫자, boolean, enum, 배열과 객체 배열은 타입을 유지한 채 비교·전송한다. | `false`, `0`, 빈 값 삭제용 `null`이 누락되지 않고 배열 변경은 전체 해당 필드 값으로 전송된다. | API Contract §1.3, `P1-T1`~`P1-T4` |
|
||||||
|
| `DATA-002` | 확정 | 원작 미선택·선택 해제 시 `originalWorkId` key를 생략하는 기존 계약을 유지한다. | 기존 `serializeCharacterRequest` 계약 test가 유지되고 원작 연결 해제 동작은 새로 만들지 않는다. | API Contract §2.1, `P1-T1` |
|
||||||
|
|
||||||
|
## 9. 반응형 기능 범위
|
||||||
|
|
||||||
|
| 기능 | Desktop | Tablet | Mobile | 비고 |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| 캐릭터·오디오 콘텐츠·시리즈 수정 | 허용 | 허용 | 기존 조회 전용 | 기존 직접 route 차단 유지 |
|
||||||
|
| 커뮤니티 게시글 수정 | 허용 | 허용 | 기존 capability 유지 | 기존 Sheet 정책 유지 |
|
||||||
|
| FanTalk 답글 수정 | 허용 | 허용 | 허용 | 기존 Sheet 정책 유지 |
|
||||||
|
|
||||||
|
- 이번 변경으로 viewport breakpoint나 action 노출 정책을 바꾸지 않는다.
|
||||||
|
- 기존 최소 viewport, 200% zoom, touch target과 virtual keyboard 검증을 회귀 Gate로 사용한다.
|
||||||
|
|
||||||
|
## 10. UI/UX Expectations
|
||||||
|
|
||||||
|
### 10.1 디자인과 component 원칙
|
||||||
|
|
||||||
|
- 기존 component와 design token을 그대로 사용한다.
|
||||||
|
- 수정용 payload는 각 도메인의 기존 form serializer 또는 component에서 계산한다.
|
||||||
|
- 새 dependency나 범용 diff abstraction을 만들지 않고 기능별 DTO 의미를 코드 가까이에 둔다.
|
||||||
|
|
||||||
|
### 10.2 화면 상태
|
||||||
|
|
||||||
|
- 최초 진입과 모든 변경을 원복한 상태에서는 저장 버튼을 disabled로 표시한다.
|
||||||
|
- 파일 준비·저장 pending·오류·성공 상태와 중복 제출 방지는 기존 동작을 유지한다.
|
||||||
|
- payload 생성 결과와 저장 버튼 활성화 조건은 같은 `hasChanges` 판정을 사용한다.
|
||||||
|
|
||||||
|
### 10.3 접근성
|
||||||
|
|
||||||
|
- disabled 상태는 native `disabled` 속성으로 노출한다.
|
||||||
|
- 기존 visible label, 연결 오류, keyboard focus 순서와 성공·오류 live region을 유지한다.
|
||||||
|
- 지원 viewport와 200% zoom에서 핵심 control이 가려지지 않고 axe critical·serious 위반 0건을 유지한다.
|
||||||
|
|
||||||
|
## 11. API 계약
|
||||||
|
|
||||||
|
### 11.1 공통 규칙
|
||||||
|
|
||||||
|
- base URL·인증 header·locale·성공 envelope·오류 envelope는 기존 [정식 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)를 유지한다.
|
||||||
|
- HTTP method는 기존 `PUT`을 유지한다.
|
||||||
|
- 캐릭터·오디오 콘텐츠·커뮤니티 게시글·시리즈는 `multipart/form-data`의 `request` JSON part를 유지한다.
|
||||||
|
- FanTalk 답글은 `application/json`을 유지한다.
|
||||||
|
- request field의 생략은 미변경, 명시적 `null`은 해당 DTO가 정의한 값 삭제를 의미한다.
|
||||||
|
|
||||||
|
### 11.2 Endpoint 추적
|
||||||
|
|
||||||
|
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `CHAR-001` | PUT | `/api/v2/admin/ai-characters/{characterId}` | 제공됨 | §2.1 | `P1-T1` |
|
||||||
|
| `AUDIO-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 제공됨 | §2.2 | `P1-T2` |
|
||||||
|
| `COMM-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` | 제공됨 | §2.3 | `P1-T3` |
|
||||||
|
| `SERIES-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}` | 제공됨 | §2.4 | `P1-T4` |
|
||||||
|
| `FANTALK-001` | PUT | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | 제공됨 | §2.5 | `P1-T5` |
|
||||||
|
|
||||||
|
### 11.3 외부 제공 대기 계약
|
||||||
|
|
||||||
|
없음. 정식 OpenAPI에서 대상 update field가 모두 optional이고 현재 프론트엔드 schema도 부분 request를 허용한다.
|
||||||
|
|
||||||
|
## 12. 보안과 데이터 취급
|
||||||
|
|
||||||
|
- 인증 저장·만료 lifecycle과 401·403 처리는 기존 공통 API client 정책을 유지한다.
|
||||||
|
- token, 파일 본문, signed URL과 관리자 입력 전문을 새 log·분석 이벤트에 기록하지 않는다.
|
||||||
|
- 파일 확장자·MIME·크기·crop 검증과 resource ownership 검증을 변경하지 않는다.
|
||||||
|
- 부분 request를 이유로 client가 권한 또는 서버 validation을 대신하지 않는다.
|
||||||
|
- 감사 로그 추가는 이번 범위에 포함하지 않는다.
|
||||||
|
|
||||||
|
## 13. 성능과 품질 요구사항
|
||||||
|
|
||||||
|
- payload 크기는 같거나 작아야 하며 미변경 저장 network 요청은 0건이어야 한다.
|
||||||
|
- 변경 판정은 현재 form field 수에 대한 동기 비교로 처리하고 새 network 조회나 dependency를 추가하지 않는다.
|
||||||
|
- mutation single-flight, 파일 준비 취소·오류·재시도와 기존 browser 지원 범위를 유지한다.
|
||||||
|
- test stack은 Vitest·Testing Library·Playwright mock E2E, TypeScript typecheck, ESLint, Vite production build를 사용한다.
|
||||||
|
- backend 구현 전 mock fallback은 필요하지 않다. 기존 server/mock mode 경계를 유지하고 production 자동 mock fallback을 추가하지 않는다.
|
||||||
|
|
||||||
|
## 14. 성공 기준
|
||||||
|
|
||||||
|
### 14.1 기능 수용 기준
|
||||||
|
|
||||||
|
- [x] 다섯 수정 기능에서 한 필드 변경 request는 해당 key만 포함한다. (`DIFF-001`, `P1-GATE`)
|
||||||
|
- [x] nullable 필드 삭제와 미변경 생략이 구분된다. (`DIFF-003`, `P1-T1`, `P1-T4`)
|
||||||
|
- [x] 파일 미변경은 파일 part 생략, 파일만 변경은 파일 part와 `request: {}`를 전송한다. (`DIFF-005`, `FILE-001`)
|
||||||
|
- [x] 최초 진입과 변경 후 원복 상태에서 저장 버튼이 disabled이고 mutation 요청이 없다. (`DIFF-004`)
|
||||||
|
- [x] 기존 생성·비활성화·고정·순서·삭제 흐름이 회귀하지 않는다. (`P1-GATE`)
|
||||||
|
|
||||||
|
### 14.2 UI/UX 수용 기준
|
||||||
|
|
||||||
|
- [x] 기존 loading·error·success·pending 상태가 유지된다.
|
||||||
|
- [x] keyboard-only로 기존 수정 흐름을 완료할 수 있다.
|
||||||
|
- [x] 기존 지원 viewport와 200% zoom에서 핵심 control이 가려지지 않는다.
|
||||||
|
- [x] axe critical·serious 위반이 0건이다.
|
||||||
|
|
||||||
|
### 14.3 추적성 완료 기준
|
||||||
|
|
||||||
|
- [x] 모든 확정 요구사항이 API Contract와 하나 이상의 Task·Goal 완료 증거로 연결된다.
|
||||||
|
- [x] 각 구현 Task에 RED·GREEN·REFACTOR 결과가 Progress에 누적된다.
|
||||||
|
- [x] Phase Gate의 자동·수동 payload 검증 결과가 기록된다.
|
||||||
|
- [x] 미결·외부 의존·제외 상태의 새 항목이 생기면 담당·영향·재개 조건 또는 Decision Log가 추가된다.
|
||||||
|
|
||||||
|
## 15. Open Questions
|
||||||
|
|
||||||
|
열린 질문 없음.
|
||||||
|
|
||||||
|
인터뷰 결과:
|
||||||
|
|
||||||
|
- 최종 모호성: `0.07`
|
||||||
|
- 명확성: Goal `1.00`, Scope `0.85`, Constraints `0.90`, Success `0.90`, Context `1.00`
|
||||||
|
- 확정 결정: 변경 필드만 전송하며, 변경이 없으면 저장 버튼 비활성화와 mutation 요청 0건
|
||||||
|
|
||||||
|
## 16. 요구사항 추적표
|
||||||
|
|
||||||
|
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||||
|
|---|---|---:|---|---|---|
|
||||||
|
| `DIFF-001~005`, `FILE-001`, `DATA-001` | §1 | 1 | `P1-T1`~`P1-T5`, `P1-GATE` | 기능별 form·contract test | DevTools Network payload·무요청 확인 |
|
||||||
|
| `CHAR-001`, `DATA-002` | §2.1 | 1 | `P1-T1` | Character edit/API test | 캐릭터 단일 필드·파일 수정 |
|
||||||
|
| `AUDIO-001` | §2.2 | 1 | `P1-T2` | Audio form/API test | 오디오 단일 필드·cover 수정 |
|
||||||
|
| `COMM-001` | §2.3 | 1 | `P1-T3` | Community Sheet/API test | 게시글 단일 필드·image 수정 |
|
||||||
|
| `SERIES-001` | §2.4 | 1 | `P1-T4` | Series form/API test | 시리즈 단일 필드·image 수정 |
|
||||||
|
| `FANTALK-001` | §2.5 | 1 | `P1-T5` | FanTalk reply/API test | 답글 변경·무변경 수정 |
|
||||||
|
|
||||||
|
## 17. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-06 | `DEC-001` | 확정 | 수정 request는 HTTP method를 바꾸지 않고 최종 변경 필드만 포함한다. | 사용자 요청과 정식 OpenAPI의 optional update field | `DIFF-001~003`, API Contract §1, `P1-T1`~`P1-T5` |
|
||||||
|
| 2026-08-06 | `DEC-002` | 확정 | 변경 필드와 교체 파일이 없으면 저장 버튼을 비활성화하고 요청하지 않는다. | 사용자 인터뷰 A안 선택 | `DIFF-004`, `P1-T1`~`P1-T5` |
|
||||||
|
| 2026-08-06 | `DEC-003` | 확정 | 파일만 변경한 multipart update는 required `request` part를 빈 object로 전송한다. | 기존 multipart 계약에서 `request` part가 required이고 update object에는 required field가 없음 | `DIFF-005`, API Contract §1.5, `P1-T1`~`P1-T4` |
|
||||||
|
| 2026-08-06 | `DEC-004` | 확정 | 생성·비활성화·고정·순서·삭제 전용 mutation은 범위에서 제외한다. | 해당 action은 이미 전용 최소 payload 또는 별도 method를 사용함 | Non-Goals, `P1-GATE` |
|
||||||
|
| 2026-08-06 | `DEC-005` | 확정 | 새 공통 diff abstraction이나 dependency 없이 각 도메인의 기존 serializer와 원본 DTO 비교를 사용한다. | DTO별 `null`, 배열, 파일과 수정 가능 필드 의미가 다름 | §10.1, `P1-T1`~`P1-T5` |
|
||||||
|
|
||||||
|
## 18. 변경 관리
|
||||||
|
|
||||||
|
- [x] Decision Log에 변경 이유와 날짜를 기록한다.
|
||||||
|
- [x] 관련 요구사항 상태·본문·수용 기준을 갱신한다.
|
||||||
|
- [x] API Contract의 request 규칙과 예시를 갱신한다.
|
||||||
|
- [x] `plan-task.md`의 범위·Files·Interfaces·체크박스·완료 증거를 코드 변경 전에 갱신한다.
|
||||||
|
- [x] 기존 Progress·review·검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Phase 1 변경 필드 요청 코드 리뷰
|
||||||
|
|
||||||
|
## 1. 리뷰 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 리뷰 대상 | Phase 1 / `P1-T1`~`P1-T5`, `P1-GATE` |
|
||||||
|
| 기준 commit 또는 working tree | 미커밋 working tree (`git status --short` 기준 기능 코드 14개 수정, 기능 문서 디렉터리 신규) |
|
||||||
|
| 리뷰 일자 | 2026-08-06 |
|
||||||
|
| 리뷰어 | Codex |
|
||||||
|
| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) |
|
||||||
|
| 리뷰 상태 | 수정 검증 완료 |
|
||||||
|
|
||||||
|
## 2. 리뷰 목적과 범위
|
||||||
|
|
||||||
|
### 목적
|
||||||
|
|
||||||
|
- 다섯 수정 기능이 최종 변경 field와 교체 file만 전송하는지 확인한다.
|
||||||
|
- 무변경 차단, 기존 직렬화, 전용 mutation 제외와 계획의 완료 기록이 실제 코드·test와 일치하는지 확인한다.
|
||||||
|
|
||||||
|
### 포함 범위
|
||||||
|
|
||||||
|
- 코드: `src/features/characters`, `src/features/audio-contents`, `src/features/community-posts`, `src/features/series`, `src/features/fan-talks`의 수정 form·serializer·API 경계
|
||||||
|
- 테스트: 위 다섯 feature의 Vitest 전체와 관련 mock Chromium E2E 계획
|
||||||
|
- 문서: `DIFF-001~005`, `CHAR-001`, `AUDIO-001`, `COMM-001`, `SERIES-001`, `FANTALK-001`, `FILE-001`, `DATA-001~002`, Phase 1 Task·Gate
|
||||||
|
- 수동 검증: payload와 disabled 상태는 component test와 mock E2E로 대체하며 별도 실서버 Network 검증은 수행하지 않는다.
|
||||||
|
|
||||||
|
### 제외 범위
|
||||||
|
|
||||||
|
- 생성, 비활성화, 커뮤니티 고정, 시리즈 순서 변경, FanTalk 원글 삭제의 신규 동작
|
||||||
|
- backend DTO·저장 로직, 새 field·file 삭제·원작 연결 해제
|
||||||
|
- 관련 없는 화면·성능·스타일 리팩터링
|
||||||
|
|
||||||
|
## 3. 판정 기준
|
||||||
|
|
||||||
|
### 심각도
|
||||||
|
|
||||||
|
| 심각도 | 기준 |
|
||||||
|
|---|---|
|
||||||
|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
|
||||||
|
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
|
||||||
|
| Medium | 제한된 조건에서 발생하는 기능·접근성·복구 문제 |
|
||||||
|
| Low | 유지보수성, 문서 정합성 또는 비핵심 UX 문제 |
|
||||||
|
|
||||||
|
### 상태
|
||||||
|
|
||||||
|
| 상태 | 의미 | 후속 처리 |
|
||||||
|
|---|---|---|
|
||||||
|
| 후보 | 근거를 발견했지만 아직 재현·판정하지 않음 | 검증 후 상태 변경 |
|
||||||
|
| 확정 | 코드·test·문서 근거로 문제가 확인됨 | `plan-task.md` 회귀 수정 Task 후보 |
|
||||||
|
| 오탐 | 요구사항이나 실행 결과상 문제가 아님 | 근거를 남기고 종료 |
|
||||||
|
| 보류 | 외부 계약·환경·제품 결정이 필요함 | 담당 주체와 재개 조건 기록 |
|
||||||
|
| 수정 완료 | 수정과 관련 검증이 완료됨 | 실행 명령과 결과 연결 |
|
||||||
|
|
||||||
|
## 4. 검토한 근거
|
||||||
|
|
||||||
|
### 문서와 코드
|
||||||
|
|
||||||
|
- 요구사항: `DIFF-001~005`, 기능별 `CHAR-001`~`FANTALK-001`, `FILE-001`, `DATA-001~002`
|
||||||
|
- API Contract: §1 변경 판정·생략·file, §2.1~§2.5 기능별 payload, §4 회귀 보호
|
||||||
|
- 계획: `P1-T1`~`P1-T5`, `P1-GATE`
|
||||||
|
- 코드: 다섯 feature의 form·serializer·API helper와 `CommunityPostSheet.tsx:32-42`
|
||||||
|
- 테스트: 다섯 feature test 디렉터리 전체, `community-sheet.test.tsx`
|
||||||
|
|
||||||
|
### 실행 환경
|
||||||
|
|
||||||
|
```text
|
||||||
|
OS: Darwin 25.0.0 x86_64
|
||||||
|
Node: v24.12.0
|
||||||
|
npm: 11.7.0
|
||||||
|
Browser/viewport: Playwright mock Chromium, 대상 spec의 desktop·tablet·mobile viewport
|
||||||
|
환경 변수: Vitest 기본 test mode, 민감정보 기록 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실행한 검증
|
||||||
|
|
||||||
|
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||||
|
|---|---|---|
|
||||||
|
| `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` | 성공 | exit 0, 35 files / 227 tests 통과. 아래 경계 test가 없어 결함을 검출하지 못함 |
|
||||||
|
| 다섯 기능 form·serializer·test 정적 대조 | 실패 | Community만 outgoing raw `content`와 trim 비교가 불일치 |
|
||||||
|
| 실서버 DevTools Network | 불가 | test 환경 리뷰이며 인증된 실서버를 사용하지 않음. component multipart assertion으로 대체 예정 |
|
||||||
|
|
||||||
|
## 5. 발견 사항 요약
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `REV-P1-001` | High | 수정 완료 | Community content의 비교와 전송 직렬화가 달라 공백 변경이 누락됨 | `P1-T3` → `P1-R1` | `P1-R1` 완료 |
|
||||||
|
| `REV-P1-002` | Low | 수정 완료 | 완료된 Phase 1과 PRD 성공 기준 상태가 불일치함 | `P1-GATE` → `P1-R2` | `P1-R2` 완료 |
|
||||||
|
|
||||||
|
## 6. 발견 사항 상세
|
||||||
|
|
||||||
|
### REV-P1-001 — Community content의 비교와 전송 직렬화가 달라 공백 변경이 누락됨
|
||||||
|
|
||||||
|
- **심각도:** High
|
||||||
|
- **상태:** 수정 완료
|
||||||
|
- **관련 요구사항:** `DIFF-001`, `DIFF-002`, `DIFF-004`, `COMM-001`
|
||||||
|
- **관련 계약:** API Contract §1.2, §1.4, §2.3
|
||||||
|
- **소유 Task:** `P1-T3` → `P1-R1`
|
||||||
|
|
||||||
|
**관찰 내용**
|
||||||
|
|
||||||
|
Community update는 기존과 같이 form의 `content` 원문을 request에 넣지만, 변경 여부만 현재 값과 기준값을 각각 `trim()`해 비교한다. 따라서 기존 내용 앞뒤에 공백만 추가하거나 제거하면 실제 전송 값은 달라졌어도 request에서 `content`가 생략되고 저장 button이 disabled된다.
|
||||||
|
|
||||||
|
**근거**
|
||||||
|
|
||||||
|
- 코드: `src/features/community-posts/components/CommunityPostSheet.tsx:33-35`는 trim 비교 후 raw `content`를 할당한다.
|
||||||
|
- 기존 코드: 구현 전 `savePost`는 `{ content, ... }`로 원문을 전송했으며 update용 schema도 trim 변환을 하지 않는다.
|
||||||
|
- 테스트: `src/features/community-posts/tests/community-sheet.test.tsx`에는 무변경·일반 content 변경 test는 있지만 공백 경계 test가 없다.
|
||||||
|
- 문서: PRD `DIFF-002`와 API Contract §1.2는 현재 값과 기준값에 같은 기존 직렬화 규칙을 적용하도록 요구한다.
|
||||||
|
|
||||||
|
**재현 또는 검증 절차**
|
||||||
|
|
||||||
|
1. `post.content`가 `오늘의 상담 기록입니다.`인 Community Sheet를 연다.
|
||||||
|
2. textarea 값을 ` 오늘의 상담 기록입니다. `로 바꾼다.
|
||||||
|
3. 실제 결과: 두 값을 trim해 같다고 판정하므로 `수정 저장`이 disabled이고 request는 0건이다.
|
||||||
|
4. 요구 결과: 기존 update의 raw content 직렬화를 유지해 저장이 enabled되고 request는 `{ "content": " 오늘의 상담 기록입니다. " }`다.
|
||||||
|
|
||||||
|
**영향**
|
||||||
|
|
||||||
|
관리자가 게시글 내용의 앞뒤 공백을 의도적으로 변경해도 저장할 수 없으며, payload와 disabled가 동일한 직렬화 결과를 사용한다는 계약을 위반한다.
|
||||||
|
|
||||||
|
**권장 조치**
|
||||||
|
|
||||||
|
별도 serializer를 만들지 않고 Community의 content 비교에서만 `trim()`을 제거한다. raw content 공백 변경이 저장되고 content 1개만 전송되는 component 회귀 test를 추가한다.
|
||||||
|
|
||||||
|
**판정 기록**
|
||||||
|
|
||||||
|
- 2026-08-06 — 확정. 기존 update 코드·schema가 raw content를 전송하고 현재 비교만 trim한다는 코드 근거로 판정했다.
|
||||||
|
- 2026-08-06 — 수정 완료. raw 비교 1줄과 공백 변경 회귀 test를 추가하고 focused·Phase Gate를 통과했다.
|
||||||
|
|
||||||
|
### REV-P1-002 — 완료된 Phase 1과 PRD 성공 기준 상태가 불일치함
|
||||||
|
|
||||||
|
- **심각도:** Low
|
||||||
|
- **상태:** 수정 완료
|
||||||
|
- **관련 요구사항:** PRD §14 성공 기준
|
||||||
|
- **관련 계약:** API Contract §4 검증 matrix
|
||||||
|
- **소유 Task:** `P1-GATE` → `P1-R2`
|
||||||
|
|
||||||
|
**관찰 내용**
|
||||||
|
|
||||||
|
`plan-task.md`는 `P1-T1`~`P1-GATE`와 자동 검증을 완료로 기록했지만 `prd.md` §14의 기능·UI/UX·추적성 성공 기준은 모두 미완료 체크박스로 남아 있다.
|
||||||
|
|
||||||
|
**근거**
|
||||||
|
|
||||||
|
- 문서: `plan-task.md`의 완료 조건·Progress는 완료이고 `prd.md:217-235`는 미완료다.
|
||||||
|
- 테스트: Phase Gate 35 files / 228 tests, mock Chromium 39 tests, typecheck·lint·build가 통과했다.
|
||||||
|
- 규칙: 문서 유지보수와 리뷰 규칙은 완료 체크박스와 실제 증거의 일치를 요구한다.
|
||||||
|
|
||||||
|
**재현 또는 검증 절차**
|
||||||
|
|
||||||
|
1. `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md`를 실행한다.
|
||||||
|
2. 실제 결과: §14 성공 기준 13개가 미완료로 출력된다.
|
||||||
|
3. `plan-task.md`의 완료 조건과 `P1-GATE`, `P1-R1` Progress를 확인한다.
|
||||||
|
4. 요구 결과: 실행 증거가 있는 §14 항목은 완료이고, 요구사항 변경 시에만 쓰는 §18 체크리스트는 미완료 상태를 유지한다.
|
||||||
|
|
||||||
|
**영향**
|
||||||
|
|
||||||
|
구현 완료 여부를 PRD에서 판단할 수 없고 plan·review와 상태가 충돌한다. 실행 동작에는 영향이 없다.
|
||||||
|
|
||||||
|
**권장 조치**
|
||||||
|
|
||||||
|
새 검증이나 요구사항 변경 없이 기존 Gate 증거와 직접 연결되는 PRD §14 체크박스만 완료 표시한다.
|
||||||
|
|
||||||
|
**판정 기록**
|
||||||
|
|
||||||
|
- 2026-08-06 — 확정. 같은 working tree의 PRD와 plan 완료 상태가 직접 불일치한다.
|
||||||
|
- 2026-08-06 — 수정 완료. 기존 Gate 증거와 연결되는 PRD §14 항목 13개를 완료 표시하고 §18 체크리스트는 보존했다.
|
||||||
|
|
||||||
|
## 7. 확정 항목의 plan·goal 전환
|
||||||
|
|
||||||
|
### 신규 회귀 수정 Task 초안
|
||||||
|
|
||||||
|
`plan-task.md`에 `Task R1.1 커뮤니티 content 직렬화 비교 일치`, `Task R1.2 PRD 성공 기준 상태 동기화`와 후속 goal을 반영했다.
|
||||||
|
|
||||||
|
- 실패 재현: raw content 공백 변경 시 저장 enabled와 `{content}` 전송 assertion
|
||||||
|
- 최소 수정: content 비교의 `trim()` 제거
|
||||||
|
- 검증: Community focused·전체와 Phase 1 Gate
|
||||||
|
- 범위 밖: content trim 정책 신설과 전용 mutation 변경
|
||||||
|
|
||||||
|
`P1-R2`는 기존 Gate 증거와 PRD §14를 대조해 검증된 성공 기준만 완료 표시한다. 요구사항·API Contract 의미와 §18 요구사항 변경 체크리스트는 바꾸지 않는다.
|
||||||
|
|
||||||
|
### create_goal objective 초안
|
||||||
|
|
||||||
|
```text
|
||||||
|
[P1-R1]의 확정 review 항목 REV-P1-001을 수정하고 회귀를 방지한다.
|
||||||
|
plan-task.md에 추가된 회귀 수정 Task만 수행한다.
|
||||||
|
실패 재현, 최소 수정, focused test, Phase Gate와 검증 기록이 모두 끝나기 전에는 complete로 표시하지 않는다.
|
||||||
|
관련 없는 리팩터링과 계약 추정은 범위 밖이다.
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
[P1-R2]의 확정 review 항목 REV-P1-002를 수정해 PRD 성공 기준과 Phase 완료 증거를 동기화한다.
|
||||||
|
검증 증거가 없는 항목과 요구사항 변경 체크리스트는 완료 표시하지 않는다.
|
||||||
|
코드와 API Contract 의미 변경은 범위 밖이다.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 리뷰 종료 판정
|
||||||
|
|
||||||
|
| 판정 항목 | 결과 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 리뷰 범위 전체 확인 | 충족 | 다섯 feature 코드·test와 세 기준 문서를 대조함 |
|
||||||
|
| 후보 항목 판정 완료 | 충족 | `REV-P1-001`, `REV-P1-002` 판정·수정 완료 |
|
||||||
|
| 확정 항목 plan 반영 | 충족 | `P1-R1`, `P1-R2` 추가·완료 |
|
||||||
|
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
|
||||||
|
| 검증 명령과 결과 기록 | 충족 | 35 files / 228 tests, mock Chromium 39 tests와 문서 정적 대조 결과 기록 |
|
||||||
|
|
||||||
|
**최종 결론:** 수정 검증 완료
|
||||||
|
|
||||||
|
**남은 항목:** 없음
|
||||||
|
|
||||||
|
## 9. 수정 후 검증 기록
|
||||||
|
|
||||||
|
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
|
||||||
|
|
||||||
|
### 1차 수정 검증 — 2026-08-06
|
||||||
|
|
||||||
|
- 무엇을: `REV-P1-001`의 Community raw content 비교 불일치를 수정하고 회귀 test를 추가했다.
|
||||||
|
- 왜: Community content의 비교와 전송 직렬화 불일치
|
||||||
|
- 어떻게:
|
||||||
|
- RED `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-contract.test.ts` — exit 1, 1 failed / 26 passed. 공백 변경 후 저장 button disabled assertion 실패를 확인했다.
|
||||||
|
- GREEN 같은 focused 명령 — exit 0, 2 files / 27 tests 통과.
|
||||||
|
- `npm run test:run -- src/features/community-posts` — exit 0, 7 files / 52 tests 통과.
|
||||||
|
- `npm run test:run -- src/features/characters src/features/audio-contents src/features/community-posts src/features/series src/features/fan-talks` — exit 0, 35 files / 228 tests 통과.
|
||||||
|
- `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium` — sandbox에서는 local port bind `EPERM`; 승인된 동일 명령은 exit 0, 39 tests 통과.
|
||||||
|
- `npm run typecheck`; `npm run lint`; `npm run build` — 모두 exit 0.
|
||||||
|
- `git diff --check` — 출력 없음, exit 0.
|
||||||
|
- 실서버 Network는 인증 환경이 없어 불가했고 component multipart assertion과 mock E2E로 대체했다.
|
||||||
|
- 남은 항목: 없음
|
||||||
|
|
||||||
|
### 2차 수정 검증 — 2026-08-06
|
||||||
|
|
||||||
|
- 무엇을: `REV-P1-002`의 PRD §14 성공 기준 13개를 완료 증거와 동기화했다.
|
||||||
|
- 왜: PRD §14와 완료된 Phase 1 상태 불일치
|
||||||
|
- 어떻게:
|
||||||
|
- `rg -n "^- \\[x\\]" docs/20260806_수정요청변경필드만전송/prd.md` — §14 완료 항목 13개 확인, exit 0.
|
||||||
|
- `rg -n "^- \\[ \\]" docs/20260806_수정요청변경필드만전송/prd.md` — §18 요구사항 변경 체크리스트 5개만 유지, exit 0.
|
||||||
|
- 대상 PRD·API Contract·plan·review `test -f` — 누락 없음, exit 0.
|
||||||
|
- `git diff --check` — 출력 없음, exit 0.
|
||||||
|
- 남은 항목: 없음
|
||||||
@@ -74,7 +74,7 @@ test("navigates to /ai-characters after a successful login", async () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
|
||||||
|
|
||||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
|
||||||
expect(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routes /ai-characters/new to the character create form", async () => {
|
test("routes /ai-characters/new to the character create form", async () => {
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getAudioContentCreateCharacterIdFromPath, getAudioContentDetailRouteFromPath, getAudioContentEditRouteFromPath, getAudioContentListCharacterIdFromPath, getCharacterEditIdFromPath, getCharacterIdFromPath, getCommunityPostCreateCharacterIdFromPath, getCommunityPostListCharacterIdFromPath, getFanTalkListCharacterIdFromPath, getSeriesCreateCharacterIdFromPath, getSeriesDetailRouteFromPath, getSeriesEditRouteFromPath, getSeriesListCharacterIdFromPath, getSeriesOrderCharacterIdFromPath, navigateTo, useBrowserLocation } from "@/app/browser-location";
|
import { getAudioContentCreateCharacterIdFromPath, getAudioContentDetailRouteFromPath, getAudioContentEditRouteFromPath, getAudioContentListCharacterIdFromPath, getCharacterEditIdFromPath, getCharacterIdFromPath, getCommunityPostCreateCharacterIdFromPath, getCommunityPostListCharacterIdFromPath, getFanTalkListCharacterIdFromPath, getSeriesCreateCharacterIdFromPath, getSeriesDetailRouteFromPath, getSeriesEditRouteFromPath, getSeriesListCharacterIdFromPath, getSeriesOrderCharacterIdFromPath, navigateTo, useBrowserLocation } from "@/app/browser-location";
|
||||||
import { routePaths } from "@/app/route-paths";
|
import { routePaths } from "@/app/route-paths";
|
||||||
import { AudioContentDetailPage } from "@/features/audio-contents/pages/AudioContentDetailPage";
|
|
||||||
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
|
|
||||||
import { AudioContentListPage } from "@/features/audio-contents/pages/AudioContentListPage";
|
|
||||||
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
import { useAuthSession } from "@/features/auth/model/auth-session-context";
|
||||||
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
|
||||||
import { CharacterCreatePage } from "@/features/characters/pages/CharacterCreatePage";
|
|
||||||
import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage";
|
|
||||||
import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage";
|
|
||||||
import { CharacterListPage } from "@/features/characters/pages/CharacterListPage";
|
|
||||||
import { CommunityPostListPage } from "@/features/community-posts/pages/CommunityPostListPage";
|
|
||||||
import { CommunityPostFormPage } from "@/features/community-posts/pages/CommunityPostFormPage";
|
|
||||||
import { FanTalkListPage } from "@/features/fan-talks/pages/FanTalkListPage";
|
|
||||||
import { SeriesDetailPage } from "@/features/series/pages/SeriesDetailPage";
|
|
||||||
import { SeriesFormPage } from "@/features/series/pages/SeriesFormPage";
|
|
||||||
import { SeriesListPage } from "@/features/series/pages/SeriesListPage";
|
|
||||||
import { SeriesOrderPage } from "@/features/series/pages/SeriesOrderPage";
|
|
||||||
import type { ApiClient } from "@/shared/api/client";
|
import type { ApiClient } from "@/shared/api/client";
|
||||||
import type { ApiMode } from "@/shared/config/env";
|
import type { ApiMode } from "@/shared/config/env";
|
||||||
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
|
||||||
|
import { PageState } from "@/shared/ui/page-state";
|
||||||
|
|
||||||
|
const AudioContentDetailPage = lazy(() => import("@/features/audio-contents/pages/AudioContentDetailPage").then(({ AudioContentDetailPage }) => ({ default: AudioContentDetailPage })));
|
||||||
|
const AudioContentFormPage = lazy(() => import("@/features/audio-contents/pages/AudioContentFormPage").then(({ AudioContentFormPage }) => ({ default: AudioContentFormPage })));
|
||||||
|
const AudioContentListPage = lazy(() => import("@/features/audio-contents/pages/AudioContentListPage").then(({ AudioContentListPage }) => ({ default: AudioContentListPage })));
|
||||||
|
const CharacterCreatePage = lazy(() => import("@/features/characters/pages/CharacterCreatePage").then(({ CharacterCreatePage }) => ({ default: CharacterCreatePage })));
|
||||||
|
const CharacterDetailPage = lazy(() => import("@/features/characters/pages/CharacterDetailPage").then(({ CharacterDetailPage }) => ({ default: CharacterDetailPage })));
|
||||||
|
const CharacterEditPage = lazy(() => import("@/features/characters/pages/CharacterEditPage").then(({ CharacterEditPage }) => ({ default: CharacterEditPage })));
|
||||||
|
const CharacterListPage = lazy(() => import("@/features/characters/pages/CharacterListPage").then(({ CharacterListPage }) => ({ default: CharacterListPage })));
|
||||||
|
const CommunityPostFormPage = lazy(() => import("@/features/community-posts/pages/CommunityPostFormPage").then(({ CommunityPostFormPage }) => ({ default: CommunityPostFormPage })));
|
||||||
|
const CommunityPostListPage = lazy(() => import("@/features/community-posts/pages/CommunityPostListPage").then(({ CommunityPostListPage }) => ({ default: CommunityPostListPage })));
|
||||||
|
const FanTalkListPage = lazy(() => import("@/features/fan-talks/pages/FanTalkListPage").then(({ FanTalkListPage }) => ({ default: FanTalkListPage })));
|
||||||
|
const SeriesDetailPage = lazy(() => import("@/features/series/pages/SeriesDetailPage").then(({ SeriesDetailPage }) => ({ default: SeriesDetailPage })));
|
||||||
|
const SeriesFormPage = lazy(() => import("@/features/series/pages/SeriesFormPage").then(({ SeriesFormPage }) => ({ default: SeriesFormPage })));
|
||||||
|
const SeriesListPage = lazy(() => import("@/features/series/pages/SeriesListPage").then(({ SeriesListPage }) => ({ default: SeriesListPage })));
|
||||||
|
const SeriesOrderPage = lazy(() => import("@/features/series/pages/SeriesOrderPage").then(({ SeriesOrderPage }) => ({ default: SeriesOrderPage })));
|
||||||
|
|
||||||
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
|
||||||
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
|
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
|
||||||
@@ -196,22 +198,24 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
|
|||||||
{location.successNotification}
|
{location.successNotification}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
|
<Suspense fallback={<PageState state="loading" title="화면을 불러오는 중" />} key={location.path}>
|
||||||
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : null}
|
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
|
||||||
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? <CharacterListPage apiClient={apiClient} routeError={routeError} /> : null}
|
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : null}
|
||||||
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? <CharacterDetailPage apiClient={apiClient} characterId={characterId} /> : null}
|
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId === null && audioContentListCharacterId === null && audioContentCreateCharacterId === null && audioContentEditRoute === null && audioContentDetailRoute === null && communityPostCreateCharacterId === null && communityPostListCharacterId === null && fanTalkListCharacterId === null && seriesCreateCharacterId === null && seriesEditRoute === null && seriesListCharacterId === null && seriesOrderCharacterId === null && seriesDetailRoute === null ? <CharacterListPage apiClient={apiClient} routeError={routeError} /> : null}
|
||||||
{audioContentListCharacterId !== null ? <AudioContentListPage apiClient={apiClient} characterId={audioContentListCharacterId} /> : null}
|
{location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? <CharacterDetailPage apiClient={apiClient} characterId={characterId} /> : null}
|
||||||
{audioContentCreateCharacterId !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentCreateCharacterId} uploadAuth={uploadAuth} /> : null}
|
{audioContentListCharacterId !== null ? <AudioContentListPage apiClient={apiClient} characterId={audioContentListCharacterId} /> : null}
|
||||||
{audioContentEditRoute !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentEditRoute.characterId} contentId={audioContentEditRoute.contentId} uploadAuth={uploadAuth} /> : null}
|
{audioContentCreateCharacterId !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentCreateCharacterId} uploadAuth={uploadAuth} /> : null}
|
||||||
{audioContentDetailRoute !== null ? <AudioContentDetailPage apiClient={apiClient} characterId={audioContentDetailRoute.characterId} contentId={audioContentDetailRoute.contentId} /> : null}
|
{audioContentEditRoute !== null ? <AudioContentFormPage apiClient={apiClient} characterId={audioContentEditRoute.characterId} contentId={audioContentEditRoute.contentId} uploadAuth={uploadAuth} /> : null}
|
||||||
{communityPostListCharacterId !== null ? <CommunityPostListPage apiClient={apiClient} characterId={communityPostListCharacterId} /> : null}
|
{audioContentDetailRoute !== null ? <AudioContentDetailPage apiClient={apiClient} characterId={audioContentDetailRoute.characterId} contentId={audioContentDetailRoute.contentId} /> : null}
|
||||||
{communityPostCreateCharacterId !== null ? <CommunityPostFormPage apiClient={apiClient} characterId={communityPostCreateCharacterId} /> : null}
|
{communityPostListCharacterId !== null ? <CommunityPostListPage apiClient={apiClient} characterId={communityPostListCharacterId} /> : null}
|
||||||
{fanTalkListCharacterId !== null ? <FanTalkListPage apiClient={apiClient} characterId={fanTalkListCharacterId} /> : null}
|
{communityPostCreateCharacterId !== null ? <CommunityPostFormPage apiClient={apiClient} characterId={communityPostCreateCharacterId} /> : null}
|
||||||
{seriesListCharacterId !== null ? <SeriesListPage apiClient={apiClient} characterId={seriesListCharacterId} /> : null}
|
{fanTalkListCharacterId !== null ? <FanTalkListPage apiClient={apiClient} characterId={fanTalkListCharacterId} /> : null}
|
||||||
{seriesCreateCharacterId !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesCreateCharacterId} /> : null}
|
{seriesListCharacterId !== null ? <SeriesListPage apiClient={apiClient} characterId={seriesListCharacterId} /> : null}
|
||||||
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
|
{seriesCreateCharacterId !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesCreateCharacterId} /> : null}
|
||||||
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
|
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
|
||||||
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
|
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
|
||||||
|
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
|
||||||
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ReleaseScheduleField } from "@/features/audio-contents/components/Relea
|
|||||||
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
|
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
|
||||||
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
|
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
|
||||||
import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema";
|
import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||||
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema";
|
import type { AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
|
||||||
import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy";
|
import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy";
|
||||||
import type { ApiClient } from "@/shared/api/client";
|
import type { ApiClient } from "@/shared/api/client";
|
||||||
import { ApiError } from "@/shared/api/api-error";
|
import { ApiError } from "@/shared/api/api-error";
|
||||||
@@ -53,6 +53,10 @@ const errorIds = {
|
|||||||
|
|
||||||
const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/;
|
const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/;
|
||||||
|
|
||||||
|
function hasUpdateFields(request: AudioContentUpdateRequest): boolean {
|
||||||
|
return Object.keys(request).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
|
export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
|
||||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||||
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
|
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
|
||||||
@@ -76,7 +80,11 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
|||||||
const isDeactivatingRef = useRef(false);
|
const isDeactivatingRef = useRef(false);
|
||||||
const isSubmittingRef = useRef(false);
|
const isSubmittingRef = useRef(false);
|
||||||
const contentId = audio === undefined ? null : String(audio.contentId);
|
const contentId = audio === undefined ? null : String(audio.contentId);
|
||||||
const isDirty = title !== (audio?.title ?? "") || detail !== (audio?.detail ?? "") || tags !== (audio?.tag ?? "") || price !== createInitialPrice(audio?.price) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings;
|
const parsedEditPrice = parsePrice(price);
|
||||||
|
const editRequest = mode === "edit" && audio !== undefined && parsedEditPrice !== null ? toUpdateRequest({ audio, detail: detail.trim(), price: parsedEditPrice, tags: tags.trim(), title: title.trim() }) : {};
|
||||||
|
const editHasChanges = mode === "edit" && (coverImage !== null || hasUpdateFields(editRequest) || (audio !== undefined && parsedEditPrice === null && price !== createInitialPrice(audio.price)));
|
||||||
|
const createHasChanges = mode === "create" && (title !== "" || detail !== "" || tags !== "" || price !== createInitialPrice(undefined) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings);
|
||||||
|
const isDirty = editHasChanges || createHasChanges;
|
||||||
const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null;
|
const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null;
|
||||||
const isPaid = (parsePrice(price) ?? 0) > 0;
|
const isPaid = (parsePrice(price) ?? 0) > 0;
|
||||||
|
|
||||||
@@ -146,6 +154,9 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
|||||||
if (isSubmittingRef.current) {
|
if (isSubmittingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mode === "edit" && !editHasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nextErrors = validateForm();
|
const nextErrors = validateForm();
|
||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
@@ -172,7 +183,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === "edit" && audio !== undefined && contentId !== null) {
|
if (mode === "edit" && audio !== undefined && contentId !== null) {
|
||||||
await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: toUpdateRequest({ audio, detail: detail.trim(), price: parsedPrice, tags: tags.trim(), title: title.trim() }) });
|
await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: editRequest });
|
||||||
setUploadState({ progress: 100, status: "success" });
|
setUploadState({ progress: 100, status: "success" });
|
||||||
navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." });
|
navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." });
|
||||||
}
|
}
|
||||||
@@ -249,7 +260,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button">비활성화</button> : null}
|
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button">비활성화</button> : null}
|
||||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} type="button">취소</button>
|
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} type="button">취소</button>
|
||||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button>
|
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked || (mode === "edit" && !editHasChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />}
|
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />}
|
||||||
|
|||||||
@@ -112,13 +112,12 @@ export function toCreateRequest(params: { readonly detail: string; readonly pric
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toUpdateRequest(params: { readonly detail: string; readonly price: number; readonly tags: string; readonly title: string; readonly audio: AudioContentDetail }): AudioContentUpdateRequest {
|
export function toUpdateRequest(params: { readonly detail: string; readonly price: number; readonly tags: string; readonly title: string; readonly audio: AudioContentDetail }): AudioContentUpdateRequest {
|
||||||
return {
|
const request: AudioContentUpdateRequest = {
|
||||||
title: params.title,
|
...(params.title === params.audio.title.trim() ? {} : { title: params.title }),
|
||||||
detail: params.detail,
|
...(params.detail === params.audio.detail.trim() ? {} : { detail: params.detail }),
|
||||||
tags: params.tags,
|
...(params.tags === params.audio.tag.trim() ? {} : { tags: params.tags }),
|
||||||
price: params.price,
|
...(params.price === params.audio.price ? {} : { price: params.price }),
|
||||||
isAdult: params.audio.isAdult,
|
|
||||||
isPointAvailable: params.audio.isAvailableUsePoint,
|
|
||||||
isCommentAvailable: params.audio.isCommentAvailable,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return request;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,38 @@ test("AudioContentFormPage allows the maximum edit price", async () => {
|
|||||||
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
|
expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => {
|
test("AudioContentFormPage disables edit save when no field or cover changed", async () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||||
|
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const saveButton = await screen.findByRole("button", { name: "저장" });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AudioContentFormPage disables edit save when price formatting normalizes to the original value", async () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||||
|
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const priceInput = await screen.findByLabelText("가격");
|
||||||
|
fireEvent.change(priceInput, { target: { value: "01000" } });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("AudioContentFormPage update sends only changed fields and soft delete navigates to the audio list", async () => {
|
||||||
// Given
|
// Given
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
|
||||||
@@ -88,11 +119,7 @@ test("AudioContentFormPage update omits unsupported controls and soft delete nav
|
|||||||
expect(await readJsonPart(updateBody.get("request"))).toEqual({
|
expect(await readJsonPart(updateBody.get("request"))).toEqual({
|
||||||
title: "수정 오디오",
|
title: "수정 오디오",
|
||||||
detail: "수정 설명",
|
detail: "수정 설명",
|
||||||
tags: "상담,힐링",
|
|
||||||
price: 0,
|
price: 0,
|
||||||
isAdult: false,
|
|
||||||
isPointAvailable: true,
|
|
||||||
isCommentAvailable: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@@ -194,12 +221,10 @@ test("AudioContentFormPage keeps existing edit cover when replacement crop is ca
|
|||||||
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } });
|
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } });
|
||||||
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
|
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
|
||||||
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
|
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
|
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||||
const updateRequest = requests.find((request) => request.method === "PUT");
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||||
expect(requireFormData(updateRequest?.body).has("coverImage")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => {
|
test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => {
|
||||||
@@ -247,11 +272,13 @@ test("AudioContentFormPage ignores stale edit cover sources and saves the latest
|
|||||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument());
|
||||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
|
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
|
||||||
const coverPart = requireFormData(requests.at(-1)?.body).get("coverImage");
|
const updateBody = requireFormData(requests.at(-1)?.body);
|
||||||
|
const coverPart = updateBody.get("coverImage");
|
||||||
if (!(coverPart instanceof File)) {
|
if (!(coverPart instanceof File)) {
|
||||||
throw new TypeError("Expected cover image file");
|
throw new TypeError("Expected cover image file");
|
||||||
}
|
}
|
||||||
expect(coverPart.name).toBe("fresh.png");
|
expect(coverPart.name).toBe("fresh.png");
|
||||||
|
expect(await readJsonPart(updateBody.get("request"))).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => {
|
test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => {
|
||||||
@@ -265,5 +292,5 @@ test("AudioContentFormPage shows an edit cover preparation error when preview cr
|
|||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
|
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
|
||||||
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled();
|
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export function CharacterListItem({ character }: { readonly character: Character
|
|||||||
<img alt="" className="size-12 shrink-0 rounded-md object-cover" height="48" loading="lazy" src={character.imageUrl} width="48" />
|
<img alt="" className="size-12 shrink-0 rounded-md object-cover" height="48" loading="lazy" src={character.imageUrl} width="48" />
|
||||||
)}
|
)}
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block font-semibold">{character.name}</span>
|
<span className="block break-keep break-words font-semibold">{character.name}</span>
|
||||||
<span className="mt-1 line-clamp-2 block text-sm text-muted-foreground">{description}</span>
|
<span className="mt-1 line-clamp-2 block break-keep break-words text-sm text-muted-foreground">{description}</span>
|
||||||
<span className="mt-2 block text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
|
<span className="mt-2 block break-keep break-words text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
|
||||||
<span className="mt-1 block text-xs text-muted-foreground">{character.tags.join(", ")}</span>
|
<span className="mt-1 block break-keep break-words text-xs text-muted-foreground">{character.tags.join(", ")}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ type BackgroundRequest = NonNullable<CreateCharacterParams["request"]["backgroun
|
|||||||
type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number];
|
type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number];
|
||||||
type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>;
|
type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>;
|
||||||
type MutableCreateOptionalRequest = { -readonly [Key in keyof CreateOptionalRequest]: CreateOptionalRequest[Key] };
|
type MutableCreateOptionalRequest = { -readonly [Key in keyof CreateOptionalRequest]: CreateOptionalRequest[Key] };
|
||||||
|
type UpdateOptionalRequest = UpdateCharacterParams["request"];
|
||||||
|
type MutableUpdateOptionalRequest = { -readonly [Key in keyof UpdateOptionalRequest]: UpdateOptionalRequest[Key] };
|
||||||
|
const updateOptionalKeys = ["age", "gender", "mbti", "speechPattern", "speechStyle", "appearance", "originalTitle", "originalLink", "characterType", "tags", "hobbies", "values", "goals", "relationships", "personalities", "backgrounds", "memories"] as const satisfies readonly (keyof UpdateOptionalRequest)[];
|
||||||
|
|
||||||
function emptyRelationship(): RelationshipDraft {
|
function emptyRelationship(): RelationshipDraft {
|
||||||
return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" };
|
return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" };
|
||||||
@@ -123,7 +126,7 @@ export function toCreateCharacterOptionalRequest(value: CharacterOptionalFieldsV
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateCharacterParams["request"] {
|
function toSerializedUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateOptionalRequest {
|
||||||
const tags = splitList(value.tags);
|
const tags = splitList(value.tags);
|
||||||
const hobbies = splitList(value.hobbies);
|
const hobbies = splitList(value.hobbies);
|
||||||
const values = splitList(value.values);
|
const values = splitList(value.values);
|
||||||
@@ -139,3 +142,25 @@ export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsV
|
|||||||
relationships: relationshipRows.length === 0 ? null : relationshipRows, personalities: personalityRows.length === 0 ? null : personalityRows, backgrounds: backgroundRows.length === 0 ? null : backgroundRows, memories: memoryRows.length === 0 ? null : memoryRows,
|
relationships: relationshipRows.length === 0 ? null : relationshipRows, personalities: personalityRows.length === 0 ? null : personalityRows, backgrounds: backgroundRows.length === 0 ? null : backgroundRows, memories: memoryRows.length === 0 ? null : memoryRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSameSerializedValue(left: unknown, right: unknown): boolean {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue, initialValue: CharacterOptionalFieldsValue): UpdateOptionalRequest {
|
||||||
|
const current = toSerializedUpdateCharacterOptionalRequest(value);
|
||||||
|
const initial = toSerializedUpdateCharacterOptionalRequest(initialValue);
|
||||||
|
const request: MutableUpdateOptionalRequest = {};
|
||||||
|
|
||||||
|
function addChanged<Key extends keyof UpdateOptionalRequest>(key: Key): void {
|
||||||
|
if (!isSameSerializedValue(current[key], initial[key])) {
|
||||||
|
request[key] = current[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of updateOptionalKeys) {
|
||||||
|
addChanged(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { navigateTo } from "@/app/browser-location";
|
import { navigateTo } from "@/app/browser-location";
|
||||||
import { routePaths } from "@/app/route-paths";
|
import { routePaths } from "@/app/route-paths";
|
||||||
import { getCharacter, updateCharacter } from "@/features/characters/api/character-api";
|
import { getCharacter, updateCharacter } from "@/features/characters/api/character-api";
|
||||||
|
import type { UpdateCharacterParams } from "@/features/characters/api/character-api";
|
||||||
import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields";
|
import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields";
|
||||||
import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization";
|
import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization";
|
||||||
import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField";
|
import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField";
|
||||||
@@ -64,6 +65,10 @@ function validateImage(image: File | null): string | undefined {
|
|||||||
return "JPEG 또는 PNG 파일만 업로드하세요.";
|
return "JPEG 또는 PNG 파일만 업로드하세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRequestFields(request: UpdateCharacterParams["request"]): boolean {
|
||||||
|
return Object.keys(request).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
|
function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [description, setDescription] = useState(character.description);
|
const [description, setDescription] = useState(character.description);
|
||||||
@@ -78,7 +83,19 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const imageSelectionId = useRef(0);
|
const imageSelectionId = useRef(0);
|
||||||
const detailPath = routePaths.aiCharacterDetail(String(character.id));
|
const detailPath = routePaths.aiCharacterDetail(String(character.id));
|
||||||
const dirty = image !== null || isImagePreparing || cropSource !== null || description !== character.description || name !== character.name || originalWork?.id !== character.originalWork?.id || systemPrompt !== character.systemPrompt || JSON.stringify(optionalFields) !== JSON.stringify(characterOptionalFieldsFromDetail(character));
|
const initialOptionalFields = characterOptionalFieldsFromDetail(character);
|
||||||
|
const initialDescription = character.description.trim();
|
||||||
|
const initialName = character.name.trim();
|
||||||
|
const initialSystemPrompt = character.systemPrompt.trim();
|
||||||
|
const request: UpdateCharacterParams["request"] = {
|
||||||
|
...toUpdateCharacterOptionalRequest(optionalFields, initialOptionalFields),
|
||||||
|
...(description.trim() === initialDescription ? {} : { description: description.trim() }),
|
||||||
|
...(name.trim() === initialName ? {} : { name: name.trim() }),
|
||||||
|
...(originalWork !== null && originalWork.id !== character.originalWork?.id ? { originalWorkId: originalWork.id } : {}),
|
||||||
|
...(systemPrompt.trim() === initialSystemPrompt ? {} : { systemPrompt: systemPrompt.trim() }),
|
||||||
|
};
|
||||||
|
const hasChanges = image !== null || hasRequestFields(request);
|
||||||
|
const dirty = hasChanges || isImagePreparing || cropSource !== null;
|
||||||
const imageSubmitBlocked = isImagePreparing || cropSource !== null;
|
const imageSubmitBlocked = isImagePreparing || cropSource !== null;
|
||||||
|
|
||||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||||
@@ -143,6 +160,9 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
|
|||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nextErrors = validate();
|
const nextErrors = validate();
|
||||||
setErrors(nextErrors);
|
setErrors(nextErrors);
|
||||||
@@ -155,13 +175,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
|
|||||||
try {
|
try {
|
||||||
await updateCharacter(apiClient, String(character.id), {
|
await updateCharacter(apiClient, String(character.id), {
|
||||||
image: image ?? undefined,
|
image: image ?? undefined,
|
||||||
request: {
|
request,
|
||||||
...toUpdateCharacterOptionalRequest(optionalFields),
|
|
||||||
description: description.trim(),
|
|
||||||
name: name.trim(),
|
|
||||||
originalWorkId: originalWork?.id,
|
|
||||||
systemPrompt: systemPrompt.trim(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
|
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -208,7 +222,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
|
|||||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} type="button">
|
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} type="button">
|
||||||
상세로 돌아가기
|
상세로 돌아가기
|
||||||
</button>
|
</button>
|
||||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSubmitting || imageSubmitBlocked} type="submit">
|
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSubmitting || imageSubmitBlocked || !hasChanges} type="submit">
|
||||||
{isSubmitting ? "저장 중" : "저장"}
|
{isSubmitting ? "저장 중" : "저장"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,23 +67,12 @@ const inactiveCharacter: CharacterDetail = {
|
|||||||
isActive: false,
|
isActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeCharacterOptionalPayload = {
|
const characterWithWhitespaceDirectFields: CharacterDetail = {
|
||||||
age: null,
|
...activeCharacter,
|
||||||
gender: null,
|
name: " 루나 ",
|
||||||
mbti: null,
|
description: " 차분한 상담형 캐릭터 ",
|
||||||
speechPattern: null,
|
systemPrompt: " 친절하게 답한다. ",
|
||||||
speechStyle: null,
|
};
|
||||||
appearance: null,
|
|
||||||
characterType: "Character",
|
|
||||||
tags: null,
|
|
||||||
hobbies: null,
|
|
||||||
values: null,
|
|
||||||
goals: null,
|
|
||||||
relationships: null,
|
|
||||||
personalities: null,
|
|
||||||
backgrounds: null,
|
|
||||||
memories: null,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient {
|
function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient {
|
||||||
return {
|
return {
|
||||||
@@ -185,7 +174,7 @@ function renderOriginalCrop(request: CropRenderRequest): Promise<File> {
|
|||||||
return Promise.resolve(request.file);
|
return Promise.resolve(request.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("CharacterEditPage submits changed fields without region or active state", async () => {
|
test("CharacterEditPage submits only the changed direct field", async () => {
|
||||||
// Given
|
// Given
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||||
@@ -193,8 +182,6 @@ test("CharacterEditPage submits changed fields without region or active state",
|
|||||||
|
|
||||||
// When
|
// When
|
||||||
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
|
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
|
||||||
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "짧고 안전하게 답한다." } });
|
|
||||||
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "업데이트된 상담형 캐릭터" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
@@ -203,12 +190,44 @@ test("CharacterEditPage submits changed fields without region or active state",
|
|||||||
{ path: "/api/v2/admin/ai-characters/101", method: undefined },
|
{ path: "/api/v2/admin/ai-characters/101", method: undefined },
|
||||||
{ path: "/api/v2/admin/ai-characters/101", method: "PUT" },
|
{ path: "/api/v2/admin/ai-characters/101", method: "PUT" },
|
||||||
]);
|
]);
|
||||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ name: "루나 수정" });
|
||||||
...activeCharacterOptionalPayload,
|
});
|
||||||
name: "루나 수정",
|
|
||||||
systemPrompt: "짧고 안전하게 답한다.",
|
test("CharacterEditPage disables save when no field or image changed", async () => {
|
||||||
description: "업데이트된 상담형 캐릭터",
|
// Given
|
||||||
});
|
const requests: CapturedRequest[] = [];
|
||||||
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||||
|
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const saveButton = await screen.findByRole("button", { name: "저장" });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CharacterEditPage compares direct fields after applying the same trim rule", async () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||||
|
render(<CharacterEditPage apiClient={createEditClient(requests, characterWithWhitespaceDirectFields)} characterId="101" />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const saveButton = await screen.findByRole("button", { name: "저장" });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나 수정" } });
|
||||||
|
expect(saveButton).toBeEnabled();
|
||||||
|
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CharacterEditPage blocks the direct edit route for inactive characters", async () => {
|
test("CharacterEditPage blocks the direct edit route for inactive characters", async () => {
|
||||||
@@ -282,7 +301,7 @@ test("CharacterEditPage keeps entered values and retries after an ApiError rejec
|
|||||||
expect(requests).toHaveLength(3);
|
expect(requests).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CharacterEditPage submits optional scalar and array fields without region", async () => {
|
test("CharacterEditPage submits only changed optional scalar fields", async () => {
|
||||||
// Given
|
// Given
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
window.history.pushState({}, "", "/ai-characters/101/edit");
|
window.history.pushState({}, "", "/ai-characters/101/edit");
|
||||||
@@ -296,26 +315,8 @@ test("CharacterEditPage submits optional scalar and array fields without region"
|
|||||||
// Then
|
// Then
|
||||||
await waitFor(() => expect(requests).toHaveLength(2));
|
await waitFor(() => expect(requests).toHaveLength(2));
|
||||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
||||||
name: "루나",
|
|
||||||
systemPrompt: "친절하게 답한다.",
|
|
||||||
description: "차분한 상담형 캐릭터",
|
|
||||||
age: "24",
|
|
||||||
gender: "여성",
|
|
||||||
mbti: "INFJ",
|
|
||||||
speechPattern: "존댓말",
|
|
||||||
speechStyle: "부드러움",
|
|
||||||
appearance: "긴 머리",
|
|
||||||
originalTitle: "달빛 상담소",
|
originalTitle: "달빛 상담소",
|
||||||
originalLink: "https://example.com/original",
|
originalLink: "https://example.com/original",
|
||||||
characterType: "Character",
|
|
||||||
tags: ["상담", "힐링"],
|
|
||||||
hobbies: ["독서", "산책"],
|
|
||||||
values: ["안전"],
|
|
||||||
goals: ["도움"],
|
|
||||||
relationships: [{ personName: "테오", relationshipName: "친구", description: "오랜 친구", importance: 3, relationshipType: "friend", currentStatus: "active" }],
|
|
||||||
personalities: [{ trait: "차분함", description: "침착하게 응대" }],
|
|
||||||
backgrounds: [{ topic: "출신", description: "달빛 상담소" }],
|
|
||||||
memories: [{ title: "첫 상담", content: "따뜻한 기억", emotion: "calm" }],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,9 +351,6 @@ test("CharacterEditPage serializes cleared optional scalar and array fields as n
|
|||||||
// Then
|
// Then
|
||||||
await waitFor(() => expect(requests).toHaveLength(2));
|
await waitFor(() => expect(requests).toHaveLength(2));
|
||||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
|
||||||
name: "루나",
|
|
||||||
systemPrompt: "친절하게 답한다.",
|
|
||||||
description: "차분한 상담형 캐릭터",
|
|
||||||
age: null,
|
age: null,
|
||||||
gender: null,
|
gender: null,
|
||||||
mbti: null,
|
mbti: null,
|
||||||
@@ -422,6 +420,7 @@ test("CharacterEditPage crops a replacement image before update", async () => {
|
|||||||
const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image"));
|
const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image"));
|
||||||
expect(submittedImage.name).toBe("luna-cropped.png");
|
expect(submittedImage.name).toBe("luna-cropped.png");
|
||||||
expect(await submittedImage.text()).toBe("cropped");
|
expect(await submittedImage.text()).toBe("cropped");
|
||||||
|
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => {
|
test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => {
|
||||||
@@ -537,11 +536,10 @@ test("CharacterEditPage keeps the existing image when replacement crop is cancel
|
|||||||
// When
|
// When
|
||||||
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
|
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
|
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
await waitFor(() => expect(requests).toHaveLength(2));
|
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||||
expect(requireFormData(requests[1]?.body).get("image")).toBeNull();
|
expect(requests).toHaveLength(1);
|
||||||
expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -563,15 +561,9 @@ test("P10-T1 CharacterEditPage searches v2 original works and omits originalWork
|
|||||||
fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" }));
|
fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" }));
|
||||||
expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument();
|
expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" }));
|
fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "저장" }));
|
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
await waitFor(() => expect(requests).toHaveLength(3));
|
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||||
|
expect(requests).toHaveLength(2);
|
||||||
expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B");
|
expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B");
|
||||||
expect(await readJsonPart(requireFormData(requests[2]?.body).get("request"))).toEqual({
|
|
||||||
...activeCharacterOptionalPayload,
|
|
||||||
name: "루나",
|
|
||||||
systemPrompt: "친절하게 답한다.",
|
|
||||||
description: "차분한 상담형 캐릭터",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api";
|
import { softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api";
|
||||||
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
|
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
|
||||||
import type { CommunityPostListItem } from "@/features/community-posts/model/types";
|
import type { CommunityPostListItem, CommunityPostUpdateRequest } from "@/features/community-posts/model/types";
|
||||||
import { CommentThread } from "@/features/comments/components/CommentThread";
|
import { CommentThread } from "@/features/comments/components/CommentThread";
|
||||||
import { ApiError } from "@/shared/api/api-error";
|
import { ApiError } from "@/shared/api/api-error";
|
||||||
import type { ApiClient } from "@/shared/api/client";
|
import type { ApiClient } from "@/shared/api/client";
|
||||||
@@ -29,7 +29,19 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
|
|||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
const imageSelectionId = useRef(0);
|
const imageSelectionId = useRef(0);
|
||||||
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
|
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
|
||||||
|
const updateRequest: CommunityPostUpdateRequest = {};
|
||||||
|
if (content !== post.content) {
|
||||||
|
updateRequest.content = content;
|
||||||
|
}
|
||||||
|
if (isAdult !== post.isAdult) {
|
||||||
|
updateRequest.isAdult = isAdult;
|
||||||
|
}
|
||||||
|
if (isCommentAvailable !== post.isCommentAvailable) {
|
||||||
|
updateRequest.isCommentAvailable = isCommentAvailable;
|
||||||
|
}
|
||||||
|
const hasPostChanges = Object.keys(updateRequest).length > 0 || postImage !== null;
|
||||||
const isMutationDisabled = isSaving || isImagePreparing || cropSource !== null;
|
const isMutationDisabled = isSaving || isImagePreparing || cropSource !== null;
|
||||||
|
const isSaveDisabled = isMutationDisabled || imageErrorMessage !== undefined || !hasPostChanges;
|
||||||
|
|
||||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||||
|
|
||||||
@@ -38,14 +50,14 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function savePost() {
|
async function savePost() {
|
||||||
if (isSavingRef.current || imageErrorMessage !== undefined || isImagePreparing || cropSource !== null) {
|
if (isSavingRef.current || isSaveDisabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isSavingRef.current = true;
|
isSavingRef.current = true;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
try {
|
try {
|
||||||
await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: { content, isAdult, isCommentAvailable, isFixed: post.isFixed } });
|
await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: updateRequest });
|
||||||
onMutated();
|
onMutated();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setErrorMessage(getMutationErrorMessage(error));
|
setErrorMessage(getMutationErrorMessage(error));
|
||||||
@@ -191,7 +203,7 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
|
|||||||
{post.firstComment === null ? null : <p className="rounded-lg border border-border bg-card p-3 text-sm">첫 댓글: {post.firstComment.comment}</p>}
|
{post.firstComment === null ? null : <p className="rounded-lg border border-border bg-card p-3 text-sm">첫 댓글: {post.firstComment.comment}</p>}
|
||||||
{post.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={canMutateComments} target={{ kind: "community", characterId, postId: String(post.postId), creatorId: post.creatorId }} /> : null}
|
{post.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={canMutateComments} target={{ kind: "community", characterId, postId: String(post.postId), creatorId: post.creatorId }} /> : null}
|
||||||
{canMutate ? <div className="flex flex-wrap gap-2">
|
{canMutate ? <div className="flex flex-wrap gap-2">
|
||||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void savePost()} type="button">수정 저장</button>
|
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaveDisabled} onClick={() => void savePost()} type="button">수정 저장</button>
|
||||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void toggleFixed()} type="button">{post.isFixed ? "고정 해제" : "고정하기"}</button>
|
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void toggleFixed()} type="button">{post.isFixed ? "고정 해제" : "고정하기"}</button>
|
||||||
<button className="rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => setIsDeleteDialogOpen(true)} type="button">비활성화</button>
|
<button className="rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => setIsDeleteDialogOpen(true)} type="button">비활성화</button>
|
||||||
</div> : null}
|
</div> : null}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ test("Community Sheet sends actual multipart update requests without audio or pr
|
|||||||
const updateBody = requireFormData(requests[0]?.body);
|
const updateBody = requireFormData(requests[0]?.body);
|
||||||
expect(updateBody.has("audioFile")).toBe(false);
|
expect(updateBody.has("audioFile")).toBe(false);
|
||||||
expect(updateBody.has("price")).toBe(false);
|
expect(updateBody.has("price")).toBe(false);
|
||||||
expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false, isFixed: false });
|
expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false });
|
||||||
|
|
||||||
// When
|
// When
|
||||||
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
|
fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
|
||||||
@@ -129,6 +129,41 @@ test("Community Sheet sends actual multipart update requests without audio or pr
|
|||||||
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false, isFixed: false });
|
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false, isFixed: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Community Sheet disables save when no field or image changed", () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedApiRequest[] = [];
|
||||||
|
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const saveButton = screen.getByRole("button", { name: "수정 저장" });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
expect(requests).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Community Sheet preserves whitespace-only content changes", async () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedApiRequest[] = [];
|
||||||
|
const changedContent = ` ${basePost.content} `;
|
||||||
|
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.change(screen.getByLabelText("내용"), { target: { value: changedContent } });
|
||||||
|
const saveButton = screen.getByRole("button", { name: "수정 저장" });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(saveButton).not.toBeDisabled();
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await waitFor(() => expect(requests).toHaveLength(1));
|
||||||
|
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({ content: changedContent });
|
||||||
|
});
|
||||||
|
|
||||||
test("Community Sheet keeps save impossible while image preparation is pending", async () => {
|
test("Community Sheet keeps save impossible while image preparation is pending", async () => {
|
||||||
// Given
|
// Given
|
||||||
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
|
let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
|
||||||
@@ -175,6 +210,7 @@ test("Community Sheet ignores stale image preparation results", async () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(requests).toHaveLength(1));
|
await waitFor(() => expect(requests).toHaveLength(1));
|
||||||
expect(requireFormData(requests[0]?.body).get("postImage")).toBe(freshGif);
|
expect(requireFormData(requests[0]?.body).get("postImage")).toBe(freshGif);
|
||||||
|
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Community Sheet resets saving state and shows an alert when mutation fails", async () => {
|
test("Community Sheet resets saving state and shows an alert when mutation fails", async () => {
|
||||||
@@ -191,6 +227,7 @@ test("Community Sheet resets saving state and shows an alert when mutation fails
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
|
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
|
||||||
|
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "저장 실패 테스트" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
|
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패");
|
expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
|
|||||||
|
|
||||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||||
|
|
||||||
export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) {
|
export function FanTalkReplyForm({ content, errorMessage, isSaving, isSubmitDisabled = false, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly isSubmitDisabled?: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) {
|
||||||
const errorId = "fan-talk-reply-error";
|
const errorId = "fan-talk-reply-error";
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null);
|
const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null);
|
||||||
@@ -39,7 +39,7 @@ export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, on
|
|||||||
}} value={content} />
|
}} value={content} />
|
||||||
</label>
|
</label>
|
||||||
{visibleErrorMessage === null ? null : <p aria-label="답변 내용 오류" className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{visibleErrorMessage}</p>}
|
{visibleErrorMessage === null ? null : <p aria-label="답변 내용 오류" className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{visibleErrorMessage}</p>}
|
||||||
<button className="w-fit rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving} type="submit">
|
<button className="w-fit rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isSubmitDisabled} type="submit">
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
|
|||||||
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
|
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
|
||||||
const visibleReply = savedReply ?? existingReply;
|
const visibleReply = savedReply ?? existingReply;
|
||||||
const isEditing = !startedWithoutReply && existingReply !== null;
|
const isEditing = !startedWithoutReply && existingReply !== null;
|
||||||
|
const isReplyChanged = existingReply === null || content !== existingReply.content;
|
||||||
|
|
||||||
async function submitReply() {
|
async function submitReply() {
|
||||||
if (isSavingRef.current) {
|
if (isSavingRef.current || (isEditing && !isReplyChanged)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
|
|||||||
<p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p>
|
<p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p>
|
||||||
</section>
|
</section>
|
||||||
{successMessage === null ? null : <p aria-label="답변 저장 성공" className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{successMessage}</p>}
|
{successMessage === null ? null : <p aria-label="답변 저장 성공" className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{successMessage}</p>}
|
||||||
{visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />}
|
{visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} isSubmitDisabled={isEditing && !isReplyChanged} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />}
|
||||||
{visibleReply === null || errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
|
{visibleReply === null || errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
|
||||||
<button className="w-fit rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsDeleteDialogOpen(true)} type="button">FanTalk 원글 삭제</button>
|
<button className="w-fit rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsDeleteDialogOpen(true)} type="button">FanTalk 원글 삭제</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -216,6 +216,47 @@ test("FanTalk reply form creates unanswered items with POST and edits existing r
|
|||||||
expect(screen.getAllByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })[0]).toBeInTheDocument();
|
expect(screen.getAllByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("FanTalk reply edit only submits when content changed", async () => {
|
||||||
|
// Given
|
||||||
|
saveAdminSession();
|
||||||
|
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
|
||||||
|
useAiCharactersResponse();
|
||||||
|
useAiCharacterDetailResponse("101");
|
||||||
|
const requests: Request[] = [];
|
||||||
|
const state = { items: [pendingFanTalk, answeredFanTalk] };
|
||||||
|
await renderFanTalkPage(state, requests);
|
||||||
|
clickFirstButton("두 번째로 온 응원입니다. 답변 보기");
|
||||||
|
const dialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
|
||||||
|
const contentInput = within(dialog).getByLabelText("답변 내용");
|
||||||
|
const submitButton = within(dialog).getByRole("button", { name: "답변 수정" });
|
||||||
|
|
||||||
|
// When / Then
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.change(contentInput, { target: { value: "수정한 답변입니다." } });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(submitButton).not.toBeDisabled();
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.change(contentInput, { target: { value: "이미 답변했습니다." } });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.change(contentInput, { target: { value: "수정한 답변입니다." } });
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
test("FanTalk reply fast double submit sends one POST, reflects returned reply, and refreshes current page", async () => {
|
test("FanTalk reply fast double submit sends one POST, reflects returned reply, and refreshes current page", async () => {
|
||||||
// Given
|
// Given
|
||||||
saveAdminSession();
|
saveAdminSession();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createSeries, deactivateSeries, updateSeries } from "@/features/series/
|
|||||||
import { GenreCombobox } from "@/features/series/components/GenreCombobox";
|
import { GenreCombobox } from "@/features/series/components/GenreCombobox";
|
||||||
import { PublishedDaysField } from "@/features/series/components/PublishedDaysField";
|
import { PublishedDaysField } from "@/features/series/components/PublishedDaysField";
|
||||||
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
|
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
|
||||||
import type { SeriesPublishedDay, SeriesState } from "@/features/series/schemas/series-schema";
|
import type { SeriesPublishedDay, SeriesState, SeriesUpdateRequest } from "@/features/series/schemas/series-schema";
|
||||||
import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy";
|
import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy";
|
||||||
import { ApiError } from "@/shared/api/api-error";
|
import { ApiError } from "@/shared/api/api-error";
|
||||||
import type { ApiClient } from "@/shared/api/client";
|
import type { ApiClient } from "@/shared/api/client";
|
||||||
@@ -69,10 +69,6 @@ function dayError(days: readonly SeriesPublishedDay[]): string | undefined {
|
|||||||
return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined;
|
return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function editedState(state: SeriesState, series: SeriesListItem | undefined): SeriesState | undefined {
|
|
||||||
return state === (series?.state ?? "PROCEEDING") ? undefined : state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasErrors(errors: FieldErrors): boolean {
|
function hasErrors(errors: FieldErrors): boolean {
|
||||||
return Object.values(errors).some((error) => error !== undefined);
|
return Object.values(errors).some((error) => error !== undefined);
|
||||||
}
|
}
|
||||||
@@ -100,8 +96,40 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
|||||||
const isDeactivatingRef = useRef(false);
|
const isDeactivatingRef = useRef(false);
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
const seriesId = series === undefined ? null : String(series.seriesId);
|
const seriesId = series === undefined ? null : String(series.seriesId);
|
||||||
const dirty = image !== null || title !== (series?.title ?? "") || introduction !== (series?.introduction ?? "") || keyword !== "" || genreId !== (series?.genreId ?? null) || isAdult !== (series?.isAdult ?? false) || publishedDays.join(",") !== (series?.publishedDaysOfWeek ?? []).join(",") || state !== (series?.state ?? "PROCEEDING") || studio !== (series?.studio ?? "") || writer !== (series?.writer ?? "");
|
|
||||||
const isImageSubmitBlocked = isImagePreparing || cropSource !== null;
|
const isImageSubmitBlocked = isImagePreparing || cropSource !== null;
|
||||||
|
const editRequest: SeriesUpdateRequest = {};
|
||||||
|
if (mode === "edit" && series !== undefined) {
|
||||||
|
const nextTitle = title.trim();
|
||||||
|
const nextIntroduction = introduction.trim();
|
||||||
|
const nextWriter = textOrNull(writer);
|
||||||
|
const nextStudio = textOrNull(studio);
|
||||||
|
if (nextTitle !== series.title.trim()) {
|
||||||
|
editRequest.title = nextTitle;
|
||||||
|
}
|
||||||
|
if (nextIntroduction !== series.introduction.trim()) {
|
||||||
|
editRequest.introduction = nextIntroduction;
|
||||||
|
}
|
||||||
|
if (publishedDays.join(",") !== series.publishedDaysOfWeek.join(",")) {
|
||||||
|
editRequest.publishedDaysOfWeek = [...publishedDays];
|
||||||
|
}
|
||||||
|
if (genreId !== series.genreId) {
|
||||||
|
editRequest.genreId = genreId;
|
||||||
|
}
|
||||||
|
if (isAdult !== series.isAdult) {
|
||||||
|
editRequest.isAdult = isAdult;
|
||||||
|
}
|
||||||
|
if (state !== series.state) {
|
||||||
|
editRequest.state = state;
|
||||||
|
}
|
||||||
|
if (nextWriter !== textOrNull(series.writer ?? "")) {
|
||||||
|
editRequest.writer = nextWriter;
|
||||||
|
}
|
||||||
|
if (nextStudio !== textOrNull(series.studio ?? "")) {
|
||||||
|
editRequest.studio = nextStudio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hasEditChanges = image !== null || Object.keys(editRequest).length > 0;
|
||||||
|
const dirty = mode === "create" ? image !== null || title !== "" || introduction !== "" || keyword !== "" || genreId !== null || isAdult || publishedDays.length > 0 || studio !== "" || writer !== "" : hasEditChanges;
|
||||||
|
|
||||||
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
useEffect(() => () => cropSource?.release?.(), [cropSource]);
|
||||||
|
|
||||||
@@ -157,7 +185,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
|||||||
|
|
||||||
async function submit(event: { readonly preventDefault: () => void }): Promise<void> {
|
async function submit(event: { readonly preventDefault: () => void }): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isSavingRef.current) {
|
if (isSavingRef.current || (mode === "edit" && !hasEditChanges)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextErrors = validate();
|
const nextErrors = validate();
|
||||||
@@ -175,7 +203,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === "edit" && seriesId !== null) {
|
if (mode === "edit" && seriesId !== null) {
|
||||||
await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: { title: title.trim(), introduction: introduction.trim(), publishedDaysOfWeek: [...publishedDays], genreId, isAdult, state: editedState(state, series), writer: textOrNull(writer), studio: textOrNull(studio) } });
|
await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: editRequest });
|
||||||
navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." });
|
navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." });
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -234,7 +262,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button">비활성화</button> : null}
|
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button">비활성화</button> : null}
|
||||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} type="button">취소</button>
|
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} type="button">취소</button>
|
||||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isImageSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button>
|
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isImageSubmitBlocked || (mode === "edit" && !hasEditChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_POLICY} renderCrop={renderCrop} />}
|
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_POLICY} renderCrop={renderCrop} />}
|
||||||
|
|||||||
@@ -88,9 +88,8 @@ test("SeriesForm keeps existing edit image when replacement crop is canceled", a
|
|||||||
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
|
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
|
||||||
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
||||||
|
|
||||||
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/series/5001"));
|
expect(window.location.pathname).toBe("/ai-characters/101/series/5001/edit");
|
||||||
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||||
expect(updateBody.get("image")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(["create", "edit"] as const)("SeriesForm shows an image preparation error when %s preview creation rejects", async (mode) => {
|
test.each(["create", "edit"] as const)("SeriesForm shows an image preparation error when %s preview creation rejects", async (mode) => {
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ test("SeriesForm edits enum fields without keyword or image requirement and deac
|
|||||||
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
||||||
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
|
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
|
||||||
expect(updateBody.get("image")).toBeNull();
|
expect(updateBody.get("image")).toBeNull();
|
||||||
await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "달빛 상담 시리즈", introduction: "밤마다 이어지는 상담 에피소드", publishedDaysOfWeek: ["RANDOM"], genreId: 77, isAdult: false, state: "COMPLETE", writer: "스튜디오 루나", studio: "소다랩" }));
|
await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ publishedDaysOfWeek: ["RANDOM"], state: "COMPLETE" }));
|
||||||
expect(window.location.pathname).toBe("/ai-characters/101/series/5001");
|
expect(window.location.pathname).toBe("/ai-characters/101/series/5001");
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@@ -249,15 +249,59 @@ test("SeriesForm omits unchanged state from edit payload", async () => {
|
|||||||
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
||||||
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({
|
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({
|
||||||
title: "달빛 상담 시리즈 수정",
|
title: "달빛 상담 시리즈 수정",
|
||||||
introduction: "밤마다 이어지는 상담 에피소드",
|
|
||||||
publishedDaysOfWeek: ["SUN", "WED"],
|
|
||||||
genreId: 77,
|
|
||||||
isAdult: false,
|
|
||||||
writer: "스튜디오 루나",
|
|
||||||
studio: "소다랩",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SeriesForm disables edit save when unchanged", () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
|
||||||
|
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const saveButton = screen.getByRole("button", { name: "저장" });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(saveButton).toBeDisabled();
|
||||||
|
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
||||||
|
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SeriesForm sends null when optional writer is cleared", async () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
|
||||||
|
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.change(screen.getByLabelText("작가"), { target: { value: "" } });
|
||||||
|
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
||||||
|
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({ writer: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SeriesForm sends empty request when only image changes", async () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
|
||||||
|
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("changed.png")] } });
|
||||||
|
await screen.findByRole("dialog", { name: "이미지 crop" });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "적용" }));
|
||||||
|
await screen.findByText("cropped-series.png");
|
||||||
|
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
|
||||||
|
const requestBody = requests.find((request) => request.method === "PUT")?.body as FormData;
|
||||||
|
expect(requestBody.get("image")).toBeInstanceOf(File);
|
||||||
|
await expect(readRequestPart(requestBody)).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
test("SeriesForm rejects mismatched image extension and MIME before crop", async () => {
|
test("SeriesForm rejects mismatched image extension and MIME before crop", async () => {
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
let cropSourceCalls = 0;
|
let cropSourceCalls = 0;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { build } from "vite";
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
describe("production mock graph", () => {
|
describe("production mock graph", () => {
|
||||||
test("excludes the browser mock module from the production bundle", async () => {
|
test("splits production chunks and excludes the browser mock module from the production bundle", async () => {
|
||||||
// Given
|
// Given
|
||||||
const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-"));
|
const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-"));
|
||||||
const previousNodeEnv = process.env.NODE_ENV;
|
const previousNodeEnv = process.env.NODE_ENV;
|
||||||
@@ -21,12 +21,16 @@ describe("production mock graph", () => {
|
|||||||
mode: "production",
|
mode: "production",
|
||||||
});
|
});
|
||||||
const outputFiles = collectFiles(outDir);
|
const outputFiles = collectFiles(outDir);
|
||||||
const output = outputFiles
|
const jsFiles = outputFiles.filter((filePath) => filePath.endsWith(".js"));
|
||||||
.filter((filePath) => filePath.endsWith(".js"))
|
const output = jsFiles
|
||||||
.map((filePath) => readFileSync(filePath, "utf8"))
|
.map((filePath) => readFileSync(filePath, "utf8"))
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
|
expect(jsFiles.length).toBeGreaterThanOrEqual(2);
|
||||||
|
for (const jsFile of jsFiles) {
|
||||||
|
expect(statSync(jsFile).size).toBeLessThanOrEqual(500_000);
|
||||||
|
}
|
||||||
expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false);
|
expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false);
|
||||||
expect(output).not.toContain("mockServiceWorker.js");
|
expect(output).not.toContain("mockServiceWorker.js");
|
||||||
expect(output).not.toContain("startMockWorker");
|
expect(output).not.toContain("startMockWorker");
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ test("ResourcePagination connects each page size label to a unique select", () =
|
|||||||
expect(labels.map((label) => label.control)).toEqual(selects);
|
expect(labels.map((label) => label.control)).toEqual(selects);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ResourcePagination separates page size from an equal-width mobile movement row", () => {
|
test("ResourcePagination separates page size from stacked mobile movement controls", () => {
|
||||||
render(<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
|
render(<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
|
||||||
|
|
||||||
const sizeControls = screen.getByRole("group", { name: "페이지 크기 설정" });
|
const sizeControls = screen.getByRole("group", { name: "페이지 크기 설정" });
|
||||||
@@ -67,7 +67,7 @@ test("ResourcePagination separates page size from an equal-width mobile movement
|
|||||||
|
|
||||||
expect(within(sizeControls).getByLabelText("페이지 크기")).toBeInTheDocument();
|
expect(within(sizeControls).getByLabelText("페이지 크기")).toBeInTheDocument();
|
||||||
expect(sizeControls).not.toContainElement(previous);
|
expect(sizeControls).not.toContainElement(previous);
|
||||||
expect(movementControls).toHaveClass("grid-cols-2");
|
expect(movementControls).toHaveClass("grid-cols-1");
|
||||||
expect(previous).toHaveClass("min-h-11", "w-full");
|
expect(previous).toHaveClass("min-h-11", "w-full", "whitespace-nowrap");
|
||||||
expect(next).toHaveClass("min-h-11", "w-full");
|
expect(next).toHaveClass("min-h-11", "w-full", "whitespace-nowrap");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm font-semibold text-muted-foreground">총 {data.totalCount.toLocaleString("ko-KR")}개 · {data.page + 1}페이지</p>
|
<p className="break-keep text-sm font-semibold text-muted-foreground">총 {data.totalCount.toLocaleString("ko-KR")}개 · {data.page + 1}페이지</p>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<div aria-label="페이지 크기 설정" className="flex items-center gap-2" role="group">
|
<div aria-label="페이지 크기 설정" className="flex items-center gap-2" role="group">
|
||||||
<label className="text-sm font-semibold" htmlFor={pageSizeId}>
|
<label className="break-keep text-sm font-semibold" htmlFor={pageSizeId}>
|
||||||
페이지 크기
|
페이지 크기
|
||||||
</label>
|
</label>
|
||||||
<select className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
|
<select className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
|
||||||
@@ -25,11 +25,11 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div aria-label="페이지 이동" className="grid grid-cols-2 gap-2 sm:flex" role="group">
|
<div aria-label="페이지 이동" className="grid grid-cols-1 gap-2 sm:flex" role="group">
|
||||||
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
|
<button className="min-h-11 w-full whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
|
||||||
이전 페이지
|
이전 페이지
|
||||||
</button>
|
</button>
|
||||||
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
|
<button className="min-h-11 w-full whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
|
||||||
다음 페이지
|
다음 페이지
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import AxeBuilder from "@axe-core/playwright";
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import type { Locator, Page } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// allow: SIZE_OK - Character workspace browser scenarios share one authenticated E2E surface.
|
||||||
const interactiveActionRoles = ["button", "link", "menuitem"] as const;
|
const interactiveActionRoles = ["button", "link", "menuitem"] as const;
|
||||||
|
|
||||||
async function loginThroughMockMode(page: Page): Promise<void> {
|
async function loginThroughMockMode(page: Page): Promise<void> {
|
||||||
@@ -42,6 +43,27 @@ async function zoomTo200Percent(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectNoKoreanSyllableColumns(root: Locator): Promise<void> {
|
||||||
|
const syllableColumns = await root.evaluate((element) => Array.from(element.querySelectorAll<HTMLElement>("*"))
|
||||||
|
.filter((candidate) => candidate.childElementCount === 0 && /[가-힣]/.test(candidate.textContent ?? ""))
|
||||||
|
.filter((candidate) => {
|
||||||
|
const style = getComputedStyle(candidate);
|
||||||
|
return style.display !== "none" && style.visibility !== "hidden" && candidate.clientWidth > 0 && candidate.clientHeight > 0;
|
||||||
|
})
|
||||||
|
.flatMap((candidate) => {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(candidate);
|
||||||
|
const lineWidths = Array.from(range.getClientRects(), (rect) => rect.width).filter((width) => width > 0);
|
||||||
|
const fontSize = Number.parseFloat(getComputedStyle(candidate).fontSize);
|
||||||
|
const koreanSyllableCount = candidate.textContent?.match(/[가-힣]/g)?.length ?? 0;
|
||||||
|
const isSyllableColumn = koreanSyllableCount >= 3 && lineWidths.length >= 3 && Math.max(...lineWidths) <= fontSize * 2.25;
|
||||||
|
|
||||||
|
return isSyllableColumn ? [{ lineWidths, text: candidate.textContent?.trim() }] : [];
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(syllableColumns).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
|
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
|
||||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||||
if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) {
|
if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) {
|
||||||
@@ -193,6 +215,19 @@ test("mobile cards preserve the selection name and table-visible core metadata",
|
|||||||
await expect(lunaCard).toContainText("상담, 힐링");
|
await expect(lunaCard).toContainText("상담, 힐링");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mobile zoom keeps Korean list and pagination text out of syllable columns", async ({ page }) => {
|
||||||
|
// Given
|
||||||
|
await page.setViewportSize({ width: 320, height: 640 });
|
||||||
|
await loginThroughMockMode(page);
|
||||||
|
|
||||||
|
// When
|
||||||
|
await zoomTo200Percent(page);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
await expectNoKoreanSyllableColumns(page.getByRole("region", { name: "AI 캐릭터 목록" }));
|
||||||
|
await expectNoKoreanSyllableColumns(page.getByRole("navigation", { name: "페이지" }));
|
||||||
|
});
|
||||||
|
|
||||||
test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({ browserName, isMobile, page }) => {
|
test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({ browserName, isMobile, page }) => {
|
||||||
test.skip(isMobile, "Mobile view intentionally hides Character mutation actions.");
|
test.skip(isMobile, "Mobile view intentionally hides Character mutation actions.");
|
||||||
test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path.");
|
test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path.");
|
||||||
|
|||||||
Reference in New Issue
Block a user