From 00f06b992f25dbfe3b700babfa6dc28ed08f967b Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 6 Aug 2026 17:43:43 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(ai-character):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EB=8C=93=EA=B8=80=20=EC=B2=AB=20=EB=8B=B5?= =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260806_커뮤니티댓글답글/api-contract.md | 96 +++++++ docs/20260806_커뮤니티댓글답글/plan-task.md | 240 ++++++++++++++++++ docs/20260806_커뮤니티댓글답글/prd.md | 227 +++++++++++++++++ .../reviews/phase1-community-comment-replies.md | 170 +++++++++++++ .../comments/components/CommentThread.tsx | 2 +- .../comments/tests/comment-thread.test.tsx | 18 +- tests/e2e/comments.spec.ts | 39 ++- 7 files changed, 785 insertions(+), 7 deletions(-) create mode 100644 docs/20260806_커뮤니티댓글답글/api-contract.md create mode 100644 docs/20260806_커뮤니티댓글답글/plan-task.md create mode 100644 docs/20260806_커뮤니티댓글답글/prd.md create mode 100644 docs/20260806_커뮤니티댓글답글/reviews/phase1-community-comment-replies.md diff --git a/docs/20260806_커뮤니티댓글답글/api-contract.md b/docs/20260806_커뮤니티댓글답글/api-contract.md new file mode 100644 index 0000000..1a8a217 --- /dev/null +++ b/docs/20260806_커뮤니티댓글답글/api-contract.md @@ -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는 이번 기능에서 변경하지 않는다. diff --git a/docs/20260806_커뮤니티댓글답글/plan-task.md b/docs/20260806_커뮤니티댓글답글/plan-task.md new file mode 100644 index 0000000..7f1f4d7 --- /dev/null +++ b/docs/20260806_커뮤니티댓글답글/plan-task.md @@ -0,0 +1,240 @@ +# 커뮤니티 댓글 직접 답글 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-08-06 | +| 요구사항 기준 | [prd.md](./prd.md) | +| API 기준 | [api-contract.md](./api-contract.md) | +| 현재 Phase | Phase 1 완료 | +| 현재 활성 Goal | 없음 | + +## 목표 + +활성 커뮤니티 게시글의 답글 0개 원댓글에서도 기존 답글 form을 열어 첫 답글과 +여러 직접 답글을 작성할 수 있게 한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `3/3` | 없음 | 완료 | + +- Community 답글 GET·POST, form, 여러 직접 답글 조회·작성·재조회 흐름은 이미 구현돼 있다. +- 답글이 하나 이상인 Community root에는 `답글 보기`와 추가 작성 form이 제공된다. +- `replyCount === 0`인 Community root에는 첫 답글 작성 진입만 없다. +- `P1-T1` 구현과 test는 완료됐고, `P1-R1`에서 E2E fixture 검증 결함 후보를 실제 mock 실행 경로와 대조해 오탐으로 판정했다. + +## 범위의 포함·제외 + +### 포함 + +- 활성 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-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의 동일 동작을 대조한다. + +### 검증 방법 + +#### 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` + +- 동시에 하나의 미완료 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)에 근거를 기록했다. + +## 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` | + +## 발견된 문제 + +- 수정 완료: 답글 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가 통과한다. +- 외부 차단: 없음. + +## 최종 보고 형식 + +- 완료 Goal ID +- 변경한 파일과 최소 구현 내용 +- RED·GREEN·REFACTOR 및 Gate 명령과 실제 결과 +- 실행하지 못한 수동·server 검증과 이유 +- 남은 위험 또는 열린 질문 diff --git a/docs/20260806_커뮤니티댓글답글/prd.md b/docs/20260806_커뮤니티댓글답글/prd.md new file mode 100644 index 0000000..10b945a --- /dev/null +++ b/docs/20260806_커뮤니티댓글답글/prd.md @@ -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와 검증 기록은 삭제하거나 덮어쓰지 않는다. diff --git a/docs/20260806_커뮤니티댓글답글/reviews/phase1-community-comment-replies.md b/docs/20260806_커뮤니티댓글답글/reviews/phase1-community-comment-replies.md new file mode 100644 index 0000000..7e568bd --- /dev/null +++ b/docs/20260806_커뮤니티댓글답글/reviews/phase1-community-comment-replies.md @@ -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 +- 남은 항목: 없음 diff --git a/src/features/comments/components/CommentThread.tsx b/src/features/comments/components/CommentThread.tsx index 0ae01e1..c00cb89 100644 --- a/src/features/comments/components/CommentThread.tsx +++ b/src/features/comments/components/CommentThread.tsx @@ -167,7 +167,7 @@ export function CommentThread({ apiClient, canMutate = true, target }: { readonl
{roots.data.items.map((comment) => (
- 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} /> + 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)}
))} diff --git a/src/features/comments/tests/comment-thread.test.tsx b/src/features/comments/tests/comment-thread.test.tsx index a053ddb..b68696b 100644 --- a/src/features/comments/tests/comment-thread.test.tsx +++ b/src/features/comments/tests/comment-thread.test.tsx @@ -135,7 +135,7 @@ function getFormForControl(control: HTMLElement): HTMLFormElement { throw new Error("expected parent form"); } -test("CommentThread opens the existing reply form for a first Audio reply only", async () => { +test("CommentThread opens the existing reply form for a first Audio reply", async () => { // Given const requests: CapturedRequest[] = []; render(); @@ -154,21 +154,29 @@ test("CommentThread opens the existing reply form for a first Audio reply only", expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument(); }); -test("CommentThread keeps first-reply entry out of Community and read-only Audio roots", async () => { +test("CommentThread creates a first Community reply and keeps read-only Community roots closed", async () => { // Given const communityRequests: CapturedRequest[] = []; const readOnlyRequests: CapturedRequest[] = []; // When const { unmount } = render(); + 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 - expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "AI 루트 댓글 답글 작성" })).not.toBeInTheDocument(); + 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(); + render(); // Then expect(await screen.findByText("AI 루트 댓글")).toBeInTheDocument(); diff --git a/tests/e2e/comments.spec.ts b/tests/e2e/comments.spec.ts index 4984721..422a89a 100644 --- a/tests/e2e/comments.spec.ts +++ b/tests/e2e/comments.spec.ts @@ -87,6 +87,13 @@ test("Audio comments create reply edit AI rows and delete fan or AI rows through test("Community sheet comments keep two-level controls usable at 320px", async ({ page }) => { // 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); @@ -99,18 +106,48 @@ 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(); - await expect(dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" })).toBeVisible(); await expectNoHorizontalOverflow(page); // When await dialog.getByLabel("새 댓글").fill("커뮤니티 루트 생성"); await dialog.getByRole("button", { name: "댓글 등록" }).click(); await expect(dialog.getByText("커뮤니티 루트 생성", { exact: true })).toBeVisible(); + await dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" }).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 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(); From 46868351ac21b3b2a58a450152cee17082a6470a Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 6 Aug 2026 18:04:34 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs(ai-character):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EB=8B=B5=EA=B8=80=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260806_커뮤니티댓글답글/plan-task.md | 63 ++++- .../reviews/phase1-final-commit-audit.md | 216 ++++++++++++++++++ 2 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md diff --git a/docs/20260806_커뮤니티댓글답글/plan-task.md b/docs/20260806_커뮤니티댓글답글/plan-task.md index 7f1f4d7..522dfe6 100644 --- a/docs/20260806_커뮤니티댓글답글/plan-task.md +++ b/docs/20260806_커뮤니티댓글답글/plan-task.md @@ -18,12 +18,13 @@ | Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | |---:|---|---:|---|---| -| 1 | 완료 | `3/3` | 없음 | 완료 | +| 1 | 완료 | `4/4` | 없음 | 완료 | - Community 답글 GET·POST, form, 여러 직접 답글 조회·작성·재조회 흐름은 이미 구현돼 있다. - 답글이 하나 이상인 Community root에는 `답글 보기`와 추가 작성 form이 제공된다. -- `replyCount === 0`인 Community root에는 첫 답글 작성 진입만 없다. +- `replyCount === 0`인 활성 Community root에도 `답글 작성` 진입과 기존 답글 form이 제공된다. - `P1-T1` 구현과 test는 완료됐고, `P1-R1`에서 E2E fixture 검증 결함 후보를 실제 mock 실행 경로와 대조해 오탐으로 판정했다. +- 최종 커밋 감사에서 위 문장의 기존 표현이 실제 완료 상태와 충돌해 `CCR-REV-P1-002`로 확정됐고 `P1-R2`에서 정정했다. ## 범위의 포함·제외 @@ -60,7 +61,7 @@ **선행조건:** `CCR-001~006`과 기존 Community 댓글 GET·POST 계약 확정. -**Phase 완료 조건:** `P1-T1`, `P1-GATE` 완료와 Progress 기록. +**Phase 완료 조건:** `P1-T1`, `P1-R1`, `P1-R2`, `P1-GATE` 완료와 Progress 기록. ### Task 1.1 커뮤니티 첫 답글 진입 @@ -133,6 +134,44 @@ production 또는 fixture 수정이 필요하지 않은 오탐으로 판정됐 - **기대 결과:** 기존 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 @@ -166,6 +205,8 @@ git diff --check 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을 생성하지 않는다. @@ -213,6 +254,18 @@ git diff --check - **왜:** 코드와 자동 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 | 날짜 | 결정 | 근거 | 영향 | @@ -223,12 +276,16 @@ git diff --check | 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`) - 외부 차단: 없음. ## 최종 보고 형식 diff --git a/docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md b/docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md new file mode 100644 index 0000000..853a06f --- /dev/null +++ b/docs/20260806_커뮤니티댓글답글/reviews/phase1-final-commit-audit.md @@ -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건 +- 남은 항목: 없음. From 68959cb33bb38b8733c054a97579ff5f29ce4a0a Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 6 Aug 2026 18:51:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(ai-character):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EB=B2=84=ED=8A=BC=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260806_댓글액션버튼라벨/plan-task.md | 219 ++++++++++++++++++ docs/20260806_댓글액션버튼라벨/prd.md | 215 +++++++++++++++++ .../comments/components/CommentItem.tsx | 6 +- .../comments/tests/comment-thread.test.tsx | 26 +++ tests/e2e/comments.spec.ts | 17 +- 5 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 docs/20260806_댓글액션버튼라벨/plan-task.md create mode 100644 docs/20260806_댓글액션버튼라벨/prd.md diff --git a/docs/20260806_댓글액션버튼라벨/plan-task.md b/docs/20260806_댓글액션버튼라벨/plan-task.md new file mode 100644 index 0000000..12d5594 --- /dev/null +++ b/docs/20260806_댓글액션버튼라벨/plan-task.md @@ -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}` +``` + +최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다. diff --git a/docs/20260806_댓글액션버튼라벨/prd.md b/docs/20260806_댓글액션버튼라벨/prd.md new file mode 100644 index 0000000..358071e --- /dev/null +++ b/docs/20260806_댓글액션버튼라벨/prd.md @@ -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` | diff --git a/src/features/comments/components/CommentItem.tsx b/src/features/comments/components/CommentItem.tsx index f438867..eb695f7 100644 --- a/src/features/comments/components/CommentItem.tsx +++ b/src/features/comments/components/CommentItem.tsx @@ -25,9 +25,9 @@ export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDe

{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}

- {replyActionLabel !== undefined && onShowReplies !== undefined ? : null} - {canEdit ? : null} - {canDelete ? : null} + {replyActionLabel !== undefined && onShowReplies !== undefined ? : null} + {canEdit ? : null} + {canDelete ? : null}
{isEditing ? ( diff --git a/src/features/comments/tests/comment-thread.test.tsx b/src/features/comments/tests/comment-thread.test.tsx index b68696b..2e9d582 100644 --- a/src/features/comments/tests/comment-thread.test.tsx +++ b/src/features/comments/tests/comment-thread.test.tsx @@ -154,6 +154,32 @@ test("CommentThread opens the existing reply form for a first Audio reply", asyn 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(); + 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[] = []; diff --git a/tests/e2e/comments.spec.ts b/tests/e2e/comments.spec.ts index 422a89a..88ff74b 100644 --- a/tests/e2e/comments.spec.ts +++ b/tests/e2e/comments.spec.ts @@ -106,14 +106,16 @@ 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(); - await expect(dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" })).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 dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" }).click(); + await writeReply.click(); const emptyReplies = dialog.getByRole("region", { name: "커뮤니티 AI 루트 댓글 답글" }); const emptyReplyInput = emptyReplies.getByLabel("커뮤니티 AI 루트 댓글에 답글"); await expect(emptyReplyInput).toBeVisible(); @@ -143,6 +145,7 @@ test("Community sheet comments keep two-level controls usable at 320px", async ( 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(); @@ -150,11 +153,15 @@ test("Community sheet comments keep two-level controls usable at 320px", async ( 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(); From 2a04955bd7110040f3a1d0d092ae84e20f5a2a10 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 6 Aug 2026 21:38:12 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(ai-character):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 353 ++++++++++++++++++ docs/20260806_관리자라우트지연로딩/prd.md | 228 +++++++++++ .../reviews/phase1-admin-route-lazy-loading.md | 177 +++++++++ src/app/App.test.tsx | 2 +- src/app/protected-admin-shell.tsx | 66 ++-- .../components/CharacterListItem.tsx | 8 +- .../mocks/__tests__/production-graph.test.ts | 10 +- .../ui/__tests__/resource-pagination.test.tsx | 8 +- src/shared/ui/resource-pagination.tsx | 10 +- tests/e2e/character-workspace.spec.ts | 35 ++ 10 files changed, 849 insertions(+), 48 deletions(-) create mode 100644 docs/20260806_관리자라우트지연로딩/plan-task.md create mode 100644 docs/20260806_관리자라우트지연로딩/prd.md create mode 100644 docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md diff --git a/docs/20260806_관리자라우트지연로딩/plan-task.md b/docs/20260806_관리자라우트지연로딩/plan-task.md new file mode 100644 index 0000000..dc73531 --- /dev/null +++ b/docs/20260806_관리자라우트지연로딩/plan-task.md @@ -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` — + - Network·1280px·320px·200% zoom·keyboard·axe — <실제 결과> +- 남은 항목: <없음 또는 구체적인 항목> +- 문서: `docs/20260806_관리자라우트지연로딩/{prd.md,plan-task.md}` +``` + +최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다. diff --git a/docs/20260806_관리자라우트지연로딩/prd.md b/docs/20260806_관리자라우트지연로딩/prd.md new file mode 100644 index 0000000..e2cde7e --- /dev/null +++ b/docs/20260806_관리자라우트지연로딩/prd.md @@ -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` | diff --git a/docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md b/docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md new file mode 100644 index 0000000..125cc5b --- /dev/null +++ b/docs/20260806_관리자라우트지연로딩/reviews/phase1-admin-route-lazy-loading.md @@ -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건. +- 남은 항목: 없음. diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 3048337..2345a8c 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -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 () => { diff --git a/src/app/protected-admin-shell.tsx b/src/app/protected-admin-shell.tsx index 724c0dc..9ad878b 100644 --- a/src/app/protected-admin-shell.tsx +++ b/src/app/protected-admin-shell.tsx @@ -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,22 +198,24 @@ export function ProtectedAdminShell({ apiClient, apiMode, routeError }: { readon {location.successNotification}

)} - {location.path === routePaths.aiCharacterCreate ? : null} - {characterEditId !== null ? : 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 ? : null} - {location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? : null} - {audioContentListCharacterId !== null ? : null} - {audioContentCreateCharacterId !== null ? : null} - {audioContentEditRoute !== null ? : null} - {audioContentDetailRoute !== null ? : null} - {communityPostListCharacterId !== null ? : null} - {communityPostCreateCharacterId !== null ? : null} - {fanTalkListCharacterId !== null ? : null} - {seriesListCharacterId !== null ? : null} - {seriesCreateCharacterId !== null ? : null} - {seriesEditRoute !== null ? : null} - {seriesOrderCharacterId !== null ? : null} - {seriesDetailRoute !== null ? : null} + } key={location.path}> + {location.path === routePaths.aiCharacterCreate ? : null} + {characterEditId !== null ? : 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 ? : null} + {location.path !== routePaths.aiCharacterCreate && characterEditId === null && characterId !== null ? : null} + {audioContentListCharacterId !== null ? : null} + {audioContentCreateCharacterId !== null ? : null} + {audioContentEditRoute !== null ? : null} + {audioContentDetailRoute !== null ? : null} + {communityPostListCharacterId !== null ? : null} + {communityPostCreateCharacterId !== null ? : null} + {fanTalkListCharacterId !== null ? : null} + {seriesListCharacterId !== null ? : null} + {seriesCreateCharacterId !== null ? : null} + {seriesEditRoute !== null ? : null} + {seriesOrderCharacterId !== null ? : null} + {seriesDetailRoute !== null ? : null} + diff --git a/src/features/characters/components/CharacterListItem.tsx b/src/features/characters/components/CharacterListItem.tsx index b20550b..f791cce 100644 --- a/src/features/characters/components/CharacterListItem.tsx +++ b/src/features/characters/components/CharacterListItem.tsx @@ -25,10 +25,10 @@ export function CharacterListItem({ character }: { readonly character: Character )} - {character.name} - {description} - ID {character.id} · {character.region} - {character.tags.join(", ")} + {character.name} + {description} + ID {character.id} · {character.region} + {character.tags.join(", ")} diff --git a/src/shared/mocks/__tests__/production-graph.test.ts b/src/shared/mocks/__tests__/production-graph.test.ts index 1da5828..ed1bfdb 100644 --- a/src/shared/mocks/__tests__/production-graph.test.ts +++ b/src/shared/mocks/__tests__/production-graph.test.ts @@ -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"); diff --git a/src/shared/ui/__tests__/resource-pagination.test.tsx b/src/shared/ui/__tests__/resource-pagination.test.tsx index 2351f77..734c64b 100644 --- a/src/shared/ui/__tests__/resource-pagination.test.tsx +++ b/src/shared/ui/__tests__/resource-pagination.test.tsx @@ -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(); 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"); }); diff --git a/src/shared/ui/resource-pagination.tsx b/src/shared/ui/resource-pagination.tsx index 5563976..461feed 100644 --- a/src/shared/ui/resource-pagination.tsx +++ b/src/shared/ui/resource-pagination.tsx @@ -13,10 +13,10 @@ export function ResourcePagination({ data, onPageChange, onSizeChange, sizeOptio return (