Compare commits

...

6 Commits

39 changed files with 4075 additions and 181 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,219 @@
# 댓글 액션 버튼 표시 라벨 간소화 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-06 |
| 요구사항 기준 | [prd.md](./prd.md) |
| API 기준 | 변경 불필요 — 기존 Audio·Community 댓글 계약 유지 |
| 현재 Phase | Phase 1 완료 |
| 현재 활성 Goal | 없음 |
## 목표
Audio·Community 댓글 액션은 짧은 동작명만 화면에 표시하고, 스크린 리더에는 대상 댓글 문맥을 유지한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `1/1` | 없음 | 없음 |
- `CommentItem`은 화면에 답글·수정·삭제 동작명만 표시하고 명시적 `aria-label`로 댓글 문맥을 유지한다.
- focused test는 `9/9`, Comments unit은 `16/16`, Comments mock E2E는 `3/3` 통과했다.
- 1280px, 320px와 200% zoom 상당 환경에서 수평 overflow가 없고 axe critical·serious 위반이 0건이다.
## 범위
### 포함
- `CommentItem``답글 작성`, `답글 보기`, `수정`, `삭제` visible label 간소화
- 기존 `댓글 내용 + 동작` accessible name 유지
- Audio·Community 원댓글과 답글의 단위·mock E2E 회귀 검증
- 320px, 200% zoom, keyboard와 접근성 확인
### 제외
- API, model, pagination, 권한, mutation과 error handling 변경
- `수정 저장`, `취소`, `답글 등록`과 form·region label 변경
- FanTalk, Community 게시글 열기 등 `CommentItem` 밖의 버튼 변경
- 새 component, helper, dependency 또는 style 추가
## 기술적 제약
- 기술 스택: React 19.2.8, TypeScript 6.0.3, Vitest 4.1.10, Playwright 1.61.1.
- 아키텍처: 공유 `CommentItem`의 표시 책임 안에서만 변경한다.
- 접근성: visible label 전체가 accessible name에 포함되고 댓글 문맥으로 반복 버튼을 구분해야 한다.
- 데이터·보안: 댓글 값을 새로 저장·전송·log하지 않는다.
- 호환성: 기존 desktop·tablet·mobile과 최소 320px 지원 범위를 유지한다.
- 의존성: 추가하지 않는다.
- 구현: RED → GREEN → REFACTOR 순서와 실제 검증 결과를 Progress에 기록한다.
## Phase 1. 표시 라벨과 accessible name 분리
**Phase 결과:** 댓글 본문은 카드에서 한 번만 보이고, 액션 버튼에는 동작명만 보이면서 보조기술은 기존 문맥형 이름을 읽는다.
**선행조건:** `CLB-001~007`, `CLB-DEC-001~002` 확정.
**Phase 완료 조건:** `P1-T1``P1-GATE` 완료, PRD 성공 기준과 Progress 갱신.
### 구현 항목
#### Task 1.1 공유 댓글 액션 라벨 분리
**Goal 실행 `P1-T1`:** 공유 `CommentItem`의 visible label을 동작명으로 줄이고 기존 contextual accessible name을 보존한다.
- **시작 조건:** `prd.md`가 구현 기준 확정 상태이고 활성 goal이 없음.
- **완료 증거:** RED·GREEN·REFACTOR 체크박스, focused `9/9`, Comments E2E `3/3`, Progress 기록.
- **범위 밖:** 다른 component의 버튼 라벨, action 배치·style과 댓글 동작 변경.
**Files:**
- Create: 없음
- Modify: `src/features/comments/components/CommentItem.tsx`
- Test: `src/features/comments/tests/comment-thread.test.tsx`
- Test: `tests/e2e/comments.spec.ts`
**Interfaces:**
- Consumes: 기존 `CommentItem` props의 `comment.comment`, `replyActionLabel`, `canEdit`, `canDelete`, `onShowReplies`.
- Produces: 기존 props와 callback contract를 바꾸지 않는 짧은 visible label과 `댓글 내용 + 동작` accessible name.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인**`comment-thread.test.tsx`에 답글·수정·삭제 버튼의 `textContent`가 동작명과 정확히 일치하고 role·name은 기존 `댓글 내용 + 동작`으로 조회되는 test 1개를 추가한다. `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`가 visible label 불일치로 `1 failed, 8 passed`인지 확인한다.
- [x] **GREEN: 최소 구현/통과 확인**`CommentItem.tsx`의 세 버튼에 기존 contextual `aria-label`을 명시하고 children에서는 댓글 내용만 제거한다. 같은 명령이 `exit 0`, `9/9`인지 확인한다.
- [x] **REFACTOR: 정리/회귀 확인** — 새 abstraction 없이 중복 변수만 최소화한 뒤 focused test와 `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`이 각각 `9/9`, `3/3`으로 통과하는지 확인한다.
- [x] `comments.spec.ts`의 320px Community 흐름에서 visible label과 contextual accessible name을 함께 확인한다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`; `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`.
- **기대 결과:** 모든 명령 `exit 0`, focused `9/9`, Comments E2E `3/3`, type·lint 오류 0건.
- **수동 확인:** 1280px Audio와 320px Community에서 버튼에는 동작명만 보이고, 접근성 트리에는 `댓글 내용 + 동작`이 보이며 수평 overflow가 없다.
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
### 완료 조건
- [x] `P1-T1`의 체크박스와 완료 증거가 모두 충족됐다.
- [x] `CLB-001~007`이 구현 또는 Gate 증거로 추적된다.
- [x] PRD 성공 기준과 현재 상태를 실제 결과로 갱신했다.
- [x] 알려진 문서와 구현의 차이가 없다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 짧은 표시 라벨, contextual accessible name과 기존 댓글 동작의 회귀 여부를 최종 판정한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
- **범위 밖:** test 삭제·완화, 관련 없는 UI·API 수정.
**실행 명령:**
```bash
npm run test:run -- src/features/comments
npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium
npm run typecheck
npm run lint
npm run build:prod
git diff --check
```
**기대 결과:** 모든 명령 `exit 0`, Comments unit test `16/16` 이상, Comments E2E `3/3`, type·lint·build 오류 0건, whitespace 오류 0건.
**수동 확인:**
- [x] 1280px Audio 상세와 320px Community Sheet에서 답글·수정·삭제 visible label을 확인한다.
- [x] 접근성 트리에서 각 버튼의 `댓글 내용 + 동작` 이름과 keyboard focus 순서를 확인한다.
- [x] 200% zoom에서 수평 overflow와 가려진 action이 없는지 확인한다.
- [x] axe critical·serious 위반이 0건인지 확인한다.
## 실행 순서와 의존성
1. `P1-T1`에서 실패 test를 먼저 만들고 최소 UI 변경과 focused·E2E 회귀 검증을 완료한다.
2. `P1-GATE`에서 전체 Comments test와 공통 품질·수동 접근성 검증을 완료한다.
`P1-GATE``P1-T1` 완료 전 시작하지 않는다.
## 변경 금지 항목
- 기존 완료 기록과 관련 PRD의 Decision Log를 삭제하거나 덮어쓰지 않는다.
- 댓글 endpoint, payload, model과 권한 조건을 변경하지 않는다.
- `CommentItem` 밖의 action label을 함께 정리하지 않는다.
- 새 dependency, helper 또는 shared abstraction을 추가하지 않는다.
- test를 삭제·skip·완화하거나 타입 오류를 우회하지 않는다.
## 의사결정 및 중단 규칙
- visible label은 동작명만, accessible name은 `댓글 내용 + 동작`으로 유지한다.
- 구현 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다.
- 기존 role·name selector가 깨지면 accessible name 유지 요구사항을 우선하고 visible text selector만 보정한다.
- 같은 차단 사유가 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
- 코드와 일부 test만 완료된 상태에서는 goal을 `complete`로 갱신하지 않는다.
## Progress
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
### 계획 작성 — 2026-08-06
- 상태: 완료
- 무엇을: 사용자 선택 A를 `CLB-001~007`, 단일 구현 Task와 Phase Gate로 정규화했다.
- 왜: 현재 UI는 댓글 본문을 각 action에 반복하고 화면 표시와 accessible name을 분리하지 않는다.
- 어떻게:
- `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` — 성공, exit 0, baseline `8/8`.
- 코드·E2E 변경과 수동 UI 검증 — 미실행, 구현 요청 범위가 아님.
- 남은 항목: `P1-T1`, `P1-GATE`.
- 다음 행동: `P1-T1` RED test 작성.
### 1차 구현 — 2026-08-06
- 상태: 완료
- 무엇을: `CommentItem`의 답글·수정·삭제 visible label을 동작명으로 줄이고 `댓글 내용 + 동작` accessible name을 명시적으로 보존했다. 320px Community E2E에 visible label 검증을 추가했다.
- 왜: 댓글 본문과 액션 영역의 시각적 중복을 제거하면서 보조기술의 대상 식별 문맥을 유지하기 위해서다.
- 어떻게:
- RED `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` — 예상 실패, `1 failed, 8 passed`; `답글 보기` 기대값에 기존 `팬 루트 댓글 답글 보기`가 표시됨을 확인했다.
- GREEN 같은 명령 — 성공, exit 0, `9/9`.
- `npm run test:run -- src/features/comments` — 성공, exit 0, `16/16`.
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — 성공, exit 0, `3/3`.
- `npm run typecheck`; `npm run lint`; `npm run build:prod`; `git diff --check` — 모두 성공, exit 0. production build에는 기존 500kB 초과 chunk 경고만 있었고 오류는 없었다.
- Playwright 실제 화면 — 1280px Audio와 320px Community에서 visible label과 contextual accessible name 일치, keyboard로 댓글 액션 4개 도달, 수평 overflow 없음.
- 200% zoom 상당 검증 — 1280px의 유효 CSS 폭 640px로 확인, 수평 overflow와 가려진 action 없음.
- axe — 1280px Audio와 320px Community에서 critical·serious 위반 0건.
- 시각 QA — 기능·디자인 시스템 무결성 PASS/HIGH, 시각·CJK 정밀도 PASS/HIGH, 차단 항목 없음.
- 명세·코드 품질 review — 각각 무조건 승인, 발견 사항 없음.
- 남은 항목: 없음.
- 다음 행동: 현재 브랜치 변경 검토 후 통합 방식 결정.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-08-06 | `CLB-PLAN-DEC-001` | 확정 | 화면 표시만 간소화하고 contextual accessible name을 유지한다. | 사용자 선택 A, `CLB-DEC-001` | `P1-T1`, `P1-GATE`, `prd.md` |
| 2026-08-06 | `CLB-PLAN-DEC-002` | 확정 | 공유 `CommentItem` 한 파일에서 최소 변경한다. | 모든 대상 UI가 같은 component를 사용한다. | `P1-T1` |
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| `CLB-ISSUE-001` | Medium | 완료 | 댓글 본문이 카드 본문과 답글·수정·삭제 버튼마다 반복된다. | `P1-T1` | visible label과 accessible name 분리 완료 |
## 최종 보고 형식
```markdown
구현 결과: Audio·Community 댓글 버튼은 동작명만 표시하고 보조기술에는 댓글 문맥을 유지한다.
- 변경: `CommentItem.tsx`의 visible label과 accessible name 분리
- 결정: `CLB-DEC-001` — 화면 표시만 간소화
- 검증:
- `npm run test:run -- src/features/comments`<실제 결과>
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`<실제 결과>
- 1280px·320px·200% zoom·keyboard·접근성 트리 — <실제 결과>
- 남은 항목: <없음 또는 구체적인 항목>
- 문서: `docs/20260806_댓글액션버튼라벨/{prd.md,plan-task.md}`
```
최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.

View File

@@ -0,0 +1,215 @@
# 댓글 액션 버튼 표시 라벨 간소화 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-08-06 |
| 최종 수정일 | 2026-08-06 |
| 대상 제품 | AI 캐릭터 관리자 웹의 Audio·Community 댓글 관리 |
| 작성자·결정권자 | Codex 작성, 사용자 결정 |
| 관련 API Contract | 불필요 — 기존 댓글 API와 payload를 변경하지 않음 |
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
| 관련 review | 없음 |
### 요구사항 상태
| 상태 | 의미 | 구현 처리 |
|---|---|---|
| 확정 | 제품·기술 결정이 완료된 구현 기준 | `plan-task.md`의 Task와 완료 증거로 추적 |
| 미결 | 추가 결정 필요 | 구현 전 결정 |
| 외부 의존 | 프론트엔드 밖의 제공 필요 | 제공 전 관련 구현 중단 |
| 권고 | 확정 전 추천안 | 수용 기준으로 사용하지 않음 |
| 제외 | 이번 범위에서 구현하지 않음 | 포함 조건을 Decision Log에 기록 |
### 문서 우선순위와 갱신 순서
1. 표시 라벨과 accessible name 결정은 이 PRD가 소유한다.
2. API 변경은 없으므로 별도 API Contract를 만들지 않는다.
3. 구현 범위·순서·완료 증거는 `plan-task.md`가 소유한다.
4. 결정이 바뀌면 Decision Log → 요구사항 → 계획 순서로 갱신한다.
## 1. Overview
댓글 본문과 각 액션 버튼에 반복되는 댓글 내용을 분리한다. 화면에는 `답글 작성`, `답글 보기`, `수정`, `삭제`만 표시하고, 스크린 리더용 accessible name에는 기존처럼 `댓글 내용 + 동작`을 유지한다.
## 2. Problem Statement
현재 `CommentItem`은 댓글 본문을 별도로 표시하면서 버튼에도 같은 내용을 반복한다.
- 긴 댓글일수록 액션 영역이 커지고 동작명을 빠르게 구분하기 어렵다.
- 한 댓글의 여러 버튼에 같은 문장이 반복되어 모바일에서 시각적 밀도가 높아진다.
- 화면 표시 문구와 accessible name이 결합돼 있어 시각적 간소화와 보조기술 문맥 제공을 독립적으로 조정할 수 없다.
문제를 해결했다는 판단은 버튼 화면 텍스트가 동작명만 포함하고, 같은 버튼의 accessible name은 대상 댓글과 동작을 함께 식별할 때로 한다.
## 3. Goals
### 3.1 제품 목표
- 사용자가 댓글 본문과 액션을 빠르게 구분한다.
- Audio·Community의 공유 댓글 UI에 같은 규칙을 적용한다.
- 기존 조회·작성·수정·삭제 동작과 권한을 유지한다.
### 3.2 UX 목표
- 버튼 화면 텍스트를 `답글 작성`, `답글 보기`, `수정`, `삭제`로 제한한다.
- 스크린 리더가 버튼만 탐색해도 대상 댓글과 동작을 구분하게 한다.
- 320px 화면에서 긴 댓글이 액션 버튼마다 반복되지 않게 한다.
## 4. Non-Goals
- 댓글 API, DTO, pagination, mutation 또는 권한 정책 변경
- 댓글 본문, 작성 form, 답글 region의 label 변경
- FanTalk 답변 버튼과 Community 게시글 열기 버튼 변경
- 액션 버튼의 배치, 색상, 크기, 확인 dialog 또는 삭제 복원 기능 변경
Non-Goal을 변경하려면 Decision Log와 `plan-task.md`를 먼저 갱신한다.
## 5. Target Users and Permissions
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|---|---|---|---|
| ADMIN | 댓글별 액션을 빠르게 구분 | 답글 열기·작성, AI 댓글 수정, 댓글 삭제 | desktop, tablet, mobile |
| 읽기 전용 ADMIN | 댓글과 기존 답글 조회 | 답글 보기 | desktop, tablet, mobile |
- 기존 `canMutate`, 작성자 판정과 비활성 workspace 정책을 그대로 사용한다.
- 라벨 변경으로 숨겨진 액션이 새로 노출되거나 기존 액션이 제거되지 않는다.
## 6. 핵심 사용자 흐름
1. 사용자가 Audio 상세 또는 Community 게시글 Sheet의 댓글 목록을 연다.
2. 댓글 본문은 카드 본문에서 한 번 읽고, 액션 영역에서는 짧은 동작명을 확인한다.
3. 사용자가 `답글 작성`·`답글 보기`·`수정`·`삭제` 중 허용된 버튼을 실행한다.
4. 스크린 리더는 각 버튼을 `댓글 내용 + 동작`으로 안내한다.
5. 기존 form, network request와 성공·실패 처리가 그대로 동작한다.
## 7. 정보 구조와 라우팅
```text
/ai-characters/:characterId/audio-contents/:contentId
/ai-characters/:characterId/community-posts
└─ 게시글 Sheet의 댓글 관리
```
- 새 route와 URL 상태를 추가하지 않는다.
- 두 진입점은 공유 `CommentThread``CommentItem`을 사용한다.
## 8. 기능 요구사항
### 8.1 표시 라벨과 accessible name
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `CLB-001` | 확정 | 답글 액션의 화면 텍스트에는 `답글 작성` 또는 `답글 보기`만 표시한다. | 원댓글의 답글 버튼 `textContent`가 전달된 `replyActionLabel`과 정확히 일치한다. | contract 불필요, `P1-T1` |
| `CLB-002` | 확정 | 수정 액션의 화면 텍스트에는 `수정`만 표시한다. | 수정 가능한 원댓글·답글 버튼의 `textContent``수정`과 정확히 일치한다. | contract 불필요, `P1-T1` |
| `CLB-003` | 확정 | 삭제 액션의 화면 텍스트에는 `삭제`만 표시한다. | 삭제 가능한 원댓글·답글 버튼의 `textContent``삭제`와 정확히 일치한다. | contract 불필요, `P1-T1` |
| `CLB-004` | 확정 | 각 액션 버튼의 accessible name에는 댓글 내용과 화면 동작명을 함께 유지한다. | role·name 조회에서 `댓글 내용 + 답글 작성/답글 보기/수정/삭제`로 각 버튼을 찾을 수 있고, visible label도 accessible name에 포함된다. | contract 불필요, `P1-T1` |
| `CLB-005` | 확정 | 공유 `CommentItem`을 사용하는 Audio·Community 원댓글과 답글에 동일한 규칙을 적용한다. | 두 target의 기존 단위·E2E 흐름이 통과하며 reply row에는 기존처럼 답글 액션이 없다. | contract 불필요, `P1-GATE` |
| `CLB-006` | 확정 | 라벨 외 동작·권한·상태는 변경하지 않는다. | 기존 GET·POST·PUT·DELETE 경로와 payload, disabled 조건, form 초기화·오류 복구 test가 통과한다. | 기존 댓글 계약 재사용, `P1-GATE` |
### 8.2 공통 파일·데이터 정책
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `CLB-007` | 확정 | 댓글 원문은 가공·축약하지 않고 현재 값으로 accessible name을 구성한다. | 별도 상태·helper·dependency 없이 `CommentItem``comment.comment`와 동작명을 사용한다. | contract 불필요, `P1-T1` |
## 9. 반응형 기능 범위
| 기능 | Desktop | Tablet | Mobile | 비고 |
|---|---:|---:|---:|---|
| 짧은 화면 표시 라벨 | 지원 | 지원 | 지원 | 공유 component 적용 |
| 문맥을 포함한 accessible name | 지원 | 지원 | 지원 | viewport와 무관 |
| 기존 댓글 액션 | 유지 | 유지 | 유지 | 권한·상태 변경 없음 |
- 최소 320px에서 수평 overflow 없이 액션을 사용할 수 있어야 한다.
- 200% zoom에서도 댓글 본문과 액션을 구분할 수 있어야 한다.
## 10. UI/UX Expectations
### 10.1 디자인과 component 원칙
- 댓글 본문은 카드 본문이, 동작명은 버튼이 각각 한 번만 시각적으로 표시한다.
- 기존 버튼 style, semantic color와 최소 높이 규칙을 유지한다.
- 새 component나 공통 helper를 만들지 않고 공유 `CommentItem`에서 처리한다.
### 10.2 화면 상태
- pending 중 disabled 처리와 loading status를 유지한다.
- 수정 mode의 `수정 저장`, `취소` 문구는 대상이 아니므로 유지한다.
- 오류·성공·empty 상태를 변경하지 않는다.
### 10.3 접근성
- visible label과 accessible name을 분리하되 visible label 전체가 accessible name에 포함돼야 한다.
- 동일 동작 버튼을 보조기술로 단독 탐색해도 댓글 내용으로 대상을 구분할 수 있어야 한다.
- button semantic, keyboard focus 순서와 focus 표시를 유지한다.
- axe critical·serious 위반 0건을 유지한다.
## 11. API 계약
### 11.1 공통 규칙
- 이번 변경은 표시 계층에만 적용한다.
- 기존 Audio·Community 댓글 endpoint, request/response, 오류와 pagination 계약을 변경하지 않는다.
### 11.2 Endpoint 추적
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|---|---|---|---|---|---|
| `CLB-001~007` | 해당 없음 | 해당 없음 | 변경 불필요 | 기존 댓글 계약 유지 | `P1-T1`, `P1-GATE` |
### 11.3 외부 제공 대기 계약
없음.
## 12. 보안과 데이터 취급
- 댓글 내용은 현재처럼 DOM과 접근성 트리에 표시되며 새 저장·전송·log를 추가하지 않는다.
- 인증, 리소스 ownership과 mutation 권한 정책을 변경하지 않는다.
- 라벨을 analytics 또는 외부 서비스로 전송하지 않는다.
## 13. 성능과 품질 요구사항
- 새 dependency, state, effect 또는 network request를 추가하지 않는다.
- React 19.2.8, TypeScript 6.0.3과 기존 지원 browser를 유지한다.
- focused unit test, Comments mock E2E, typecheck, lint와 production build를 Gate로 사용한다.
- backend와 mock 계약 변경이 없으므로 별도 preview mode를 추가하지 않는다.
## 14. 성공 기준
### 14.1 기능 수용 기준
- [x] Audio·Community 댓글의 화면 액션은 짧은 동작명만 표시한다. (`CLB-001~003`)
- [x] 기존 답글·수정·삭제 동작과 권한이 유지된다. (`CLB-005~006`)
### 14.2 UI/UX 수용 기준
- [x] 모든 대상 버튼의 visible label과 contextual accessible name이 분리된다. (`CLB-004`)
- [x] 320px와 200% zoom에서 액션 사용과 본문 구분에 문제가 없다.
- [x] keyboard 흐름과 axe critical·serious 0건을 유지한다.
### 14.3 추적성 완료 기준
- [x] `CLB-001~007``P1-T1` 또는 `P1-GATE` 완료 증거로 연결된다.
- [x] API Contract가 불필요한 표시 계층 변경임을 기록했다.
- [x] 미결·외부 의존 항목이 없다.
## 15. Open Questions
없음. 사용자는 화면 표시에서만 댓글 내용을 제거하고 accessible name에는 댓글 문맥을 유지하는 A안을 선택했다.
## 16. 요구사항 추적표
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---|---:|---|---|---|
| `CLB-001~004`, `CLB-007` | 불필요 | 1 | `P1-T1` | `comment-thread.test.tsx` | 화면 텍스트와 접근성 트리 비교 |
| `CLB-005~006` | 기존 계약 유지 | 1 | `P1-GATE` | Comments unit·E2E, typecheck, lint, build | 1280px·320px·200% zoom·keyboard |
## 17. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|---|---|---|---|---|---|
| 2026-08-06 | `CLB-DEC-001` | 확정 | 화면 버튼에서는 댓글 내용을 제거하고 accessible name에는 `댓글 내용 + 동작`을 유지한다. | 사용자 선택 A. 시각적 중복을 줄이면서 보조기술의 대상 식별을 보존한다. | `CLB-001~004`, `P1-T1` |
| 2026-08-06 | `CLB-DEC-002` | 확정 | 공유 `CommentItem` 한 곳에서 Audio·Community 원댓글과 답글의 표시를 변경한다. | 모든 대상 호출이 같은 component를 사용하며 API·상태 변경이 필요 없다. | `CLB-005~007`, `P1-T1`, `P1-GATE` |

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

