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