Merge pull request 'feat(ai-character): 오디오 댓글 첫 답글 작성 지원' (#1) from test into main
Reviewed-on: #1
This commit is contained in:
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은 기존 제외 범위로 유지한다.
|
||||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
|||||||
import type { CommentRecord } from "@/features/comments/model/types";
|
import type { CommentRecord } from "@/features/comments/model/types";
|
||||||
import { formatSeoulDateTime } from "@/shared/lib/formatters";
|
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 [draft, setDraft] = useState(comment.comment);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const label = `${comment.comment}`;
|
const label = `${comment.comment}`;
|
||||||
@@ -25,7 +25,7 @@ export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDe
|
|||||||
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
|
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{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}
|
{replyActionLabel !== undefined && onShowReplies !== undefined ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{label} {replyActionLabel}</button> : null}
|
||||||
{canEdit ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">{label} 수정</button> : null}
|
{canEdit ? <button 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}
|
{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}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function CommentThread({ apiClient, canMutate = true, target }: { readonl
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{roots.data.items.map((comment) => (
|
{roots.data.items.map((comment) => (
|
||||||
<div className="flex flex-col gap-3" key={comment.id}>
|
<div className="flex flex-col gap-3" key={comment.id}>
|
||||||
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} 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) ? "답글 보기" : requestTarget.kind === "audio" && canMutate ? "답글 작성" : undefined} />
|
||||||
{renderReplies(comment)}
|
{renderReplies(comment)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type CapturedRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const target = { kind: "audio", characterId: "101", contentId: "9001", creatorId: 101 } satisfies CommentTarget;
|
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 = {
|
const fanRoot = {
|
||||||
id: 1101,
|
id: 1101,
|
||||||
@@ -59,6 +60,9 @@ function createThreadClient(requests: CapturedRequest[]): ApiClient {
|
|||||||
if (options.path.includes("/1101/replies")) {
|
if (options.path.includes("/1101/replies")) {
|
||||||
return options.responseSchema.parse({ totalCount: 2, items: [fanReply, aiReply] });
|
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] });
|
return options.responseSchema.parse({ totalCount: 2, items: [fanRoot, aiRoot] });
|
||||||
},
|
},
|
||||||
@@ -131,6 +135,46 @@ function getFormForControl(control: HTMLElement): HTMLFormElement {
|
|||||||
throw new Error("expected parent form");
|
throw new Error("expected parent form");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("CommentThread opens the existing reply form for a first Audio reply only", async () => {
|
||||||
|
// 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 first-reply entry out of Community and read-only Audio roots", async () => {
|
||||||
|
// Given
|
||||||
|
const communityRequests: CapturedRequest[] = [];
|
||||||
|
const readOnlyRequests: CapturedRequest[] = [];
|
||||||
|
|
||||||
|
// When
|
||||||
|
const { unmount } = render(<CommentThread apiClient={createThreadClient(communityRequests)} target={communityTarget} />);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "AI 루트 댓글 답글 작성" })).not.toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// When
|
||||||
|
render(<CommentThread apiClient={createThreadClient(readOnlyRequests)} canMutate={false} target={target} />);
|
||||||
|
|
||||||
|
// 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 () => {
|
test("CommentThread links and focuses empty root and reply comment errors without sending mutations", async () => {
|
||||||
// Given
|
// Given
|
||||||
const requests: CapturedRequest[] = [];
|
const requests: CapturedRequest[] = [];
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ test("Audio comments create reply edit AI rows and delete fan or AI rows through
|
|||||||
// When
|
// When
|
||||||
await page.getByLabel("새 댓글").fill("오디오 루트 생성");
|
await page.getByLabel("새 댓글").fill("오디오 루트 생성");
|
||||||
await page.getByRole("button", { name: "댓글 등록" }).click();
|
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();
|
await page.getByRole("button", { name: "오디오 팬 루트 댓글 답글 보기" }).click();
|
||||||
const replies = page.getByRole("region", { name: "오디오 팬 루트 댓글 답글" });
|
const replies = page.getByRole("region", { name: "오디오 팬 루트 댓글 답글" });
|
||||||
await replies.getByLabel("오디오 팬 루트 댓글에 답글").fill("오디오 답글 생성");
|
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(page.getByText("오디오 AI 루트 댓글", { exact: true })).toBeHidden();
|
||||||
await expect(replies.getByText("오디오 답글 생성", { exact: true })).toBeVisible();
|
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: 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: "오디오 답글 생성", 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: 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: "" });
|
expect(commentRequests).toContainEqual({ body: null, method: "DELETE", path: "/api/v2/admin/ai-characters/101/audio-contents/9001/comments/1201", search: "" });
|
||||||
@@ -91,6 +99,7 @@ test("Community sheet comments keep two-level controls usable at 320px", async (
|
|||||||
await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible();
|
await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible();
|
||||||
await expect(dialog.getByLabel("새 댓글")).toBeInViewport();
|
await expect(dialog.getByLabel("새 댓글")).toBeInViewport();
|
||||||
await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible();
|
await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" })).toHaveCount(0);
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@@ -123,6 +132,7 @@ test("keyboard-only Audio comment flow reaches form and reply controls", async (
|
|||||||
await loginThroughMockMode(page);
|
await loginThroughMockMode(page);
|
||||||
await page.goto("/ai-characters/101/audio-contents/9001");
|
await page.goto("/ai-characters/101/audio-contents/9001");
|
||||||
const rootInput = page.getByLabel("새 댓글");
|
const rootInput = page.getByLabel("새 댓글");
|
||||||
|
const firstReplyAction = page.getByRole("button", { name: "오디오 AI 루트 댓글 답글 작성" });
|
||||||
|
|
||||||
// When / Then
|
// When / Then
|
||||||
await pressTabUntilFocused(page, rootInput);
|
await pressTabUntilFocused(page, rootInput);
|
||||||
@@ -130,4 +140,7 @@ test("keyboard-only Audio comment flow reaches form and reply controls", async (
|
|||||||
await pressTabUntilFocused(page, page.getByRole("button", { name: "댓글 등록" }));
|
await pressTabUntilFocused(page, page.getByRole("button", { name: "댓글 등록" }));
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await expect(page.getByText("키보드 루트 댓글", { exact: true })).toBeVisible();
|
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