@@ -0,0 +1,96 @@
# 커뮤니티 댓글 직접 답글 API Contract
## 문서 정보
| 항목 | 내용 |
|---|---|
| 상태 | 기존 계약 재사용 확정 |
| 작성일 | 2026-08-06 |
| 원본 계약 | [프로젝트 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json) |
| 관련 PRD | [prd.md](./prd.md) |
| 관련 계획 | [plan-task.md](./plan-task.md) |
## 계약 변경 여부
백엔드 API 변경은 없다. 이 문서는 이번 기능이 소비하는 기존 OpenAPI 범위와
프론트엔드 전송값만 좁게 기록한다. 충돌하면 원본 OpenAPI가 우선한다.
## 댓글 구조 불변식
- `parentId=null` 또는 생략: 원댓글
- `parentId=원댓글 ID`: 해당 원댓글의 직접 답글
- 하나의 원댓글 ID를 여러 POST의 `parentId`로 사용할 수 있으며 각 응답은 별도 직접 답글 row가 된다.
- `parentId=답글 ID`인 3단계 작성은 허용하지 않는다.
- parent는 같은 `characterId`·`postId`의 활성 원댓글이어야 한다.
## Endpoint
### 직접 답글 목록
```http
GET /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies?page=0&size=20
Authorization: Bearer {jwt-token}
Accept-Language: ko
```
- `commentId`: 답글 영역을 연 원댓글 ID
- 성공: `data={ totalCount, items }`
- 답글 0개도 `totalCount=0`, `items=[]`인 정상 성공이다.
- 여러 직접 답글은 `items`의 독립 row로 반환되며 기존 pagination을 사용한다.
### 댓글 또는 직접 답글 작성
```http
POST /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments
Authorization: Bearer {jwt-token}
Accept-Language: ko
Content-Type: application/json
```
직접 답글 request:
```json
{
"comment": "답글 내용",
"parentId": 2102,
"isSecret": false
}
```
| field | 형식 | 이번 기능의 값 |
|---|---|---|
| `comment` | string, required | trim 후 빈 문자열이 아닌 입력값 |
| `parentId` | nullable int64, optional | 답글 대상 활성 원댓글 ID |
| `isSecret` | boolean, optional | `false` |
- Community request에는 Audio 전용 `languageCode`를 보내지 않는다.
- 같은 원댓글에 추가 답글을 쓸 때도 같은 endpoint와 원댓글 `parentId`를 사용한다.
- 성공 envelope의 `data``null`이다.
- 성공 후 원댓글 목록과 열린 원댓글의 현재 답글 page를 재조회한다.
## 오류 응답
원본 OpenAPI의 공통 오류 envelope와 다음 status를 그대로 사용한다.
| Status | 처리 |
|---:|---|
| 400 | invalid target·parent 또는 binding 오류를 화면 alert로 표시 |
| 401 | 공통 session 만료 처리 |
| 403 | 공통 접근 거부 처리 |
| 404 | target 또는 root를 찾을 수 없음 표시 |
| 405, 406, 415, 500 | 서버 message를 우선 표시하고 기존 재시도 정책 적용 |
도메인별 message key와 validation 상한을 새로 추정하지 않는다.
## 프론트엔드 연결
| 역할 | 기존 구현 |
|---|---|
| target path 선택 | `commentCollectionPath()``community` branch |
| 답글 조회 | `getReplies()` |
| 답글 작성 | `createComment()`의 Community overload |
| request schema | `communityCommentCreateRequestSchema` |
| 답글 상태·pagination | `CommentThread``replies`, `loadReplies()` |
| 성공 후 재조회 | `CommentThread.runMutation()` |
API, schema, mock handler와 store는 이번 기능에서 변경하지 않는다.

View File

