Compare commits

..

4 Commits

28 changed files with 2544 additions and 168 deletions

View 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}`
```
최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.

View 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` |

View File

@@ -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건.
- 남은 항목: 없음.

View 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`

View 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 링크
```
최종 보고는 성공을 추정하지 않고 실제 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.

View 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·검증 기록을 삭제하거나 덮어쓰지 않는다.

View File

@@ -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.
- 남은 항목: 없음

View File

@@ -74,7 +74,7 @@ test("navigates to /ai-characters after a successful login", async () => {
fireEvent.click(screen.getByRole("button", { name: "로그인" }));
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 () => {

View File

@@ -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 { 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 { 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 { ApiMode } from "@/shared/config/env";
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 sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
@@ -196,6 +198,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
{location.successNotification}
</p>
)}
<Suspense fallback={<PageState state="loading" title="화면을 불러오는 중" />} key={location.path}>
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : 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}
@@ -212,6 +215,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
{seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
</Suspense>
</main>
</div>
</div>

View File

@@ -13,7 +13,7 @@ import { ReleaseScheduleField } from "@/features/audio-contents/components/Relea
import type { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { AudioContentDetail } from "@/features/audio-contents/model/types";
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 type { ApiClient } from "@/shared/api/client";
import { ApiError } from "@/shared/api/api-error";
@@ -53,6 +53,10 @@ const errorIds = {
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 }) {
const [audioFile, setAudioFile] = useState<File | null>(null);
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
@@ -76,7 +80,11 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
const isDeactivatingRef = useRef(false);
const isSubmittingRef = useRef(false);
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 isPaid = (parsePrice(price) ?? 0) > 0;
@@ -146,6 +154,9 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
if (isSubmittingRef.current) {
return;
}
if (mode === "edit" && !editHasChanges) {
return;
}
const nextErrors = validateForm();
setErrors(nextErrors);
@@ -172,7 +183,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
return;
}
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" });
navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." });
}
@@ -249,7 +260,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
<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}
<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>
</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} />}

View File

@@ -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 {
return {
title: params.title,
detail: params.detail,
tags: params.tags,
price: params.price,
isAdult: params.audio.isAdult,
isPointAvailable: params.audio.isAvailableUsePoint,
isCommentAvailable: params.audio.isCommentAvailable,
const request: AudioContentUpdateRequest = {
...(params.title === params.audio.title.trim() ? {} : { title: params.title }),
...(params.detail === params.audio.detail.trim() ? {} : { detail: params.detail }),
...(params.tags === params.audio.tag.trim() ? {} : { tags: params.tags }),
...(params.price === params.audio.price ? {} : { price: params.price }),
};
return request;
}

View File

@@ -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 });
});
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
const requests: CapturedRequest[] = [];
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({
title: "수정 오디오",
detail: "수정 설명",
tags: "상담,힐링",
price: 0,
isAdult: false,
isPointAvailable: true,
isCommentAvailable: true,
});
// 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" })] } });
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
const updateRequest = requests.find((request) => request.method === "PUT");
expect(requireFormData(updateRequest?.body).has("coverImage")).toBe(false);
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
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());
fireEvent.click(screen.getByRole("button", { name: "저장" }));
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)) {
throw new TypeError("Expected cover image file");
}
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 () => {
@@ -265,5 +292,5 @@ test("AudioContentFormPage shows an edit cover preparation error when preview cr
// Then
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled();
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
});

View File

@@ -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" />
)}
<span className="min-w-0 flex-1">
<span className="block font-semibold">{character.name}</span>
<span className="mt-1 line-clamp-2 block 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-1 block text-xs text-muted-foreground">{character.tags.join(", ")}</span>
<span className="block break-keep break-words font-semibold">{character.name}</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 break-keep break-words text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
<span className="mt-1 block break-keep break-words text-xs text-muted-foreground">{character.tags.join(", ")}</span>
</span>
</span>
</a>

View File

@@ -8,6 +8,9 @@ type BackgroundRequest = NonNullable<CreateCharacterParams["request"]["backgroun
type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number];
type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>;
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 {
return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" };
@@ -123,7 +126,7 @@ export function toCreateCharacterOptionalRequest(value: CharacterOptionalFieldsV
return request;
}
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateCharacterParams["request"] {
function toSerializedUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateOptionalRequest {
const tags = splitList(value.tags);
const hobbies = splitList(value.hobbies);
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,
};
}
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;
}

View File

@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths";
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 { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization";
import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField";
@@ -64,6 +65,10 @@ function validateImage(image: File | null): string | undefined {
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> }) {
const formRef = useRef<HTMLFormElement>(null);
const [description, setDescription] = useState(character.description);
@@ -78,7 +83,19 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
const [isSubmitting, setIsSubmitting] = useState(false);
const imageSelectionId = useRef(0);
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;
useEffect(() => () => cropSource?.release?.(), [cropSource]);
@@ -143,6 +160,9 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
if (isSubmitting) {
return;
}
if (!hasChanges) {
return;
}
const nextErrors = validate();
setErrors(nextErrors);
@@ -155,13 +175,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
try {
await updateCharacter(apiClient, String(character.id), {
image: image ?? undefined,
request: {
...toUpdateCharacterOptionalRequest(optionalFields),
description: description.trim(),
name: name.trim(),
originalWorkId: originalWork?.id,
systemPrompt: systemPrompt.trim(),
},
request,
});
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
} 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>
<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 ? "저장 중" : "저장"}
</button>
</div>

View File

@@ -67,23 +67,12 @@ const inactiveCharacter: CharacterDetail = {
isActive: false,
};
const activeCharacterOptionalPayload = {
age: null,
gender: null,
mbti: null,
speechPattern: null,
speechStyle: null,
appearance: null,
characterType: "Character",
tags: null,
hobbies: null,
values: null,
goals: null,
relationships: null,
personalities: null,
backgrounds: null,
memories: null,
} as const;
const characterWithWhitespaceDirectFields: CharacterDetail = {
...activeCharacter,
name: " 루나 ",
description: " 차분한 상담형 캐릭터 ",
systemPrompt: " 친절하게 답한다. ",
};
function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient {
return {
@@ -185,7 +174,7 @@ function renderOriginalCrop(request: CropRenderRequest): Promise<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
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit");
@@ -193,8 +182,6 @@ test("CharacterEditPage submits changed fields without region or active state",
// When
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: "저장" }));
// 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: "PUT" },
]);
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
...activeCharacterOptionalPayload,
name: "루나 수정",
systemPrompt: "짧고 안전하게 답한다.",
description: "업데이트된 상담형 캐릭터",
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ name: "루나 수정" });
});
test("CharacterEditPage disables save when no field or image changed", async () => {
// 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 () => {
@@ -282,7 +301,7 @@ test("CharacterEditPage keeps entered values and retries after an ApiError rejec
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
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit");
@@ -296,26 +315,8 @@ test("CharacterEditPage submits optional scalar and array fields without region"
// Then
await waitFor(() => expect(requests).toHaveLength(2));
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
age: "24",
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "부드러움",
appearance: "긴 머리",
originalTitle: "달빛 상담소",
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
await waitFor(() => expect(requests).toHaveLength(2));
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
age: null,
gender: 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"));
expect(submittedImage.name).toBe("luna-cropped.png");
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 () => {
@@ -537,11 +536,10 @@ test("CharacterEditPage keeps the existing image when replacement crop is cancel
// When
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
fireEvent.click(await screen.findByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then
await waitFor(() => expect(requests).toHaveLength(2));
expect(requireFormData(requests[1]?.body).get("image")).toBeNull();
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
expect(requests).toHaveLength(1);
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: "별빛 기록실 선택" }));
expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// 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(await readJsonPart(requireFormData(requests[2]?.body).get("request"))).toEqual({
...activeCharacterOptionalPayload,
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
});
});

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
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 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 { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client";
@@ -29,7 +29,19 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
const isSavingRef = useRef(false);
const imageSelectionId = useRef(0);
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 isSaveDisabled = isMutationDisabled || imageErrorMessage !== undefined || !hasPostChanges;
useEffect(() => () => cropSource?.release?.(), [cropSource]);
@@ -38,14 +50,14 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
}
async function savePost() {
if (isSavingRef.current || imageErrorMessage !== undefined || isImagePreparing || cropSource !== null) {
if (isSavingRef.current || isSaveDisabled) {
return;
}
isSavingRef.current = true;
setIsSaving(true);
setErrorMessage(null);
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();
} catch (error: unknown) {
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.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">
<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-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}

View File

@@ -111,7 +111,7 @@ test("Community Sheet sends actual multipart update requests without audio or pr
const updateBody = requireFormData(requests[0]?.body);
expect(updateBody.has("audioFile")).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
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 });
});
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 () => {
// Given
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));
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 () => {
@@ -191,6 +227,7 @@ test("Community Sheet resets saving state and shows an alert when mutation fails
render(<App />);
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "저장 실패 테스트" } });
fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패");

View File

@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
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 formRef = useRef<HTMLFormElement>(null);
const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null);
@@ -39,7 +39,7 @@ export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, on
}} value={content} />
</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>}
<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}
</button>
</form>

View File

@@ -36,9 +36,10 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
const visibleReply = savedReply ?? existingReply;
const isEditing = !startedWithoutReply && existingReply !== null;
const isReplyChanged = existingReply === null || content !== existingReply.content;
async function submitReply() {
if (isSavingRef.current) {
if (isSavingRef.current || (isEditing && !isReplyChanged)) {
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>
</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>}
{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>}
<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>

View File

@@ -216,6 +216,47 @@ test("FanTalk reply form creates unanswered items with POST and edits existing r
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 () => {
// Given
saveAdminSession();

View File

@@ -6,7 +6,7 @@ import { createSeries, deactivateSeries, updateSeries } from "@/features/series/
import { GenreCombobox } from "@/features/series/components/GenreCombobox";
import { PublishedDaysField } from "@/features/series/components/PublishedDaysField";
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 { ApiError } from "@/shared/api/api-error";
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;
}
function editedState(state: SeriesState, series: SeriesListItem | undefined): SeriesState | undefined {
return state === (series?.state ?? "PROCEEDING") ? undefined : state;
}
function hasErrors(errors: FieldErrors): boolean {
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 isSavingRef = useRef(false);
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 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]);
@@ -157,7 +185,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
async function submit(event: { readonly preventDefault: () => void }): Promise<void> {
event.preventDefault();
if (isSavingRef.current) {
if (isSavingRef.current || (mode === "edit" && !hasEditChanges)) {
return;
}
const nextErrors = validate();
@@ -175,7 +203,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
return;
}
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: "시리즈를 저장했습니다." });
}
} catch (error: unknown) {
@@ -234,7 +262,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
<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}
<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>
</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} />}

View File

@@ -88,9 +88,8 @@ test("SeriesForm keeps existing edit image when replacement crop is canceled", a
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/series/5001"));
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
expect(updateBody.get("image")).toBeNull();
expect(window.location.pathname).toBe("/ai-characters/101/series/5001/edit");
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test.each(["create", "edit"] as const)("SeriesForm shows an image preparation error when %s preview creation rejects", async (mode) => {

View File

@@ -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));
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
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");
// 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 expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({
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 () => {
const requests: CapturedRequest[] = [];
let cropSourceCalls = 0;

View File

@@ -5,7 +5,7 @@ import { build } from "vite";
import { describe, expect, test } from "vitest";
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
const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-"));
const previousNodeEnv = process.env.NODE_ENV;
@@ -21,12 +21,16 @@ describe("production mock graph", () => {
mode: "production",
});
const outputFiles = collectFiles(outDir);
const output = outputFiles
.filter((filePath) => filePath.endsWith(".js"))
const jsFiles = outputFiles.filter((filePath) => filePath.endsWith(".js"));
const output = jsFiles
.map((filePath) => readFileSync(filePath, "utf8"))
.join("\n");
// 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(output).not.toContain("mockServiceWorker.js");
expect(output).not.toContain("startMockWorker");

View File

@@ -57,7 +57,7 @@ test("ResourcePagination connects each page size label to a unique select", () =
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()} />);
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(sizeControls).not.toContainElement(previous);
expect(movementControls).toHaveClass("grid-cols-2");
expect(previous).toHaveClass("min-h-11", "w-full");
expect(next).toHaveClass("min-h-11", "w-full");
expect(movementControls).toHaveClass("grid-cols-1");
expect(previous).toHaveClass("min-h-11", "w-full", "whitespace-nowrap");
expect(next).toHaveClass("min-h-11", "w-full", "whitespace-nowrap");
});

View File

@@ -13,10 +13,10 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
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">
<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 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>
<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>
</div>
<div aria-label="페이지 이동" className="grid grid-cols-2 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">
<div aria-label="페이지 이동" className="grid grid-cols-1 gap-2 sm:flex" role="group">
<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 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>
</div>

View File

@@ -2,6 +2,7 @@ import AxeBuilder from "@axe-core/playwright";
import { expect, test } 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;
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> {
for (let attempt = 0; attempt < 80; attempt += 1) {
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("상담, 힐링");
});
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.skip(isMobile, "Mobile view intentionally hides Character mutation actions.");
test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path.");