@@ -0,0 +1,297 @@
# 커뮤니티 댓글 직접 답글 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-06 |
| 요구사항 기준 | [prd.md](./prd.md) |
| API 기준 | [api-contract.md](./api-contract.md) |
| 현재 Phase | Phase 1 완료 |
| 현재 활성 Goal | 없음 |
## 목표
활성 커뮤니티 게시글의 답글 0개 원댓글에서도 기존 답글 form을 열어 첫 답글과
여러 직접 답글을 작성할 수 있게 한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `4/4` | 없음 | 완료 |
- Community 답글 GET·POST, form, 여러 직접 답글 조회·작성·재조회 흐름은 이미 구현돼 있다.
- 답글이 하나 이상인 Community root에는 `답글 보기`와 추가 작성 form이 제공된다.
- `replyCount === 0`인 활성 Community root에도 `답글 작성` 진입과 기존 답글 form이 제공된다.
- `P1-T1` 구현과 test는 완료됐고, `P1-R1`에서 E2E fixture 검증 결함 후보를 실제 mock 실행 경로와 대조해 오탐으로 판정했다.
- 최종 커밋 감사에서 위 문장의 기존 표현이 실제 완료 상태와 충돌해 `CCR-REV-P1-002`로 확정됐고 `P1-R2`에서 정정했다.
## 범위의 포함·제외
### 포함
- 활성 Community root의 `replyCount === 0`일 때 `답글 작성` 버튼 표시
- 기존 답글 영역, form, GET·POST와 mutation 상태 재사용
- 같은 원댓글에 첫 답글과 여러 직접 답글 작성
- Community 첫 답글과 Audio·비활성·reply row 경계 회귀 test
- 기존 Comments Chromium mock E2E와 정적 검증
### 제외
- 새 endpoint, DTO, component, state library 또는 dependency
- form 상시 노출, reply-of-reply, payload 정책 변경
- 기존 답글 수정·삭제·pagination 리팩터링
- Audio 전용 `languageCode`의 Community payload 추가
- optimistic update와 답글 전체 선조회
## 기술적 제약
- React·TypeScript strict, Vitest·React Testing Library와 기존 Playwright 구성을 사용한다.
- [api-contract.md](./api-contract.md)의 기존 GET·POST만 사용한다.
- `CommentThread`, `CommentItem`, `CommentForm`의 현재 책임 경계를 유지한다.
- `CommentItem`의 기존 `replyActionLabel`, `CommentThread.toggleReplies()`와 reply state를 재사용한다.
- 공통 조건 한 곳에서 Audio와 Community의 첫 답글 진입을 일치시키며 target별 분기를 추가하지 않는다.
- RED → GREEN → REFACTOR 순서와 최소 변경을 지킨다.
## Phase 1. 커뮤니티 직접 답글 진입 구현·검증
**Phase 결과:** 관리자가 활성 Community의 답글 0개 원댓글에서 첫 답글을
작성하고 같은 원댓글에 여러 직접 답글을 추가하며, 기존 Audio·읽기 전용·2단계
경계가 유지된다.
**선행조건:** `CCR-001~006`과 기존 Community 댓글 GET·POST 계약 확정.
**Phase 완료 조건:** `P1-T1`, `P1-R1`, `P1-R2`, `P1-GATE` 완료와 Progress 기록.
### Task 1.1 커뮤니티 첫 답글 진입
**Goal 실행 `P1-T1`:** Community의 답글 0개 원댓글에 기존 답글 영역을 여는
`답글 작성` action을 추가하고 직접 답글 작성 흐름을 검증한다.
- **시작 조건:** [prd.md](./prd.md)의 `CCR-001~006`, [api-contract.md](./api-contract.md).
- **완료 증거:** TDD 체크박스, focused·회귀·E2E·정적 검증과 Progress 기록.
- **범위 밖:** API·mock·schema 변경, 새 UI 구조, 관련 없는 Comments 리팩터링.
**Files:**
- Modify: `src/features/comments/components/CommentThread.tsx`
- Modify: `src/features/comments/tests/comment-thread.test.tsx`
- Modify: `tests/e2e/comments.spec.ts`
- Test: `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts`
**Interfaces:**
- Consumes: `CommentRecord.replyCount`, `canMutate`, `expandedRootIds`, `toggleReplies()`, `CommentForm`, Community `createComment()` overload.
- Produces: 활성 Audio·Community 원댓글에 공통 적용되는 첫 답글 action 노출 조건.
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인**`comment-thread.test.tsx`에 Community `replyCount=0` root의 `답글 작성` 노출, 클릭 후 form, `parentId` POST와 `languageCode` 미전송을 검증하고 `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`가 버튼 부재로 실패하는지 확인한다.
- [x] **GREEN: 최소 구현/통과 확인**`CommentThread.tsx`의 기존 optional action label 조건에서 Audio 전용 제한만 제거하고 같은 명령이 exit 0인지 확인한다.
- [x] **REFACTOR: 정리/회귀 확인** — 추가 helper·component 없이 조건을 읽기 쉬운 최소 표현으로 유지하고 focused test와 `npm run test:run -- src/features/comments`가 모두 exit 0인지 확인한다.
- [x] 기존 Community mock E2E에 답글 0개 root의 첫 답글 작성과 같은 root에 추가 직접 답글 작성 journey를 검증한다.
- [x] 검증 결과를 Progress에 기록한다.
**검증 기준:**
- **실행 명령:** `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`; `npm run test:run -- src/features/comments`; `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`.
- **기대 결과:** 모든 명령 exit 0, Community 첫 답글 POST 1회 이상, `parentId`는 원댓글 ID, Community body의 `languageCode` 0건, reply row의 답글 action 0건, 기존 Audio·Comments 회귀 실패 0건.
- **수동 확인:** 활성 Community Sheet에서 답글 0개 root의 `답글 작성` → form 노출 → 첫 답글 등록 → 같은 root 추가 답글 등록을 확인한다. 비활성 workspace와 reply row에는 작성 진입이 없어야 한다.
### 완료 조건
- [x] `P1-T1`의 모든 TDD·검증 체크박스가 완료됐다.
- [x] `CCR-001~006`이 구현 또는 검증 증거에 연결됐다.
- [x] API·mock·schema와 범위 밖 파일 변경이 없다.
### Task 1.R1 Community E2E fixture 검증
**Goal 실행 `P1-R1`:** `CCR-REV-P1-001`의 E2E fixture 분류 오류 후보가 실제
mock E2E 실행 경로에 영향을 주는지 검증하고 판정한다.
- **시작 조건:** `P1-T1` 완료, `CCR-REV-P1-001` 확정.
- **완료 증거:** 실제 mock 요청 소유권 확인, 후보를 구분하는 E2E assertion, Chromium·Comments 회귀·정적 검증과 Progress 기록.
- **범위 밖:** 애플리케이션 mock handler·store, API·schema, production 댓글 동작 변경.
**Files:**
- Modify: `tests/e2e/comments.spec.ts`
- Test: `tests/e2e/comments.spec.ts`
**TDD 예외 사유:** 리뷰 후보를 구분하는 assertion이 기존 mock E2E에서도 통과해
production 또는 fixture 수정이 필요하지 않은 오탐으로 판정됐다. 실패하는 구현 변경이
없으므로 RED → GREEN 대신 실제 요청 소유권과 기존 동작을 대체 검증했다.
- [x] root `2102`의 초기 reply region에 root 댓글이 없고, 첫·두 번째 답글이 region에 표시되며 중첩 action이 없는 assertion을 추가했다.
- [x] 기존 `comments-test-support.ts`를 유지한 상태에서 Chromium E2E `3/3` 통과를 두 번 확인했다.
- [x] `VITE_API_MODE=mock`의 Browser MSW Service Worker가 mock 요청을 처리하며 `page.route` fixture 후보가 실제 실행 경로를 소유하지 않음을 확인했다.
- [x] fixture 변경을 폐기하고 Comments 회귀·typecheck·lint·`git diff --check`를 통과했다.
- [x] 검증 결과와 `CCR-REV-P1-001` 오탐 판정을 Progress에 기록했다.
**검증 기준:**
- **실행 명령:** `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run test:run -- src/features/comments`; `npm run typecheck`; `npm run lint`; `git diff --check`.
- **기대 결과:** 기존 fixture를 변경하지 않고 모든 명령 exit 0, Chromium `3/3`, 첫·추가 답글이 root `2102` region에만 표시된다.
- **수동 확인:** 기존 `P1-GATE`의 Community 첫·추가 답글 browser QA 결과와 mock E2E의 동일 동작을 대조한다.
### Task 1.R2 완료 문서 현재 상태 정합성 복구
**Goal 실행 `P1-R2`:** `CCR-REV-P1-002`의 미구현 문장을 실제 완료 상태로
정정하고 기존 Progress와 결정 기록을 보존한다.
- **연결 리뷰:** [최종 커밋 감사](./reviews/phase1-final-commit-audit.md) — `CCR-REV-P1-002`
- **시작 조건:** `CCR-REV-P1-002` 확정, 완료된 `P1-T1`, `P1-R1`, `P1-GATE`.
- **완료 증거:** 현재 상태 문장 정정, 아래 체크박스·문서 검증 통과, review 수정 완료 기록과 Progress 누적.
- **범위 밖:** 애플리케이션 코드·test·API Contract, 기존 Progress·Decision Log 삭제 또는 덮어쓰기.
**Files:**
- Modify: `docs/20260806_커뮤니티댓글답글/plan-task.md`
- Modify: `docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md`
- Test: 없음 — 애플리케이션 동작을 변경하지 않는 문서 정합성 수정이다.
**Interfaces:**
- Consumes: `CCR-REV-P1-002`, `CCR-001`, 완료된 `P1-T1`·`P1-GATE` 검증 증거.
- Produces: 실제 구현과 일치하는 plan 현재 상태와 수정 완료 review 기록.
**TDD 예외 사유:** 애플리케이션 코드·동작을 바꾸지 않는 문서 정정이므로 실패
unit test를 추가하지 않는다.
**대체 검증 방법:** stale 미구현 marker 부재, 완료 상태 문장·review 상태·상호
링크 존재와 Markdown diff를 명령으로 확인한다.
- [x] `replyCount === 0`인 Community root의 현재 상태를 실제 구현 완료 내용으로 정정한다.
- [x] `CCR-REV-P1-002`의 상태와 리뷰 종료 판정을 `수정 완료`로 갱신한다.
- [x] 기존 Progress와 Decision Log를 보존하고 `P1-R2` 기록을 누적한다.
- [x] 문서 marker·link·diff 검증 결과를 Progress와 review에 기록한다.
**검증 기준:**
- **실행 명령:** `! sed -n '17,28p' docs/20260806_커뮤니티댓글답글/plan-task.md | rg -n '첫 답글 작성 진입만 없다'`; `sed -n '17,28p' docs/20260806_커뮤니티댓글답글/plan-task.md | rg -n 'replyCount === 0.*답글 작성.*제공'`; `rg -n 'CCR-REV-P1-002.*수정 완료' docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md`; `test -f docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md`; `git diff --check`.
- **기대 결과:** 모든 명령 exit 0, stale 미구현 marker 0건, 완료 상태·review 수정 완료 marker와 링크 각 1건 이상, whitespace 오류 0건.
- **수동 확인:** 없음 — 제품 동작을 바꾸지 않으며 문서의 정확한 marker와 link를 명령으로 판정한다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 커뮤니티 첫·추가 직접 답글 journey와 Comments 공통
경계를 최종 판정한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 아래 명령·수동 확인 통과와 Progress 기록.
- **범위 밖:** test 완화, timeout 상향과 관련 없는 수정.
**실행 명령:**
```bash
npm run test:run -- src/features/comments
npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium
npm run typecheck
npm run lint
git diff --check
```
**기대 결과:** 모든 명령 exit 0, `CCR-001~006` 위반 0건.
**수동 확인:** 활성·비활성 Community와 활성 Audio에서 action 노출 경계를
대조한다. Community Sheet를 1280px·320px와 200% zoom에서 열어 수평 overflow
없이 첫·추가 답글을 작성하고 keyboard-only로 form에 진입한다.
## 실행 순서와 의존성
1. `P1-T1` RED
2. `P1-T1` GREEN
3. `P1-T1` REFACTOR·회귀
4. `P1-GATE`
5. 최종 커밋 감사에서 확정된 `CCR-REV-P1-002``P1-R2`로 전환
6. `P1-R2` 문서 정정·검증과 review 수정 완료 처리
- 동시에 하나의 미완료 goal만 운용한다.
- 사용자가 goal 실행을 요청하기 전에는 goal을 생성하지 않는다.
## 변경 금지 항목
- 기존 OpenAPI, API client, request schema, mock handler·store 변경
- 새 dependency, state library, component 또는 speculative abstraction
- 답글의 답글, optimistic update와 form 상시 노출
- Audio payload와 기존 수정·삭제·pagination 동작 변경
- 실패 test 삭제·skip, timeout 상향으로 Gate 통과
- 기존 Progress와 결정 기록 삭제·덮어쓰기
## 의사결정 및 중단 규칙
- `replyCount === 0`, `canMutate === true`인 Audio·Community 원댓글에만 `답글 작성`을 표시한다.
- `replyCount > 0` 또는 펼친 원댓글은 기존 `답글 보기` label을 유지한다.
- reply row에는 `onShowReplies`를 전달하지 않으며 3단계 작성 경로를 만들지 않는다.
- API 응답이나 오류가 [api-contract.md](./api-contract.md)와 다르면 추정 수정하지 않고 외부 의존으로 기록한다.
- 범위가 바뀌면 코드보다 PRD Decision Log와 이 계획을 먼저 갱신한다.
## Progress
### 2026-08-06 요구사항·설계
- **무엇을:** 활성 Community 원댓글의 첫 답글 진입, 여러 직접 답글과 2단계 제한을 요구사항·API 재사용 계약·단일 구현 Task로 정리했다.
- **왜:** Community 답글 조회·작성 흐름은 이미 있으나 `replyCount === 0`이면 진입 action이 없어 첫 답글만 작성할 수 없다.
- **어떻게:** 선행 Audio 답글 문서, 프로젝트 OpenAPI, `CommentThread`, request schema, mock handler·store, unit·E2E를 대조했다. 기존 공통 흐름을 재사용할 수 있어 새 API·컴포넌트·mock을 계획에서 제외했다. 애플리케이션 코드와 test는 변경하지 않았다.
### 2026-08-06 `P1-T1` 커뮤니티 첫 답글 진입
- **무엇을:** 활성 Community의 `replyCount=0` 원댓글에도 기존 `답글 작성` action을 노출하고, 같은 원댓글에 첫 번째와 두 번째 직접 답글을 작성하는 단위·Chromium E2E를 추가했다. reply row의 중첩 답글 action 부재와 Community payload의 `languageCode` 미전송도 검증했다.
- **왜:** 기존 공통 GET·POST·form·재조회 흐름은 완성돼 있었지만 action label 조건이 Audio target만 허용해 Community 첫 답글 진입이 막혀 있었다.
- **어떻게:** RED에서 `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`를 실행해 `AI 루트 댓글 답글 작성` 버튼 부재로 `1 failed, 7 passed`를 확인했다. GREEN에서 `CommentThread.tsx`의 Audio 전용 조건만 제거한 뒤 focused test `8/8`을 통과했다. REFACTOR·회귀로 `npm run test:run -- src/features/comments``15/15`, `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium``3/3`, `npm run typecheck``npm run lint`는 exit 0이었다. API·schema·mock·dependency는 변경하지 않았다.
### 2026-08-06 `P1-R1` E2E fixture 후보 판정
- **무엇을:** `CCR-REV-P1-001`이 지적한 단일 `replyRootId` fixture가 mock E2E의 root `2102` 답글을 오분류하는지 검증했다.
- **왜:** 코드만 보면 `comments-test-support.ts`가 root `2101`만 replies로 처리하지만, 실제 mock E2E가 이 fixture를 사용하는지 확인하지 않으면 오탐 수정으로 범위를 확장할 수 있다.
- **어떻게:** 기존 fixture를 유지한 상태에서 `2102` 초기 reply region에 root 댓글 0건, 첫·두 번째 답글 표시, dialog 내 각 1건, 중첩 action 0건을 추가하고 Chromium E2E `3/3` 통과를 두 번 확인했다. `playwright.config.ts``VITE_API_MODE=mock``src/shared/mocks/browser.ts``setupWorker(...)`를 대조해 Browser MSW가 Service Worker에서 요청을 처리하며 `page.route`가 해당 요청을 소유하지 않음을 확인했다. fixture 변경은 폐기했고 `CCR-REV-P1-001`을 오탐으로 판정했다.
### 2026-08-06 `P1-GATE` Phase 1 최종 검증
- **무엇을:** Community 첫·추가 직접 답글, 2단계·권한 경계, Comments 회귀와 반응형·keyboard·CJK 품질을 최종 판정했다.
- **왜:** 코드와 자동 test 통과만으로는 실제 Sheet의 keyboard 진입, 320px·200% zoom, 한국어 줄바꿈과 reviewer 차단 해소를 증명할 수 없다.
- **어떻게:** `npm run test:run -- src/features/comments``15/15`, `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium``3/3`, `npm run typecheck`, `npm run lint`, `npm run build:dev`, `git diff --check`는 exit 0이었다. 실제 Chromium에서 첫·두 번째 답글, input 초기화, 중첩 action 0건, keyboard-only 진입과 1280px·320px·200% zoom의 수평 overflow 0건을 확인했다. 독립 goal·코드 품질·보안·컨텍스트·기능·visual/CJK 리뷰는 최종 PASS였고 [Phase 1 리뷰](./reviews/phase1-community-comment-replies.md)에 근거를 기록했다.
### 2026-08-06 `P1-R2` 완료 문서 현재 상태 정합성 복구
- **무엇을:** `CCR-REV-P1-002``P1-R2`로 전환한 뒤 `replyCount === 0`인 활성 Community root의 현재 상태를 실제 구현 완료 내용으로 정정하고 최종 커밋 감사 상태를 수정 완료로 갱신했다.
- **왜:** plan의 완료 상태·코드·test와 반대인 구현 전 문장 때문에 후속 작업자가 첫 답글 진입을 미구현으로 오인할 수 있었다.
- **어떻게:** stale 현재 상태 marker 부재, 완료 상태 문장 존재, review 파일과 수정 완료 marker 존재를 `rg`·`test -f`로 확인하고 trailing whitespace 검사와 `git diff --check`를 실행해 모두 exit 0을 확인했다. 애플리케이션 코드·test·API Contract는 변경하지 않았다.
### 2026-08-06 `P1-R2` 후 기능 회귀 감사
- **무엇을:** 문서 정정 뒤 Community 첫·추가 직접 답글과 Comments 공통 회귀, 정적 품질과 development build를 다시 확인했다.
- **왜:** 문서 전용 변경임을 diff로 확인하고 최종 완료 상태가 기존 기능 검증 증거와 계속 일치하는지 판정하기 위해서다.
- **어떻게:** `npm run test:run -- src/features/comments``15/15`, 샌드박스 밖에서 실행한 `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium``3/3`, `npm run typecheck`, `npm run lint`, `npm run build:dev`, `git diff --check`는 모두 exit 0이었다. build의 기존 500kB chunk warning 외 실패는 없었다.
## Decision Log
| 날짜 | 결정 | 근거 | 영향 |
|---|---|---|---|
| 2026-08-06 | Audio와 동일한 `답글 작성` 진입을 활성 Community 원댓글에도 적용한다. | 사용자 요청 | `CCR-001~003`, `P1-T1` |
| 2026-08-06 | 한 원댓글에 여러 직접 답글을 허용하고 reply-of-reply는 제외한다. | 사용자 요청 | `CCR-004~005`, `P1-T1`, `P1-GATE` |
| 2026-08-06 | 기존 공통 UI와 Community GET·POST를 재사용하고 API·mock·schema는 변경하지 않는다. | OpenAPI와 코드 확인 | `CCR-002~006`, `P1-T1` |
| 2026-08-06 | 구현은 공통 action 조건의 Audio 전용 제한 제거와 기존 test 보강으로 제한한다. | `CommentThread` 흐름 확인과 최소 변경 원칙 | `P1-T1` Files·Interfaces |
| 2026-08-06 | E2E 전용 route fixture가 특정 root만 replies로 처리하는 결함을 `P1-R1`에서 수정한다. | 최종 코드 품질·컨텍스트 리뷰에서 `2102` 답글이 roots에 저장돼 E2E가 오탐 통과함을 확인 | `CCR-REV-P1-001`, `P1-R1`, `P1-GATE` |
| 2026-08-06 | 정정: `CCR-REV-P1-001`은 mock mode에서 Browser MSW가 요청을 소유해 E2E route fixture 분기가 실행되지 않으므로 오탐이다. fixture를 변경하지 않는다. | 기존 fixture 상태에서 2102 빈 reply·첫·추가 답글 assertion과 Chromium `3/3` 통과, `VITE_API_MODE=mock`·`setupWorker(...)` 확인 | `CCR-REV-P1-001`, `P1-R1`, `P1-GATE` |
| 2026-08-06 | 최종 커밋 감사에서 확정된 stale 현재 상태 문장을 문서 전용 회귀 Task로 수정한다. | `CCR-REV-P1-002`의 plan·코드·test 불일치 | `P1-R2` |
| 2026-08-06 | `P1-R2`에서 현재 상태 문장을 실제 구현과 일치시키고 review를 수정 완료 처리한다. | 문서 marker·link·diff 검증 통과 | `CCR-REV-P1-002`, `P1-R2` |
## 발견된 문제
- 수정 완료: 답글 0개 Community root의 첫 답글 작성 진입을 `P1-T1`에서 구현하고 `P1-GATE`에서 검증했다.
- 확정: E2E 전용 fixture가 `replyRootId` 하나만 replies로 분류해 다른 root의 직접 답글을 roots에 저장한다. (`CCR-REV-P1-001`, `P1-R1`에서 수정 예정)
- 오탐: `CCR-REV-P1-001` — mock mode에서는 Browser MSW가 요청을 처리해 해당 E2E route fixture 분기가 실행되지 않으며, 기존 fixture 상태에서 root `2102`의 빈 reply·첫·추가 답글 journey가 통과한다.
- 확정: 완료된 현재 상태에 첫 답글 진입이 없다는 구현 전 문장이 남아 있다. (`CCR-REV-P1-002`, `P1-R2` 진행 중)
- 수정 완료: `CCR-REV-P1-002`의 stale 현재 상태 문장을 실제 구현 완료 내용으로 정정하고 문서 검증을 통과했다. (`P1-R2`)
- 외부 차단: 없음.
## 최종 보고 형식
- 완료 Goal ID
- 변경한 파일과 최소 구현 내용
- RED·GREEN·REFACTOR 및 Gate 명령과 실제 결과
- 실행하지 못한 수동·server 검증과 이유
- 남은 위험 또는 열린 질문

View File

@@ -0,0 +1,227 @@
# 커뮤니티 댓글 직접 답글 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-08-06 |
| 최종 수정일 | 2026-08-06 |
| 대상 기능 | 커뮤니티 게시글 댓글의 직접 답글 작성 진입 |
| 작성자·결정권자 | Codex 작성, 사용자 결정 |
| 상위 제품 기준 | [AI 캐릭터 관리자 웹 PRD](../20260725_AI캐릭터관리자웹/prd.md) |
| 선행 기능 기준 | [오디오 콘텐츠 댓글 답글 PRD](../20260805_오디오콘텐츠댓글답글/prd.md) |
| 관련 API Contract | [api-contract.md](./api-contract.md) |
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
| 관련 review | [Phase 1 커뮤니티 댓글 직접 답글 리뷰](./reviews/phase1-community-comment-replies.md) |
### 요구사항 상태
| 상태 | 의미 |
|---|---|
| 확정 | 구현과 검증 기준으로 사용한다. |
| 미결 | 제품 결정 전에는 구현하지 않는다. |
| 외부 의존 | 외부 계약이 제공될 때까지 영향 범위를 구현 완료로 표시하지 않는다. |
| 제외 | 현재 기능 범위에 포함하지 않는다. |
## 1. Overview
활성 AI 캐릭터의 커뮤니티 게시글 원댓글에 직접 답글을 작성할 수 있게 한다.
원댓글 아래에는 여러 개의 직접 답글을 추가할 수 있지만, 답글에 다시 답글을
다는 3단계 구조는 허용하지 않는다. 기존 오디오 콘텐츠 댓글과 같은 진입 UI,
답글 영역, 작성 form과 mutation 상태를 재사용한다.
## 2. Problem Statement
커뮤니티 답글 조회·작성 API와 UI는 이미 구현돼 있어 답글이 하나 이상인
원댓글에는 추가 답글을 작성할 수 있다. 그러나 `replyCount=0`인 원댓글에는
답글 영역을 여는 action이 없어 첫 답글을 작성할 수 없다.
문제를 해결했다는 판단은 답글 0개인 활성 커뮤니티 원댓글에서 `답글 작성`
눌러 첫 답글을 등록하고, 같은 원댓글에 여러 직접 답글을 계속 추가할 수 있는지로
한다.
## 3. Goals
### 3.1 제품 목표
- 활성 커뮤니티 게시글의 모든 원댓글에 첫 답글을 작성할 수 있다.
- 하나의 원댓글 아래 여러 직접 답글을 작성·조회할 수 있다.
- 원댓글과 직접 답글로 끝나는 기존 2단계 댓글 구조를 유지한다.
### 3.2 UX 목표
- 답글이 0개인 원댓글에는 `답글 작성`이라는 명확한 진입점을 표시한다.
- 버튼을 누르면 기존 답글 영역과 작성 form을 펼친다.
- 기존 답글이 있는 원댓글은 `답글 보기`로 같은 영역을 열고 추가 답글을 작성한다.
- 기존 loading, 오류, 전송 중, 실패 후 초안 보존 동작을 유지한다.
## 4. Non-Goals
- 답글의 답글을 포함한 3단계 이상의 댓글 구조
- 답글 form 상시 노출
- 새 endpoint, DTO, 상태관리, 컴포넌트 또는 UI dependency 추가
- 오디오 콘텐츠 댓글 동작이나 payload 정책 변경
- 기존 답글 수정·삭제·pagination 정책 변경
- optimistic update 또는 답글 전체 선조회
## 5. Target Users and Permissions
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|---|---|---|---|
| ADMIN | AI 캐릭터 명의로 커뮤니티 원댓글에 직접 답글 작성 | 답글 영역 열기, 작성, 재시도 | desktop, tablet, mobile |
- 인증과 ADMIN 권한은 상위 제품 기준을 따른다.
- 활성 AI 캐릭터 workspace에서만 답글 작성 control을 제공한다.
- 비활성 AI 캐릭터 workspace는 기존처럼 조회 전용이다.
- 원댓글 작성자가 팬인지 AI 캐릭터인지와 관계없이 답글을 작성할 수 있다.
## 6. 핵심 사용자 흐름
1. 관리자가 활성 AI 캐릭터의 커뮤니티 게시글 목록에 진입한다.
2. 게시글 Sheet를 열고 답글이 0개인 원댓글에서 `답글 작성`을 누른다.
3. UI가 해당 원댓글의 직접 답글 GET을 실행하고 답글 영역과 작성 form을 표시한다.
4. 관리자가 내용을 입력해 등록한다.
5. 기존 커뮤니티 댓글 POST에 원댓글 ID를 `parentId`로 보내고 성공 후 원댓글·열린 답글 목록을 재조회한다.
6. 관리자는 같은 form으로 동일 원댓글에 추가 직접 답글을 작성할 수 있다.
7. 실패하면 오류를 표시하고 입력 초안을 유지해 재시도할 수 있다.
## 7. 정보 구조와 라우팅
```text
/ai-characters/:characterId/community-posts
└─ 커뮤니티 게시글 Sheet
└─ 댓글 관리
└─ 원댓글
└─ 직접 답글 목록 및 작성 form
```
- 새 route와 query parameter를 추가하지 않는다.
- 기존 `CommunityPostSheet``CommentThread` 안에서만 동작한다.
- 답글 pagination 상태는 기존 component의 로컬 상태를 사용한다.
## 8. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `CCR-001` | 확정 | 활성 Community target의 답글 0개 원댓글에 `답글 작성` 버튼을 표시한다. | `replyCount=0`, `canMutate=true`인 Community root에서 버튼을 찾을 수 있다. | contract 불필요, `P1-T1` |
| `CCR-002` | 확정 | `답글 작성`을 누르면 선택한 원댓글의 기존 직접 답글 영역과 작성 form을 연다. | 버튼 클릭 뒤 해당 원댓글 이름과 연결된 답글 region·textarea·등록 버튼이 표시되고 page 0 GET을 한 번 요청한다. | 답글 GET, `P1-T1` |
| `CCR-003` | 확정 | 첫 답글과 후속 직접 답글은 기존 Community 댓글 POST를 사용한다. | body가 trim된 `comment`, 원댓글 ID `parentId`, `isSecret=false`를 포함하고 `languageCode`는 보내지 않는다. | 댓글 POST, `P1-T1` |
| `CCR-004` | 확정 | 하나의 원댓글에는 여러 직접 답글을 추가할 수 있다. | 답글 등록 성공 후 form을 다시 사용할 수 있고 원댓글·현재 답글 page를 재조회해 추가된 답글을 표시한다. | 답글 GET·댓글 POST, `P1-T1`, `P1-GATE` |
| `CCR-005` | 확정 | 댓글 구조는 원댓글과 직접 답글의 2단계로 제한한다. | reply row에는 답글 action이 없고 답글 ID를 `parentId`로 보내는 작성 경로가 없다. | 댓글 POST, `P1-T1` |
| `CCR-006` | 확정 | 기존 권한과 mutation 상태를 유지한다. | `canMutate=false`이면 첫 답글 작성 진입과 form이 없고, pending 중 중복 POST가 없으며 실패 시 초안 유지·성공 시 초기화된다. | `NullSuccess`, `P1-GATE` |
## 9. 반응형 기능 범위
| 기능 | Desktop | Tablet | Mobile | 비고 |
|---|---:|---:|---:|---|
| `답글 작성`·`답글 보기` 진입 | 지원 | 지원 | 지원 | 기존 댓글 action layout 재사용 |
| 여러 직접 답글 조회·작성 | 지원 | 지원 | 지원 | 기존 page size 20과 pagination 재사용 |
- 상위 제품의 최소 320px, 200% zoom, keyboard-only와 touch target 기준을 유지한다.
- Sheet 내부에서 수평 overflow 없이 form과 action을 사용할 수 있어야 한다.
## 10. UI/UX Expectations
### 10.1 디자인과 component 원칙
- `CommentThread`, `CommentItem`, `CommentForm`을 재사용한다.
- 오디오와 커뮤니티에 동일한 action label과 펼침 동작을 사용한다.
- 새 component나 dependency를 추가하지 않는다.
- 기존 답글이 있는 원댓글의 `답글 보기` UI는 유지한다.
### 10.2 화면 상태
- 클릭 직후 기존 답글 loading 상태를 표시한다.
- 빈 답글 응답 뒤에도 작성 form을 표시한다.
- 조회 오류는 기존 재시도 UI를 사용한다.
- 작성 중·성공·실패는 기존 Comments mutation 정책을 사용한다.
- 답글 작성 성공 후 form은 빈 값으로 초기화되고 다시 입력할 수 있다.
### 10.3 접근성
- 버튼의 accessible name은 원댓글 내용과 `답글 작성` 또는 `답글 보기`를 조합해 식별 가능해야 한다.
- form의 visible label과 오류 연결, keyboard focus 표시를 유지한다.
- 답글 region은 원댓글 내용과 `답글`을 조합한 accessible name을 유지한다.
- keyboard-only로 Sheet의 원댓글에서 답글 form까지 진입하고 등록할 수 있어야 한다.
## 11. API 계약
### 11.1 공통 규칙
- 이 기능은 API를 변경하지 않는다.
- 정확한 request, response와 오류는 [기능 API Contract](./api-contract.md)를 따른다.
- 원본 OpenAPI는 [프로젝트 OpenAPI](../20260725_AI캐릭터관리자웹/api-contract.openapi.json)다.
### 11.2 Endpoint 추적
| 요구사항 | Method | Path | 계약 상태 | 소유 Goal |
|---|---|---|---|---|
| `CCR-002`, `CCR-004` | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` | 기존 제공·구현됨 | `P1-T1` |
| `CCR-003~005` | POST | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | 기존 제공·구현됨 | `P1-T1` |
### 11.3 외부 제공 대기 계약
없음. 필요한 GET·POST, DTO와 mock handler가 이미 제공돼 있다.
## 12. 보안과 데이터 취급
- 기존 Bearer 인증, ADMIN 권한과 `characterId`·`postId` target 격리를 유지한다.
- `parentId`는 현재 Community target에서 응답받은 활성 원댓글 ID만 사용한다.
- 댓글 본문과 인증 정보는 console, 분석 이벤트와 영구 저장소에 기록하지 않는다.
- 401·403은 공통 인증·인가 정책을 따른다.
- 클라이언트 validation은 서버의 target·parent 소유권 검증을 대체하지 않는다.
## 13. 성능과 품질 요구사항
- 답글 action을 누를 때 선택한 원댓글의 답글 page 0만 기존 방식으로 조회한다.
- 답글 page size 20과 기존 pagination을 유지하며 전체 답글을 선조회하지 않는다.
- 새 dependency, 캐시 계층과 optimistic update를 추가하지 않는다.
- Vitest focused test, Comments 회귀, Chromium mock E2E, typecheck와 lint를 통과한다.
- server 404나 network error를 mock으로 자동 전환하지 않는다.
## 14. 성공 기준
### 14.1 기능 수용 기준
- [x] 답글 0개인 활성 Community root에서 첫 답글을 작성한다. (`CCR-001~003`)
- [x] 같은 원댓글에 여러 직접 답글을 작성·조회한다. (`CCR-004`)
- [x] reply row와 비활성 workspace의 2단계·권한 경계가 유지된다. (`CCR-005~006`)
- [x] 실패·재시도와 중복 제출 방지가 회귀하지 않는다. (`CCR-006`)
### 14.2 UI/UX 수용 기준
- [x] 버튼·답글 region·form의 accessible name과 label이 연결된다.
- [x] 320px·200% zoom에서 수평 overflow 없이 답글을 작성한다.
- [x] keyboard-only로 답글 form에 진입하고 등록할 수 있다.
### 14.3 추적성 완료 기준
- [x] 모든 확정 요구사항이 API 또는 contract 불필요 판정, `P1-T1`, `P1-GATE`와 연결된다.
- [x] 구현·검증 결과가 [plan-task.md](./plan-task.md)의 Progress에 기록된다.
- [x] 완료된 Phase의 리뷰가 `reviews/` 아래에 기록된다.
## 15. Open Questions
없음.
## 16. 요구사항 추적표
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---|---:|---|---|---|
| `CCR-001~006` | [api-contract.md](./api-contract.md) | 1 | `P1-T1`, `P1-GATE` | `comment-thread.test.tsx`, `comments.spec.ts` | 활성 Community 첫·추가 답글, 비활성·2단계·320px·keyboard 경계 |
## 17. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|---|---|---|---|---|---|
| 2026-08-06 | `CCR-DEC-001` | 확정 | 오디오 콘텐츠와 동일한 첫 답글 진입을 활성 커뮤니티 원댓글에도 적용한다. | 사용자 요청 | `CCR-001~003`, `P1-T1` |
| 2026-08-06 | `CCR-DEC-002` | 확정 | 댓글 트리는 원댓글 아래 여러 직접 답글을 허용하되 답글의 답글은 허용하지 않는다. | 사용자 요청의 “1단계 추가, 여러 개” 조건 | `CCR-004~005`, [api-contract.md](./api-contract.md) |
| 2026-08-06 | `CCR-DEC-003` | 확정 | 새 API·컴포넌트 없이 기존 Community GET·POST와 Comments UI를 재사용한다. | OpenAPI와 구현 확인 | `CCR-002~006`, `P1-T1` |
## 18. 변경 관리
- 범위가 바뀌면 이 문서의 Decision Log와 요구사항을 먼저 갱신한다.
- API가 바뀌면 [api-contract.md](./api-contract.md)와 원본 OpenAPI의 제공 버전을 확인한다.
- 구현 범위가 바뀌면 코드보다 [plan-task.md](./plan-task.md)를 먼저 갱신한다.
- 기존 Progress, review와 검증 기록은 삭제하거나 덮어쓰지 않는다.

View File

@@ -0,0 +1,170 @@
# 커뮤니티 댓글 직접 답글 Phase 1 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-R1`, `P1-GATE` |
| 기준 commit 또는 working tree | `e82e209300d2c30843b6a2ef2c9e126ade6bba63` 기반 working tree |
| 리뷰 일자 | 2026-08-06 |
| 리뷰어 | Sisyphus, 독립 goal·품질·보안·컨텍스트·visual QA reviewer |
| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `CCR-001~006`과 Community 첫·추가 직접 답글 journey가 구현됐는지 확인한다.
- API·schema·application mock·dependency 변경 없이 기존 2단계 댓글 경계와 권한을 유지하는지 확인한다.
- TDD, 자동 Gate, 실제 Chromium과 문서 기록이 완료 조건과 일치하는지 판정한다.
### 포함 범위
- 코드: `src/features/comments/components/CommentThread.tsx`
- 테스트: `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts`
- 문서: `CCR-001~006`, Community 댓글 API Contract, `P1-T1`, `P1-R1`, `P1-GATE`
- 수동 검증: Chromium mock mode, keyboard-only, 1280px, 320px, 200% zoom, CJK·수평 overflow
### 제외 범위
- 실제 개발 API integration, 새 endpoint·schema·mock store, 답글 수정·삭제·pagination 정책 변경
- 3단계 댓글, optimistic update, form 상시 노출
## 3. 판정 기준
### 심각도
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 journey 불능, 완료 판정 무효 |
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
| Medium | 제한 조건의 기능·접근성·복구 문제 |
| Low | 비핵심 유지보수성·문서 정합성 문제 |
### 상태
| 상태 | 의미 | 후속 처리 |
|---|---|---|
| 후보 | 근거를 발견했지만 판정 전 | 재현 후 상태 변경 |
| 확정 | 코드·test·문서로 문제 확인 | 회귀 Task 전환 |
| 오탐 | 실제 실행 경로나 요구사항 위반이 아님 | 판정 근거를 보존하고 종료 |
| 보류 | 외부 계약·환경·제품 결정 필요 | 담당·재개 조건 기록 |
| 수정 완료 | 수정과 관련 검증 완료 | 검증 결과 누적 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `CCR-001~006`
- API Contract: 직접 답글 GET, Community 댓글 POST, 2단계 불변식
- 계획: `P1-T1`, `P1-R1`, `P1-GATE`
- 코드: `CommentThread.tsx``replyActionLabel`, `toggleReplies()`, `createReply()`
- 테스트: `CommentThread creates a first Community reply...`, `Community sheet comments keep two-level controls usable at 320px`
### 실행 환경
```text
OS: macOS
Node: v24.12.0
npm: 11.7.0
Browser/viewport: Playwright Chromium, 1280x900, 320x640, CSS zoom 200%
환경 변수: VITE_API_MODE=mock
```
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` | 성공 | `8/8` |
| `npm run test:run -- src/features/comments` | 성공 | `15/15` |
| `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` | 성공 | `3/3`; 2102 빈 reply, 첫·두 답글, payload, 2단계 경계 |
| `npm run typecheck` | 성공 | exit 0 |
| `npm run lint` | 성공 | exit 0 |
| `npm run build:dev` | 성공 | Vite build exit 0; 기존 500kB chunk warning만 발생 |
| `git diff --check` | 성공 | 출력 없음 |
| 실제 Chromium keyboard journey | 성공 | 답글 action·textarea keyboard 진입, 첫·두 답글 표시, input 초기화, 중첩 action 0건 |
| 1280px·320px·200% visual QA | 성공 | 수평 overflow 없음, CJK clipping·고아줄 없음, 독립 visual reviewer PASS |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `CCR-REV-P1-001` | High | 오탐 | E2E route fixture가 root 2102 답글을 잘못 분류한다 | `P1-R1` | 없음 |
확정 발견 사항 없음.
## 6. 발견 사항 상세
### CCR-REV-P1-001 — E2E route fixture root 분류 후보
- **심각도:** High
- **상태:** 오탐
- **관련 요구사항:** `CCR-002`, `CCR-004~005`
- **관련 계약:** Community 직접 답글 GET·POST, 2단계 불변식
- **소유 Task:** `P1-R1`
**관찰 내용**
`tests/e2e/comments-test-support.ts`는 단일 `replyRootId`만 replies로 분류하지만,
필수 mock E2E에서는 이 Playwright route fixture가 응답을 소유하지 않는다.
**근거**
- `playwright.config.ts`는 mock E2E를 `VITE_API_MODE=mock`으로 실행한다.
- 앱은 렌더 전에 `src/shared/mocks/browser.ts``setupWorker(...)`를 시작한다.
- Browser MSW handler·store는 `commentId``parentId`로 root 2102 답글을 분리한다.
- 기존 E2E route fixture를 변경하지 않은 상태에서 2102 초기 reply region의 root 댓글 0건, 첫·두 답글 각 1건, 중첩 action 0건과 Chromium `3/3`을 반복 확인했다.
- 별도 브라우저 probe에서 `page.route` 호출 0회와 Community mock 요청 9회를 관찰했다.
**재현 또는 검증 절차**
1. `VITE_API_MODE=mock`으로 `comments.spec.ts` Chromium을 실행한다.
2. root 2102의 답글 영역을 열고 다른 root 댓글이 없음을 확인한다.
3. 같은 root에 첫·두 번째 답글을 등록하고 Sheet를 다시 연다.
4. 두 답글이 region에 각 1건 표시되고 중첩 답글 action이 없음을 확인한다.
**영향**
필수 mock E2E와 제품 동작에는 영향이 없다. Server-mode 전용 test route helper의
일반화는 이번 기능 범위와 실행 경로 밖이다.
**권장 조치**
없음. 실행되지 않는 fixture를 speculative하게 변경하지 않는다.
**판정 기록**
- 2026-08-06 — 코드 형태만 근거로 확정 후보로 분류했다.
- 2026-08-06 — mock 요청 소유권, 기존 fixture 상태의 E2E, 실제 브라우저를 대조해 오탐으로 정정했다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음. `CCR-REV-P1-001``P1-R1`에서 오탐으로 판정됐다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 요구사항·계약·코드·test·실제 Chromium·visual QA 확인 |
| 후보 항목 판정 완료 | 충족 | `CCR-REV-P1-001` 오탐 판정 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
| 검증 명령과 결과 기록 | 충족 | 자동·수동 검증 표와 `plan-task.md` Progress 기록 |
**최종 결론:** 확정 발견 사항 없음
**남은 항목:** 실제 개발 API integration은 이번 mock 기능 Gate 범위 밖이다.
## 9. 수정 후 검증 기록
### 1차 리뷰 후보 검증 — 2026-08-06
- 무엇을: `CCR-REV-P1-001`의 실제 mock E2E 영향 여부를 검증했다.
- 왜: 실행되지 않는 route fixture를 수정하면 범위를 불필요하게 확장할 수 있다.
- 어떻게:
- 기존 fixture 상태의 Chromium E2E — `3/3` 성공
- Comments Vitest — `15/15` 성공
- typecheck·lint·`git diff --check` — exit 0
- 실제 Chromium·visual QA — 첫·추가 답글, keyboard, 1280px·320px·200% PASS
- 남은 항목: 없음

View File

@@ -0,0 +1,216 @@
# 커뮤니티 댓글 직접 답글 Phase 1 최종 커밋 감사
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-R1`, `P1-R2`, `P1-GATE` |
| 기준 commit 또는 working tree | `00f06b992f25dbfe3b700babfa6dc28ed08f967b` + `P1-R2` 문서 working tree |
| 리뷰 일자 | 2026-08-06 |
| 리뷰어 | Codex |
| 기준 문서 | [prd.md](../prd.md), [api-contract.md](../api-contract.md), [plan-task.md](../plan-task.md) |
| 리뷰 상태 | 수정 검증 완료 |
## 2. 리뷰 목적과 범위
### 목적
- 최종 커밋의 코드·test가 `CCR-001~006`과 API Contract를 충족하는지 확인한다.
- 완료된 계획의 Files·Interfaces·검증 기록이 실제 commit diff와 일치하는지 확인한다.
- 기존 리뷰 결과와 현재 HEAD의 자동 검증 결과를 독립적으로 대조한다.
### 포함 범위
- 코드: `src/features/comments/components/CommentThread.tsx`
- 테스트: `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts`
- 문서: `prd.md`, `api-contract.md`, `plan-task.md`, `phase1-community-comment-replies.md`
- 검증: Comments Vitest, Chromium mock E2E, typecheck, lint, development build, commit diff
### 제외 범위
- 실제 개발 API를 사용한 server integration
- 답글 수정·삭제·pagination의 기존 구현 재설계
- 이번 감사에서 별도 browser 수동 QA 재실행
## 3. 판정 기준
### 심각도
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 journey 불능, 완료 판정 무효 |
| High | 확정 요구사항·API Contract 위반 또는 주요 회귀 |
| Medium | 제한 조건의 기능·접근성·복구 문제 |
| Low | 비핵심 유지보수성·문서 정합성 문제 |
### 상태
| 상태 | 의미 | 후속 처리 |
|---|---|---|
| 후보 | 근거를 발견했지만 판정 전 | 재현 후 상태 변경 |
| 확정 | 코드·test·문서로 문제 확인 | 회귀 Task 전환 |
| 오탐 | 실제 실행 경로나 요구사항 위반이 아님 | 근거를 보존하고 종료 |
| 보류 | 외부 계약·환경·제품 결정 필요 | 담당·재개 조건 기록 |
| 수정 완료 | 수정과 관련 검증 완료 | 검증 결과 누적 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `CCR-001~006`
- API Contract: 직접 답글 GET, Community 댓글 POST, 2단계 댓글 구조 불변식
- 계획: `P1-T1`, `P1-R1`, `P1-R2`, `P1-GATE`
- 구현: `CommentThread.tsx``replyActionLabel`, `toggleReplies()`, `createReply()`
- 테스트: Community 첫 답글 unit test, Community 첫·두 번째 답글 Chromium E2E
- commit 범위: 문서 4개, 구현 1개, test 2개
### 실행 환경
```text
OS: macOS 26.0
Node: v24.12.0
npm: 11.7.0
Browser: Playwright Chromium
환경 변수: VITE_API_MODE=mock
```
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` | 성공 | exit 0, `8/8` |
| `npm run test:run -- src/features/comments` | 성공 | exit 0, `15/15` |
| `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` | 성공 | 샌드박스 밖 재실행 exit 0, `3/3` |
| 동일 Chromium E2E의 최초 샌드박스 실행 | 실행 불가 | `127.0.0.1:8889` listen `EPERM`; 제품 실패가 아닌 실행 권한 제한 |
| `npm run typecheck` | 성공 | exit 0 |
| `npm run lint` | 성공 | exit 0 |
| `npm run build:dev` | 성공 | exit 0, 기존 500kB chunk warning만 발생 |
| `git diff --check e82e209..00f06b9` | 성공 | 출력 없음 |
| commit 파일 범위 대조 | 성공 | API·schema·mock·dependency 변경 0건 |
| 별도 browser 수동 QA | 불가 | 이번 감사에서는 재실행하지 않았으며 기존 Phase 리뷰의 기록만 확인 |
## 5. 요구사항별 판정
| 요구사항 | 판정 | 근거 |
|---|---|---|
| `CCR-001` | 충족 | 활성 Community의 `replyCount=0` root에 `답글 작성` label을 전달하며 unit·E2E에서 노출 확인 |
| `CCR-002` | 충족 | 클릭 후 root ID의 replies page 0 GET과 답글 region·form을 unit·E2E에서 확인 |
| `CCR-003` | 충족 | Community POST가 trim된 `comment`, root `parentId`, `isSecret=false`만 전송하고 `languageCode`를 보내지 않음 |
| `CCR-004` | 충족 | E2E가 같은 root `2102`에 첫·두 번째 답글을 등록하고 재조회 후 각각 1건 표시함 |
| `CCR-005` | 충족 | reply row에 `onShowReplies`를 전달하지 않으며 E2E에서 중첩 답글 action 0건 확인 |
| `CCR-006` | 충족 | `canMutate=false` Community root 진입 차단 unit test와 기존 공통 pending·실패·초안 회귀 test `15/15` 통과 |
## 6. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `CCR-REV-P1-002` | Low | 수정 완료 | 완료된 plan 현재 상태에 구현 전 문장이 남아 있다 | `P1-R2` | `P1-R2` 완료 |
기능·API Contract 위반에 해당하는 확정 발견 사항은 없다.
## 7. 발견 사항 상세
### CCR-REV-P1-002 — 완료된 plan 현재 상태에 구현 전 문장이 남아 있다
- **심각도:** Low
- **상태:** 수정 완료
- **관련 요구사항:** `CCR-001`
- **관련 계약:** 없음
- **소유 Task:** `P1-R2`
**관찰 내용**
`plan-task.md`는 상태와 Phase를 구현 완료로 표시하지만 현재 상태에서
`replyCount === 0`인 Community root에는 첫 답글 작성 진입이 없다고 기록한다.
**근거**
- 문서: `plan-task.md:5`, `plan-task.md:9`, `plan-task.md:21`은 완료 상태다.
- 문서: `plan-task.md:25`는 첫 답글 작성 진입이 없다고 서술한다.
- 코드: `CommentThread.tsx:170`은 활성 Community 빈 root에 `답글 작성`을 표시한다.
- 테스트: focused `8/8`, Comments `15/15`, Chromium `3/3`이 해당 동작을 확인한다.
**재현 또는 검증 절차**
1. `plan-task.md`의 문서 상태와 현재 상태 표를 확인한다.
2. 같은 문서 25행의 미구현 문장을 확인한다.
3. `CommentThread.tsx`의 action 조건 및 Community unit·E2E 결과와 대조한다.
4. 완료 문서가 실제 구현 상태와 반대인 한 문장을 포함함을 확인한다.
**영향**
제품 동작에는 영향이 없다. 후속 작업자가 기능이 미구현됐다고 오인할 수 있고,
문서 완료 상태와 현재 상태 설명이 충돌한다.
**권장 조치**
해당 문장을 “`replyCount === 0`인 Community root에도 `답글 작성` 진입이
제공된다.”로 정정하고 문서 전용 검증 기록을 누적한다.
**판정 기록**
- 2026-08-06 — 최종 commit의 plan·코드·test 대조로 문서 정합성 문제를 확정했다.
- 2026-08-06 — `P1-R2`에서 현재 상태 문장을 실제 구현 완료 내용으로 정정하고 marker·link·diff 검증을 통과해 수정 완료로 판정했다.
## 8. 확정 항목의 plan·goal 전환
이번 요청은 최종 커밋의 읽기·진단 감사이므로 기존 `plan-task.md`를 변경하지
않았다. 수정 시 아래 문서 전용 회귀 Task를 먼저 계획에 추가한다.
### 신규 회귀 수정 Task 초안
```markdown
### Task R1.2 완료 문서 현재 상태 정합성 복구
**Goal 실행 `P1-R2`:** `CCR-REV-P1-002`의 미구현 문장을 실제 완료 상태로
정정하고 기존 Progress와 결정 기록을 보존한다.
- **시작 조건:** `CCR-REV-P1-002` 확정, 완료된 `P1-T1`, `P1-GATE`.
- **완료 증거:** 현재 상태 문장 정정, 기존 기록 보존, 문서 marker·link·diff 검증.
- **범위 밖:** 애플리케이션 코드·test·API Contract 변경.
```
### 후속 plan 반영
2026-08-06 — 사용자의 회귀 수정 요청에 따라 위 초안을 `plan-task.md`
`P1-R2`로 반영하고 완료했다. 애플리케이션 코드·test·API Contract는 변경하지
않았다.
## 9. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 최종 commit 문서·코드·test·diff 확인 |
| 후보 항목 판정 완료 | 충족 | `CCR-REV-P1-002` 수정 완료 |
| 확정 항목 plan 반영 | 충족 | `P1-R2` 추가·완료와 Progress 기록 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
| 검증 명령과 결과 기록 | 충족 | 자동 검증 표에 실제 결과와 E2E 최초 실행 불가 사유 기록 |
**최종 결론:** 수정 검증 완료. 기능 구현과 자동 검증은 문서 요구사항을
충족하고 `CCR-REV-P1-002`의 문서 불일치도 해소됐다.
**남은 항목:** 실제 개발 API integration과 별도 수동 browser QA는 이번 감사
범위 밖이다.
## 10. 수정 후 검증 기록
### 1차 수정 검증 — 2026-08-06
- 무엇을: `CCR-REV-P1-002``P1-R2`로 전환하고 stale 현재 상태 문장을 정정했다.
- 왜: 완료 상태·코드·test와 현재 상태 한 문장이 충돌했다.
- 어떻게:
- stale 현재 상태 marker 부재 검사 — 성공, 0건
- 완료 상태 문장과 review 상호 링크 검사 — 성공, 각 1건 이상
- trailing whitespace 검사와 `git diff --check` — 성공, 오류 0건
- 남은 항목: 없음.
### 2차 기능 회귀 감사 — 2026-08-06
- 무엇을: `P1-R2` 문서 정정 뒤 기존 기능과 정적 품질이 유지되는지 확인했다.
- 왜: 최종 완료 상태가 코드·test·문서에서 동일한지 다시 판정하기 위해서다.
- 어떻게:
- `npm run test:run -- src/features/comments` — 성공, `15/15`
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — 성공, `3/3`
- `npm run typecheck`, `npm run lint`, `npm run build:dev` — 모두 exit 0; 기존 500kB chunk warning만 발생
- `git diff --check` — 성공, 오류 0건
- 남은 항목: 없음.

View File

@@ -74,7 +74,7 @@ test("navigates to /ai-characters after a successful login", async () => {
fireEvent.click(screen.getByRole("button", { name: "로그인" })); fireEvent.click(screen.getByRole("button", { name: "로그인" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters")); await waitFor(() => expect(window.location.pathname).toBe("/ai-characters"));
expect(screen.getByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "AI 캐릭터" })).toBeInTheDocument();
}); });
test("routes /ai-characters/new to the character create form", async () => { test("routes /ai-characters/new to the character create form", async () => {

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 { getAudioContentCreateCharacterIdFromPath, getAudioContentDetailRouteFromPath, getAudioContentEditRouteFromPath, getAudioContentListCharacterIdFromPath, getCharacterEditIdFromPath, getCharacterIdFromPath, getCommunityPostCreateCharacterIdFromPath, getCommunityPostListCharacterIdFromPath, getFanTalkListCharacterIdFromPath, getSeriesCreateCharacterIdFromPath, getSeriesDetailRouteFromPath, getSeriesEditRouteFromPath, getSeriesListCharacterIdFromPath, getSeriesOrderCharacterIdFromPath, navigateTo, useBrowserLocation } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths"; import { routePaths } from "@/app/route-paths";
import { AudioContentDetailPage } from "@/features/audio-contents/pages/AudioContentDetailPage";
import { AudioContentFormPage } from "@/features/audio-contents/pages/AudioContentFormPage";
import { AudioContentListPage } from "@/features/audio-contents/pages/AudioContentListPage";
import { useAuthSession } from "@/features/auth/model/auth-session-context"; import { useAuthSession } from "@/features/auth/model/auth-session-context";
import { authSessionStorage } from "@/features/auth/model/auth-session-storage"; import { authSessionStorage } from "@/features/auth/model/auth-session-storage";
import { CharacterCreatePage } from "@/features/characters/pages/CharacterCreatePage";
import { CharacterDetailPage } from "@/features/characters/pages/CharacterDetailPage";
import { CharacterEditPage } from "@/features/characters/pages/CharacterEditPage";
import { CharacterListPage } from "@/features/characters/pages/CharacterListPage";
import { CommunityPostListPage } from "@/features/community-posts/pages/CommunityPostListPage";
import { CommunityPostFormPage } from "@/features/community-posts/pages/CommunityPostFormPage";
import { FanTalkListPage } from "@/features/fan-talks/pages/FanTalkListPage";
import { SeriesDetailPage } from "@/features/series/pages/SeriesDetailPage";
import { SeriesFormPage } from "@/features/series/pages/SeriesFormPage";
import { SeriesListPage } from "@/features/series/pages/SeriesListPage";
import { SeriesOrderPage } from "@/features/series/pages/SeriesOrderPage";
import type { ApiClient } from "@/shared/api/client"; import type { ApiClient } from "@/shared/api/client";
import type { ApiMode } from "@/shared/config/env"; import type { ApiMode } from "@/shared/config/env";
import { MockModeBanner } from "@/shared/ui/mock-mode-banner"; import { MockModeBanner } from "@/shared/ui/mock-mode-banner";
import { PageState } from "@/shared/ui/page-state";
const AudioContentDetailPage = lazy(() => import("@/features/audio-contents/pages/AudioContentDetailPage").then(({ AudioContentDetailPage }) => ({ default: AudioContentDetailPage })));
const AudioContentFormPage = lazy(() => import("@/features/audio-contents/pages/AudioContentFormPage").then(({ AudioContentFormPage }) => ({ default: AudioContentFormPage })));
const AudioContentListPage = lazy(() => import("@/features/audio-contents/pages/AudioContentListPage").then(({ AudioContentListPage }) => ({ default: AudioContentListPage })));
const CharacterCreatePage = lazy(() => import("@/features/characters/pages/CharacterCreatePage").then(({ CharacterCreatePage }) => ({ default: CharacterCreatePage })));
const CharacterDetailPage = lazy(() => import("@/features/characters/pages/CharacterDetailPage").then(({ CharacterDetailPage }) => ({ default: CharacterDetailPage })));
const CharacterEditPage = lazy(() => import("@/features/characters/pages/CharacterEditPage").then(({ CharacterEditPage }) => ({ default: CharacterEditPage })));
const CharacterListPage = lazy(() => import("@/features/characters/pages/CharacterListPage").then(({ CharacterListPage }) => ({ default: CharacterListPage })));
const CommunityPostFormPage = lazy(() => import("@/features/community-posts/pages/CommunityPostFormPage").then(({ CommunityPostFormPage }) => ({ default: CommunityPostFormPage })));
const CommunityPostListPage = lazy(() => import("@/features/community-posts/pages/CommunityPostListPage").then(({ CommunityPostListPage }) => ({ default: CommunityPostListPage })));
const FanTalkListPage = lazy(() => import("@/features/fan-talks/pages/FanTalkListPage").then(({ FanTalkListPage }) => ({ default: FanTalkListPage })));
const SeriesDetailPage = lazy(() => import("@/features/series/pages/SeriesDetailPage").then(({ SeriesDetailPage }) => ({ default: SeriesDetailPage })));
const SeriesFormPage = lazy(() => import("@/features/series/pages/SeriesFormPage").then(({ SeriesFormPage }) => ({ default: SeriesFormPage })));
const SeriesListPage = lazy(() => import("@/features/series/pages/SeriesListPage").then(({ SeriesListPage }) => ({ default: SeriesListPage })));
const SeriesOrderPage = lazy(() => import("@/features/series/pages/SeriesOrderPage").then(({ SeriesOrderPage }) => ({ default: SeriesOrderPage })));
const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"; const focusableSelector = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요."; const sessionExpiredNotice = "세션이 만료되었습니다. 다시 로그인하세요.";
@@ -196,6 +198,7 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon
{location.successNotification} {location.successNotification}
</p> </p>
)} )}
<Suspense fallback={<PageState state="loading" title="화면을 불러오는 중" />} key={location.path}>
{location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null} {location.path === routePaths.aiCharacterCreate ? <CharacterCreatePage apiClient={apiClient} /> : null}
{characterEditId !== null ? <CharacterEditPage apiClient={apiClient} characterId={characterEditId} /> : 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} {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} {seriesEditRoute !== null ? <SeriesFormPage apiClient={apiClient} characterId={seriesEditRoute.characterId} seriesId={seriesEditRoute.seriesId} /> : null}
{seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null} {seriesOrderCharacterId !== null ? <SeriesOrderPage apiClient={apiClient} characterId={seriesOrderCharacterId} /> : null}
{seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null} {seriesDetailRoute !== null ? <SeriesDetailPage apiClient={apiClient} characterId={seriesDetailRoute.characterId} seriesId={seriesDetailRoute.seriesId} /> : null}
</Suspense>
</main> </main>
</div> </div>
</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 { ReleaseScheduleValue } from "@/features/audio-contents/components/ReleaseScheduleField";
import type { AudioContentDetail } from "@/features/audio-contents/model/types"; import type { AudioContentDetail } from "@/features/audio-contents/model/types";
import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema"; import { audioContentCreateResponseSchema } from "@/features/audio-contents/schemas/audio-content-schema";
import type { AudioContentTheme } from "@/features/audio-contents/schemas/audio-content-schema"; import type { AudioContentTheme, AudioContentUpdateRequest } from "@/features/audio-contents/schemas/audio-content-schema";
import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy"; import { AUDIO_COVER_POLICY } from "@/features/audio-contents/validation/audio-cover-policy";
import type { ApiClient } from "@/shared/api/client"; import type { ApiClient } from "@/shared/api/client";
import { ApiError } from "@/shared/api/api-error"; import { ApiError } from "@/shared/api/api-error";
@@ -53,6 +53,10 @@ const errorIds = {
const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/; const previewTimePattern = /^\d{2}:\d{2}:\d{2}$/;
function hasUpdateFields(request: AudioContentUpdateRequest): boolean {
return Object.keys(request).length > 0;
}
export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) { export function AudioContentForm({ apiClient, audio, characterId, createCropSource, mode, renderCrop, themes, uploadAuth, uploadAudioContentRequest = uploadAudioContent }: { readonly apiClient: ApiClient; readonly audio?: AudioContentDetail; readonly characterId: string; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly mode: "create" | "edit"; readonly renderCrop?: (request: CropRenderRequest) => Promise<File>; readonly themes: readonly AudioContentTheme[]; readonly uploadAuth?: UploadAuthDependencies; readonly uploadAudioContentRequest?: UploadAudioContentRequest }) {
const [audioFile, setAudioFile] = useState<File | null>(null); const [audioFile, setAudioFile] = useState<File | null>(null);
const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings); const [createSettings, setCreateSettings] = useState<AudioContentCreateSettings>(defaultAudioContentCreateSettings);
@@ -76,7 +80,11 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
const isDeactivatingRef = useRef(false); const isDeactivatingRef = useRef(false);
const isSubmittingRef = useRef(false); const isSubmittingRef = useRef(false);
const contentId = audio === undefined ? null : String(audio.contentId); const contentId = audio === undefined ? null : String(audio.contentId);
const isDirty = title !== (audio?.title ?? "") || detail !== (audio?.detail ?? "") || tags !== (audio?.tag ?? "") || price !== createInitialPrice(audio?.price) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings; const parsedEditPrice = parsePrice(price);
const editRequest = mode === "edit" && audio !== undefined && parsedEditPrice !== null ? toUpdateRequest({ audio, detail: detail.trim(), price: parsedEditPrice, tags: tags.trim(), title: title.trim() }) : {};
const editHasChanges = mode === "edit" && (coverImage !== null || hasUpdateFields(editRequest) || (audio !== undefined && parsedEditPrice === null && price !== createInitialPrice(audio.price)));
const createHasChanges = mode === "create" && (title !== "" || detail !== "" || tags !== "" || price !== createInitialPrice(undefined) || audioFile !== null || coverImage !== null || themeId !== null || releaseSchedule.publishMode !== "immediate" || releaseSchedule.releaseDateTime !== "" || createSettings !== defaultAudioContentCreateSettings);
const isDirty = editHasChanges || createHasChanges;
const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null; const isCoverSubmitBlocked = isCoverPreparing || cropSource !== null;
const isPaid = (parsePrice(price) ?? 0) > 0; const isPaid = (parsePrice(price) ?? 0) > 0;
@@ -146,6 +154,9 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
if (isSubmittingRef.current) { if (isSubmittingRef.current) {
return; return;
} }
if (mode === "edit" && !editHasChanges) {
return;
}
const nextErrors = validateForm(); const nextErrors = validateForm();
setErrors(nextErrors); setErrors(nextErrors);
@@ -172,7 +183,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
return; return;
} }
if (mode === "edit" && audio !== undefined && contentId !== null) { if (mode === "edit" && audio !== undefined && contentId !== null) {
await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: toUpdateRequest({ audio, detail: detail.trim(), price: parsedPrice, tags: tags.trim(), title: title.trim() }) }); await updateAudioContent(apiClient, characterId, contentId, { coverImage: coverImage ?? undefined, request: editRequest });
setUploadState({ progress: 100, status: "success" }); setUploadState({ progress: 100, status: "success" });
navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." }); navigateTo(routePaths.aiCharacterAudioContentDetail(characterId, contentId), { successNotification: "오디오 콘텐츠를 저장했습니다." });
} }
@@ -249,7 +260,7 @@ export function AudioContentForm({ apiClient, audio, characterId, createCropSour
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null} {mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null}
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} type="button"></button> <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterAudioContents(characterId) : routePaths.aiCharacterAudioContentDetail(characterId, contentId ?? "")))} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button> <button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:cursor-not-allowed disabled:opacity-60" disabled={uploadState.status === "uploading" || isCoverSubmitBlocked || (mode === "edit" && !editHasChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
</div> </div>
</form> </form>
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />} {cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setCoverImage(file); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} onCancel={() => { setCoverImage(null); setCropSource(null); setErrors((current) => ({ ...current, cover: undefined })); }} open policy={AUDIO_COVER_POLICY} renderCrop={renderCrop} />}

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

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 }); expect(await readJsonPart(requireFormData(requests.at(-1)?.body).get("request"))).toMatchObject({ price: 99999 });
}); });
test("AudioContentFormPage update omits unsupported controls and soft delete navigates to the audio list", async () => { test("AudioContentFormPage disables edit save when no field or cover changed", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
const saveButton = await screen.findByRole("button", { name: "저장" });
// Then
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage disables edit save when price formatting normalizes to the original value", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
render(<AudioContentFormPage apiClient={createFormClient(requests)} characterId="101" contentId="9001" />);
// When
const priceInput = await screen.findByLabelText("가격");
fireEvent.change(priceInput, { target: { value: "01000" } });
// Then
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "저장" }));
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("AudioContentFormPage update sends only changed fields and soft delete navigates to the audio list", async () => {
// Given // Given
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit"); window.history.pushState({}, "", "/ai-characters/101/audio-contents/9001/edit");
@@ -88,11 +119,7 @@ test("AudioContentFormPage update omits unsupported controls and soft delete nav
expect(await readJsonPart(updateBody.get("request"))).toEqual({ expect(await readJsonPart(updateBody.get("request"))).toEqual({
title: "수정 오디오", title: "수정 오디오",
detail: "수정 설명", detail: "수정 설명",
tags: "상담,힐링",
price: 0, price: 0,
isAdult: false,
isPointAvailable: true,
isCommentAvailable: true,
}); });
// When // When
@@ -194,12 +221,10 @@ test("AudioContentFormPage keeps existing edit cover when replacement crop is ca
fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } }); fireEvent.change(await screen.findByLabelText("커버 이미지"), { target: { files: [new File(["cover"], "replacement.png", { type: "image/png" })] } });
const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" }); const cropDialog = await screen.findByRole("dialog", { name: "이미지 crop" });
fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" })); fireEvent.click(within(cropDialog).getByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then // Then
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001")); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
const updateRequest = requests.find((request) => request.method === "PUT"); expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
expect(requireFormData(updateRequest?.body).has("coverImage")).toBe(false);
}); });
test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => { test("AudioContentFormPage rejects a replacement cover MIME mismatch before crop preparation", async () => {
@@ -247,11 +272,13 @@ test("AudioContentFormPage ignores stale edit cover sources and saves the latest
await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole("dialog", { name: "이미지 crop" })).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "저장" })); fireEvent.click(screen.getByRole("button", { name: "저장" }));
await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001")); await waitFor(() => expect(window.location.pathname).toBe("/ai-characters/101/audio-contents/9001"));
const coverPart = requireFormData(requests.at(-1)?.body).get("coverImage"); const updateBody = requireFormData(requests.at(-1)?.body);
const coverPart = updateBody.get("coverImage");
if (!(coverPart instanceof File)) { if (!(coverPart instanceof File)) {
throw new TypeError("Expected cover image file"); throw new TypeError("Expected cover image file");
} }
expect(coverPart.name).toBe("fresh.png"); expect(coverPart.name).toBe("fresh.png");
expect(await readJsonPart(updateBody.get("request"))).toEqual({});
}); });
test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => { test("AudioContentFormPage shows an edit cover preparation error when preview creation rejects", async () => {
@@ -265,5 +292,5 @@ test("AudioContentFormPage shows an edit cover preparation error when preview cr
// Then // Then
expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다."); expect(await screen.findByRole("alert")).toHaveTextContent("이미지 미리보기 준비에 실패했습니다.");
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
}); });

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" /> <img alt="" className="size-12 shrink-0 rounded-md object-cover" height="48" loading="lazy" src={character.imageUrl} width="48" />
)} )}
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-semibold">{character.name}</span> <span className="block break-keep break-words font-semibold">{character.name}</span>
<span className="mt-1 line-clamp-2 block text-sm text-muted-foreground">{description}</span> <span className="mt-1 line-clamp-2 block break-keep break-words text-sm text-muted-foreground">{description}</span>
<span className="mt-2 block text-xs font-semibold text-info">ID {character.id} · {character.region}</span> <span className="mt-2 block break-keep break-words text-xs font-semibold text-info">ID {character.id} · {character.region}</span>
<span className="mt-1 block text-xs text-muted-foreground">{character.tags.join(", ")}</span> <span className="mt-1 block break-keep break-words text-xs text-muted-foreground">{character.tags.join(", ")}</span>
</span> </span>
</span> </span>
</a> </a>

View File

@@ -8,6 +8,9 @@ type BackgroundRequest = NonNullable<CreateCharacterParams["request"]["backgroun
type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number]; type MemoryRequest = NonNullable<CreateCharacterParams["request"]["memories"]>[number];
type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>; type CreateOptionalRequest = Partial<CreateCharacterParams["request"]>;
type MutableCreateOptionalRequest = { -readonly [Key in keyof CreateOptionalRequest]: CreateOptionalRequest[Key] }; type MutableCreateOptionalRequest = { -readonly [Key in keyof CreateOptionalRequest]: CreateOptionalRequest[Key] };
type UpdateOptionalRequest = UpdateCharacterParams["request"];
type MutableUpdateOptionalRequest = { -readonly [Key in keyof UpdateOptionalRequest]: UpdateOptionalRequest[Key] };
const updateOptionalKeys = ["age", "gender", "mbti", "speechPattern", "speechStyle", "appearance", "originalTitle", "originalLink", "characterType", "tags", "hobbies", "values", "goals", "relationships", "personalities", "backgrounds", "memories"] as const satisfies readonly (keyof UpdateOptionalRequest)[];
function emptyRelationship(): RelationshipDraft { function emptyRelationship(): RelationshipDraft {
return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" }; return { personName: "", relationshipName: "", description: "", importance: "", relationshipType: "", currentStatus: "" };
@@ -123,7 +126,7 @@ export function toCreateCharacterOptionalRequest(value: CharacterOptionalFieldsV
return request; return request;
} }
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateCharacterParams["request"] { function toSerializedUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue): UpdateOptionalRequest {
const tags = splitList(value.tags); const tags = splitList(value.tags);
const hobbies = splitList(value.hobbies); const hobbies = splitList(value.hobbies);
const values = splitList(value.values); const values = splitList(value.values);
@@ -139,3 +142,25 @@ export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsV
relationships: relationshipRows.length === 0 ? null : relationshipRows, personalities: personalityRows.length === 0 ? null : personalityRows, backgrounds: backgroundRows.length === 0 ? null : backgroundRows, memories: memoryRows.length === 0 ? null : memoryRows, relationships: relationshipRows.length === 0 ? null : relationshipRows, personalities: personalityRows.length === 0 ? null : personalityRows, backgrounds: backgroundRows.length === 0 ? null : backgroundRows, memories: memoryRows.length === 0 ? null : memoryRows,
}; };
} }
function isSameSerializedValue(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
export function toUpdateCharacterOptionalRequest(value: CharacterOptionalFieldsValue, initialValue: CharacterOptionalFieldsValue): UpdateOptionalRequest {
const current = toSerializedUpdateCharacterOptionalRequest(value);
const initial = toSerializedUpdateCharacterOptionalRequest(initialValue);
const request: MutableUpdateOptionalRequest = {};
function addChanged<Key extends keyof UpdateOptionalRequest>(key: Key): void {
if (!isSameSerializedValue(current[key], initial[key])) {
request[key] = current[key];
}
}
for (const key of updateOptionalKeys) {
addChanged(key);
}
return request;
}

View File

@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { navigateTo } from "@/app/browser-location"; import { navigateTo } from "@/app/browser-location";
import { routePaths } from "@/app/route-paths"; import { routePaths } from "@/app/route-paths";
import { getCharacter, updateCharacter } from "@/features/characters/api/character-api"; import { getCharacter, updateCharacter } from "@/features/characters/api/character-api";
import type { UpdateCharacterParams } from "@/features/characters/api/character-api";
import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields"; import { CharacterOptionalFields } from "@/features/characters/components/CharacterOptionalFields";
import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization"; import { characterOptionalFieldsFromDetail, toUpdateCharacterOptionalRequest } from "@/features/characters/components/character-optional-field-serialization";
import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField"; import { OriginalWorkSearchField } from "@/features/characters/components/OriginalWorkSearchField";
@@ -64,6 +65,10 @@ function validateImage(image: File | null): string | undefined {
return "JPEG 또는 PNG 파일만 업로드하세요."; return "JPEG 또는 PNG 파일만 업로드하세요.";
} }
function hasRequestFields(request: UpdateCharacterParams["request"]): boolean {
return Object.keys(request).length > 0;
}
function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) { function CharacterEditForm({ apiClient, character, createCropSource, renderCrop }: { readonly apiClient: ApiClient; readonly character: CharacterDetail; readonly createCropSource: (file: File) => Promise<CropSourceImage>; readonly renderCrop?: (request: CropRenderRequest) => Promise<File> }) {
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [description, setDescription] = useState(character.description); const [description, setDescription] = useState(character.description);
@@ -78,7 +83,19 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const imageSelectionId = useRef(0); const imageSelectionId = useRef(0);
const detailPath = routePaths.aiCharacterDetail(String(character.id)); const detailPath = routePaths.aiCharacterDetail(String(character.id));
const dirty = image !== null || isImagePreparing || cropSource !== null || description !== character.description || name !== character.name || originalWork?.id !== character.originalWork?.id || systemPrompt !== character.systemPrompt || JSON.stringify(optionalFields) !== JSON.stringify(characterOptionalFieldsFromDetail(character)); const initialOptionalFields = characterOptionalFieldsFromDetail(character);
const initialDescription = character.description.trim();
const initialName = character.name.trim();
const initialSystemPrompt = character.systemPrompt.trim();
const request: UpdateCharacterParams["request"] = {
...toUpdateCharacterOptionalRequest(optionalFields, initialOptionalFields),
...(description.trim() === initialDescription ? {} : { description: description.trim() }),
...(name.trim() === initialName ? {} : { name: name.trim() }),
...(originalWork !== null && originalWork.id !== character.originalWork?.id ? { originalWorkId: originalWork.id } : {}),
...(systemPrompt.trim() === initialSystemPrompt ? {} : { systemPrompt: systemPrompt.trim() }),
};
const hasChanges = image !== null || hasRequestFields(request);
const dirty = hasChanges || isImagePreparing || cropSource !== null;
const imageSubmitBlocked = isImagePreparing || cropSource !== null; const imageSubmitBlocked = isImagePreparing || cropSource !== null;
useEffect(() => () => cropSource?.release?.(), [cropSource]); useEffect(() => () => cropSource?.release?.(), [cropSource]);
@@ -143,6 +160,9 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
if (isSubmitting) { if (isSubmitting) {
return; return;
} }
if (!hasChanges) {
return;
}
const nextErrors = validate(); const nextErrors = validate();
setErrors(nextErrors); setErrors(nextErrors);
@@ -155,13 +175,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
try { try {
await updateCharacter(apiClient, String(character.id), { await updateCharacter(apiClient, String(character.id), {
image: image ?? undefined, image: image ?? undefined,
request: { request,
...toUpdateCharacterOptionalRequest(optionalFields),
description: description.trim(),
name: name.trim(),
originalWorkId: originalWork?.id,
systemPrompt: systemPrompt.trim(),
},
}); });
navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." }); navigateTo(routePaths.aiCharacterDetail(String(character.id)), { successNotification: "AI 캐릭터를 저장했습니다." });
} catch (error: unknown) { } catch (error: unknown) {
@@ -208,7 +222,7 @@ function CharacterEditForm({ apiClient, character, createCropSource, renderCrop
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} type="button"> <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(detailPath))} type="button">
</button> </button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSubmitting || imageSubmitBlocked} type="submit"> <button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSubmitting || imageSubmitBlocked || !hasChanges} type="submit">
{isSubmitting ? "저장 중" : "저장"} {isSubmitting ? "저장 중" : "저장"}
</button> </button>
</div> </div>

View File

@@ -67,23 +67,12 @@ const inactiveCharacter: CharacterDetail = {
isActive: false, isActive: false,
}; };
const activeCharacterOptionalPayload = { const characterWithWhitespaceDirectFields: CharacterDetail = {
age: null, ...activeCharacter,
gender: null, name: " 루나 ",
mbti: null, description: " 차분한 상담형 캐릭터 ",
speechPattern: null, systemPrompt: " 친절하게 답한다. ",
speechStyle: null, };
appearance: null,
characterType: "Character",
tags: null,
hobbies: null,
values: null,
goals: null,
relationships: null,
personalities: null,
backgrounds: null,
memories: null,
} as const;
function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient { function createEditClient(requests: CapturedRequest[], character: CharacterDetail = activeCharacter): ApiClient {
return { return {
@@ -185,7 +174,7 @@ function renderOriginalCrop(request: CropRenderRequest): Promise<File> {
return Promise.resolve(request.file); return Promise.resolve(request.file);
} }
test("CharacterEditPage submits changed fields without region or active state", async () => { test("CharacterEditPage submits only the changed direct field", async () => {
// Given // Given
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit"); window.history.pushState({}, "", "/ai-characters/101/edit");
@@ -193,8 +182,6 @@ test("CharacterEditPage submits changed fields without region or active state",
// When // When
fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } }); fireEvent.change(await screen.findByLabelText("이름"), { target: { value: "루나 수정" } });
fireEvent.change(screen.getByLabelText("시스템 프롬프트"), { target: { value: "짧고 안전하게 답한다." } });
fireEvent.change(screen.getByLabelText("설명"), { target: { value: "업데이트된 상담형 캐릭터" } });
fireEvent.click(screen.getByRole("button", { name: "저장" })); fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then // Then
@@ -203,12 +190,44 @@ test("CharacterEditPage submits changed fields without region or active state",
{ path: "/api/v2/admin/ai-characters/101", method: undefined }, { path: "/api/v2/admin/ai-characters/101", method: undefined },
{ path: "/api/v2/admin/ai-characters/101", method: "PUT" }, { path: "/api/v2/admin/ai-characters/101", method: "PUT" },
]); ]);
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ name: "루나 수정" });
...activeCharacterOptionalPayload, });
name: "루나 수정",
systemPrompt: "짧고 안전하게 답한다.", test("CharacterEditPage disables save when no field or image changed", async () => {
description: "업데이트된 상담형 캐릭터", // Given
}); const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit");
render(<CharacterEditPage apiClient={createEditClient(requests)} characterId="101" />);
// When
const saveButton = await screen.findByRole("button", { name: "저장" });
// Then
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
});
test("CharacterEditPage compares direct fields after applying the same trim rule", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit");
render(<CharacterEditPage apiClient={createEditClient(requests, characterWithWhitespaceDirectFields)} characterId="101" />);
// When
const saveButton = await screen.findByRole("button", { name: "저장" });
// Then
expect(saveButton).toBeDisabled();
// When
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나 수정" } });
expect(saveButton).toBeEnabled();
fireEvent.change(screen.getByLabelText("이름"), { target: { value: "루나" } });
// Then
expect(saveButton).toBeDisabled();
expect(requests).toEqual([{ path: "/api/v2/admin/ai-characters/101", method: undefined }]);
}); });
test("CharacterEditPage blocks the direct edit route for inactive characters", async () => { test("CharacterEditPage blocks the direct edit route for inactive characters", async () => {
@@ -282,7 +301,7 @@ test("CharacterEditPage keeps entered values and retries after an ApiError rejec
expect(requests).toHaveLength(3); expect(requests).toHaveLength(3);
}); });
test("CharacterEditPage submits optional scalar and array fields without region", async () => { test("CharacterEditPage submits only changed optional scalar fields", async () => {
// Given // Given
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/edit"); window.history.pushState({}, "", "/ai-characters/101/edit");
@@ -296,26 +315,8 @@ test("CharacterEditPage submits optional scalar and array fields without region"
// Then // Then
await waitFor(() => expect(requests).toHaveLength(2)); await waitFor(() => expect(requests).toHaveLength(2));
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
age: "24",
gender: "여성",
mbti: "INFJ",
speechPattern: "존댓말",
speechStyle: "부드러움",
appearance: "긴 머리",
originalTitle: "달빛 상담소", originalTitle: "달빛 상담소",
originalLink: "https://example.com/original", originalLink: "https://example.com/original",
characterType: "Character",
tags: ["상담", "힐링"],
hobbies: ["독서", "산책"],
values: ["안전"],
goals: ["도움"],
relationships: [{ personName: "테오", relationshipName: "친구", description: "오랜 친구", importance: 3, relationshipType: "friend", currentStatus: "active" }],
personalities: [{ trait: "차분함", description: "침착하게 응대" }],
backgrounds: [{ topic: "출신", description: "달빛 상담소" }],
memories: [{ title: "첫 상담", content: "따뜻한 기억", emotion: "calm" }],
}); });
}); });
@@ -350,9 +351,6 @@ test("CharacterEditPage serializes cleared optional scalar and array fields as n
// Then // Then
await waitFor(() => expect(requests).toHaveLength(2)); await waitFor(() => expect(requests).toHaveLength(2));
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
age: null, age: null,
gender: null, gender: null,
mbti: null, mbti: null,
@@ -422,6 +420,7 @@ test("CharacterEditPage crops a replacement image before update", async () => {
const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image")); const submittedImage = requireFile(requireFormData(requests[1]?.body).get("image"));
expect(submittedImage.name).toBe("luna-cropped.png"); expect(submittedImage.name).toBe("luna-cropped.png");
expect(await submittedImage.text()).toBe("cropped"); expect(await submittedImage.text()).toBe("cropped");
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({});
}); });
test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => { test("CharacterEditPage blocks submit while replacement crop source preparation is pending", async () => {
@@ -537,11 +536,10 @@ test("CharacterEditPage keeps the existing image when replacement crop is cancel
// When // When
fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } }); fireEvent.change(await screen.findByLabelText("프로필 이미지"), { target: { files: [originalImage] } });
fireEvent.click(await screen.findByRole("button", { name: "취소" })); fireEvent.click(await screen.findByRole("button", { name: "취소" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then // Then
await waitFor(() => expect(requests).toHaveLength(2)); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
expect(requireFormData(requests[1]?.body).get("image")).toBeNull(); expect(requests).toHaveLength(1);
expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /기존 이미지 삭제/ })).not.toBeInTheDocument();
}); });
@@ -563,15 +561,9 @@ test("P10-T1 CharacterEditPage searches v2 original works and omits originalWork
fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" })); fireEvent.click(await screen.findByRole("button", { name: "별빛 기록실 선택" }));
expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument(); expect(screen.getByText("선택된 원작: 별빛 기록실")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" })); fireEvent.click(screen.getByRole("button", { name: "원작 선택 해제" }));
fireEvent.click(screen.getByRole("button", { name: "저장" }));
// Then // Then
await waitFor(() => expect(requests).toHaveLength(3)); expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
expect(requests).toHaveLength(2);
expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B"); expect(requests[1]?.path).toBe("/api/v2/admin/ai-characters/original-works/search?searchTerm=%EB%B3%84%EB%B9%9B");
expect(await readJsonPart(requireFormData(requests[2]?.body).get("request"))).toEqual({
...activeCharacterOptionalPayload,
name: "루나",
systemPrompt: "친절하게 답한다.",
description: "차분한 상담형 캐릭터",
});
}); });

View File

@@ -25,9 +25,9 @@ export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDe
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p> <p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{replyActionLabel !== undefined && onShowReplies !== undefined ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{label} {replyActionLabel}</button> : null} {replyActionLabel !== undefined && onShowReplies !== undefined ? <button aria-label={`${label} ${replyActionLabel}`} className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{replyActionLabel}</button> : null}
{canEdit ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">{label} </button> : null} {canEdit ? <button aria-label={`${label} 수정`} className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button"></button> : null}
{canDelete ? <button className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button">{label} </button> : null} {canDelete ? <button aria-label={`${label} 삭제`} className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button"></button> : null}
</div> </div>
</div> </div>
{isEditing ? ( {isEditing ? (

View File

@@ -167,7 +167,7 @@ export function CommentThread({ apiClient, canMutate = true, target }: { readonl
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{roots.data.items.map((comment) => ( {roots.data.items.map((comment) => (
<div className="flex flex-col gap-3" key={comment.id}> <div className="flex flex-col gap-3" key={comment.id}>
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} replyActionLabel={comment.replyCount > 0 || expandedRootIds.includes(comment.id) ? "답글 보기" : requestTarget.kind === "audio" && canMutate ? "답글 작성" : undefined} /> <CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} replyActionLabel={comment.replyCount > 0 || expandedRootIds.includes(comment.id) ? "답글 보기" : canMutate ? "답글 작성" : undefined} />
{renderReplies(comment)} {renderReplies(comment)}
</div> </div>
))} ))}

View File

@@ -135,7 +135,7 @@ function getFormForControl(control: HTMLElement): HTMLFormElement {
throw new Error("expected parent form"); throw new Error("expected parent form");
} }
test("CommentThread opens the existing reply form for a first Audio reply only", async () => { test("CommentThread opens the existing reply form for a first Audio reply", async () => {
// Given // Given
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
render(<CommentThread apiClient={createThreadClient(requests)} target={target} />); render(<CommentThread apiClient={createThreadClient(requests)} target={target} />);
@@ -154,21 +154,55 @@ test("CommentThread opens the existing reply form for a first Audio reply only",
expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument(); expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument();
}); });
test("CommentThread keeps first-reply entry out of Community and read-only Audio roots", async () => { test("CommentThread keeps contextual action names while showing concise button labels", async () => {
// Given
const requests: CapturedRequest[] = [];
render(<CommentThread apiClient={createThreadClient(requests)} target={target} />);
const showRepliesButton = await screen.findByRole("button", { name: "팬 루트 댓글 답글 보기" });
const writeReplyButton = screen.getByRole("button", { name: "AI 루트 댓글 답글 작성" });
const rootEditButton = screen.getByRole("button", { name: "AI 루트 댓글 수정" });
const rootDeleteButton = screen.getByRole("button", { name: "팬 루트 댓글 삭제" });
// When
fireEvent.click(showRepliesButton);
const repliesRegion = await screen.findByRole("region", { name: "팬 루트 댓글 답글" });
const replyArticle = within(repliesRegion).getByRole("article", { name: "루나 댓글" });
const replyEditButton = within(replyArticle).getByRole("button", { name: "AI 답글 수정" });
const replyDeleteButton = within(replyArticle).getByRole("button", { name: "AI 답글 삭제" });
// Then
expect(within(replyArticle).queryByRole("button", { name: /답글 (작성|보기)/ })).not.toBeInTheDocument();
expect(showRepliesButton.textContent).toBe("답글 보기");
expect(writeReplyButton.textContent).toBe("답글 작성");
expect(rootEditButton.textContent).toBe("수정");
expect(rootDeleteButton.textContent).toBe("삭제");
expect(replyEditButton.textContent).toBe("수정");
expect(replyDeleteButton.textContent).toBe("삭제");
});
test("CommentThread creates a first Community reply and keeps read-only Community roots closed", async () => {
// Given // Given
const communityRequests: CapturedRequest[] = []; const communityRequests: CapturedRequest[] = [];
const readOnlyRequests: CapturedRequest[] = []; const readOnlyRequests: CapturedRequest[] = [];
// When // When
const { unmount } = render(<CommentThread apiClient={createThreadClient(communityRequests)} target={communityTarget} />); const { unmount } = render(<CommentThread apiClient={createThreadClient(communityRequests)} target={communityTarget} />);
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "AI 루트 댓글 답글 작성" }));
const repliesRegion = await screen.findByRole("region", { name: "AI 루트 댓글 답글" });
await waitFor(() => expect(communityRequests.filter((request) => request.method === undefined && request.path.includes("/1102/replies?page=0&size=20"))).toHaveLength(1));
fireEvent.change(within(repliesRegion).getByLabelText("AI 루트 댓글에 답글"), { target: { value: " 커뮤니티 첫 답글 " } });
fireEvent.click(within(repliesRegion).getByRole("button", { name: "답글 등록" }));
// Then // Then
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument(); await waitFor(() => expect(communityRequests.filter((request) => request.method === "POST")).toEqual([
expect(screen.queryByRole("button", { name: "AI 루트 댓글 답글 작성" })).not.toBeInTheDocument(); { body: JSON.stringify({ comment: "커뮤니티 첫 답글", parentId: 1102, isSecret: false }), method: "POST", path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments" },
]));
expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument();
unmount(); unmount();
// When // When
render(<CommentThread apiClient={createThreadClient(readOnlyRequests)} canMutate={false} target={target} />); render(<CommentThread apiClient={createThreadClient(readOnlyRequests)} canMutate={false} target={communityTarget} />);
// Then // Then
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument(); expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api"; import { softDeleteCommunityPost, updateCommunityPost } from "@/features/community-posts/api/community-post-api";
import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy"; import { COMMUNITY_POST_IMAGE_POLICY, prepareCommunityPostImage } from "@/features/community-posts/validation/community-post-media-policy";
import type { CommunityPostListItem } from "@/features/community-posts/model/types"; import type { CommunityPostListItem, CommunityPostUpdateRequest } from "@/features/community-posts/model/types";
import { CommentThread } from "@/features/comments/components/CommentThread"; import { CommentThread } from "@/features/comments/components/CommentThread";
import { ApiError } from "@/shared/api/api-error"; import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client"; import type { ApiClient } from "@/shared/api/client";
@@ -29,7 +29,19 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const imageSelectionId = useRef(0); const imageSelectionId = useRef(0);
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true); const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
const updateRequest: CommunityPostUpdateRequest = {};
if (content !== post.content) {
updateRequest.content = content;
}
if (isAdult !== post.isAdult) {
updateRequest.isAdult = isAdult;
}
if (isCommentAvailable !== post.isCommentAvailable) {
updateRequest.isCommentAvailable = isCommentAvailable;
}
const hasPostChanges = Object.keys(updateRequest).length > 0 || postImage !== null;
const isMutationDisabled = isSaving || isImagePreparing || cropSource !== null; const isMutationDisabled = isSaving || isImagePreparing || cropSource !== null;
const isSaveDisabled = isMutationDisabled || imageErrorMessage !== undefined || !hasPostChanges;
useEffect(() => () => cropSource?.release?.(), [cropSource]); useEffect(() => () => cropSource?.release?.(), [cropSource]);
@@ -38,14 +50,14 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
} }
async function savePost() { async function savePost() {
if (isSavingRef.current || imageErrorMessage !== undefined || isImagePreparing || cropSource !== null) { if (isSavingRef.current || isSaveDisabled) {
return; return;
} }
isSavingRef.current = true; isSavingRef.current = true;
setIsSaving(true); setIsSaving(true);
setErrorMessage(null); setErrorMessage(null);
try { try {
await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: { content, isAdult, isCommentAvailable, isFixed: post.isFixed } }); await updateCommunityPost(apiClient, characterId, String(post.postId), { postImage: postImage ?? undefined, request: updateRequest });
onMutated(); onMutated();
} catch (error: unknown) { } catch (error: unknown) {
setErrorMessage(getMutationErrorMessage(error)); setErrorMessage(getMutationErrorMessage(error));
@@ -191,7 +203,7 @@ export function CommunityPostSheet({ apiClient, canMutate, canMutateComments = c
{post.firstComment === null ? null : <p className="rounded-lg border border-border bg-card p-3 text-sm"> : {post.firstComment.comment}</p>} {post.firstComment === null ? null : <p className="rounded-lg border border-border bg-card p-3 text-sm"> : {post.firstComment.comment}</p>}
{post.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={canMutateComments} target={{ kind: "community", characterId, postId: String(post.postId), creatorId: post.creatorId }} /> : null} {post.isCommentAvailable ? <CommentThread apiClient={apiClient} canMutate={canMutateComments} target={{ kind: "community", characterId, postId: String(post.postId), creatorId: post.creatorId }} /> : null}
{canMutate ? <div className="flex flex-wrap gap-2"> {canMutate ? <div className="flex flex-wrap gap-2">
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void savePost()} type="button"> </button> <button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaveDisabled} onClick={() => void savePost()} type="button"> </button>
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void toggleFixed()} type="button">{post.isFixed ? "고정 해제" : "고정하기"}</button> <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => void toggleFixed()} type="button">{post.isFixed ? "고정 해제" : "고정하기"}</button>
<button className="rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => setIsDeleteDialogOpen(true)} type="button"></button> <button className="rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isMutationDisabled} onClick={() => setIsDeleteDialogOpen(true)} type="button"></button>
</div> : null} </div> : null}

View File

@@ -111,7 +111,7 @@ test("Community Sheet sends actual multipart update requests without audio or pr
const updateBody = requireFormData(requests[0]?.body); const updateBody = requireFormData(requests[0]?.body);
expect(updateBody.has("audioFile")).toBe(false); expect(updateBody.has("audioFile")).toBe(false);
expect(updateBody.has("price")).toBe(false); expect(updateBody.has("price")).toBe(false);
expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false, isFixed: false }); expect(await readJsonPart(updateBody.get("request"))).toEqual({ content: "수정된 커뮤니티 게시글", isAdult: true, isCommentAvailable: false });
// When // When
fireEvent.click(screen.getByRole("button", { name: "비활성화" })); fireEvent.click(screen.getByRole("button", { name: "비활성화" }));
@@ -129,6 +129,41 @@ test("Community Sheet sends actual multipart update requests without audio or pr
expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false, isFixed: false }); expect(await readJsonPart(requireFormData(requests[1]?.body).get("request"))).toEqual({ isActive: false, isFixed: false });
}); });
test("Community Sheet disables save when no field or image changed", () => {
// Given
const requests: CapturedApiRequest[] = [];
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
// When
const saveButton = screen.getByRole("button", { name: "수정 저장" });
// Then
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(requests).toHaveLength(0);
});
test("Community Sheet preserves whitespace-only content changes", async () => {
// Given
const requests: CapturedApiRequest[] = [];
const changedContent = ` ${basePost.content} `;
render(<CommunityPostSheet apiClient={createSheetClient(requests)} canMutate characterId="101" createCropSource={createCropSource} onClose={() => undefined} onDeleted={() => undefined} onMutated={() => undefined} post={basePost} />);
// When
fireEvent.change(screen.getByLabelText("내용"), { target: { value: changedContent } });
const saveButton = screen.getByRole("button", { name: "수정 저장" });
// Then
expect(saveButton).not.toBeDisabled();
// When
fireEvent.click(saveButton);
// Then
await waitFor(() => expect(requests).toHaveLength(1));
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({ content: changedContent });
});
test("Community Sheet keeps save impossible while image preparation is pending", async () => { test("Community Sheet keeps save impossible while image preparation is pending", async () => {
// Given // Given
let resolveCropSource: (source: CropSourceImage) => void = () => undefined; let resolveCropSource: (source: CropSourceImage) => void = () => undefined;
@@ -175,6 +210,7 @@ test("Community Sheet ignores stale image preparation results", async () => {
await waitFor(() => expect(requests).toHaveLength(1)); await waitFor(() => expect(requests).toHaveLength(1));
expect(requireFormData(requests[0]?.body).get("postImage")).toBe(freshGif); expect(requireFormData(requests[0]?.body).get("postImage")).toBe(freshGif);
expect(await readJsonPart(requireFormData(requests[0]?.body).get("request"))).toEqual({});
}); });
test("Community Sheet resets saving state and shows an alert when mutation fails", async () => { test("Community Sheet resets saving state and shows an alert when mutation fails", async () => {
@@ -191,6 +227,7 @@ test("Community Sheet resets saving state and shows an alert when mutation fails
render(<App />); render(<App />);
fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]); fireEvent.click((await screen.findAllByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }))[0]);
fireEvent.change(screen.getByLabelText("내용"), { target: { value: "저장 실패 테스트" } });
fireEvent.click(screen.getByRole("button", { name: "수정 저장" })); fireEvent.click(screen.getByRole("button", { name: "수정 저장" }));
expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패"); expect(await screen.findByRole("alert")).toHaveTextContent("저장 실패");

View File

@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control"; import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) { export function FanTalkReplyForm({ content, errorMessage, isSaving, isSubmitDisabled = false, onChange, onSubmit, submitLabel = "답변 등록" }: { readonly content: string; readonly errorMessage: string | null; readonly isSaving: boolean; readonly isSubmitDisabled?: boolean; readonly onChange: (content: string) => void; readonly onSubmit: () => void; readonly submitLabel?: string }) {
const errorId = "fan-talk-reply-error"; const errorId = "fan-talk-reply-error";
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null); const [contentErrorMessage, setContentErrorMessage] = useState<string | null>(null);
@@ -39,7 +39,7 @@ export function FanTalkReplyForm({ content, errorMessage, isSaving, onChange, on
}} value={content} /> }} value={content} />
</label> </label>
{visibleErrorMessage === null ? null : <p aria-label="답변 내용 오류" className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{visibleErrorMessage}</p>} {visibleErrorMessage === null ? null : <p aria-label="답변 내용 오류" className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{visibleErrorMessage}</p>}
<button className="w-fit rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving} type="submit"> <button className="w-fit rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isSubmitDisabled} type="submit">
{submitLabel} {submitLabel}
</button> </button>
</form> </form>

View File

@@ -36,9 +36,10 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true); const { dialogRef, trapFocus } = useModalFocus<HTMLDivElement>(true);
const visibleReply = savedReply ?? existingReply; const visibleReply = savedReply ?? existingReply;
const isEditing = !startedWithoutReply && existingReply !== null; const isEditing = !startedWithoutReply && existingReply !== null;
const isReplyChanged = existingReply === null || content !== existingReply.content;
async function submitReply() { async function submitReply() {
if (isSavingRef.current) { if (isSavingRef.current || (isEditing && !isReplyChanged)) {
return; return;
} }
@@ -113,7 +114,7 @@ export function FanTalkReplySheet({ apiClient, characterId, fanTalk, onClose, on
<p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p> <p className="mt-2 text-xs text-muted-foreground">{formatSeoulDateTime(fanTalk.createdAtUtc)}</p>
</section> </section>
{successMessage === null ? null : <p aria-label="답변 저장 성공" className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{successMessage}</p>} {successMessage === null ? null : <p aria-label="답변 저장 성공" className="rounded-md border border-border bg-success-surface p-3 text-sm font-semibold text-success" role="status">{successMessage}</p>}
{visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />} {visibleReply === null || isEditing ? <FanTalkReplyForm content={content} errorMessage={errorMessage} isSaving={isSaving} isSubmitDisabled={isEditing && !isReplyChanged} onChange={setContent} onSubmit={() => void submitReply()} submitLabel={isEditing ? "답변 수정" : "답변 등록"} /> : <ReadonlyReply reply={visibleReply} />}
{visibleReply === null || errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>} {visibleReply === null || errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
<button className="w-fit rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsDeleteDialogOpen(true)} type="button">FanTalk </button> <button className="w-fit rounded-md border border-destructive bg-card px-4 py-2 font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsDeleteDialogOpen(true)} type="button">FanTalk </button>
</div> </div>

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(); expect(screen.getAllByRole("button", { name: "두 번째로 온 응원입니다. 답변 보기" })[0]).toBeInTheDocument();
}); });
test("FanTalk reply edit only submits when content changed", async () => {
// Given
saveAdminSession();
vi.stubEnv("VITE_API_BASE_URL", apiBaseUrl);
useAiCharactersResponse();
useAiCharacterDetailResponse("101");
const requests: Request[] = [];
const state = { items: [pendingFanTalk, answeredFanTalk] };
await renderFanTalkPage(state, requests);
clickFirstButton("두 번째로 온 응원입니다. 답변 보기");
const dialog = screen.getByRole("dialog", { name: "FanTalk 답변" });
const contentInput = within(dialog).getByLabelText("답변 내용");
const submitButton = within(dialog).getByRole("button", { name: "답변 수정" });
// When / Then
expect(submitButton).toBeDisabled();
fireEvent.click(submitButton);
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
// When
fireEvent.change(contentInput, { target: { value: "수정한 답변입니다." } });
// Then
expect(submitButton).not.toBeDisabled();
// When
fireEvent.change(contentInput, { target: { value: "이미 답변했습니다." } });
// Then
expect(submitButton).toBeDisabled();
fireEvent.click(submitButton);
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
// When
fireEvent.change(contentInput, { target: { value: "수정한 답변입니다." } });
fireEvent.click(submitButton);
// Then
await waitFor(() => expect(requests.filter((request) => request.method === "PUT")).toHaveLength(1));
});
test("FanTalk reply fast double submit sends one POST, reflects returned reply, and refreshes current page", async () => { test("FanTalk reply fast double submit sends one POST, reflects returned reply, and refreshes current page", async () => {
// Given // Given
saveAdminSession(); saveAdminSession();

View File

@@ -6,7 +6,7 @@ import { createSeries, deactivateSeries, updateSeries } from "@/features/series/
import { GenreCombobox } from "@/features/series/components/GenreCombobox"; import { GenreCombobox } from "@/features/series/components/GenreCombobox";
import { PublishedDaysField } from "@/features/series/components/PublishedDaysField"; import { PublishedDaysField } from "@/features/series/components/PublishedDaysField";
import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types"; import type { SeriesGenreItem, SeriesListItem } from "@/features/series/model/types";
import type { SeriesPublishedDay, SeriesState } from "@/features/series/schemas/series-schema"; import type { SeriesPublishedDay, SeriesState, SeriesUpdateRequest } from "@/features/series/schemas/series-schema";
import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy"; import { SERIES_IMAGE_POLICY, validateSeriesImageFile } from "@/features/series/validation/series-image-policy";
import { ApiError } from "@/shared/api/api-error"; import { ApiError } from "@/shared/api/api-error";
import type { ApiClient } from "@/shared/api/client"; import type { ApiClient } from "@/shared/api/client";
@@ -69,10 +69,6 @@ function dayError(days: readonly SeriesPublishedDay[]): string | undefined {
return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined; return days.includes("RANDOM") && days.length > 1 ? "랜덤은 단독으로만 선택하세요." : undefined;
} }
function editedState(state: SeriesState, series: SeriesListItem | undefined): SeriesState | undefined {
return state === (series?.state ?? "PROCEEDING") ? undefined : state;
}
function hasErrors(errors: FieldErrors): boolean { function hasErrors(errors: FieldErrors): boolean {
return Object.values(errors).some((error) => error !== undefined); return Object.values(errors).some((error) => error !== undefined);
} }
@@ -100,8 +96,40 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
const isDeactivatingRef = useRef(false); const isDeactivatingRef = useRef(false);
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const seriesId = series === undefined ? null : String(series.seriesId); const seriesId = series === undefined ? null : String(series.seriesId);
const dirty = image !== null || title !== (series?.title ?? "") || introduction !== (series?.introduction ?? "") || keyword !== "" || genreId !== (series?.genreId ?? null) || isAdult !== (series?.isAdult ?? false) || publishedDays.join(",") !== (series?.publishedDaysOfWeek ?? []).join(",") || state !== (series?.state ?? "PROCEEDING") || studio !== (series?.studio ?? "") || writer !== (series?.writer ?? "");
const isImageSubmitBlocked = isImagePreparing || cropSource !== null; const isImageSubmitBlocked = isImagePreparing || cropSource !== null;
const editRequest: SeriesUpdateRequest = {};
if (mode === "edit" && series !== undefined) {
const nextTitle = title.trim();
const nextIntroduction = introduction.trim();
const nextWriter = textOrNull(writer);
const nextStudio = textOrNull(studio);
if (nextTitle !== series.title.trim()) {
editRequest.title = nextTitle;
}
if (nextIntroduction !== series.introduction.trim()) {
editRequest.introduction = nextIntroduction;
}
if (publishedDays.join(",") !== series.publishedDaysOfWeek.join(",")) {
editRequest.publishedDaysOfWeek = [...publishedDays];
}
if (genreId !== series.genreId) {
editRequest.genreId = genreId;
}
if (isAdult !== series.isAdult) {
editRequest.isAdult = isAdult;
}
if (state !== series.state) {
editRequest.state = state;
}
if (nextWriter !== textOrNull(series.writer ?? "")) {
editRequest.writer = nextWriter;
}
if (nextStudio !== textOrNull(series.studio ?? "")) {
editRequest.studio = nextStudio;
}
}
const hasEditChanges = image !== null || Object.keys(editRequest).length > 0;
const dirty = mode === "create" ? image !== null || title !== "" || introduction !== "" || keyword !== "" || genreId !== null || isAdult || publishedDays.length > 0 || studio !== "" || writer !== "" : hasEditChanges;
useEffect(() => () => cropSource?.release?.(), [cropSource]); useEffect(() => () => cropSource?.release?.(), [cropSource]);
@@ -157,7 +185,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
async function submit(event: { readonly preventDefault: () => void }): Promise<void> { async function submit(event: { readonly preventDefault: () => void }): Promise<void> {
event.preventDefault(); event.preventDefault();
if (isSavingRef.current) { if (isSavingRef.current || (mode === "edit" && !hasEditChanges)) {
return; return;
} }
const nextErrors = validate(); const nextErrors = validate();
@@ -175,7 +203,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
return; return;
} }
if (mode === "edit" && seriesId !== null) { if (mode === "edit" && seriesId !== null) {
await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: { title: title.trim(), introduction: introduction.trim(), publishedDaysOfWeek: [...publishedDays], genreId, isAdult, state: editedState(state, series), writer: textOrNull(writer), studio: textOrNull(studio) } }); await updateSeries(apiClient, { characterId, seriesId, image: image ?? undefined, request: editRequest });
navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." }); navigateTo(routePaths.aiCharacterSeriesDetail(characterId, seriesId), { successNotification: "시리즈를 저장했습니다." });
} }
} catch (error: unknown) { } catch (error: unknown) {
@@ -234,7 +262,7 @@ export function SeriesForm({ apiClient, characterId, createCropSource, genres, m
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null} {mode === "edit" ? <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => setIsDeactivateDialogOpen(true)} type="button"></button> : null}
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} type="button"></button> <button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={(event) => requestRouteLeave(event.currentTarget, () => navigateTo(mode === "create" ? routePaths.aiCharacterSeries(characterId) : routePaths.aiCharacterSeriesDetail(characterId, seriesId ?? "")))} type="button"></button>
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isImageSubmitBlocked} type="submit">{mode === "create" ? "생성" : "저장"}</button> <button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || isImageSubmitBlocked || (mode === "edit" && !hasEditChanges)} type="submit">{mode === "create" ? "생성" : "저장"}</button>
</div> </div>
</form> </form>
{cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_POLICY} renderCrop={renderCrop} />} {cropSource === null ? null : <ImageCropDialog image={cropSource} onApply={(file) => { setImage(file); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} onCancel={() => { setImage(null); setCropSource(null); setErrors((current) => ({ ...current, image: undefined })); }} open policy={SERIES_IMAGE_POLICY} renderCrop={renderCrop} />}

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

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)); await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData; const updateBody = requests.find((request) => request.method === "PUT")?.body as FormData;
expect(updateBody.get("image")).toBeNull(); expect(updateBody.get("image")).toBeNull();
await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ title: "달빛 상담 시리즈", introduction: "밤마다 이어지는 상담 에피소드", publishedDaysOfWeek: ["RANDOM"], genreId: 77, isAdult: false, state: "COMPLETE", writer: "스튜디오 루나", studio: "소다랩" })); await expect((updateBody.get("request") as Blob).text()).resolves.toBe(JSON.stringify({ publishedDaysOfWeek: ["RANDOM"], state: "COMPLETE" }));
expect(window.location.pathname).toBe("/ai-characters/101/series/5001"); expect(window.location.pathname).toBe("/ai-characters/101/series/5001");
// When // When
@@ -249,15 +249,59 @@ test("SeriesForm omits unchanged state from edit payload", async () => {
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true)); await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({ await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({
title: "달빛 상담 시리즈 수정", title: "달빛 상담 시리즈 수정",
introduction: "밤마다 이어지는 상담 에피소드",
publishedDaysOfWeek: ["SUN", "WED"],
genreId: 77,
isAdult: false,
writer: "스튜디오 루나",
studio: "소다랩",
}); });
}); });
test("SeriesForm disables edit save when unchanged", () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
const saveButton = screen.getByRole("button", { name: "저장" });
// Then
expect(saveButton).toBeDisabled();
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
expect(requests.filter((request) => request.method === "PUT")).toHaveLength(0);
});
test("SeriesForm sends null when optional writer is cleared", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
fireEvent.change(screen.getByLabelText("작가"), { target: { value: "" } });
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
await expect(readRequestPart(requests.find((request) => request.method === "PUT")?.body)).resolves.toEqual({ writer: null });
});
test("SeriesForm sends empty request when only image changes", async () => {
// Given
const requests: CapturedRequest[] = [];
window.history.pushState({}, "", "/ai-characters/101/series/5001/edit");
render(<SeriesForm apiClient={createClient(requests)} characterId="101" createCropSource={createCropSource} genres={genres} mode="edit" renderCrop={renderCrop} series={existingSeries} />);
// When
fireEvent.change(screen.getByLabelText("시리즈 이미지"), { target: { files: [createImage("changed.png")] } });
await screen.findByRole("dialog", { name: "이미지 crop" });
fireEvent.click(screen.getByRole("button", { name: "적용" }));
await screen.findByText("cropped-series.png");
fireEvent.submit(screen.getByRole("form", { name: "시리즈 수정 입력 화면" }));
// Then
await waitFor(() => expect(requests.some((request) => request.method === "PUT")).toBe(true));
const requestBody = requests.find((request) => request.method === "PUT")?.body as FormData;
expect(requestBody.get("image")).toBeInstanceOf(File);
await expect(readRequestPart(requestBody)).resolves.toEqual({});
});
test("SeriesForm rejects mismatched image extension and MIME before crop", async () => { test("SeriesForm rejects mismatched image extension and MIME before crop", async () => {
const requests: CapturedRequest[] = []; const requests: CapturedRequest[] = [];
let cropSourceCalls = 0; let cropSourceCalls = 0;

View File

@@ -5,7 +5,7 @@ import { build } from "vite";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
describe("production mock graph", () => { describe("production mock graph", () => {
test("excludes the browser mock module from the production bundle", async () => { test("splits production chunks and excludes the browser mock module from the production bundle", async () => {
// Given // Given
const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-")); const outDir = mkdtempSync(join(tmpdir(), "ai-character-admin-prod-"));
const previousNodeEnv = process.env.NODE_ENV; const previousNodeEnv = process.env.NODE_ENV;
@@ -21,12 +21,16 @@ describe("production mock graph", () => {
mode: "production", mode: "production",
}); });
const outputFiles = collectFiles(outDir); const outputFiles = collectFiles(outDir);
const output = outputFiles const jsFiles = outputFiles.filter((filePath) => filePath.endsWith(".js"));
.filter((filePath) => filePath.endsWith(".js")) const output = jsFiles
.map((filePath) => readFileSync(filePath, "utf8")) .map((filePath) => readFileSync(filePath, "utf8"))
.join("\n"); .join("\n");
// Then // Then
expect(jsFiles.length).toBeGreaterThanOrEqual(2);
for (const jsFile of jsFiles) {
expect(statSync(jsFile).size).toBeLessThanOrEqual(500_000);
}
expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false); expect(outputFiles.some((filePath) => filePath.endsWith("mockServiceWorker.js"))).toBe(false);
expect(output).not.toContain("mockServiceWorker.js"); expect(output).not.toContain("mockServiceWorker.js");
expect(output).not.toContain("startMockWorker"); expect(output).not.toContain("startMockWorker");

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); expect(labels.map((label) => label.control)).toEqual(selects);
}); });
test("ResourcePagination separates page size from an equal-width mobile movement row", () => { test("ResourcePagination separates page size from stacked mobile movement controls", () => {
render(<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />); render(<ResourcePagination data={pageData} onPageChange={vi.fn()} onSizeChange={vi.fn()} />);
const sizeControls = screen.getByRole("group", { name: "페이지 크기 설정" }); const sizeControls = screen.getByRole("group", { name: "페이지 크기 설정" });
@@ -67,7 +67,7 @@ test("ResourcePagination separates page size from an equal-width mobile movement
expect(within(sizeControls).getByLabelText("페이지 크기")).toBeInTheDocument(); expect(within(sizeControls).getByLabelText("페이지 크기")).toBeInTheDocument();
expect(sizeControls).not.toContainElement(previous); expect(sizeControls).not.toContainElement(previous);
expect(movementControls).toHaveClass("grid-cols-2"); expect(movementControls).toHaveClass("grid-cols-1");
expect(previous).toHaveClass("min-h-11", "w-full"); expect(previous).toHaveClass("min-h-11", "w-full", "whitespace-nowrap");
expect(next).toHaveClass("min-h-11", "w-full"); expect(next).toHaveClass("min-h-11", "w-full", "whitespace-nowrap");
}); });

View File

@@ -13,10 +13,10 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
return ( return (
<nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between"> <nav aria-label="페이지" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p> <p className="break-keep text-sm font-semibold text-muted-foreground"> {data.totalCount.toLocaleString("ko-KR")} · {data.page + 1}</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div aria-label="페이지 크기 설정" className="flex items-center gap-2" role="group"> <div aria-label="페이지 크기 설정" className="flex items-center gap-2" role="group">
<label className="text-sm font-semibold" htmlFor={pageSizeId}> <label className="break-keep text-sm font-semibold" htmlFor={pageSizeId}>
</label> </label>
<select className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}> <select className="min-h-11 rounded-md border border-input bg-card px-3 py-2 text-base" id={pageSizeId} onChange={(event) => onSizeChange(Number(event.currentTarget.value))} value={data.size}>
@@ -25,11 +25,11 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio
))} ))}
</select> </select>
</div> </div>
<div aria-label="페이지 이동" className="grid grid-cols-2 gap-2 sm:flex" role="group"> <div aria-label="페이지 이동" className="grid grid-cols-1 gap-2 sm:flex" role="group">
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button"> <button className="min-h-11 w-full whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={data.page <= 0} onClick={() => onPageChange(data.page - 1)} type="button">
</button> </button>
<button className="min-h-11 w-full rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button"> <button className="min-h-11 w-full whitespace-nowrap rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent disabled:opacity-60 sm:w-auto" disabled={!data.hasNext} onClick={() => onPageChange(data.page + 1)} type="button">
</button> </button>
</div> </div>

View File

@@ -2,6 +2,7 @@ import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import type { Locator, Page } from "@playwright/test"; import type { Locator, Page } from "@playwright/test";
// allow: SIZE_OK - Character workspace browser scenarios share one authenticated E2E surface.
const interactiveActionRoles = ["button", "link", "menuitem"] as const; const interactiveActionRoles = ["button", "link", "menuitem"] as const;
async function loginThroughMockMode(page: Page): Promise<void> { async function loginThroughMockMode(page: Page): Promise<void> {
@@ -42,6 +43,27 @@ async function zoomTo200Percent(page: Page): Promise<void> {
}); });
} }
async function expectNoKoreanSyllableColumns(root: Locator): Promise<void> {
const syllableColumns = await root.evaluate((element) => Array.from(element.querySelectorAll<HTMLElement>("*"))
.filter((candidate) => candidate.childElementCount === 0 && /[가-힣]/.test(candidate.textContent ?? ""))
.filter((candidate) => {
const style = getComputedStyle(candidate);
return style.display !== "none" && style.visibility !== "hidden" && candidate.clientWidth > 0 && candidate.clientHeight > 0;
})
.flatMap((candidate) => {
const range = document.createRange();
range.selectNodeContents(candidate);
const lineWidths = Array.from(range.getClientRects(), (rect) => rect.width).filter((width) => width > 0);
const fontSize = Number.parseFloat(getComputedStyle(candidate).fontSize);
const koreanSyllableCount = candidate.textContent?.match(/[가-힣]/g)?.length ?? 0;
const isSyllableColumn = koreanSyllableCount >= 3 && lineWidths.length >= 3 && Math.max(...lineWidths) <= fontSize * 2.25;
return isSyllableColumn ? [{ lineWidths, text: candidate.textContent?.trim() }] : [];
}));
expect(syllableColumns).toEqual([]);
}
async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> { async function pressTabUntilFocused(page: Page, target: Locator): Promise<void> {
for (let attempt = 0; attempt < 80; attempt += 1) { for (let attempt = 0; attempt < 80; attempt += 1) {
if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) { if (await target.evaluate((element) => element === document.activeElement || element.contains(document.activeElement))) {
@@ -193,6 +215,19 @@ test("mobile cards preserve the selection name and table-visible core metadata",
await expect(lunaCard).toContainText("상담, 힐링"); await expect(lunaCard).toContainText("상담, 힐링");
}); });
test("mobile zoom keeps Korean list and pagination text out of syllable columns", async ({ page }) => {
// Given
await page.setViewportSize({ width: 320, height: 640 });
await loginThroughMockMode(page);
// When
await zoomTo200Percent(page);
// Then
await expectNoKoreanSyllableColumns(page.getByRole("region", { name: "AI 캐릭터 목록" }));
await expectNoKoreanSyllableColumns(page.getByRole("navigation", { name: "페이지" }));
});
test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({ browserName, isMobile, page }) => { test("keyboard-only path reaches the edit form and dirty-leave dialog", async ({ browserName, isMobile, page }) => {
test.skip(isMobile, "Mobile view intentionally hides Character mutation actions."); test.skip(isMobile, "Mobile view intentionally hides Character mutation actions.");
test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path."); test.skip(browserName === "webkit", "WebKit does not consistently tab-focus links in this keyboard path.");

View File

@@ -87,6 +87,13 @@ test("Audio comments create reply edit AI rows and delete fan or AI rows through
test("Community sheet comments keep two-level controls usable at 320px", async ({ page }) => { test("Community sheet comments keep two-level controls usable at 320px", async ({ page }) => {
// Given // Given
const commentRequests: { readonly body: string | null; readonly method: string; readonly path: string; readonly search: string }[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname.includes("/community-posts/7001/comments")) {
commentRequests.push({ body: request.postData(), method: request.method(), path: url.pathname, search: url.search });
}
});
await page.setViewportSize({ width: 320, height: 640 }); await page.setViewportSize({ width: 320, height: 640 });
await loginThroughMockMode(page); await loginThroughMockMode(page);
@@ -99,25 +106,62 @@ test("Community sheet comments keep two-level controls usable at 320px", async (
await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible(); await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible();
await expect(dialog.getByLabel("새 댓글")).toBeInViewport(); await expect(dialog.getByLabel("새 댓글")).toBeInViewport();
await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible(); await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible();
await expect(dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" })).toHaveCount(0); const writeReply = dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" });
await expect(writeReply).toBeVisible();
await expect(writeReply).toHaveText("답글 작성");
await expectNoHorizontalOverflow(page); await expectNoHorizontalOverflow(page);
// When // When
await dialog.getByLabel("새 댓글").fill("커뮤니티 루트 생성"); await dialog.getByLabel("새 댓글").fill("커뮤니티 루트 생성");
await dialog.getByRole("button", { name: "댓글 등록" }).click(); await dialog.getByRole("button", { name: "댓글 등록" }).click();
await expect(dialog.getByText("커뮤니티 루트 생성", { exact: true })).toBeVisible(); await expect(dialog.getByText("커뮤니티 루트 생성", { exact: true })).toBeVisible();
await writeReply.click();
const emptyReplies = dialog.getByRole("region", { name: "커뮤니티 AI 루트 댓글 답글" });
const emptyReplyInput = emptyReplies.getByLabel("커뮤니티 AI 루트 댓글에 답글");
await expect(emptyReplyInput).toBeVisible();
await expect(emptyReplies.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toHaveCount(0);
await expect.poll(() => commentRequests.filter((request) => request.method === "GET" && request.path.endsWith("/2102/replies") && request.search === "?page=0&size=20")).toHaveLength(1);
await emptyReplyInput.fill(" 커뮤니티 첫 답글 생성 ");
await emptyReplies.getByRole("button", { name: "답글 등록" }).click();
await expect(emptyReplyInput).toHaveValue("");
await emptyReplyInput.fill("커뮤니티 두 번째 답글 생성");
await emptyReplies.getByRole("button", { name: "답글 등록" }).click();
await expect(emptyReplyInput).toHaveValue("");
await expect.poll(() => commentRequests.filter((request) => request.method === "POST")).toEqual([
{ body: JSON.stringify({ comment: "커뮤니티 루트 생성", parentId: null, isSecret: false }), method: "POST", path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments", search: "" },
{ body: JSON.stringify({ comment: "커뮤니티 첫 답글 생성", parentId: 2102, isSecret: false }), method: "POST", path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments", search: "" },
{ body: JSON.stringify({ comment: "커뮤니티 두 번째 답글 생성", parentId: 2102, isSecret: false }), method: "POST", path: "/api/v2/admin/ai-characters/101/community-posts/7001/comments", search: "" },
]);
await dialog.getByRole("button", { name: "닫기" }).click();
await expect(dialog).toBeHidden();
await page.getByRole("button", { name: "오늘의 상담 기록입니다. 게시글 열기" }).click();
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: /^커뮤니티 AI 루트 댓글 답글 (작성|보기)$/ }).click();
await expect(emptyReplies).toBeVisible();
await expect(emptyReplies.getByText("커뮤니티 첫 답글 생성", { exact: true })).toBeVisible();
await expect(emptyReplies.getByText("커뮤니티 두 번째 답글 생성", { exact: true })).toBeVisible();
await expect(dialog.getByText("커뮤니티 첫 답글 생성", { exact: true })).toHaveCount(1);
await expect(dialog.getByText("커뮤니티 두 번째 답글 생성", { exact: true })).toHaveCount(1);
await expect(emptyReplies.getByRole("button", { name: /^(커뮤니티 첫 답글 생성|커뮤니티 두 번째 답글 생성) 답글 (작성|보기)$/ })).toHaveCount(0);
const showReplies = dialog.getByRole("button", { name: "커뮤니티 팬 루트 댓글 답글 보기" }); const showReplies = dialog.getByRole("button", { name: "커뮤니티 팬 루트 댓글 답글 보기" });
await expect(showReplies).toBeEnabled(); await expect(showReplies).toBeEnabled();
await expect(showReplies).toHaveText("답글 보기");
await showReplies.click(); await showReplies.click();
const replies = dialog.getByRole("region", { name: "커뮤니티 팬 루트 댓글 답글" }); const replies = dialog.getByRole("region", { name: "커뮤니티 팬 루트 댓글 답글" });
await expect(replies).toBeVisible(); await expect(replies).toBeVisible();
await expect(replies.getByText("커뮤니티 AI 답글", { exact: true })).toBeVisible();
await expect(replies.getByRole("button", { name: /^커뮤니티 AI 답글 답글 (작성|보기)$/ })).toHaveCount(0);
await replies.getByLabel("커뮤니티 팬 루트 댓글에 답글").fill("커뮤니티 답글 생성"); await replies.getByLabel("커뮤니티 팬 루트 댓글에 답글").fill("커뮤니티 답글 생성");
await replies.getByRole("button", { name: "답글 등록" }).click(); await replies.getByRole("button", { name: "답글 등록" }).click();
await replies.getByRole("button", { name: "커뮤니티 AI 답글 수정" }).click(); const editReply = replies.getByRole("button", { name: "커뮤니티 AI 답글 수정" });
await expect(editReply).toHaveText("수정");
await editReply.click();
await replies.getByLabel("댓글 수정 내용").fill("커뮤니티 AI 답글 수정"); await replies.getByLabel("댓글 수정 내용").fill("커뮤니티 AI 답글 수정");
await replies.getByRole("button", { name: "수정 저장" }).click(); await replies.getByRole("button", { name: "수정 저장" }).click();
await expect.poll(() => replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" }).isEnabled()).toBe(true); const deleteReply = replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" });
await replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" }).click(); await expect(deleteReply).toHaveText("삭제");
await expect.poll(() => deleteReply.isEnabled()).toBe(true);
await deleteReply.click();
// Then // Then
await expect(replies.getByText("커뮤니티 팬 답글", { exact: true })).toBeHidden(); await expect(replies.getByText("커뮤니티 팬 답글", { exact: true })).toBeHidden();