From 55ba0df77aca9eea0f6aaff760782ef1db51aa2d Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Sat, 1 Aug 2026 01:30:10 +0900 Subject: [PATCH] =?UTF-8?q?docs(ai-character):=20=EB=A6=AC=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EA=B4=80=EB=A6=AC=20=EB=AC=B8=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DESIGN.md | 17 +- README.md | 21 +- .../api-contract.openapi.json | 551 ++- docs/20260725_AI캐릭터관리자웹/plan-task.md | 3598 +++++++++++++++-- docs/20260725_AI캐릭터관리자웹/prd.md | 186 +- .../reviews/phase0-project-foundation.md | 241 ++ .../reviews/phase1-platform-auth-shared-ui.md | 504 +++ .../reviews/phase10-openapi-follow-up.md | 852 ++++ .../reviews/phase2-mock-preview.md | 146 + .../reviews/phase3-character-workspace.md | 350 ++ .../reviews/phase4-audio-content.md | 361 ++ .../reviews/phase5-series-management.md | 283 ++ .../reviews/phase6-community-posts.md | 291 ++ .../reviews/phase7-fan-talk.md | 189 + .../reviews/phase8-comments.md | 286 ++ .../reviews/phase9-cross-cutting-quality.md | 828 ++++ .../reviews/review-phase-0-20260729.md | 90 + .../reviews/review-phase-1-20260729.md | 189 + .../reviews/review-phase-10-20260729.md | 167 + .../reviews/review-phase-2-20260729.md | 90 + .../reviews/review-phase-3-20260729.md | 184 + .../reviews/review-phase-4-20260729.md | 247 ++ .../reviews/review-phase-5-20260729.md | 184 + .../reviews/review-phase-6-20260729.md | 129 + .../reviews/review-phase-7-20260729.md | 89 + .../reviews/review-phase-8-20260729.md | 163 + .../reviews/review-phase-9-20260729.md | 164 + docs/agent-guide/environment.md | 4 +- docs/agent-guide/review.md | 3 +- docs/agent-guide/scripts.md | 4 +- p5-r3-mobile-series-after.png | Bin 0 -> 69650 bytes 31 files changed, 10021 insertions(+), 390 deletions(-) create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase0-project-foundation.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase1-platform-auth-shared-ui.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase2-mock-preview.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase3-character-workspace.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase4-audio-content.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase5-series-management.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase6-community-posts.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase7-fan-talk.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase8-comments.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-0-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-1-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-10-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-2-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-3-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-4-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-5-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-6-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-7-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-8-20260729.md create mode 100644 docs/20260725_AI캐릭터관리자웹/reviews/review-phase-9-20260729.md create mode 100644 p5-r3-mobile-series-after.png diff --git a/DESIGN.md b/DESIGN.md index 9ec3d00..eba3e5c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -46,7 +46,7 @@ A bright Korean operations console: dense, calm, and explicit. The signature is | Input | `--input` | `#577581` | Required control boundary | | Primary | `--primary` | `#00BDF7` | Main CTA | | Primary foreground | `--primary-foreground` | `#062B36` | Text/icons on primary | -| Ring / Link | `--ring`, `--link` | `#007EA8` | Focus and link | +| Ring / Link | `--ring`, `--link` | `#007EA8` / `#086789` | Focus and link | | Info | `--info` | `#086789` | Small informational labels | | Success | `--success` | `#167347` | Open/success state | | Success surface | `--success-surface` | `#EAF8F0` | Success Badge surface | @@ -165,6 +165,14 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system - Accessibility: progressbar exposes `aria-valuenow`; cancel/retry are native buttons. - Motion: none. +### SuccessNotification + +- Structure: one shell-level `role="status"` message after successful create, update, or deactivate navigation. +- Variants: success only. +- Tokens: `--success-surface`, `--success`, `--border`, `--radius-lg`, `--space-3`, `--space-4`. +- Accessibility: `aria-label="작업 성공"`; no timer or dismiss control in P3-T3. +- Motion: none. + ### AdminAudioPlayer - Structure: native audio element wrapped with play/pause, seek, time, volume, speed, generic error, and manual retry controls. @@ -179,6 +187,13 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system - Accessibility: no direct rendered surface. - Motion: none. +### CommentThread + +- Structure: two-level comment management surface with root comment form, root rows, direct reply expansion, reply form, and edit/delete row actions. +- Variants: audio target includes `languageCode`; community target omits it. Replies never expose nested reply controls. +- Accessibility: forms use visible labels, rows are named articles, reply lists are named regions, and mutation actions are native buttons. +- Motion: none beyond existing control state color. + ## 6. Motion & Interaction - Motion is limited to 150ms micro-interactions for real control state changes. diff --git a/README.md b/README.md index 4e7769d..f79142a 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,15 @@ React, TypeScript, Vite 기반 독립 관리자 SPA입니다. ```bash npm ci -npx playwright install chromium webkit +npx playwright install chromium ``` ## Environment Vite mode별 API base URL은 아래 파일에 둡니다. -- `.env.development`: `https://test-character-admin.sodalive.net` -- `.env.production`: `https://character-admin.sodalive.net` +- `.env.development`: `https://test-api.sodalive.net` +- `.env.production`: `https://api.sodalive.net` - `server mode`: `VITE_API_MODE=server`이며 기본 개발 서버가 실제 개발 API를 사용합니다. - `mock mode`: `VITE_API_MODE=mock`이며 개발 전용 browser MSW fixture로 제공 계약 범위만 미리 봅니다. - `mock data reset`: mock data는 browser storage에 영구 저장하지 않고 새 mock store/session이 시작될 때 seed 기준으로 초기화됩니다. @@ -38,10 +38,23 @@ Vite mode별 API base URL은 아래 파일에 둡니다. - `npm run test`: Vitest watch입니다. - `npm run test:run`: Vitest 단발 실행입니다. - `npm run e2e (VITE_API_MODE=server playwright test)`: server mode Playwright입니다. 대상 spec은 `playwright.config.ts`의 server mode `testMatch`가 제한하며, 추가 file filter를 넘기면 교집합만 실행합니다. -- `npm run e2e:mock (VITE_API_MODE=mock playwright test)`: mock mode Playwright입니다. 대상 spec은 `playwright.config.ts`의 mock mode `testMatch`가 제한하며, 추가 file filter를 넘기면 교집합만 실행합니다. +- `npm run e2e:mock`: mock mode Playwright입니다. 인자 없이 실행하면 Chromium/mobile Chrome matrix를 분할 실행하고, file filter나 `--project` 인자를 넘기면 `playwright.config.ts`의 mock mode `testMatch`와 교집합만 실행합니다. + +## Browser Support + +- 지원 기준: 데스크톱 Chrome과 모바일 Chrome입니다. +- 로컬 자동 검증은 Playwright Chromium/mobile Chrome project로 수행합니다. ## Mock Preview Ownership - Phase 2 mock preview는 auth shell과 제공 계약 기반 공통 fixture까지만 포함합니다. - 후속 도메인 Phase는 자기 domain handler, fixture, mock E2E를 같은 Phase에서 추가하고 검증합니다. - mock mode 통과는 최종 UI 확인 증거이며 실제 server integration 완료 증거가 아닙니다. + +## Known Backend Constraints + +- 원작 lookup과 장르 lookup은 OpenAPI 2.3.0의 v2 계약으로 구현됐습니다. 이전 후보 endpoint는 이력 문서에만 보존합니다. +- FanTalk 답변 수정·팬 원글 삭제, Comments CRUD, Community pagination은 Phase 10에서 구현됐습니다. FanTalk 별도 상세·filter/sort·중복 오류 계약은 제품 범위 밖이며, active-only 반환 보장은 실제 개발 API 수동 QA에서 분리 확인합니다. +- price 최대값은 `99999`이며, 파일 용량·MIME 등은 backend가 동일 검증합니다. 이미지 비율·crop 결과는 client가 보장하고 backend는 검증하지 않습니다. +- OpenAPI 밖의 도메인별 오류는 `알 수 없는 오류가 발생했습니다.`로 처리합니다. +- 현재 server mode E2E allowlist는 shell/auth/server-boundary 검증 중심입니다. 도메인별 mock E2E 통과를 실제 개발 API integration 완료로 해석하지 않습니다. diff --git a/docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json b/docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json index 46385e0..bd33a0f 100644 --- a/docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json +++ b/docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json @@ -2,17 +2,17 @@ "openapi": "3.1.0", "info": { "title": "AI 캐릭터 관리자 API", - "version": "2.0.0", - "description": "클라이언트 개발용 정식 계약. 신규 관리자 endpoint와 path target을 사용하되 JSON 필드명, 타입, optional/nullable, 기본값과 성공 data 형태는 레거시 API를 유지한다. path로 이동한 ID만 body에서 제거한다." + "version": "2.3.0", + "description": "클라이언트 개발용 정식 계약. 신규 관리자 endpoint와 path target을 사용하되 JSON 필드명, 타입, optional/nullable, 기본값과 성공 data 형태는 승인된 예외 외에는 레거시 API를 유지한다. path로 이동한 ID만 body에서 제거한다. AI 캐릭터 관리자 API의 날짜·시간 예외 계약은 timezone query/body 없이 ISO-8601 UTC(Z)를 사용한다." }, "servers": [{"url": "/", "description": "현재 호스트"}], "security": [{"bearerAuth": []}], "tags": [ - {"name": "Character", "description": "AI 캐릭터 조회·생성·수정"}, - {"name": "AudioContent", "description": "오디오 콘텐츠 테마·조회·생성·수정"}, - {"name": "Series", "description": "시리즈와 연결 콘텐츠 관리"}, - {"name": "Community", "description": "커뮤니티 게시글 관리"}, - {"name": "FanTalk", "description": "FanTalk 관리자 목록과 creator reply"} + {"name": "Character", "description": "AI 캐릭터 조회·생성·수정과 등록용 원작 검색"}, + {"name": "AudioContent", "description": "오디오 콘텐츠 테마·조회·생성·수정과 댓글 관리"}, + {"name": "Series", "description": "시리즈·등록용 장르·연결 콘텐츠 관리"}, + {"name": "Community", "description": "커뮤니티 게시글과 댓글 관리"}, + {"name": "FanTalk", "description": "FanTalk 관리자 목록·creator reply 작성·수정·팬 원글 삭제"} ], "paths": { "/api/v2/admin/ai-characters": { @@ -21,7 +21,7 @@ "tags": ["Character"], "summary": "AI 캐릭터 목록/검색", "operationId": "listAiCharacters", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AdminChatCharacterController.getCharacterList", "AdminChatCharacterController.searchCharacters", "ChatCharacterListPageResponse", "ChatCharacterSearchListPageResponse"], "parameters": [ {"name": "searchTerm", "in": "query", "required": false, "description": "생략하면 활성 목록, 지정하면 레거시 검색을 수행한다.", "schema": {"type": "string"}}, @@ -43,7 +43,7 @@ "tags": ["Character"], "summary": "AI 캐릭터 생성", "operationId": "createAiCharacter", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AdminChatCharacterController.registerCharacter", "ChatCharacterRegisterRequest"], "requestBody": {"$ref": "#/components/requestBodies/CharacterCreateMultipart"}, "responses": { @@ -59,13 +59,36 @@ } } }, + "/api/v2/admin/ai-characters/original-works/search": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}], + "get": { + "tags": ["Character"], + "summary": "캐릭터 등록용 원작 검색", + "operationId": "searchAiCharacterOriginalWorks", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AdminOriginalWorkController.search", "AdminOriginalWorkService.searchOriginalWorksAll", "OriginalWorkResponse"], + "parameters": [ + {"name": "searchTerm", "in": "query", "required": true, "description": "제목·콘텐츠 타입·카테고리 부분 검색어", "schema": {"type": "string"}} + ], + "responses": { + "200": {"$ref": "#/components/responses/OriginalWorkSearchSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, "/api/v2/admin/ai-characters/{characterId}": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], "get": { "tags": ["Character"], "summary": "AI 캐릭터 상세", "operationId": "getAiCharacter", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AdminChatCharacterController.getCharacterDetail", "ChatCharacterDetailResponse"], "responses": { "200": {"$ref": "#/components/responses/CharacterDetailSuccess"}, @@ -83,7 +106,7 @@ "summary": "AI 캐릭터 수정/soft delete", "description": "레거시 ChatCharacterUpdateRequest의 id만 path characterId로 이동한다.", "operationId": "updateAiCharacter", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AdminChatCharacterController.updateCharacter", "ChatCharacterUpdateRequest"], "requestBody": {"$ref": "#/components/requestBodies/CharacterUpdateMultipart"}, "responses": { @@ -105,7 +128,7 @@ "tags": ["AudioContent"], "summary": "활성 오디오 콘텐츠 테마 목록", "operationId": "listAiCharacterAudioContentThemes", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["GetAudioContentThemeResponse"], "responses": { "200": {"$ref": "#/components/responses/AudioThemeListSuccess"}, @@ -125,7 +148,7 @@ "tags": ["AudioContent"], "summary": "오디오 콘텐츠 목록/검색", "operationId": "listAiCharacterAudioContents", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentController.getAudioContentList", "CreatorAdminContentController.searchAudioContent", "GetCreatorAdminContentListResponse"], "parameters": [ {"name": "search_word", "in": "query", "required": false, "description": "지정하면 레거시 검색을 수행하며 2자 이상이어야 한다.", "schema": {"type": "string"}}, @@ -147,7 +170,7 @@ "tags": ["AudioContent"], "summary": "오디오 콘텐츠 생성", "operationId": "createAiCharacterAudioContent", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AudioContentController.createAudioContent", "CreateAudioContentRequest", "CreateAudioContentResponse"], "requestBody": {"$ref": "#/components/requestBodies/AudioContentCreateMultipart"}, "responses": { @@ -169,9 +192,8 @@ "tags": ["AudioContent"], "summary": "오디오 콘텐츠 상세", "operationId": "getAiCharacterAudioContent", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["AudioContentController.getDetail", "GetAudioContentDetailResponse"], - "parameters": [{"$ref": "#/components/parameters/Timezone"}], "responses": { "200": {"$ref": "#/components/responses/AudioContentDetailSuccess"}, "400": {"$ref": "#/components/responses/BadRequest"}, @@ -188,7 +210,7 @@ "summary": "오디오 콘텐츠 수정/soft delete", "description": "레거시 UpdateCreatorAdminContentRequest의 id만 path contentId로 이동한다.", "operationId": "updateAiCharacterAudioContent", - "x-implementation-status": "implemented-contract-alignment-required", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentController.modifyAudioContent", "UpdateCreatorAdminContentRequest"], "requestBody": {"$ref": "#/components/requestBodies/AudioContentUpdateMultipart"}, "responses": { @@ -204,13 +226,161 @@ } } }, + "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/ContentId"} + ], + "get": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 댓글 목록", + "operationId": "listAiCharacterAudioContentComments", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentController.getCommentList", "GetAudioContentCommentListResponse"], + "parameters": [ + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/AudioContentCommentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 댓글 또는 답글 작성", + "description": "parentId가 없으면 원댓글, 있으면 같은 콘텐츠의 활성 원댓글에 대한 답글을 target AI 캐릭터 명의로 작성한다.", + "operationId": "createAiCharacterAudioContentComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentController.registerComment", "RegisterCommentRequest"], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentCommentCreateRequest"}}} + }, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/ContentId"}, + {"$ref": "#/components/parameters/CommentId"} + ], + "put": { + "tags": ["AudioContent"], + "summary": "AI 캐릭터 작성 오디오 콘텐츠 댓글 수정", + "operationId": "updateAiCharacterAudioContentComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentController.modifyComment", "ModifyCommentRequest"], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommentUpdateRequest"}}} + }, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "delete": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 댓글 soft delete", + "description": "target 소유 콘텐츠의 댓글 또는 답글을 작성자와 관계없이 해당 row만 isActive=false로 변경한다. 이미 비활성이면 성공 no-op이며 하위 답글은 변경하지 않는다.", + "operationId": "deleteAiCharacterAudioContentComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentService.modifyComment", "ModifyCommentRequest.isActive"], + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/ContentId"}, + {"$ref": "#/components/parameters/CommentId"} + ], + "get": { + "tags": ["AudioContent"], + "summary": "오디오 콘텐츠 댓글 답글 목록", + "operationId": "listAiCharacterAudioContentCommentReplies", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AudioContentCommentController.getCommentReplyList", "GetAudioContentCommentListResponse"], + "parameters": [ + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/AudioContentCommentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/series-genres": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}], + "get": { + "tags": ["Series"], + "summary": "시리즈 등록용 활성 장르 목록", + "operationId": "listAiCharacterSeriesGenres", + "x-implementation-status": "implemented", + "x-legacy-sources": ["AdminContentSeriesGenreController.getSeriesGenreList", "GetSeriesGenreListResponse"], + "responses": { + "200": {"$ref": "#/components/responses/SeriesGenreListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, "/api/v2/admin/ai-characters/{characterId}/series": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], "get": { "tags": ["Series"], "summary": "시리즈 목록", "operationId": "listAiCharacterSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesList", "GetCreatorAdminContentSeriesListResponse"], "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], "responses": { @@ -228,7 +398,7 @@ "tags": ["Series"], "summary": "시리즈 생성", "operationId": "createAiCharacterSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.createSeries", "CreateSeriesRequest"], "requestBody": {"$ref": "#/components/requestBodies/SeriesCreateMultipart"}, "responses": { @@ -250,7 +420,7 @@ "tags": ["Series"], "summary": "시리즈 순서 변경", "operationId": "reorderAiCharacterSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.updateSeriesOrders", "UpdateOrdersRequest"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesOrderUpdateRequest"}}}}, "responses": { @@ -271,9 +441,10 @@ "get": { "tags": ["Series"], "summary": "시리즈 상세", + "description": "성공 data는 시리즈 목록 items의 단일 항목과 동일한 schema를 사용한다.", "operationId": "getAiCharacterSeries", - "x-implementation-status": "planned", - "x-legacy-sources": ["CreatorAdminContentSeriesController.getDetail", "GetCreatorAdminContentSeriesDetailResponse"], + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesList", "GetCreatorAdminContentSeriesListItem"], "responses": { "200": {"$ref": "#/components/responses/SeriesDetailSuccess"}, "400": {"$ref": "#/components/responses/BadRequest"}, @@ -290,7 +461,7 @@ "summary": "시리즈 수정/soft delete", "description": "레거시 ModifySeriesRequest의 seriesId만 path로 이동한다.", "operationId": "updateAiCharacterSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.modifySeries", "ModifySeriesRequest"], "requestBody": {"$ref": "#/components/requestBodies/SeriesUpdateMultipart"}, "responses": { @@ -312,7 +483,7 @@ "tags": ["Series"], "summary": "시리즈 연결 콘텐츠 목록", "operationId": "listAiCharacterSeriesContents", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.getSeriesContent", "GetCreatorAdminContentSeriesContentResponse"], "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], "responses": { @@ -331,7 +502,7 @@ "summary": "시리즈 콘텐츠 연결", "description": "레거시 AddingContentToTheSeriesRequest의 seriesId만 path로 이동한다.", "operationId": "addAiCharacterSeriesContents", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.addingContentToTheSeries", "AddingContentToTheSeriesRequest"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentAddRequest"}}}}, "responses": { @@ -353,7 +524,7 @@ "tags": ["Series"], "summary": "시리즈 미연결 콘텐츠 검색", "operationId": "searchAiCharacterContentsNotInSeries", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.searchContentNotInSeries", "SearchContentNotInSeriesResponse"], "parameters": [{"name": "search_word", "in": "query", "required": true, "schema": {"type": "string"}}], "responses": { @@ -375,7 +546,7 @@ "summary": "시리즈 콘텐츠 연결 해제", "description": "레거시 RemoveContentToTheSeriesRequest의 seriesId와 contentId를 path로 이동해 body가 없다.", "operationId": "removeAiCharacterSeriesContent", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorAdminContentSeriesController.removeContentInTheSeries", "RemoveContentToTheSeriesRequest"], "responses": { "200": {"$ref": "#/components/responses/NullSuccess"}, @@ -395,9 +566,9 @@ "tags": ["Community"], "summary": "커뮤니티 게시글 목록", "operationId": "listAiCharacterCommunityPosts", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorCommunityController.getCommunityPostList", "GetCommunityPostListResponse"], - "parameters": [{"$ref": "#/components/parameters/Timezone"}, {"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], + "parameters": [{"$ref": "#/components/parameters/Page"}, {"$ref": "#/components/parameters/Size"}], "responses": { "200": {"$ref": "#/components/responses/CommunityPostListSuccess"}, "400": {"$ref": "#/components/responses/BadRequest"}, @@ -413,7 +584,7 @@ "tags": ["Community"], "summary": "커뮤니티 게시글 등록", "operationId": "createAiCharacterCommunityPost", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorCommunityController.createCommunityPost", "CreateCommunityPostRequest"], "requestBody": {"$ref": "#/components/requestBodies/CommunityPostCreateMultipart"}, "responses": { @@ -436,7 +607,7 @@ "summary": "커뮤니티 게시글 수정/고정/soft delete", "description": "ModifyCommunityPostRequest의 creatorCommunityId와 UpdateCommunityPostFixedRequest의 postId를 path로 이동하고 나머지 레거시 필드를 하나의 request에 합친다.", "operationId": "updateAiCharacterCommunityPost", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorCommunityController.modifyCommunityPost", "CreatorCommunityController.updateCommunityPostFixed", "ModifyCommunityPostRequest", "UpdateCommunityPostFixedRequest"], "requestBody": {"$ref": "#/components/requestBodies/CommunityPostUpdateMultipart"}, "responses": { @@ -452,6 +623,134 @@ } } }, + "/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/PostId"} + ], + "get": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 댓글 목록", + "operationId": "listAiCharacterCommunityPostComments", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.getCommunityPostCommentList", "GetCommunityPostCommentListResponse"], + "parameters": [ + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/CommunityCommentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "post": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 댓글 또는 답글 작성", + "description": "parentId가 없으면 원댓글, 있으면 같은 게시글의 활성 원댓글에 대한 답글을 target AI 캐릭터 명의로 작성한다.", + "operationId": "createAiCharacterCommunityPostComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.createCommunityPostComment", "CreateCommunityPostCommentRequest"], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommunityCommentCreateRequest"}}} + }, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/PostId"}, + {"$ref": "#/components/parameters/CommentId"} + ], + "put": { + "tags": ["Community"], + "summary": "AI 캐릭터 작성 커뮤니티 댓글 수정", + "operationId": "updateAiCharacterCommunityPostComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.modifyCommunityPostComment", "ModifyCommunityPostCommentRequest"], + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommentUpdateRequest"}}} + }, + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + }, + "delete": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 댓글 soft delete", + "description": "target 소유 게시글의 댓글 또는 답글을 작성자와 관계없이 해당 row만 isActive=false로 변경한다. 이미 비활성이면 성공 no-op이며 하위 답글은 변경하지 않는다.", + "operationId": "deleteAiCharacterCommunityPostComment", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityService.modifyCommunityPostComment", "ModifyCommunityPostCommentRequest.isActive"], + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, + "/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/PostId"}, + {"$ref": "#/components/parameters/CommentId"} + ], + "get": { + "tags": ["Community"], + "summary": "커뮤니티 게시글 댓글 답글 목록", + "operationId": "listAiCharacterCommunityPostCommentReplies", + "x-implementation-status": "implemented", + "x-legacy-sources": ["CreatorCommunityController.getCommentReplyList", "GetCommunityPostCommentListResponse"], + "parameters": [ + {"$ref": "#/components/parameters/Page"}, + {"$ref": "#/components/parameters/Size"} + ], + "responses": { + "200": {"$ref": "#/components/responses/CommunityCommentListSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, "/api/v2/admin/ai-characters/{characterId}/fan-talks": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}], "get": { @@ -459,7 +758,7 @@ "summary": "FanTalk 관리자 목록", "description": "공개 v2 응답 필드 형태를 유지하되 관리자 target/ownership 정책을 사용하고 viewer/block 필터를 적용하지 않는다.", "operationId": "listAiCharacterFanTalks", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["CreatorChannelFanTalkController.getFanTalkTab", "CreatorChannelFanTalkTabResponse"], "parameters": [{"$ref": "#/components/parameters/FanTalkPage"}, {"$ref": "#/components/parameters/FanTalkSize"}], "responses": { @@ -474,6 +773,31 @@ } } }, + "/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}": { + "parameters": [ + {"$ref": "#/components/parameters/AcceptLanguage"}, + {"$ref": "#/components/parameters/CharacterId"}, + {"$ref": "#/components/parameters/FanTalkId"} + ], + "delete": { + "tags": ["FanTalk"], + "summary": "팬 작성 FanTalk 원글 soft delete", + "description": "target 채널의 팬 작성 root만 비활성화하며 이미 비활성이면 성공 no-op이다. 연결 creator reply row는 변경하지 않는다.", + "operationId": "deleteAiCharacterFanTalk", + "x-implementation-status": "implemented", + "x-legacy-sources": ["ExplorerService.modifyCheers", "PutWriteCheersRequest.isActive"], + "responses": { + "200": {"$ref": "#/components/responses/NullSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } + }, "/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies": { "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/FanTalkId"}], "post": { @@ -481,7 +805,7 @@ "summary": "FanTalk 답변 작성", "description": "사용자가 승인한 예외로 신규 관리자 축약 응답을 반환한다.", "operationId": "createAiCharacterFanTalkReply", - "x-implementation-status": "planned", + "x-implementation-status": "implemented", "x-legacy-sources": ["ExplorerController.writeCheers", "PostWriteCheersRequest", "CreatorChannelFanTalkReplyResponse"], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyCreateRequest"}}}}, "responses": { @@ -496,6 +820,29 @@ "500": {"$ref": "#/components/responses/InternalServerError"} } } + }, + "/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}": { + "parameters": [{"$ref": "#/components/parameters/AcceptLanguage"}, {"$ref": "#/components/parameters/CharacterId"}, {"$ref": "#/components/parameters/FanTalkId"}, {"$ref": "#/components/parameters/ReplyId"}], + "put": { + "tags": ["FanTalk"], + "summary": "FanTalk 답변 수정", + "description": "target AI가 작성하고 path의 활성 root에 직접 연결된 reply만 수정한다. 레거시처럼 optional/nullable content와 isActive의 non-null 값만 반영하며 빈 객체는 성공 no-op이다. 비활성 reply는 isActive=true로 재활성화할 수 있고 성공 data는 CreatorChannelFanTalkResponse 필드 형태다.", + "operationId": "updateAiCharacterFanTalkReply", + "x-implementation-status": "implemented", + "x-legacy-sources": ["ExplorerController.modifyCheers", "ExplorerService.modifyCheers", "PutWriteCheersRequest", "CreatorChannelFanTalkResponse"], + "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyUpdateRequest"}}}}, + "responses": { + "200": {"$ref": "#/components/responses/FanTalkReplyUpdateSuccess"}, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "405": {"$ref": "#/components/responses/MethodNotAllowed"}, + "406": {"$ref": "#/components/responses/NotAcceptable"}, + "415": {"$ref": "#/components/responses/UnsupportedMediaType"}, + "500": {"$ref": "#/components/responses/InternalServerError"} + } + } } }, "components": { @@ -514,12 +861,13 @@ "ContentId": {"name": "contentId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, "SeriesId": {"name": "seriesId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, "PostId": {"name": "postId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "CommentId": {"name": "commentId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, "FanTalkId": {"name": "fanTalkId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, + "ReplyId": {"name": "replyId", "in": "path", "required": true, "schema": {"type": "integer", "format": "int64"}}, "Page": {"name": "page", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 0, "minimum": 0}}, "Size": {"name": "size", "in": "query", "required": false, "schema": {"type": "integer", "format": "int32", "default": 20, "minimum": 1}}, "FanTalkPage": {"name": "page", "in": "query", "required": false, "description": "공개 v2 정책에서 0 이상으로 보정한다.", "schema": {"type": "integer", "format": "int32", "default": 0}}, - "FanTalkSize": {"name": "size", "in": "query", "required": false, "description": "공개 v2 정책에서 20..50으로 보정한다.", "schema": {"type": "integer", "format": "int32", "default": 20}}, - "Timezone": {"name": "timezone", "in": "query", "required": true, "example": "Asia/Seoul", "schema": {"type": "string"}} + "FanTalkSize": {"name": "size", "in": "query", "required": false, "description": "공개 v2 정책에서 20..50으로 보정한다.", "schema": {"type": "integer", "format": "int32", "default": 20}} }, "requestBodies": { "CharacterCreateMultipart": { @@ -558,17 +906,22 @@ "responses": { "CharacterListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CharacterListApiResponse"}}}}, "CharacterDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CharacterDetailApiResponse"}}}}, + "OriginalWorkSearchSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OriginalWorkSearchApiResponse"}}}}, "AudioThemeListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioThemeListApiResponse"}}}}, "AudioContentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentListApiResponse"}}}}, "AudioContentCreateSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentCreateApiResponse"}}}}, "AudioContentDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentDetailApiResponse"}}}}, + "AudioContentCommentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/AudioContentCommentListApiResponse"}}}}, "SeriesListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesListApiResponse"}}}}, "SeriesDetailSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesDetailApiResponse"}}}}, + "SeriesGenreListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesGenreListApiResponse"}}}}, "SeriesContentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentListApiResponse"}}}}, "SeriesContentSearchSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SeriesContentSearchApiResponse"}}}}, "CommunityPostListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommunityPostListApiResponse"}}}}, + "CommunityCommentListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CommunityCommentListApiResponse"}}}}, "FanTalkListSuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkListApiResponse"}}}}, "FanTalkReplySuccess": {"description": "성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyApiResponse"}}}}, + "FanTalkReplyUpdateSuccess": {"description": "레거시 FanTalk 답변 수정 성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FanTalkReplyUpdateApiResponse"}}}}, "NullSuccess": {"description": "레거시 mutation 성공", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/NullSuccessResponse"}}}}, "BadRequest": {"description": "잘못된 요청/target/domain 오류", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, "Unauthorized": {"description": "인증 실패", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ApiErrorResponse"}}}}, @@ -796,6 +1149,27 @@ }, "CharacterListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CharacterListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "CharacterDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CharacterDetailResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "OriginalWorkSearchItem": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "contentType", "category", "isAdult", "description", "originalWork", "originalLink", "writer", "studio", "originalLinks", "tags", "imageUrl"], + "properties": { + "id": {"type": "integer", "format": "int64"}, + "title": {"type": "string"}, + "contentType": {"type": "string"}, + "category": {"type": "string"}, + "isAdult": {"type": "boolean"}, + "description": {"type": "string"}, + "originalWork": {"$ref": "#/components/schemas/NullableString"}, + "originalLink": {"$ref": "#/components/schemas/NullableString"}, + "writer": {"$ref": "#/components/schemas/NullableString"}, + "studio": {"$ref": "#/components/schemas/NullableString"}, + "originalLinks": {"type": "array", "items": {"type": "string"}}, + "tags": {"type": "array", "items": {"type": "string"}}, + "imageUrl": {"$ref": "#/components/schemas/NullableString"} + } + }, + "OriginalWorkSearchApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/OriginalWorkSearchItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "AudioContentTheme": { "type": "object", @@ -812,11 +1186,10 @@ "title": {"type": "string"}, "detail": {"type": "string"}, "tags": {"type": "string"}, - "price": {"type": "integer", "format": "int32"}, + "price": {"type": "integer", "format": "int32", "minimum": 0, "maximum": 99999}, "purchaseOption": {"$ref": "#/components/schemas/PurchaseOption", "default": "BOTH"}, "limited": {"$ref": "#/components/schemas/NullableInt32"}, - "timezone": {"type": "string", "default": "Asia/Seoul"}, - "releaseDate": {"type": ["string", "null"], "description": "yyyy-MM-dd HH:mm"}, + "releaseDate": {"type": ["string", "null"], "format": "date-time", "pattern": "Z$", "description": "클라이언트가 UTC로 변환해 보내는 ISO-8601 시각. 예: 2026-07-29T09:00:00Z"}, "themeId": {"type": "integer", "format": "int64", "default": 0, "description": "0은 binding 기본값이며 domain validation에서 유효하지 않다."}, "isAdult": {"type": "boolean", "default": false}, "isGeneratePreview": {"type": "boolean", "default": false}, @@ -836,7 +1209,7 @@ "title": {"$ref": "#/components/schemas/NullableString"}, "detail": {"$ref": "#/components/schemas/NullableString"}, "tags": {"$ref": "#/components/schemas/NullableString"}, - "price": {"$ref": "#/components/schemas/NullableInt32"}, + "price": {"$ref": "#/components/schemas/NullableInt32", "minimum": 0, "maximum": 99999}, "isAdult": {"$ref": "#/components/schemas/NullableBoolean"}, "isActive": {"$ref": "#/components/schemas/NullableBoolean"}, "isPointAvailable": {"$ref": "#/components/schemas/NullableBoolean"}, @@ -938,10 +1311,37 @@ "languageCode": {"$ref": "#/components/schemas/NullableString"}, "isSecret": {"type": "boolean"}, "donationCan": {"type": "integer", "format": "int32"}, - "date": {"type": "string"}, + "date": {"type": "string", "format": "date-time", "pattern": "Z$", "description": "ISO-8601 UTC 시각(Z)"}, "replyCount": {"type": "integer", "format": "int32"} } }, + "AudioContentCommentCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["comment"], + "properties": { + "comment": {"type": "string"}, + "parentId": {"$ref": "#/components/schemas/NullableInt64"}, + "isSecret": {"type": "boolean", "default": false}, + "languageCode": {"$ref": "#/components/schemas/NullableString"} + } + }, + "CommentUpdateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["comment"], + "properties": {"comment": {"type": "string"}} + }, + "AudioContentCommentListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": { + "totalCount": {"type": "integer", "format": "int32"}, + "items": {"type": "array", "items": {"$ref": "#/components/schemas/AudioContentComment"}} + } + }, + "AudioContentCommentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/AudioContentCommentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "TranslatedContent": { "type": "object", "additionalProperties": false, @@ -963,7 +1363,7 @@ "tag": {"type": "string"}, "price": {"type": "integer", "format": "int32"}, "duration": {"type": "string"}, - "releaseDate": {"$ref": "#/components/schemas/NullableString"}, + "releaseDate": {"type": ["string", "null"], "format": "date-time", "pattern": "Z$", "description": "기존 null/노출 조건을 유지하는 ISO-8601 UTC 시각(Z)"}, "totalContentCount": {"$ref": "#/components/schemas/NullableInt32"}, "remainingContentCount": {"$ref": "#/components/schemas/NullableInt32"}, "orderSequence": {"$ref": "#/components/schemas/NullableInt32"}, @@ -1060,22 +1460,14 @@ "required": ["totalCount", "items"], "properties": {"totalCount": {"type": "integer", "format": "int32"}, "items": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesListItem"}}} }, - "SeriesDetailResponse": { + "SeriesGenreItem": { "type": "object", "additionalProperties": false, - "required": ["seriesId", "title", "introduction", "coverImageUrl", "publishedDaysOfWeek", "genre", "keywords", "isAdult", "state", "writer", "studio"], + "required": ["id", "genre", "isAdult"], "properties": { - "seriesId": {"type": "integer", "format": "int64"}, - "title": {"type": "string"}, - "introduction": {"type": "string"}, - "coverImageUrl": {"type": "string"}, - "publishedDaysOfWeek": {"type": "string", "description": "예: 월, 수"}, + "id": {"type": "integer", "format": "int64"}, "genre": {"type": "string"}, - "keywords": {"type": "string"}, - "isAdult": {"type": "boolean"}, - "state": {"type": "string", "description": "레거시 String 필드. 현재 관찰 값: 연재중, 휴재중, 완결"}, - "writer": {"$ref": "#/components/schemas/NullableString"}, - "studio": {"$ref": "#/components/schemas/NullableString"} + "isAdult": {"type": "boolean"} } }, "SeriesContentListItem": { @@ -1109,7 +1501,8 @@ "properties": {"ids": {"type": "array", "items": {"type": "integer", "format": "int64"}}} }, "SeriesListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, - "SeriesDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesDetailResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "SeriesDetailApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesListItem"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "SeriesGenreListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesGenreItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "SeriesContentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/SeriesContentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "SeriesContentSearchApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/SeriesContentSearchItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, @@ -1124,10 +1517,30 @@ "profileUrl": {"type": "string"}, "comment": {"type": "string"}, "isSecret": {"type": "boolean"}, - "date": {"type": "string"}, + "date": {"type": "string", "format": "date-time", "pattern": "Z$", "description": "ISO-8601 UTC 시각(Z)"}, "replyCount": {"type": "integer", "format": "int32"} } }, + "CommunityCommentCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["comment"], + "properties": { + "parentId": {"$ref": "#/components/schemas/NullableInt64"}, + "comment": {"type": "string"}, + "isSecret": {"type": "boolean", "default": false} + } + }, + "CommunityCommentListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "items"], + "properties": { + "totalCount": {"type": "integer", "format": "int32"}, + "items": {"type": "array", "items": {"$ref": "#/components/schemas/CommunityPostComment"}} + } + }, + "CommunityCommentListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CommunityCommentListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "CommunityPostListItem": { "type": "object", "additionalProperties": false, @@ -1153,6 +1566,18 @@ "firstComment": {"oneOf": [{"$ref": "#/components/schemas/CommunityPostComment"}, {"type": "null"}]} } }, + "CommunityPostListResponse": { + "type": "object", + "additionalProperties": false, + "required": ["totalCount", "page", "size", "hasNext", "items"], + "properties": { + "totalCount": {"type": "integer", "format": "int64", "minimum": 0}, + "page": {"type": "integer", "format": "int32", "minimum": 0}, + "size": {"type": "integer", "format": "int32", "minimum": 1}, + "hasNext": {"type": "boolean"}, + "items": {"type": "array", "items": {"$ref": "#/components/schemas/CommunityPostListItem"}} + } + }, "CommunityPostCreateRequest": { "type": "object", "additionalProperties": false, @@ -1161,7 +1586,7 @@ "content": {"type": "string"}, "isCommentAvailable": {"type": "boolean"}, "isAdult": {"type": "boolean"}, - "price": {"type": "integer", "format": "int32", "default": 0} + "price": {"type": "integer", "format": "int32", "minimum": 0, "maximum": 99999, "default": 0} } }, "CommunityPostUpdateRequest": { @@ -1194,7 +1619,7 @@ "request": {"$ref": "#/components/schemas/CommunityPostUpdateRequest"} } }, - "CommunityPostListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"type": "array", "items": {"$ref": "#/components/schemas/CommunityPostListItem"}}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "CommunityPostListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/CommunityPostListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, "FanTalkCreatorReply": { "type": "object", @@ -1241,6 +1666,15 @@ "required": ["content"], "properties": {"content": {"type": "string"}} }, + "FanTalkReplyUpdateRequest": { + "type": "object", + "additionalProperties": false, + "description": "레거시 PutWriteCheersRequest에서 path로 이동한 cheersId만 제외한다. content와 isActive는 optional/nullable이며 둘 다 생략하거나 null이면 성공 no-op이다.", + "properties": { + "content": {"$ref": "#/components/schemas/NullableString"}, + "isActive": {"$ref": "#/components/schemas/NullableBoolean"} + } + }, "FanTalkReplyResponse": { "type": "object", "additionalProperties": false, @@ -1254,7 +1688,8 @@ } }, "FanTalkListApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkListResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, - "FanTalkReplyApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkReplyResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]} + "FanTalkReplyApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkReplyResponse"}, "errorProperty": {"type": ["string", "null"], "const": null}}}]}, + "FanTalkReplyUpdateApiResponse": {"allOf": [{"$ref": "#/components/schemas/ApiSuccessBase"}, {"type": "object", "required": ["message", "data", "errorProperty"], "properties": {"message": {"type": ["string", "null"]}, "data": {"$ref": "#/components/schemas/FanTalkListItem", "description": "레거시 CreatorChannelFanTalkResponse 필드 형태. fanTalkId는 수정한 reply row ID이며 creatorReplies는 빈 배열이다."}, "errorProperty": {"type": ["string", "null"], "const": null}}}]} } } } diff --git a/docs/20260725_AI캐릭터관리자웹/plan-task.md b/docs/20260725_AI캐릭터관리자웹/plan-task.md index 14e0132..9f8ceb0 100644 --- a/docs/20260725_AI캐릭터관리자웹/plan-task.md +++ b/docs/20260725_AI캐릭터관리자웹/plan-task.md @@ -6,16 +6,88 @@ **Architecture:** 프로젝트 세팅과 공통 플랫폼·인증/인가를 먼저 완결한 뒤, 각 도메인을 API·상태·화면·오류·반응형·접근성·E2E까지 포함한 vertical slice로 구현한다. 모든 하위 리소스는 URL의 `characterId`를 기준으로 격리하고, 공통 API client가 envelope parsing, 인증 header, `Accept-Language: ko`, 401/403을 담당한다. 두 개 이상의 Phase에서 동일한 의미로 반복될 것이 확정된 UI·파일·미디어 컴포넌트는 Phase 1에서 먼저 만들고, 그 밖의 UI는 도메인 Phase 안에서 작게 나눈 뒤 실제 재사용 근거가 생길 때 shared로 올린다. -**Tech Stack:** React, TypeScript, Vite, Tailwind CSS, shadcn/ui, React Router, TanStack Query, React Hook Form, Zod, Axios/XHR upload adapter, date-fns/date-fns-tz, dnd-kit, Lucide React, Vitest, React Testing Library, MSW, Playwright, axe-core. +**Tech Stack:** React, TypeScript, Vite, Tailwind CSS, TanStack Query, Zod, native history routing, fetch/XHR upload adapter, Vitest, React Testing Library, MSW, Playwright, axe-core. | 문서 항목 | 내용 | |---|---| -| 상태 | Phase 0~2 완료, Phase 3 Character workspace 착수 전 | +| 상태 | 2026-08-01 `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` 자동 보완 완료, 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기 | | 최초 작성일 | 2026-07-25 | | 재작성일 | 2026-07-26 | | 요구사항 기준 | [prd.md](./prd.md) | | API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) | +## 목표 + +ADMIN이 로그인한 뒤 AI 캐릭터를 선택하고, 선택한 캐릭터 문맥에서 Character·Audio·Series·Community·FanTalk·Comments를 안전하게 관리하는 독립 React SPA를 완성한다. + +## 현재 상태 + +| 구분 | 현재 상태 | 남은 조건 | +|---|---|---| +| 자동 검증 범위 | `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17` current-state contract 자동 보완 완료 | 실제 crop pixel·stale ADMIN·개발 API 수동 QA | +| 실제 개발 API | Series/FanTalk/Comments/file policy 수동 QA 대기 | ADMIN credential과 고정 fixture 필요 | +| 브라우저·인가 수동 QA | 실제 crop pixel 비교와 stale ADMIN server 확인 대기 | Chromium/mobile Chrome 환경과 stale ADMIN fixture 필요 | +| 문서 구조 | `P10-R9`에서 Goal 실행형 필수 section navigation 복구 | 기존 Progress·Decision 이력은 보존 | + +## 범위의 포함·제외 + +### 포함 + +- AI 캐릭터 관리자 SPA의 인증, Character workspace, Audio, Series, Community, FanTalk, Comments mock/client 구현과 회귀 보완. +- OpenAPI 2.3.0에서 제공된 v2 lookup, UTC, pagination, FanTalk 수정·삭제, Comments, 가격·오류·파일 정책 반영. +- mock/client 자동 검증과 실제 개발 API 수동 QA 대기 상태의 분리 기록. + +### 제외 + +- FanTalk 별도 상세/filter/sort, 중복 오류 key, 복원·hard delete, refresh token, 자동 mock fallback. +- 실제 개발 API 수동 QA 완료 주장. credential·fixture가 준비되기 전에는 완료로 표시하지 않는다. +- WebKit·Mobile Safari는 지원 범위에서 제외한다. E2E는 Chromium/mobile Chrome만 실행한다. + +## 기술적 제약 + +- 현재 설치 stack은 `package.json` 기준 React, TypeScript, Vite, Tailwind CSS, TanStack Query, Zod, Vitest, React Testing Library, MSW, Playwright, axe-core다. +- routing은 native history 기반이고, transport는 fetch/XHR upload adapter를 사용한다. +- API 계약은 `api-contract.openapi.json`을 우선하며 제공되지 않은 endpoint, DTO, enum, 오류 status/key, validation 상한은 추정하지 않는다. +- JWT와 ADMIN role은 `sessionStorage`에만 저장하고 민감정보를 console, 분석 이벤트, 영구 저장소에 기록하지 않는다. + +## 하나 이상의 Phase + +실행 Phase는 [Phase 지도](#3-phase-지도)와 [Phase 0](#phase-0-프로젝트-세팅)부터 [Phase 10](#phase-10-openapi-230-후속-계약-vertical-slices)까지의 본문이 소유한다. 완료된 Phase는 다시 열지 않고 후속 문제는 `P-R<번호>` 회귀 Task로 추적한다. + +## 실행 순서와 의존성 + +- 기본 순서는 Phase 0 → 1 → 2 → 3 → 4 → 5/6/7 → 8 → 9 → 10이다. +- 자동 보완 Task는 완료됐다. 이후 남은 수동 QA와 필요 시 E2E는 Chromium/mobile Chrome만 실행한다. +- 동시에 하나의 미완료 goal만 운용하고, 완료된 Task의 체크박스와 검증 기록은 되돌리지 않는다. + +## 변경 금지 항목 + +- OpenAPI에 없는 계약 추정, mock 성공을 server 완료로 대체, 실제 server QA 완료 허위 표시. +- 실패 test 삭제·skip·timeout 상향·근거 없는 browser project 제거로 Gate를 통과시키는 변경. +- 제품 범위 밖 기능 추가, 새 dependency 설치, 과거 Progress·Decision Log 삭제 또는 덮어쓰기. + +## 의사결정 및 중단 규칙 + +- 외부 계약이나 권한이 필요한 범위는 담당 주체, 영향, 재개 조건을 기록하고 수동 QA 대기로 유지한다. +- 같은 차단 사유가 3회 반복되고 문서화 외 진전이 불가능할 때만 blocked로 판정한다. +- 사용자 직접 지시는 현재 검증 범위에 우선한다. 2026-07-31 이후 지원·자동 검증 browser는 Chromium/mobile Chrome만 사용한다. + +## Progress + +상세 진행 기록은 [검증 기록](#7-검증-기록)에 누적한다. `P9-R18`~`P9-R19`, `P10-R16`~`P10-R17`에서 finding·Task·H2·실제 마지막 Progress contract를 보완해 자동 보완 완료로 판정했다. 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA는 별도 대기다. + +## Decision Log + +주요 결정은 [Phase 운영 규칙](#2-phase-운영-규칙), [Phase 3~10 OpenAPI 준비 상태](#31-phase-310-openapi-준비-상태), [요구사항 추적표](#5-요구사항-추적표), [검증 기록](#7-검증-기록)에 보존한다. 과거 결정은 삭제하지 않고 후속 정정만 누적한다. + +## 발견된 문제 + +제품·Playwright 설정의 신규 문제는 없다. `P9-R18`, `P9-R19`, `P10-R16`, `P10-R17`로 finding·Task 경계, fenced·중복 H2, 최신 및 중복 Progress marker contract를 보완했다. 실제 crop pixel·stale ADMIN과 개발 API Series/FanTalk/Comments/file policy 수동 QA가 남아 있다. + +## 최종 보고 형식 + +최종 보고는 한국어로 작성하고, 완료한 Goal ID, 변경 파일, 실행한 검증 명령과 결과, 실행하지 않은 E2E/수동 QA의 사유, 남은 위험을 구분해 요약한다. + ## 1. 전역 제약 - 계획 보완 단계에서는 PRD·API Contract·plan과 연결 가이드만 동기화하고, 애플리케이션 코드와 프로젝트 설정은 해당 Task goal 실행 때 변경한다. @@ -27,11 +99,13 @@ - `externalCharacterId`는 type, DTO, payload, fixture, UI에 만들지 않는다. - 모든 생성 payload에는 `isActive`를 넣지 않는다. - Character·Audio·Series·Community 일반 수정에는 `isActive`를 넣지 않고 soft delete에만 `isActive=false`를 보낸다. `isActive=true`, 복원, hard delete는 구현하지 않는다. -- Character·Audio·Series·Community 목록은 활성 상태 query와 client-side 활성 필터를 추가하지 않는다. active-only 반환 보장은 외부 의존으로 추적하고 서버 반환값을 그대로 사용한다. +- Character·Audio·Series·Community 목록은 서버가 `isActive=true`인 항목만 반환한다. 활성 상태 query와 client-side 활성 필터를 추가하지 않고 서버 반환값을 그대로 사용한다. - Character·Audio·Series soft delete 성공 후 해당 목록으로 이동한다. Community는 열린 Sheet를 닫고 현재 목록에서 제거한다. 모두 성공 알림을 표시한다. - 워크스페이스 상세 성공 응답의 Character가 `isActive=false`이면 모든 하위 mutation 진입점을 차단한다. soft delete 직후에는 목록 이동을 우선한다. - Series 생성 payload에는 `state`를 넣지 않는다. 수정에서 state를 바꾸지 않으면 key를 생략한다. - Series state에 `OPEN`, 요일에 `MONDAY` 같은 보정 전 enum을 사용하지 않는다. +- AI 캐릭터 관리자 API에는 `timezone` query/body field를 보내지 않는다. 오디오 예약 공개의 Asia/Seoul 입력은 client에서 ISO-8601 UTC `Z`로 변환하고 즉시 공개는 `releaseDate=null`을 보낸다. +- Audio 생성·수정과 Community 생성의 `price`는 `0..99999` 정수만 허용하고 schema·form에서 `-1`, `100000`, 소수를 거부한다. - multipart의 JSON part 이름은 `request`로 고정하고, optional 교체 파일을 보내지 않으면 기존 media를 유지한다. - 목록은 operation별 OpenAPI pagination을 사용한다. 공통 page는 기본 0·최소 0, size는 기본 20·최소 1이고 FanTalk size만 20..50으로 보정된다. 검색을 제공하는 목록은 약 300ms debounce를 일관되게 적용하고 URL query와 기존 화면 데이터를 유지한다. - image 영역은 비율과 크기를 예약하고 목록 image는 lazy load한다. 날짜·가격 공통 formatter는 Phase 1에서 만들고 상태 label은 각 도메인이 `StatusBadge`에 주입한다. @@ -117,9 +191,9 @@ import 오류, test 환경 오류, 임시 mock 누락 같은 우발적 실패는 2. 제공된 endpoint·DTO로 안전하게 구현할 수 있는 Task는 mock/server 상태를 구분해 구현·검증한다. 3. 계약이 없는 기능은 endpoint·DTO·fixture·UI 완료 상태를 추정하지 않고 해당 Task 또는 network 범위만 `외부 계약 대기` 후속으로 기록한다. 4. 외부 의존 때문에 실행하지 않은 범위가 있더라도 완료된 독립 Task를 되돌리지 않고 다음 Phase를 진행한다. -5. Phase 8 Comments 계약이 없으면 `P8-T1`의 계약 부재·재개 조건과 `P8-GATE` 제외/대기 증거를 남긴 뒤 Phase 9로 진행한다. +5. 2026-07-28 당시 Phase 8 Comments 계약 부재는 `P8-T1`과 `P8-GATE` 이력으로 보존한다. 2026-07-29 OpenAPI 2.3.0의 댓글 계약 도착 후 구현은 Phase 10에서 새 goal로 진행한다. 6. Phase 9는 제공 계약과 명시적 제외 범위만 대상으로 실행하고 결과를 `활성 범위 완료`로 기록한다. 미제공 계약을 포함한 `전체 기능 완료`로 표현하지 않는다. -7. backend 계약이 후속 도착하면 완료 Phase를 묵시적으로 다시 열지 않고 별도 vertical slice를 계획해 구현한 뒤 영향받는 Phase Gate와 `P9-GATE`를 다시 실행한다. +7. backend 계약이 후속 도착하면 완료 Phase를 묵시적으로 다시 열지 않고 별도 vertical slice를 계획해 구현한 뒤 영향받는 회귀와 최종 Gate를 다시 실행한다. OpenAPI 2.3.0 후속 범위는 Phase 10이 소유한다. 이 전략은 추가 사용자 결정을 기다리기 위한 임시 우회가 아니라, 제공 범위의 UI와 공통 품질을 먼저 완성해 전체 lead time을 줄이는 확정 실행 @@ -156,23 +230,25 @@ Goal objective 권장 형식: | 4 | Audio vertical slice | Phase 3의 workspace core | list/search/detail/play → create/edit/upload → deactivate | | 5 | Series vertical slice | Phase 4의 Audio 조회 API | list/detail → 계약 제공 후 CRUD; content link/unlink → full reorder | | 6 | Community vertical slice | Phase 4의 media/file primitive | list → collection Sheet edit/pin → media play → deactivate | -| 7 | FanTalk vertical slice | Phase 3 | list → one reply; detail/edit/filter는 계약 대기 | +| 7 | FanTalk vertical slice | Phase 3 | list → reply create/update, fan root delete; 별도 detail/filter/sort는 제품 범위 제외 | | 8 | Comments vertical slice | Phase 4 + Phase 6 | Audio/Community thread → permission별 CRUD | | 9 | 교차 회귀·인수인계 | 활성 범위의 Phase 0~8 | 전체 journey, viewport, axe, security, mock/server build | +| 10 | OpenAPI 2.3.0 후속 계약 vertical slices | Phase 9 이력 + OpenAPI 2.3.0 | v2 lookup, UTC, pagination, FanTalk 수정·삭제, Comments, 가격·오류·파일 정책, active-only server 검증 | 기본 진행 순서는 Phase 번호를 따른다. 다만 Phase 5·6·7은 자신의 선행조건과 계약이 충족되면 병행할 수 있고, 외부 계약으로 막힌 Phase가 다른 독립 Phase를 막지 않는다. -### 3.1 Phase 3~9 OpenAPI 준비 상태 +### 3.1 Phase 3~10 OpenAPI 준비 상태 | Phase | 현재 구현 가능 범위 | 외부 의존 또는 제외 범위 | 판정 | |---:|---|---|---| -| 3 Character | 활성 기본 목록·레거시 검색·상세·필수 image 생성·허용 field 수정·soft delete request와 workspace UI | original work lookup, 검색 결과 active-only 보장, 도메인 오류 key | 핵심 UI 가능, 일부 server 수용 기준 대기 | -| 4 Audio | 테마·제목 검색·상세·생성·허용 field 수정·재생·upload | active-only 반환, backend file/container 검증, 오류 key; status filter와 수정 audio/schedule/theme/series는 계약상 제외 | 제공 operation 범위 구현 가능 | -| 5 Series | 목록·상세 조회, 연결 후보 검색·연결·해제·전체 순서 | genre lookup이 생성 차단, edit DTO가 수정 차단, active-only·오류 key 대기 | CRUD 전체는 차단, 조회·연결·순서 부분 가능 | -| 6 Community | timezone 목록·Sheet·생성·허용 field 수정·고정·soft delete request·media | active-only, pagination 종료 metadata, backend file 검증과 오류 key | 핵심 UI 가능, 목록 종료·server 수용 기준 대기 | -| 7 FanTalk | page 목록과 답변 1회 생성 | 상세·답변 수정·전체 결과 filter/sort·유일성 오류 | 제공 operation 범위 구현 가능 | -| 8 Comments | 계약 독립적인 shell·상태 inventory | 두 target의 댓글 CRUD·2단계·권한 오류 전체 | network slice 차단 | +| 3 Character | 기존 목록·상세·기본 create/update/soft delete mock UI 완료 | 없음. v2 원작 검색 선택기는 Phase 10에서 구현 완료 | `EXT-001` 해결, legacy 후보 제거 완료 | +| 4 Audio | 목록·상세·create/update/soft delete mock UI와 UTC `releaseDate` 전송 완료 | status filter와 수정 schedule/theme/file은 계약상 제외 | `EXT-007` 해결, 실제 개발 API 수동 QA 대기 | +| 5 Series | 목록·상세·연결·해제·전체 순서, v2 활성 장르, CRUD form mock/client 완료 | 실제 개발 API Series 수동 QA | `EXT-002` 해결, 상세는 `SeriesListItem`으로 확정 | +| 6 Community | 목록·Sheet·create/update/pin/soft delete, pagination object, `timezone` 제거 mock/client 완료 | 실제 개발 API Community 수동 QA | `EXT-007~008` 해결, active-only 반환 확인 대기 | +| 7 FanTalk | page 목록, 답변 생성·수정, 팬 원글 삭제 mock/client UI 완료 | 별도 상세/filter/sort·중복 오류 key는 현재 제품 범위에서 제외 | `EXT-004` 해결, 실제 개발 API 수동 QA만 분리 대기 | +| 8 Comments | Audio·Community 댓글 2단계 thread와 mock/client UI 완료 | 실제 개발 API Comments 수동 QA | `EXT-005` 해결, Phase 10 network slice 구현 완료 | | 9 Final | 제공 계약과 명시적 제외 범위의 교차 회귀 | 미제공 P0 범위를 포함한 전체 릴리스 완료 주장 | 활성 범위 Gate 후 가능 | +| 10 Follow-up | 원작·장르 lookup, UTC, Community pagination, FanTalk 수정·삭제, Comments, 가격·오류·파일 정책 | 제품 범위에서 제외한 FanTalk 별도 상세/filter/sort·중복 오류 key와 인증 정식화 | 현재 확정 범위의 client/mock/server 구현 가능 | OQ-009를 포함한 프론트엔드 제품 결정 절차는 확정됐다. 위 표의 대기 사항은 추가 사용자 결정이 아니라 backend OpenAPI 보완 또는 명시적 @@ -187,6 +263,7 @@ Phase 0 Setup │ └─ Phase 6 Community ──┐ └─ Phase 7 FanTalk ├─ Phase 9 Final Phase 4 + Phase 6 ── Phase 8 Comments ┘ + └─ Phase 10 OpenAPI 2.3.0 Follow-up ``` ## 4. 파일 책임 지도 @@ -264,13 +341,13 @@ feature 내부의 `api/`, `components/`, `model/`, `pages/`, `schemas/`, `tests/ **Goal 실행 `P0-T2`:** unit·DOM·E2E test 기반과 접근 가능한 최소 App shell을 완성한다. - **시작 조건:** `P0-T1` 완료. -- **완료 증거:** 체크박스 전체, root unit test와 4개 Playwright project smoke 및 검증 기록. +- **완료 증거:** 체크박스 전체, root unit test와 현재 Playwright project smoke 및 검증 기록. - **범위 밖:** 인증과 실제 도메인 route. - [x] Vitest, jsdom, React Testing Library, jest-dom을 구성한다. - [x] 각 test 뒤 DOM·mock·storage가 정리되는 공통 setup을 만든다. -- [x] Playwright에 desktop Chromium/WebKit과 mobile Chrome/Safari viewport project, Vite webServer를 구성한다. -- [x] fresh environment에서 Chromium/WebKit browser binary를 설치하는 명령을 README와 Gate에 포함한다. +- [x] Playwright에 desktop Chromium과 mobile Chrome viewport project, Vite webServer를 구성한다. +- [x] fresh environment에서 Chromium browser binary를 설치하는 명령을 README와 Gate에 포함한다. - [x] ``, `main` landmark, root content를 확인하는 unit test를 먼저 실패시킨 뒤 최소 App shell을 만든다. - [x] 동일 shell이 각 Playwright project에서 열리는 smoke E2E를 만든다. @@ -284,7 +361,7 @@ feature 내부의 `api/`, `components/`, `model/`, `pages/`, `schemas/`, `tests/ ```bash npm ci -npx playwright install chromium webkit +npx playwright install chromium npm run typecheck npm run lint npm run test:run -- src/app/App.test.tsx src/shared/config/env.test.ts @@ -330,6 +407,60 @@ npm run build:prod - P0-GATE: `npm ci` — 성공, 307 packages, 0 vulnerabilities. `npx playwright install chromium webkit` — 성공. `npm run typecheck` — 성공. `npm run lint` — 성공. `npm run test:run -- src/app/App.test.tsx src/shared/config/env.test.ts src/shared/test/setup-isolation.test.ts` — 성공, 18 tests passed. `npm run e2e -- tests/e2e/smoke.spec.ts` — 성공, 4 projects / 4 tests passed. `npm run build:dev` — 성공. `npm run build:prod` — 성공. - 참고: 첫 Gate 병렬 실행 중 `npm ci`가 `node_modules`를 재설치하는 동안 동시에 실행한 `tsc/playwright` 명령이 bin 삭제로 실패했다. `npm ci` 완료 후 같은 명령을 재실행해 모두 성공했다. +### Task R0.2 — API origin 문서·E2E 단일 source 복구 + +**Goal 실행 `P0-R2`:** 현재 `.env` API origin을 실행 문서와 Playwright route/probe가 함께 사용하게 해 mock/server Gate의 host drift를 제거한다. + +- **연결 리뷰:** [Phase 0 현재 리뷰](./reviews/phase0-project-foundation.md) — `REV-P0-004` +- **시작 조건:** `.env.development=https://test-api.sodalive.net`, `.env.production=https://api.sodalive.net`인 현재 working tree와 `VITE_API_BASE_URL` runtime 검증. +- **완료 증거:** stale origin에서 실패하는 mock/server E2E RED, origin 단일 source 적용, focused/full mock·server E2E와 문서/diff 검증 기록. +- **Files:** Modify: `README.md`, `docs/agent-guide/environment.md`, `tests/e2e/auth.spec.ts`, `tests/e2e/accessibility-shell.spec.ts`, `tests/e2e/server-mode-boundary.spec.ts`, `tests/e2e/error-mapping.spec.ts`, `tests/e2e/comments-test-support.ts`, 필요 시 Playwright 전용 env helper/config. +- **Interfaces:** 앱, browser mock handler, Playwright route/probe는 같은 개발 API base URL에서 파생하고 개발/프로덕션 host를 여러 파일에 상수로 복제하지 않는다. +- **범위 밖:** `.env`를 과거 host로 되돌리기, unhandled request를 bypass/warn으로 완화하기, 실제 ADMIN credential 발급. + +- [x] **RED:** 현재 `.env.development`에서 과거 `test-character-admin` route/probe가 매칭되지 않아 mock error-mapping과 server auth/boundary E2E가 실패함을 기록했다. +- [x] **GREEN:** Playwright가 현재 개발 API origin을 한 source에서 읽도록 수정하고 README·환경 가이드의 현재값을 정렬했다. +- [x] **REFACTOR:** 과거 host literal 검색을 0건으로 만들고 focused Comments server route와 full mock/server Gate를 재실행했다. +- **실행 명령:** `npm run e2e:mock -- tests/e2e/error-mapping.spec.ts`; `npm run e2e:mock`; `npm run e2e`; `npm run e2e -- tests/e2e/comments.spec.ts --project=chromium`; `rg -n 'test-character-admin|character-admin\\.sodalive\\.net' README.md docs/agent-guide tests/e2e`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 자동 명령 exit 0, Playwright unhandled request 0건, 현재 개발/프로덕션 origin 문서 불일치 0건. +- **수동 확인:** mock mode는 현재 개발 origin을 intercept하고 server mode는 browser MSW 없이 같은 origin으로 요청하는지 network에서 확인한다. + +**P0-R2 수정 검증 기록 (2026-07-30):** + +- RED: `npm run e2e:mock -- tests/e2e/error-mapping.spec.ts` — 4 failed / 4 passed. 과거 `test-character-admin` 요청이 MSW `onUnhandledRequest: "error"`에 걸려 기대 401 대신 500을 반환했다. `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts --project=chromium` — 2 failed / 2 passed. stale route가 현재 `.env.development` origin 요청을 intercept하지 못해 404 한국어 message와 network retry assertion이 실패했다. +- GREEN: `tests/e2e/api-base-url.ts`를 추가해 E2E route/probe가 `.env.development`의 `VITE_API_BASE_URL`을 읽게 하고, `README.md`와 `docs/agent-guide/environment.md`를 현재 개발/프로덕션 origin으로 정렬했다. `npm run e2e:mock -- tests/e2e/error-mapping.spec.ts` — 8 passed. `npm run e2e -- tests/e2e/server-mode-boundary.spec.ts --project=chromium` — 4 passed. +- REFACTOR/회귀: `rg -n 'test-character-admin|character-admin\.sodalive\.net' README.md docs/agent-guide tests/e2e` — no matches. `npm run e2e -- tests/e2e/comments.spec.ts --project=chromium` — 3 passed. `npm run e2e` — 36 passed. `npm run e2e:mock` 단일 실행은 900초 제한으로 188/228 진행 중 timeout됐고, 같은 allowlist를 project별로 분할해 `chromium` 57 passed, `webkit` 49 passed / 8 skipped, `mobile-chrome` 52 passed / 5 skipped, `mobile-safari` 48 passed / 9 skipped를 확인했다. +- Type/LSP/build: `npm run typecheck`, `npm run lint`, `npm run build` — 모두 exit 0. LSP diagnostics: `tests/e2e` 18 files / 오류 0건, 변경 E2E 파일 개별 오류 0건. +- 수동/표면 확인: mock `error-mapping`은 현재 개발 origin을 MSW handler가 intercept해 한국어 401/403/415를 반환했고, server mode는 browser MSW 없이 같은 origin route/probe에서 404·network retry와 Comments server route가 통과했다. timeout으로 남은 Vite mock server PID는 종료해 포트 8888/8889 listen 0건을 확인했다. + +### Task R0.3 — 운영체제 메타데이터의 저장소 유입 차단 + +**Goal 실행 `P0-R3`:** 문서 디렉터리에 index된 `.DS_Store`를 제거하고 같은 운영체제 메타데이터가 다시 version control 대상이 되지 않게 한다. + +- **연결 리뷰:** [Phase 0 현재 리뷰](./reviews/phase0-project-foundation.md) — `REV-P0-005` +- **시작 조건:** `git ls-files 'docs/20260725_AI캐릭터관리자웹/.DS_Store'`가 index entry를 반환하고 `.gitignore`에 `.DS_Store` 규칙이 없는 현재 working tree. +- **완료 증거:** 해당 index entry 삭제, 저장소 전역 ignore 규칙, 추적 `.DS_Store` 0건과 targeted diff 검증 기록. +- **Files:** Modify: `.gitignore`; Delete: `docs/20260725_AI캐릭터관리자웹/.DS_Store`; Test: 없음 — runtime 동작이 없는 repository hygiene 변경이다. +- **Interfaces:** 애플리케이션 runtime, package script, 문서 내용에는 영향을 주지 않는다. +- **범위 밖:** 다른 사용자 산출물 삭제, `.gitignore` 전면 정리, 애플리케이션 코드·dependency 변경. +- **P0-GATE 연결:** runtime Gate 재실행 대신 repository 정적 검증을 수행하고 `git diff --check`가 통과하기 전에는 Task를 완료하지 않는다. +- **TDD 예외 사유:** 운영체제 metadata의 index 포함 여부와 ignore 규칙은 runtime 동작이 없어 unit test로 실패를 만드는 것보다 Git index 정적 검사가 직접적인 증거다. +- **대체 검증 방법:** cleanup 전후 `git ls-files`와 `.gitignore` 검색을 비교하고 targeted `git diff --check` 결과를 기록한다. + +- [x] **RED 대체:** `git ls-files --stage | rg '\.DS_Store'`와 `git status --short --untracked-files=all | rg '\.DS_Store'`로 현재 staged entry를 재현했다. +- [x] `.DS_Store`를 index와 working tree에서 제거하고 저장소 전역 ignore 규칙을 한 줄 추가했다. +- [x] 추적 entry 0건, ignore 적용, whitespace 오류 0건을 확인하고 Progress에 실제 결과를 기록했다. +- **실행 명령:** `git ls-files | rg '(^|/)\.DS_Store$'`; `rg -n '^\.DS_Store$' .gitignore`; `git diff --check -- .gitignore docs/20260725_AI캐릭터관리자웹`. +- **기대 결과:** 첫 명령은 no matches, ignore 검색은 정확히 한 규칙을 반환하고 diff 검증은 exit 0. +- **수동 확인:** 없음. binary 운영체제 메타데이터 제거는 index와 ignore 정적 결과로 판정한다. + +**P0-R3 수정 검증 기록 (2026-07-31):** + +- RED 대체: `git ls-files --stage | rg '\.DS_Store'`와 `git status --short --untracked-files=all | rg '\.DS_Store'`에서 `docs/20260725_AI캐릭터관리자웹/.DS_Store` staged entry를 확인했다. `rg -n '^\.DS_Store$' .gitignore`는 no matches였다. +- GREEN: `.gitignore`에 `.DS_Store` 규칙을 추가하고 `git rm --cached -- docs/20260725_AI캐릭터관리자웹/.DS_Store`와 working tree 파일 삭제로 운영체제 metadata를 제거했다. +- 검증: `git ls-files | rg '(^|/)\.DS_Store$'` — no matches. `git status --short --untracked-files=all | rg '\.DS_Store'` — no matches. `rg -n '^\.DS_Store$' .gitignore` — `7:.DS_Store`. `git diff --check -- .gitignore docs/20260725_AI캐릭터관리자웹` — exit 0. +- LSP: `.gitignore`는 extension 없는 ignore 파일이라 설정된 LSP 서버가 없어 diagnostics 대상이 아니다. runtime 동작 없는 repository hygiene 변경이므로 정적 Git 검증으로 판정했다. + --- ## Phase 1. 공통 플랫폼·인증/인가·컴포넌트 기반 @@ -608,6 +739,331 @@ npm run build - 결정: `PageHeader`는 구현·소비가 없고 현재 필요한 반복 소비 컴포넌트가 아니므로 Phase 1 shared 산출물에서 제거한다. 필요하다고 판단되는 도메인 Phase에서 추가한다. - 완료 증거: P1-R2 exact byte와 PageHeader 폐기 결정을 반영했고, closure Gate를 재실행해 열린 Blocker·High·Medium 0건으로 정리했다. +### Task R1.6 — 모든 보호 요청 401의 인증 만료 처리 + +**Goal 실행 `P1-R6`:** 응답 body 형식과 무관하게 보호 요청의 HTTP 401에서 session을 한 번 제거하고 login으로 이동한다. + +- **연결 리뷰:** [Phase 1 재리뷰](./reviews/review-phase-1-20260729.md) — `REV-P1-011` +- **시작 조건:** 기존 concurrent 401 burst 규칙과 `UNKNOWN_API_ERROR_MESSAGE`를 보존한다. +- **Files:** Modify: `src/shared/api/client.ts`, `src/shared/api/__tests__/client-auth.test.ts`, `src/shared/api/__tests__/client.test.ts`; Test: 동일 경로. +- **Interfaces:** `createApiClient({clearSession,onAuthExpired})`; malformed JSON/envelope·empty body 401도 `ApiError.status=401`을 유지한다. +- **범위 밖:** refresh token, 403 session 제거, XHR upload adapter(`P4-R2`). + +- [x] **RED:** malformed JSON, empty body, schema 불일치 401 각각에서 `clearSession/onAuthExpired`가 한 burst에 1회 호출되는 실패 test를 추가했다. +- [x] **GREEN:** HTTP status를 기준으로 인증 만료를 먼저 처리하되 기존 오류 정규화와 concurrent request coalescing을 유지했다. +- [x] **REFACTOR:** 정상 오류 envelope 401과 malformed 401의 중복 분기를 status 기반 단일 처리로 정리하고 공통 API 회귀를 실행했다. +- **실행 명령:** `npm run test:run -- src/shared/api/__tests__/client-auth.test.ts src/shared/api/__tests__/client.test.ts`; `npm run test:run -- src/features/auth src/app`. +- **기대 결과:** exit 0, 모든 보호 401에서 burst별 clear/redirect 1회, 403 clear 0회. +- **수동 확인:** session이 있는 보호 route에서 empty-body 401을 반환했을 때 login 이동과 만료 안내를 확인한다. + +**P1-R6 수정 검증 기록 (2026-07-30):** + +- RED: `npm run test:run -- src/shared/api/__tests__/client-auth.test.ts src/shared/api/__tests__/client.test.ts` — malformed JSON, empty body, schema mismatch 401에서 `clearSession` 0회로 3 tests가 기대대로 실패했다. +- GREEN focused: 같은 command — 성공, 2 files / 21 tests passed. 보호 401의 body 형식과 무관하게 burst별 `clearSession/onAuthExpired` 1회를 확인했고 403 clear 0회 회귀를 유지했다. +- LSP diagnostics: `src/shared/api/client.ts`, `src/shared/api/__tests__/client-auth.test.ts` — 오류 0건. +- Auth/session focused regression: `npm run test:run -- src/features/auth/tests/auth-session.test.tsx src/features/auth/tests/login-page.test.tsx src/app/App.test.tsx src/app/App.protected-auth.test.tsx` — 성공, 3 files / 24 tests passed. +- 참고: 계획상 broad regression `npm run test:run -- src/features/auth src/app`은 6 failed / 47 passed로 종료했다. 실패 6건은 `REV-P10-001`/`P10-R1`의 공통 오류 fallback stale assertion 범위(`API 응답 형식이 올바르지 않습니다.` vs `알 수 없는 오류가 발생했습니다.`)와 일치해 `P1-R6` 완료 판정에서 제외하고 기존 후속 Task로 유지한다. + +### Task R1.7 — zoom no-upscale와 touch pinch crop 복구 + +**Goal 실행 `P1-R7`:** 현재 zoom의 선택 source pixel을 넘지 않는 crop 결과와 pointer pinch 입력을 제공한다. + +- **연결 리뷰:** [Phase 1 재리뷰](./reviews/review-phase-1-20260729.md) — `REV-P1-012`, `REV-P1-013` +- **시작 조건:** `FILE-012`, PRD `10.4`, 기존 keyboard/range/reset contract 유지. +- **Files:** Modify: `src/shared/lib/crop-image.ts`, `src/shared/lib/crop-image.test.ts`, `src/shared/ui/image-crop-dialog.tsx`, `src/shared/ui/__tests__/image-crop-dialog.test.tsx`; Test: 동일 경로. +- **Interfaces:** output size는 현재 `CropSourceRect.width/height`와 resource `maxWidth` 중 작은 값; pinch는 두 pointer 거리 변화로 기존 `zoom 1..3`을 갱신한다. +- **범위 밖:** resource별 aspect/maxWidth 변경, GIF crop, 외부 gesture dependency. + +- [x] **RED:** zoom 1.5에서 600px source crop이 400px 이하 output을 생성하는 pixel test와 두 pointer pinch가 zoom을 바꾸는 Dialog test를 실패시킨다. +- [x] **GREEN:** output size에 현재 zoom을 전달하고 native pointer map으로 최소 pinch를 구현한다. +- [x] **REFACTOR:** drag와 pinch pointer cleanup을 정리하고 Character/Audio/Series/Community crop 회귀를 실행한다. +- **실행 명령:** `npm run test:run -- src/shared/lib/crop-image.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx src/features/characters src/features/audio-contents src/features/series src/features/community-posts`. +- **기대 결과:** exit 0, `noUpscale=true`의 output/source scale `<=1`, 기존 keyboard/range test와 resource aspect test 통과. +- **수동 확인:** tablet touch에서 drag·pinch·range·keyboard 대안과 예상 결과 pixel 표시를 확인한다. + +**P1-R7 수정 검증 기록 (2026-07-30):** + +- RED: `npm run test:run -- src/shared/lib/crop-image.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx` — 2 failed / 9 passed. `calculateCropOutputSize`는 zoom 1.5에서도 600×600을 반환했고, Dialog two-pointer pinch는 zoom을 변경하지 않아 예상 결과 400×400을 찾지 못했다. +- GREEN focused: 같은 command — 성공, 2 files / 14 tests passed. `noUpscale=true`에서 zoom 1.5의 output 400×400, 210:297 zoom 1.8의 source 이하 output 236×333, tiny source 1×1, aspect 2 tiny source 1×1, free aspect tiny source 1×1, range/keyboard output 재계산, two-pointer pinch zoom 1.5 apply payload와 `touch-action: none`을 확인했다. +- Broad crop regression: `npm run test:run -- src/shared/lib/crop-image.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx src/features/characters src/features/audio-contents src/features/series src/features/community-posts` — 성공, 23 files / 119 tests passed. +- Surface E2E: `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium` — 성공, 7 tests passed. Series 생성 흐름에서 실제 브라우저의 `이미지 crop` Dialog open/apply 경로를 확인했다. +- Type/LSP: `npm run typecheck`, `npm run lint` — 성공(exit 0). `lsp_diagnostics src/shared/ui`, `tests/e2e` — 오류 0건. `src/shared/lib/crop-image.ts`, `src/shared/lib/crop-image.test.ts` 단일 fresh diagnostics는 3초 timeout이었고 typecheck/lint로 보완 확인했다. +- Reviewer blocker 보강: 1차 `P1-R7` review는 210:297 zoom 1.8에서 output 236×334가 source 236×333을 1px 초과하고, crop preview에 `touch-action: none`이 없어 실제 touch pinch가 browser 기본 gesture에 취소될 수 있다고 BLOCKED 판정했다. 추가 RED `npm run test:run -- src/shared/lib/crop-image.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx`는 2 failed / 11 passed였고, tiny source 0×0 붕괴 RED `npm run test:run -- src/shared/lib/crop-image.test.ts`는 1 failed / 6 passed였다. 2차 review는 aspect 2 tiny source가 1×0으로 붕괴한다고 BLOCKED 판정했고 추가 RED `npm run test:run -- src/shared/lib/crop-image.test.ts`는 1 failed / 6 passed였다. 3차 review는 free aspect tiny source가 1×2로 source height를 초과한다고 BLOCKED 판정했고 추가 RED `npm run test:run -- src/shared/lib/crop-image.test.ts`는 2 failed / 5 passed였다. 수정 후 위 focused/broad/E2E/type/lint/diff 검증이 통과했다. +- 수동 확인 한계: 실제 tablet touch 장비 QA는 이 작업 환경에서 수행하지 못했다. native pointer event 회귀 test로 drag·pinch·range·keyboard 대안을 자동 검증했으며, 실기기 touch 확인은 릴리스 QA 항목으로 유지한다. + +### Task R1.8 — 사용되지 않는 공통 pagination helper 계약 정리 + +**Goal 실행 `P1-R8`:** 사용되지 않는 공통 pagination helper의 FanTalk 전용 `20..50` size clamp와 page 최소값 누락을 제거해 공통 계약을 오해할 여지를 없앤다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-014` +- **시작 조건:** PRD §11.1의 공통 `page >= 0`, `size >= 1`, 전역 최대값 없음과 FanTalk만 `size=20..50`인 예외 계약. +- **완료 증거:** production 사용처 검색, stale helper 제거, 도메인 adapter pagination 회귀와 전체 정적 Gate 통과. +- **Files:** Modify: `src/shared/api/pagination.ts`, `src/shared/api/__tests__/pagination.test.ts`; Test: 각 도메인 contract test. +- **Interfaces:** 실제 소비 중인 `PageData` type은 유지한다. production 소비자가 없는 `PageParams`, `createPageParams`와 잘못된 clamp test를 제거하고 도메인별 adapter의 계약별 normalization은 보존한다. +- **범위 밖:** 모든 도메인 pagination의 새 공통 abstraction, FanTalk의 명시적 `20..50` 보정 변경, 화면 page-size 선택지 변경. + +- [x] **RED 대체:** `rg -n 'createPageParams' src`로 production 사용처 0건과 test-only 사용을 기록하고, 현재 helper가 `{page:-1,size:1}`과 `{size:51}`을 공통 계약과 다르게 처리함을 고정했다. +- [x] **GREEN:** 사용되지 않는 `PageParams`, `createPageParams`와 그 동작 test를 삭제하고 실제 소비 중인 `PageData` type만 유지했다. +- [x] **REFACTOR:** 새 helper를 만들지 않고 Character/Audio/Series/Community/Comments/FanTalk adapter의 기존 contract test로 각 pagination 예외를 확인했다. +- **실행 명령:** `rg -n 'createPageParams' src`; `npm run test:run -- src/shared/api src/features/characters src/features/audio-contents src/features/series src/features/community-posts src/features/comments src/features/fan-talks`; `npm run typecheck`; `npm run lint`. +- **기대 결과:** production `createPageParams` 사용 0건, 공통 최대 `50` 오해를 고정하는 test 0건, 모든 명령 exit 0. +- **수동 확인:** 없음. 사용되지 않는 내부 helper 제거 Task이므로 contract test와 정적 검색으로 대체한다. + +**P1-R8 수정 검증 기록 (2026-07-30):** + +- RED 대체: `rg -n 'createPageParams|PageParams|PageData|normalizeSize' src`에서 `createPageParams` production 사용처 0건, `src/shared/api/__tests__/pagination.test.ts` test-only 사용과 `src/shared/api/pagination.ts` 정의만 확인했다. 기존 `npm run test:run -- src/shared/api/__tests__/pagination.test.ts`는 1 file / 5 tests passed로 잘못된 공통 size `20..50` clamp test가 통과함을 확인했다. +- GREEN: `src/shared/api/pagination.ts`에서 `PageParams`와 `createPageParams`를 삭제하고 `PageData`만 유지했다. `src/shared/api/__tests__/pagination.test.ts`는 `PageData` shape test만 남겼다. +- REFACTOR/회귀: `rg -n 'createPageParams|type PageParams|import .*PageParams' src` — no matches. `npm run test:run -- src/shared/api src/features/characters src/features/audio-contents src/features/series src/features/community-posts src/features/comments src/features/fan-talks` — 35 files / 201 tests passed. `npm run typecheck`, `npm run lint` — 모두 exit 0. +- LSP diagnostics: 변경 파일 단일 fresh diagnostics는 3초 제한으로 timeout됐고, `npm run typecheck`와 `npm run lint` 성공으로 보완 확인했다. + +### Task R1.9 — 공통 pagination control ID 격리 + +**Goal 실행 `P1-R9`:** 한 화면에 둘 이상의 `ResourcePagination`이 렌더링돼도 각 페이지 크기 label이 자신의 select와 유일하게 연결되도록 한다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-015` +- **시작 조건:** PRD §10.7의 visible label·연결 가능한 접근성 이름과 Comments의 root/reply pagination 동시 렌더링 구조. +- **완료 증거:** 두 인스턴스의 중복 ID를 재현하는 실패 test, React `useId` 기반 최소 수정, shared UI·Comments focused unit과 mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/shared/ui/resource-pagination.tsx`, `src/shared/ui/__tests__/resource-pagination.test.tsx`; Test: 필요 시 `src/features/comments/tests/comment-thread.test.tsx`, `tests/e2e/comments.spec.ts`. +- **Interfaces:** 페이지·크기 변경 callback과 URL/API pagination 값은 바꾸지 않고 인스턴스별 select ID와 `htmlFor`만 유일하게 만든다. +- **범위 밖:** 새 pagination abstraction, page-size option 변경, 도메인별 pagination query 정책 변경. + +- [x] **RED:** 한 container에 `ResourcePagination` 두 개를 렌더링해 select ID 중복과 두 label이 같은 control을 가리키는 실패 test를 작성했다. +- [x] **GREEN:** 컴포넌트 인스턴스별 안정적인 ID를 만들고 각 label의 `htmlFor`를 해당 select에 연결했다. +- [x] **REFACTOR:** 공개 prop는 늘리지 않고 기존 component 내부에서 ID 생성을 끝내며 단일 인스턴스 동작을 보존했다. +- **실행 명령:** `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx src/features/comments`; `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 동시 렌더링된 pagination select ID 중복 0건, 각 visible label과 control 연결 일치. +- **수동 확인:** Community Sheet에서 root와 reply pagination이 함께 보이는 fixture로 각 페이지 크기 label을 클릭해 대응 select가 focus되는지 확인한다. + +**P1-R9 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx` — 1 failed / 2 passed. 두 `ResourcePagination`의 label `control`이 같은 `id="resource-page-size"` 해석에 의존해 첫 번째 select를 가리키는 실패를 확인했다. +- GREEN focused: `npm run test:run -- src/shared/ui/__tests__/resource-pagination.test.tsx src/features/comments` — 성공, 3 files / 14 tests passed. React `useId` 기반으로 select ID 중복 0건과 각 label/control 연결 일치를 확인했다. +- 개발 중 E2E: 사용자 지시에 따라 전체 Task 완료 전 반복 실행하지 않았다. 지정 mock Comments E2E는 최종 회귀 단계에서 필요 시 실행한다. + +### Task R1.10 — crop frame과 저장 source 좌표 일치 복구 + +**Goal 실행 `P1-R10`:** `ImageCropDialog`에서 보이는 crop frame·이동·확대 결과와 canvas가 저장하는 원본 source rectangle을 같은 좌표계로 계산한다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-016` +- **시작 조건:** PRD `FILE-008`, Image crop UI 흐름 4, 현재 `ImageCropDialog`의 CSS px offset과 `calculateCropSourceRect`의 원본 px offset 직접 공유. +- **완료 증거:** 축소 표시된 대형 원본과 고정/free aspect 각각의 미리보기-저장 좌표 실패 test, 단일 좌표 변환 최소 구현, shared crop unit과 Character·Audio·Series·Community focused 회귀 및 mock Chromium crop 흐름 통과. +- **Files:** Modify: `src/shared/ui/image-crop-dialog.tsx`, `src/shared/lib/crop-image.ts`, `src/shared/ui/__tests__/image-crop-dialog.test.tsx`, `src/shared/lib/crop-image.test.ts`; Test: `src/features/characters/tests/CharacterCreatePage.test.tsx`, `src/features/audio-contents/tests/audio-form.test.tsx`, `src/features/series/tests/series-form.test.tsx`, `src/features/community-posts/tests/community-form.test.tsx`, `tests/e2e/character-workspace.spec.ts`, `tests/e2e/audio-content.spec.ts`, `tests/e2e/series.spec.ts`, `tests/e2e/community.spec.ts`. +- **Interfaces:** `CropRenderRequest`의 offset 의미를 표시 frame 기준으로 명확히 하고, preview transform과 `calculateCropSourceRect`가 동일한 scale·clamp를 사용한다. resource별 aspect와 최대 출력 크기는 바꾸지 않는다. +- **범위 밖:** 새 crop library/dependency, 회전·필터·다중 crop, 서버 파일 계약 변경. +- **Phase 1 Gate 연결:** shared crop focused unit, 네 도메인 crop 회귀, mock Chromium crop journey, `typecheck`·`lint`·build가 모두 통과하기 전에는 종결하지 않는다. + +- [x] **RED:** 4,000×3,000 원본이 341×256px로 표시될 때 10 CSS px 이동이 원본 scale에 맞게 환산되고, 1:1·210:297 frame 밖 영역이 결과에 포함되지 않아야 하는 실패 test를 작성했다. +- [x] **GREEN:** 실제 preview frame 크기와 표시 image scale을 기준으로 source offset을 계산하고, 고정/free aspect frame과 canvas source rectangle을 일치시켰다. +- [x] **REFACTOR:** 좌표 계산을 shared crop 함수 한 곳에 유지하고 도메인별 보정값이나 새 상태 library를 만들지 않은 채 관련 회귀를 실행했다. +- **실행 명령:** `npm run test:run -- src/shared/lib/crop-image.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx`; `npm run test:run -- src/features/characters src/features/audio-contents src/features/series src/features/community-posts`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/series.spec.ts tests/e2e/community.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, preview frame과 저장 결과의 source rectangle 일치, resource별 aspect·no-upscale 회귀 0건. +- **수동 확인:** 4:3 대형 image를 Character/Audio의 1:1, Series의 210:297, Community의 free crop에서 이동·확대하고 적용 전 frame과 적용 후 preview의 같은 영역을 비교한다. + +**P1-R10 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/lib/crop-image.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx` — 2 failed / 14 passed. 4,000×3,000 원본의 1:1 preview frame에서 10 CSS px 이동이 sourceX 490으로 계산됐고, Dialog의 `renderCrop` request에는 `previewFrameWidth/Height`가 없어 기대대로 실패했다. +- GREEN: `CropRenderRequest`와 `calculateCropSourceRect`가 적용 시점의 preview frame 크기를 optional로 받아 CSS px offset을 source px로 환산하게 했다. `ImageCropDialog`는 preview image의 `getBoundingClientRect()`를 request에 전달한다. +- Focused: 같은 command — 2 files / 16 tests passed. 1:1과 210:297 frame source rect 환산, 기존 zoom/no-upscale/keyboard/pinch/free ratio 회귀를 확인했다. +- 도메인 회귀: `npm run test:run -- src/features/characters` — 6 files / 43 tests passed. `npm run test:run -- src/features/audio-contents` — 8 files / 55 tests passed. `npm run test:run -- src/features/series` — 9 files / 38 tests passed. `npm run test:run -- src/features/community-posts`는 180초 timeout이 아닌 1 failed / 43 passed로 `community-sheet.test.tsx` 목록 로딩 실패가 재현됐으나, 같은 spec 단독은 1 file / 7 tests passed, `community-form.test.tsx` 단독은 1 file / 10 tests passed로 crop 변경 실패와 분리했다. +- LSP diagnostics: `src/shared/lib/crop-image.ts`, `src/shared/ui/image-crop-dialog.tsx`, `src/shared/ui/__tests__/image-crop-dialog.test.tsx`, `src/shared/lib`, `src/shared/ui` — 오류 0건. `src/shared/lib/crop-image.test.ts` 단일 diagnostics는 timeout됐고 directory diagnostics로 보완했다. +- E2E/수동 확인: 사용자 지시에 따라 개발 중 반복 E2E는 실행하지 않고 최종 회귀 단계에서 필요 시 mock Chromium crop 흐름을 실행한다. + +### Task R1.11 — crop 적용 비동기·Blob URL 수명 복구 + +**Goal 실행 `P1-R11`:** crop 적용을 single-flight로 처리하고 실패를 복구 가능하게 표시하며 생성한 Blob URL을 적용·취소·교체·unmount에서 정확히 한 번 해제한다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-017` +- **시작 조건:** `P1-R10` 완료, 현재 다섯 crop source 생성 경로가 성공한 `URL.createObjectURL`을 해제하지 않고 `ImageCropDialog.applyCrop`이 pending/error 상태를 갖지 않는 구조. +- **완료 증거:** 적용 연타·render reject·URL 교체/취소/unmount 실패 test, 최소 pending/error/cleanup contract, shared와 네 소비 도메인 회귀 및 정적 Gate 통과. +- **Files:** Create: `src/shared/lib/create-image-crop-source.ts`, `src/shared/lib/create-image-crop-source.test.ts`; Modify: `src/shared/ui/image-crop-dialog.tsx`, `src/shared/ui/__tests__/image-crop-dialog.test.tsx`, `src/features/characters/pages/CharacterCreatePage.tsx`, `src/features/characters/pages/CharacterEditPage.tsx`, `src/features/characters/tests/CharacterCreatePage.test.tsx`, `src/features/characters/tests/CharacterEditPage.test.tsx`, `src/features/audio-contents/pages/AudioContentFormPage.tsx`, `src/features/audio-contents/tests/audio-form.test.tsx`, `src/features/series/pages/SeriesFormPage.tsx`, `src/features/series/tests/series-form-crop.test.tsx`, `src/features/community-posts/lib/create-community-post-crop-source.ts`, `src/features/community-posts/tests/community-form.test.tsx`. +- **Interfaces:** crop source는 자신이 만든 Blob URL의 idempotent release 책임을 노출한다. 적용 pending 동안 적용·취소·이동 control의 재진입을 막고 `role=status`를 표시하며 reject 시 dialog와 원본 선택을 유지해 재시도할 수 있게 한다. +- **범위 밖:** upload 진행률, File 자체 폐기, 범용 async hook, 외부 image editor dependency. +- **Phase 1 Gate 연결:** shared lifecycle unit과 각 도메인 cancel/stale/reject 회귀, `typecheck`·`lint`·build가 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** 적용 연타 시 renderer 1회, reject 후 visible alert·재시도, 적용·취소·새 선택·unmount마다 소유 Blob URL `revokeObjectURL` 정확히 1회를 기대하는 실패 test를 작성한다. +- [x] **GREEN:** 공통 crop source의 idempotent release와 dialog single-flight/error 상태를 최소 구현하고 각 소비 화면이 수명 종료 시 release한다. +- [x] **REFACTOR:** 중복된 다섯 `createObjectURL` helper를 공통 함수로만 합치고 도메인 form state나 upload contract는 바꾸지 않는다. +- **실행 명령:** `npm run test:run -- src/shared/lib/create-image-crop-source.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx`; `npm run test:run -- src/features/characters src/features/audio-contents src/features/series src/features/community-posts`; `npm run typecheck`; `npm run lint`; `npm run build`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, pending 중 renderer 최대 1회, reject 후 입력 보존·재시도 가능, 소유 Blob URL 누수·중복 revoke 0건. +- **수동 확인:** 10MB에 가까운 image를 연속 선택·취소·적용하고 느린 crop renderer 조건에서 진행 안내, control 비활성화, 실패 후 재시도를 확인한다. + +**P1-R11 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/lib/create-image-crop-source.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx`는 구현 전 `create-image-crop-source` import 부재와 `ImageCropDialog` pending/error/single-flight 부재로 실패했다. +- GREEN: `createImageCropSource`가 소유 Blob URL의 idempotent `release`를 노출하고 load error에서 즉시 revoke한다. `ImageCropDialog`는 적용 중 `role=status`, control disabled, renderer reject `role=alert`, 재시도를 제공한다. +- REFACTOR: Character create/edit, Audio, Series, Community create/sheet의 중복 `createObjectURL` helper를 공통 helper로만 합쳤고, 각 crop source 교체·취소·적용·unmount와 stale selection에서 `release`를 호출한다. 도메인 form state와 upload contract는 변경하지 않았다. +- Focused 검증: `npm run test:run -- src/shared/lib/create-image-crop-source.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx` — 2 files / 11 tests passed. 인접 도메인 단독 검증은 Character create/edit, Audio form 3종, Series form — 6 files / 57 tests passed, Community form — 1 file / 10 tests passed, Community sheet 단독 — 1 file / 7 tests passed였다. +- 정적 Gate: `src/shared/lib`, `src/shared/ui`, `src/features/characters/pages`, `src/features/audio-contents`, `src/features/series`, `src/features/community-posts` LSP diagnostics 0건. `npm run typecheck`, `npm run lint`, `npm run build`는 exit 0이었다. `npm run build`는 기존 bundle size warning(502.94 kB chunk)을 표시했다. +- 넓은 회귀 참고: `npm run test:run -- src/features/characters src/features/audio-contents src/features/series src/features/community-posts`는 `audio-list.test.tsx` 1 failed 후 timeout됐고, 별도 crop 인접 재실행은 모두 통과해 이번 crop lifecycle 변경과 분리했다. E2E와 10MB 수동 확인은 사용자 지시에 따라 최종 회귀 단계에서 필요 시 실행한다. + +### Task R1.12 — fetch·XHR 교차 401 만료 전환 단일화 + +**Goal 실행 `P1-R12`:** 공통 fetch client와 Audio XHR upload가 동시에 401을 받아도 현재 session의 제거·login 이동을 합쳐 한 번만 수행하고, 새 로그인 session에서는 다음 만료를 다시 처리한다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-018` +- **시작 조건:** PRD `AUTH-005`, §13의 동시 401 단일 처리, 기존 완료 `P1-R6`과 `P4-R2`의 transport별 401 burst contract. +- **완료 증거:** 같은 mutable session dependency를 사용하는 fetch request와 XHR upload가 동시에 401일 때 callback 2회를 재현하는 실패 test, transport 간 현재 session guard 최소 수정, auth/API·Audio upload focused 및 Phase 1 Gate 검증 기록. +- **Files:** Modify: `src/shared/api/client.ts`, `src/shared/api/__tests__/client-auth.test.ts`, `src/features/audio-contents/api/upload-audio-content.ts`, `src/features/audio-contents/tests/audio-upload.test.ts`; Create: `src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts`. +- **Interfaces:** 두 transport는 401 처리 시 현재 protected session 존재 여부를 같은 `getToken` 의미로 확인하고, 첫 handler가 동기적으로 session을 제거한 뒤의 handler는 `clearSession`/`onAuthExpired`를 다시 호출하지 않는다. 새 login token이 생기면 다음 401 burst는 다시 처리한다. +- **범위 밖:** refresh token, 전역 event bus, router 교체, XHR→fetch 전환, 403 정책 변경. +- **Phase 1 Gate 연결:** cross-transport RED/GREEN, 기존 transport별 malformed/동시 401 회귀, app/auth/shared unit, `typecheck`·`lint`·build가 통과하기 전에는 완료 처리하지 않는다. +- **검증 기록:** RED callback 실제 횟수, GREEN 뒤 동일·후속 session burst 횟수와 Gate 결과를 이 Task 하단과 Phase 1 리뷰에 누적한다. + +- [x] **RED:** mutable token을 공유하는 `createApiClient` request와 `uploadAudioContent`를 동시에 401로 종료해 `clearSession`/`onAuthExpired` 각각 1회를 기대하고 현재 3회 호출 실패를 확인했다. 이어 새 token을 설정한 다음 burst는 다시 1회 처리돼야 한다. +- [x] **GREEN:** 두 transport가 callback 실행 직전 현재 session 존재를 확인하고 첫 clear 뒤 후속 handler가 만료 전환을 건너뛰도록 최소 수정했다. +- [x] **REFACTOR:** 기존 transport 내부 burst latch와 malformed response mapping을 유지하고 인증 coordinator나 새 dependency를 추가하지 않았다. +- **실행 명령:** `npm run test:run -- src/shared/api/__tests__/client-auth.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts`; `npm run test:run -- src/app src/features/auth src/shared/api src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, 같은 session의 fetch+XHR 401 callback 각각 총 1회, 새 session의 다음 401 callback 각각 추가 1회, 기존 403·malformed body·upload cancel/retry 회귀 0건. +- **수동 확인:** upload 진행 중 다른 보호 조회도 401을 반환하게 해 로그인 이동·만료 안내·browser history 변화가 한 번인지 확인하고, 다시 로그인한 뒤 같은 만료 흐름이 재동작하는지 확인한다. + +**P1-R12 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/api/__tests__/client-auth.test.ts src/features/audio-contents/tests/audio-upload.test.ts` — 1 failed / 19 passed. 신규 교차 transport test에서 `clearSession` 기대 2회 대비 실제 3회로 같은 session의 fetch+XHR 401 만료 전환 중복을 재현했다. +- GREEN: `client.ts`와 `upload-audio-content.ts`가 401 callback 직전 현재 token 존재를 다시 확인하게 수정했다. 같은 focused 명령은 2 files / 20 tests passed였다. +- REFACTOR/회귀: 새 coordinator나 dependency 없이 `readToken` helper만 중복 제거에 사용했고, lifecycle test를 별도 파일로 분리해 touched TS files를 250 LOC 이하로 유지했다. reviewer blocker로 fetch-first 순서를 추가한 뒤 `auth` 주입 시 storage fallback을 쓰지 않도록 보완했다. `npm run test:run -- src/shared/api/__tests__/client-auth.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts` — 3 files / 22 tests passed. `npm run test:run -- src/app src/features/auth src/shared/api src/features/audio-contents/tests/audio-upload.test.ts` — 13 files / 91 tests passed. `npm run test:run` — 79 files / 397 tests passed. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, targeted `git diff --check` — 모두 exit 0. build는 기존 503.04kB chunk warning만 표시했다. LSP diagnostics는 변경 파일·디렉터리 기준 오류 0건이었다. +- E2E/수동 확인: 사용자 지시에 따라 개발 중 E2E는 반복 실행하지 않고 모든 Task 구현 뒤 필요 시 수행한다. + +### Task R1.13 — crop frame과 저장 영역 시각적 일치 복구 + +**Goal 실행 `P1-R13`:** 고정 aspect image crop에서 운영자가 보는 frame과 canvas가 저장하는 source rectangle을 하나의 비변환 좌표계로 일치시킨다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-019` +- **시작 조건:** PRD `FILE-005~008`, `FILE-010`, `FILE-012`, 기존 `P1-R10` 완료 기록과 현재 `ImageCropDialog`이 transformed ``의 rect를 crop frame 크기로 사용하는 구조. +- **완료 증거:** 4:3 원본의 1:1과 `210:297` 고정 frame에서 보이는 영역과 저장 source rectangle 불일치를 재현하는 실패 test, 실제 crop viewport rect 기반 최소 수정, shared·4개 소비 도메인 회귀와 browser 수동 비교 기록. +- **Files:** Modify: `src/shared/ui/image-crop-dialog.tsx`, `src/shared/lib/crop-image.ts`; Test: `src/shared/ui/__tests__/image-crop-dialog.test.tsx`, `src/shared/lib/crop-image.test.ts`, `tests/e2e/character-workspace.spec.ts`, `tests/e2e/audio-content.spec.ts`, `tests/e2e/series.spec.ts`, `tests/e2e/community.spec.ts`. +- **Interfaces:** `previewFrameWidth/Height`는 CSS transform이 적용된 image rect가 아니라 실제 crop viewport를 의미한다. image cover scale, offset, zoom과 clamp는 한 번만 변환하고 resource별 aspect·최대 폭·no-upscale 계약은 유지한다. +- **범위 밖:** 외부 crop library, 회전·필터, 도메인별 보정값, 서버 file 계약 변경. +- **Phase 1 Gate 연결:** shared crop focused unit, Character·Audio·Series·Community crop 회귀, mock Chromium crop journey, `typecheck`·`lint`·개발/운영 build가 모두 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** 4,000×3,000 원본에 1:1과 `210:297` policy를 적용해 crop viewport의 실제 aspect, 이동·확대 후 저장 source rectangle, frame 밖 pixel 미포함을 기대하는 실패 test를 작성했다. +- [x] **GREEN:** 고정 aspect crop viewport를 보이게 렌더하고 그 viewport의 비변환 rect를 source 계산에 전달하는 최소 구현을 적용했다. +- [x] **REFACTOR:** preview→source 변환과 clamp를 shared crop 함수 한 곳에 유지하고 zoom이 `getBoundingClientRect()`와 수식에 중복 반영되지 않는지 회귀했다. +- [x] RED/GREEN/REFACTOR, focused·도메인 단위·정적 검증 결과를 Progress와 Phase 1 리뷰에 누적했다. E2E와 수동 pixel 비교는 사용자 지시에 따라 전체 Task 구현 후 필요 시 수행한다. +- **실행 명령:** `npm run test:run -- src/shared/lib/crop-image.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx`; `npm run test:run -- src/features/characters src/features/audio-contents src/features/series src/features/community-posts`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/series.spec.ts tests/e2e/community.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`. +- **기대 결과:** 모든 명령 exit 0, 고정 aspect frame과 저장 영역 불일치 0건, zoom/offset 중복 scale 0건, free aspect·no-upscale 회귀 0건. +- **수동 확인:** 4:3 대형 image를 Character/Audio 1:1과 Series `210:297`에서 이동·확대한 뒤 적용 전 frame과 적용 후 preview가 같은 영역인지 비교한다. + +**P1-R13 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/ui/__tests__/image-crop-dialog.test.tsx` — 1 failed / 9 passed. 신규 `ImageCropDialog measures the visible crop viewport instead of the transformed image`가 `이미지 crop viewport` label 부재로 실패해 고정 crop viewport가 없는 현재 구조를 재현했다. +- GREEN: `ImageCropDialog`에 고정 aspect viewport를 렌더하고 `applyCrop`이 image rect 대신 viewport rect를 `previewFrameWidth/Height`로 전달하게 했다. 1차 reviewer blocker였던 image box/frame 불일치는 source aspect 기준 cover image 배치로 보완했다. +- REFACTOR/회귀: `src/shared/lib/crop-image.test.ts`에 4,000×3,000 원본의 1:1·`210:297` viewport+zoom source rect 회귀를 추가하고, zero-overhang 축에서 화면 offset과 저장 offset이 함께 clamp되는 dialog 회귀를 추가했다. `npm run test:run -- src/shared/lib/crop-image.test.ts src/shared/ui/__tests__/image-crop-dialog.test.tsx` — 2 files / 20 tests passed. `npm run test:run -- src/features/characters src/features/audio-contents src/features/series src/features/community-posts` — 30 files / 183 tests passed. +- 정적 검증: `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. build는 기존 500kB chunk warning을 표시했다. LSP diagnostics는 `image-crop-dialog.tsx`, `image-crop-dialog.test.tsx`, `crop-image.test.ts` 오류 0건이다. +- 리뷰어 게이트: 1차 review에서 cover geometry와 offset clamp blocker 2건을 지적받아 보완했고, 2차 delta review에서 `APPROVED` 판정을 받았다. +- 환경 정리: 검증 중 `/var/.../T` 여유 공간 부족으로 Vitest가 `ENOSPC`를 반환해, 워크스페이스 밖 `/var/.../T/opencode`의 재생성 가능한 DerivedData/cache 임시 폴더를 삭제했다. `df` 기준 여유 공간은 531MiB에서 15GiB로 회복된 뒤 검증을 재실행했다. +- E2E/수동: 개발 중 E2E는 사용자 지시에 따라 매 Task마다 수행하지 않고 전체 Task 구현 후 필요 시 수행한다. 실제 pixel 수동 비교도 같은 최종 확인 범위로 남긴다. + +### Task R1.14 — Character 생성·수정 route 보호 인가 probe 복구 + +**Goal 실행 `P1-R14`:** `/ai-characters/new`와 `/ai-characters/:characterId/edit`도 다른 관리자 route와 같이 서버 보호 route probe의 401/403 정책을 먼저 적용한다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-020` +- **시작 조건:** `P1-R13` 완료, PRD `AUTH-005~006`, `isAiCharactersRoute()`가 Character list/detail과 하위 resource route는 포함하지만 create/edit route는 제외하는 현재 구조. +- **완료 증거:** create/edit 직접 URL의 probe 403에서 접근 거부 화면, 401에서 session 정리·login 이동, probe 완료 전 하위 form/API 미실행을 고정하는 실패 test와 Phase 1 Gate 기록. +- **Files:** Modify: `src/app/browser-location.ts`, 필요 시 `src/app/App.tsx`; Create: `src/app/browser-location.test.ts`; Modify: `src/app/App.protected-errors.test.tsx`, `src/app/App.test.tsx`. +- **Interfaces:** `isAiCharactersRoute` 보호 route 판정은 list, create, detail, edit와 모든 하위 resource route를 포함한다. 403은 session을 유지한 채 `/access-denied`, 401은 session을 제거하고 `/login`으로 이동하는 기존 계약을 변경하지 않는다. +- **범위 밖:** router library 도입, 서버 인증 계약 변경, role refresh, 새 access-denied 화면. + +- [x] **RED:** create/edit 직접 route에서 보호 probe 403을 반환할 때 form이 노출되거나 일반 error로 남는 현재 실패와, 401의 login 이동을 재현했다. +- [x] **GREEN:** 두 route를 보호 route 판정에 포함하는 최소 수정으로 기존 probe 흐름을 재사용했다. +- [x] **REFACTOR:** route matcher 진리표를 unit test로 고정하고 path 문자열 중복과 별도 auth abstraction을 추가하지 않았다. +- [x] RED/GREEN/REFACTOR, focused·auth/app 회귀·정적/build 결과를 Progress와 Phase 1 리뷰에 누적했다. stale ADMIN 수동 확인은 실제 credential이 필요한 외부 수동 QA로 남긴다. +- **실행 명령:** `npm run test:run -- src/app/browser-location.test.ts src/app/App.protected-errors.test.tsx src/app/App.test.tsx`; `npm run test:run -- src/app src/features/auth src/shared/api`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`. +- **기대 결과:** 모든 명령 exit 0, create/edit probe 전 form·하위 request 0건, 403 접근 거부·401 login 전환 회귀 0건. +- **수동 확인:** stale ADMIN session으로 create/edit URL을 직접 열고 개발 API 403에서 form이 잠시라도 노출되지 않으며 접근 거부 화면으로 이동하는지 확인한다. + +**P1-R14 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/app/browser-location.test.ts src/app/App.protected-errors.test.tsx src/app/App.test.tsx` — 2 failed files / 5 failed / 18 passed. `isAiCharactersRoute`가 create/edit를 false로 판정했고, `/ai-characters/new`, `/ai-characters/101/edit`의 401/403 probe 전환 대신 form 또는 detail error가 노출되는 실패를 재현했다. +- GREEN: `isAiCharactersRoute`에 `routePaths.aiCharacterCreate`와 `getCharacterEditIdFromPath(path)`를 추가해 기존 보호 route probe를 재사용했다. focused `npm run test:run -- src/app/browser-location.test.ts src/app/App.protected-errors.test.tsx src/app/App.test.tsx` — 3 files / 23 tests passed. +- REFACTOR/회귀: 새 `src/app/browser-location.test.ts`의 list/create/detail/edit 진리표로 matcher를 고정했다. `npm run test:run -- src/app src/features/auth src/shared/api` — 13 files / 84 tests passed. +- 정적 검증: LSP diagnostics `browser-location.ts`, `browser-location.test.ts`, `App.protected-errors.test.tsx` 오류 0건. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. build는 기존 500kB chunk warning을 표시했다. +- 리뷰어 게이트: P1-R14 delta review에서 create/edit 보호 probe 합류, 401/403 form 미노출 test, 문서 기록 범위가 기준을 충족해 `APPROVED` 판정을 받았다. +- 수동 확인: stale ADMIN 개발 API credential이 필요한 외부 수동 QA는 자동 검증 범위 밖으로 유지한다. + +### Task R1.15 — 잘못된 percent-encoding route 안전 처리 + +**Goal 실행 `P1-R15`:** 잘못된 percent-encoding이 포함된 직접 URL이 `URIError`로 SPA를 중단하지 않고 안전한 기본 route로 정규화되게 한다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-021` +- **시작 조건:** `P1-R14` 완료, `browser-location.ts`의 route regex가 `%`와 같은 segment를 허용하고 모든 parser가 `decodeURIComponent` 예외를 처리하지 않는 현재 구조. +- **완료 증거:** 단일·복합 param route의 malformed `%` 재현 test, 예외 없는 공통 decode helper와 `/ai-characters` fallback, 정상 한글/ASCII ID route 회귀 및 Phase 1 Gate 기록. +- **Files:** Modify: `src/app/browser-location.ts`, `src/app/browser-location.test.ts`, `src/app/App.test.tsx`. +- **Interfaces:** route param decode 실패는 `null`로 판정해 `readRoutePath()`가 `/ai-characters`로 정규화하고, 정상 percent-encoded ID와 navigation API는 유지한다. +- **범위 밖:** 404 page 신설, URL schema 재설계, router dependency, 백엔드 ID 제약 추정. + +- [x] **RED:** character/audio/series 단일·복합 param에 `%`, `%E0%A4%A`를 넣었을 때 `URIError`가 발생하는 실패 test를 작성했다. +- [x] **GREEN:** 예외를 내부에서 종료하는 작은 decode helper로 parser를 공통화하고 잘못된 path를 기본 목록으로 보냈다. +- [x] **REFACTOR:** 모든 `decodeURIComponent` 호출이 helper 하나를 통하도록 정리하고 정상 인코딩 route 회귀를 실행했다. +- [x] RED/GREEN/REFACTOR, focused·app 회귀·정적/build 결과를 Progress와 Phase 1 리뷰에 누적했다. malformed URL 수동 확인은 최종 browser 확인 범위로 남긴다. +- **실행 명령:** `npm run test:run -- src/app/browser-location.test.ts src/app/App.test.tsx`; `npm run test:run -- src/app`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`. +- **기대 결과:** 모든 명령 exit 0, malformed encoding 예외 0건, `/ai-characters` 안전 fallback, 정상 route 회귀 0건. + +**P1-R15 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/app/App.test.tsx` — 1 failed file / 3 failed / 8 passed. `/ai-characters/%`, `/ai-characters/%E0%A4%A/audio-contents/1`, `/ai-characters/101/series/%E0%A4%A`가 각각 `URIError: URI malformed`로 실패했다. +- GREEN: `browser-location.ts`에 `decodeRouteSegment` helper를 추가하고 모든 route param decode가 malformed segment에서 `null`을 반환하게 했다. `npm run test:run -- src/app/browser-location.test.ts src/app/App.test.tsx` — 2 files / 12 tests passed. +- REFACTOR/회귀: `rg -n "decodeURIComponent" src/app/browser-location.ts` — helper 내부 1건만 남았다. `npm run test:run -- src/app` — 5 files / 37 tests passed. +- 정적 검증: LSP diagnostics `browser-location.ts`, `App.test.tsx` 오류 0건. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. build는 기존 500kB chunk warning을 표시했다. +- 리뷰어 게이트: P1-R15 review에서 malformed route 예외 처리, helper 단일화, scope 제한, 문서 기록이 기준을 충족해 `APPROVED` 판정을 받았다. +- 수동 확인: malformed URL browser 직접 입력 확인은 전체 Task 구현 후 필요 시 수행하는 E2E/수동 QA 범위로 남긴다. +- **수동 확인:** 로그인/비로그인 상태에서 `/ai-characters/%`를 직접 열어 white screen 없이 안전한 화면으로 정규화되는지 확인한다. + +### Task R1.16 — malformed route의 보호 probe 경계 복구 + +**Goal 실행 `P1-R16`:** decode할 수 없는 percent-encoding route를 canonical Character 목록 snapshot으로 정규화해 기존 ADMIN 보호 probe를 거친 뒤에만 shell을 렌더한다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-022` +- **시작 조건:** `P1-R15` 완료 기록과 `readRoutePath()`가 regex에 맞는 malformed path를 raw `RoutePath`로 반환해 `isAiCharactersRoute()`가 false가 되는 재리뷰 근거. +- **완료 증거:** malformed 단일·복합 param route가 보호 probe 403에서 shell을 노출하지 않고 `/access-denied`로 전환되는 실패 test, canonical fallback snapshot 최소 수정, 정상 인코딩·create/edit·하위 route 회귀와 Phase 1 Gate 기록. +- **Files:** Modify: `src/app/browser-location.ts`, `src/app/App.protected-shell.test.tsx`; Test: `src/app/browser-location.test.ts`. +- **Interfaces:** route regex 일치 뒤 필수 segment 중 하나라도 decode 실패하면 `readRoutePath()`는 `routePaths.aiCharacters`를 반환한다. 정상 encoded route와 `navigateTo` API는 유지한다. +- **범위 밖:** router dependency, 404 page, backend ID 제약, 보호 probe 자체 재설계. +- **Phase 1 Gate 연결:** malformed 401/403 focused test, app/auth/api 회귀, `typecheck`·`lint`·개발/운영 build가 모두 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** ADMIN session과 malformed 단일 route에서 보호 probe 403을 반환할 때 현재 shell/list fallback이 먼저 렌더되는 실패 test를 작성했다. +- [x] **GREEN:** `readRoutePath()`의 route 후보를 반환하기 전에 `isAiCharactersRoute()`를 통과시키고 실패 시 Character 목록 snapshot으로 귀결시킨다. +- [x] **REFACTOR:** 정상 create/edit route parser를 유지하고 별도 auth abstraction이나 route table 재설계를 추가하지 않았다. +- [x] RED/GREEN/REFACTOR와 focused 결과를 Progress와 Phase 1 리뷰에 누적했다. Phase 1 정적/build Gate는 `P9-R11`, `P10-R10`까지 완료한 뒤 사용자 지시에 따라 통합 실행한다. +- **실행 명령:** `npm run test:run -- src/app/browser-location.test.ts src/app/App.protected-errors.test.tsx src/app/App.test.tsx`; `npm run test:run -- src/app src/features/auth src/shared/api`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`. +- **기대 결과:** 모든 명령 exit 0, malformed route의 probe 우회·protected shell 선노출 0건, 401 login·403 access-denied 전환과 정상 route 회귀 0건. +- **수동 확인:** stale ADMIN session으로 `/ai-characters/%`를 직접 열고 보호 probe가 끝나기 전 shell이 보이지 않으며 403 응답에서 접근 거부 화면으로 이동하는지 확인한다. + +**P1-R16 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/app/App.protected-shell.test.tsx` — 1 failed / 7 passed. 신규 malformed route test가 기대한 `보호 route 확인 중` 대신 `AI 캐릭터 목록을 불러오는 중`을 받아 shell/list fallback 선노출을 재현했다. +- GREEN/REFACTOR: `readRoutePath()`가 route regex 후보를 raw path로 반환하기 전에 `isAiCharactersRoute()`를 통과시키고, decode 실패로 보호 route 판정이 불가능하면 canonical `/ai-characters` snapshot으로 귀결되게 했다. 새 router dependency, auth abstraction, route table 재설계는 추가하지 않았다. +- Focused 검증: `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/browser-location.test.ts` — 2 files / 9 tests passed. 수동 QA와 E2E는 사용자 지시에 따라 전체 Task 구현 후 필요 시 실행한다. +- 통합 검증: `npm run test:run` — 81 files / 411 tests passed. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. + +### Task R1.17 — malformed route 인가 회귀 matrix·focused Gate 보강 + +**Goal 실행 `P1-R17`:** `P1-R16`의 canonical fallback을 단일·복합 malformed route와 401/403 전체 인가 경계에서 고정하고 실제 regression spec이 focused Gate에 포함되게 한다. + +- **연결 리뷰:** [Phase 1 현재 리뷰](./reviews/phase1-platform-auth-shared-ui.md) — `REV-P1-023` +- **시작 조건:** `P1-R16` 제품 코드는 focused·full unit에서 통과하지만 신규 test는 `/ai-characters/%`+403 한 건뿐이고 Task 실행 명령은 `App.protected-shell.test.tsx`를 누락한 재리뷰 근거. +- **완료 증거:** malformed 단일·Audio/Series 복합 route의 401/403 matrix에서 probe pending 중 shell 미노출, 401 login·session 제거, 403 access-denied·session 유지와 정상 encoded route 회귀, 실제 regression spec을 포함한 focused 명령 결과. +- **Files:** Modify: `src/app/App.protected-shell.test.tsx`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase1-platform-auth-shared-ui.md`; Test: 기존 `src/app/App.test.tsx`, `src/app/App.protected-errors.test.tsx`, `src/app/browser-location.test.ts`. +- **Interfaces:** 제품 route parser와 App 보호 probe는 변경하지 않는다. test는 기존 MSW handler·session helper를 재사용하고 status/path matrix만 확장한다. matrix가 실제 제품 결함을 재현할 때만 root-cause 코드 수정을 이 Task에 포함한다. +- **범위 밖:** router dependency, 별도 auth abstraction, backend ID 제약, E2E browser 확대. +- **Phase 1 Gate 연결:** malformed 인가 matrix, app/auth/api 회귀, `typecheck`·`lint`·개발/운영 build가 모두 통과하기 전에는 완료하지 않는다. + +- [x] **TDD 예외 사유:** root-cause 제품 수정은 `P1-R16`에서 이미 GREEN이며 이번 산출물은 누락된 regression matrix와 실행 명령이다. 현재 test inventory와 focused 명령 누락을 RED 대체 증거로 사용했다. +- [x] 단일·복합 malformed path × 401/403을 data-driven test로 고정하고 pending 중 protected shell·logout 미노출과 최종 auth 전환을 검증했다. +- [x] `P1-R16` 실행 명령에 `App.protected-shell.test.tsx`를 포함하고 정상 encoded/create/edit route 회귀를 함께 실행했다. +- [x] focused·Phase 1 회귀·정적/build 결과와 실제 test 수를 Progress와 Phase 1 리뷰에 누적했다. +- **대체 검증 방법:** 현재 test matrix inventory, 필요 시 guard negative-control, focused/full unit과 정적/build Gate. +- **실행 명령:** `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/App.protected-errors.test.tsx src/app/App.test.tsx src/app/browser-location.test.ts`; `npm run test:run -- src/app src/features/auth src/shared/api`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, malformed 단일·복합 401/403 matrix 누락 0건, probe 전 shell·logout 노출 0건, 401/403·정상 route 회귀 0건. +- **수동 확인:** 없음. 보호 경계는 deterministic MSW pending/status matrix로 검증하며 실제 stale ADMIN 개발 API 확인은 별도 수동 QA다. + +**P1-R17 수정 검증 기록 (2026-07-31):** + +- RED 대체: `REV-P1-023`에서 `/ai-characters/%`+403 한 건과 목록 heading만 보는 복합 malformed route test, focused 명령의 `App.protected-shell.test.tsx` 누락을 확인했다. 제품 guard는 `P1-R16`에서 이미 GREEN이라 production code는 변경하지 않았다. +- GREEN focused: `src/app/App.protected-shell.test.tsx`에 단일·Audio 복합·Series 복합 malformed path × 401/403 matrix를 추가했다. `npm run test:run -- src/app/App.protected-shell.test.tsx` — 1 file / 14 tests passed. +- REFACTOR/회귀: `npm run test:run -- src/app/App.protected-shell.test.tsx src/app/App.protected-errors.test.tsx src/app/App.test.tsx src/app/browser-location.test.ts` — 4 files / 40 tests passed. `npm run test:run -- src/app src/features/auth src/shared/api` — 13 files / 94 tests passed. +- 정적/build: `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. build는 기존 500kB 초과 chunk warning만 표시했다. E2E와 실제 stale ADMIN 개발 API 수동 QA는 사용자 지시에 따라 전체 Task 이후 필요 시/credential 준비 시로 유지한다. + --- ## Phase 2. 개발 전용 Mock Preview 기반 @@ -1365,6 +1821,13 @@ npm run build:prod --- +> **계약 이력 안내 (2026-07-29):** Phase 3~9 본문과 §7의 실행 기록은 +> OpenAPI 2.0.0 기준으로 수행된 당시 상태를 보존한다. 원작·장르 lookup, +> UTC 전송, Community pagination, FanTalk 수정·삭제, Comments와 active-only +> 계약의 최신 실행 기준은 OpenAPI 2.3.0과 **Phase 10**이다. 과거 Phase의 +> 완료 체크를 다시 열거나 과거 RED/GREEN 증거를 현재 계약 증거로 +> 재해석하지 않는다. + ## Phase 3. Character workspace vertical slice **목표:** ADMIN이 Character를 검색·생성·선택하고 workspace에서 상세·수정·soft delete 요청과 후속 목록 재조회를 완료한다. @@ -1377,7 +1840,7 @@ npm run build:prod **요구사항:** `CHAR-001~018`, `FILE-001~002`, `FILE-008~010`, `FILE-012`, PRD `7`, `9`의 Character 범위. -**외부 의존:** `EXT-007`/`CHAR-012`의 `searchTerm` 지정 결과에 대한 active-only 보장, `EXT-001`/`CHAR-013` original work lookup, `EXT-011` 신규 Character 오류 message key. `searchTerm` 생략 시 활성 목록은 OpenAPI에 명시돼 있다. `originalWorkId` 미선택은 key 생략과 `null`이 모두 가능하며 frontend canonical serializer만 하나로 고정한다. +**외부 의존:** `EXT-007`/`CHAR-012`의 `searchTerm` 지정 결과에 대한 active-only 보장. `EXT-001`/`CHAR-013` original work lookup은 legacy 후보 `GET /admin/chat/original/search?searchTerm={검색어}`로 구현 계획에 포함한다. `searchTerm` 생략 시 활성 목록은 OpenAPI에 명시돼 있다. `originalWorkId` 미선택은 key 생략과 `null`이 모두 가능하며 frontend canonical serializer만 하나로 고정한다. **주요 Files:** @@ -1387,7 +1850,7 @@ npm run build:prod - Create: `src/features/characters/validation/character-image-policy.ts` - Create: `src/features/characters/pages/{CharacterListPage,CharacterDetailPage,CharacterFormPage}.tsx` - Create: `src/features/characters/components/{CharacterList,CharacterListItem,CharacterProfile,CharacterForm,CharacterImageField}.tsx` -- Create when `CHAR-013` contract is available: `src/features/characters/components/OriginalWorkCombobox.tsx` +- Create: `src/features/characters/components/OriginalWorkCombobox.tsx` - Create: `src/features/characters/tests/character-api.test.ts` - Create: `src/features/characters/tests/{character-list,character-form}.test.tsx` - Create: `src/layouts/CharacterWorkspaceLayout.tsx` @@ -1401,7 +1864,7 @@ npm run build:prod |---|---|---|---| | `P3-T1` | Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`; Read: `docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json`; Test: 없음 | Consumes: OpenAPI `Character*` schema·4 operation. Produces: `CharacterListResponse`, `CharacterDetailResponse`, create/update multipart와 screen inventory | **TDD 예외:** 외부 계약 조사 Task다. `node -e "JSON.parse(require('fs').readFileSync('docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json','utf8'))"`와 `rg -n 'CHAR-0(1[2-8]|0[1-9])' docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md`가 exit 0인지 확인한다. 수동 확인: 제공·미제공 범위가 PRD와 일치한다. | | `P3-T2` | Create: `src/features/characters/api/character-api.ts`, `src/features/characters/model/types.ts`, `src/features/characters/pages/{CharacterListPage,CharacterDetailPage}.tsx`, `src/features/characters/components/{CharacterList,CharacterListItem,CharacterProfile}.tsx`, `src/features/characters/tests/character-list.test.tsx`, `src/layouts/CharacterWorkspaceLayout.tsx`, `src/layouts/CharacterWorkspaceLayout.test.tsx`; Modify: `src/app/router.tsx`, `src/app/route-paths.ts` | Consumes: `CharacterListResponse`, `CharacterDetailResponse`. Produces: `getCharacters({searchTerm,page,size})`, `getCharacter(characterId)`, list/workspace route | **TDD 적용:** `npm run test:run -- src/features/characters/tests/character-list.test.tsx src/layouts/CharacterWorkspaceLayout.test.tsx`; 기대 `exit 0`. 수동 확인: searchTerm request와 deep link/read-only 상태. | -| `P3-T3` | Create: `src/features/characters/schemas/character-schema.ts`, `src/features/characters/validation/character-image-policy.ts`, `src/features/characters/pages/CharacterFormPage.tsx`, `src/features/characters/components/{CharacterForm,CharacterImageField}.tsx`, `src/features/characters/tests/{character-api.test.ts,character-form.test.tsx}`; Modify: `src/features/characters/api/character-api.ts` | Consumes: Character create/update multipart. Produces: `createCharacter`, `updateCharacter`, `deactivateCharacter`, form serializer | **TDD 적용:** `npm run test:run -- src/features/characters/tests/character-api.test.ts src/features/characters/tests/character-form.test.tsx`; 기대 `exit 0`. 수동 확인: 필수 image/systemPrompt, crop, 저장·목록 이동. | +| `P3-T3` | Create: `src/features/characters/schemas/character-schema.ts`, `src/features/characters/validation/character-image-policy.ts`, `src/features/characters/pages/CharacterFormPage.tsx`, `src/features/characters/components/{CharacterForm,CharacterImageField,OriginalWorkCombobox}.tsx`, `src/features/characters/tests/{character-api.test.ts,character-form.test.tsx}`; Modify: `src/features/characters/api/character-api.ts` | Consumes: Character create/update multipart, legacy original work lookup `GET /admin/chat/original/search?searchTerm={검색어}`. Produces: `createCharacter`, `updateCharacter`, `deactivateCharacter`, `searchOriginalWorks`, form serializer | **TDD 적용:** `npm run test:run -- src/features/characters/tests/character-api.test.ts src/features/characters/tests/character-form.test.tsx`; 기대 `exit 0`. 수동 확인: 필수 image/systemPrompt, 원작 검색 선택, crop, 저장·목록 이동. | | `P3-T4` | Modify: `src/features/characters/pages/{CharacterListPage,CharacterDetailPage,CharacterFormPage}.tsx`, `src/features/characters/components/{CharacterList,CharacterListItem,CharacterProfile,CharacterForm,CharacterImageField}.tsx`; Test: `tests/e2e/character-workspace.spec.ts` | Consumes: P3-T2/T3 UI. Produces: viewport·keyboard capability evidence | **TDD 적용:** `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts`; 기대 지원 project 전부 통과. 수동 확인: 320/768/1280px, 200% zoom, keyboard, axe. | `P3-T2`~`P3-T4`는 각 row의 test에 가장 작은 실패 assertion을 먼저 @@ -1417,11 +1880,20 @@ REFACTOR와 수동 확인의 실제 결과를 `§7 검증 기록`에 누적한 - **완료 증거:** 체크박스 전체, 제공 계약 또는 제외 결정의 세 문서 일치, 상태/action inventory. - **범위 밖:** 계약을 추정한 production adapter와 Character 화면 구현. -- [ ] OpenAPI에서 목록 `searchTerm/page/size`, `data.totalCount/content`, item `id`, 상세 `characterUUID/originalWork`, mutation `data=null`을 contract fixture로 고정한다. -- [ ] original work lookup endpoint, DTO, search/page를 확인하고, 미제공이면 network control을 제외한다. `originalWorkId` 미선택 serializer는 허용된 omit 또는 `null` 중 하나를 contract test로 고정한다. -- [ ] OpenAPI 공통 400/401/403/404/405/406/415/500과 `ApiErrorResponse`를 fixture에 기록한다. Character 전용 message key는 미제공으로 표시하고 분기하지 않는다. -- [ ] 목록·상세·form·workspace의 상태/action inventory를 작성하고 Page는 route/query/policy 조합, feature component는 Character 표시·입력 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. -- [ ] 제공 계약 범위의 Character seed, loading·empty·error·success와 CRUD/deactivate browser handler 시나리오를 확정한다. 계약 미제공 original work network fixture는 만들지 않는다. +- [x] OpenAPI에서 목록 `searchTerm/page/size`, `data.totalCount/content`, item `id`, 상세 `characterUUID/originalWork`, mutation `data=null`을 contract fixture로 고정한다. +- [x] original work lookup endpoint, DTO, search/page를 확인하고, 미제공이면 network control을 제외한다. `originalWorkId` 미선택 serializer는 허용된 omit 또는 `null` 중 하나를 contract test로 고정한다. +- [x] OpenAPI 공통 400/401/403/404/405/406/415/500과 `ApiErrorResponse`를 fixture에 기록한다. Character 전용 message key는 미제공으로 표시하고 분기하지 않는다. +- [x] 목록·상세·form·workspace의 상태/action inventory를 작성하고 Page는 route/query/policy 조합, feature component는 Character 표시·입력 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. +- [x] 제공 계약 범위의 Character seed, loading·empty·error·success, legacy original work lookup과 CRUD/deactivate browser handler 시나리오를 확정한다. + +**P3-T1 확정 기록:** + +- OpenAPI 제공 범위: `GET/POST /api/v2/admin/ai-characters`, `GET/PUT /api/v2/admin/ai-characters/{characterId}`만 Character operation으로 사용한다. 목록은 query `searchTerm`, `page`, `size`와 응답 `data.totalCount`, `data.content[]`, item `id`를 fixture 기준으로 삼는다. 상세는 `characterUUID`, `originalWork`를 포함한다. 생성·수정 성공은 `NullSuccessResponse`의 `data=null`만 신뢰하고 새 ID나 상세 DTO를 추정하지 않는다. multipart part는 create `image`, `request`, update optional `image`, required `request`만 사용한다. +- Original work lookup: OpenAPI에는 없지만 legacy 후보 `GET /admin/chat/original/search?searchTerm={검색어}`를 사용한다. P3-T3에서 response DTO를 contract test로 고정하고 `OriginalWorkCombobox` network control을 만든다. `originalWorkId` 미선택 serializer는 P3-T3 contract test에서 key 생략을 canonical로 고정한다. +- 오류 fixture: Character 4개 operation의 공통 오류는 OpenAPI `ApiErrorResponse` shape, 즉 `success=false`, `message`, `data=null`, `errorProperty`만 사용한다. 400/401/403/404/405/406/415/500 status fixture를 공통 오류로 둔다. OpenAPI 밖의 Character 전용 message key는 분기하지 않고 미정의 오류는 기본 문구로 처리한다. +- 상태·action inventory: list는 `searchTerm`, `page`, `size`, `items`, `totalCount`, `selectedCharacterId`, `loading`, `empty`, `error`, `retry`; detail은 `characterId`, `profile`, `isActive`, `readOnly`, `loading`, `error`, `refresh`; form은 `mode`, `fields`, `image`, `dirty`, `submitting`, `fieldErrors`, `submit`, `cancel`, `deactivate`; workspace는 route `characterId`, active tab, breadcrumb, read-only policy, reload action을 가진다. +- component map: Page는 route param, URL query, data loading, navigation, read-only policy를 조합한다. feature component는 Character 목록 표시, 상세 표시, form 입력·직렬화, image 선택 규칙만 맡는다. shared component는 Phase 1의 `PageState`, `AppLayout`, `Button`, form primitive, dialog contract를 재사용한다. +- mock scenario: deterministic seed는 활성 Character 2건과 비활성 상세 1건, originalWork가 있는 상세 1건과 `null` 상세 1건을 둔다. handler는 list/detail loading, empty, common error, success, legacy original work lookup, create/update/deactivate 성공 및 공통 오류를 제공한다. create/update/deactivate 후 in-memory store의 list/detail을 OpenAPI 응답 shape로 갱신한다. ### Task 3.2 목록·선택·workspace @@ -1431,14 +1903,14 @@ REFACTOR와 수동 확인의 실제 결과를 `§7 검증 기록`에 누적한 - **완료 증거:** 체크박스 전체, route/list/workspace test와 read-only/error 상태 검증 기록. - **범위 밖:** Character 생성·수정·비활성화 form. -- [ ] Character 목록·생성 path에는 `characterId`가 없고 하위 resource route에만 선택한 `characterId`가 들어가는 contract test를 작성한다. -- [ ] 목록 UI의 `search` 상태를 API `searchTerm`으로 직렬화하고 `page`, `size` URL query 보존, `data.content` 역직렬화와 loading·empty·error·retry test를 작성한다. -- [ ] 목록 request에 `isActive`·`activeStatus`가 없고 client-side 활성 filter도 없는 contract test를 작성한다. active-only 보장은 외부 의존으로 남긴다. -- [ ] Character 선택 시 URL의 `characterId`로 workspace에 진입하고 새로고침·deep link가 동작하는 test를 작성한다. -- [ ] workspace header에 image, name, active 상태, `characterId`와 탭·breadcrumb를 표시한다. -- [ ] 상세 성공 응답이 `isActive=false`이면 read-only 배너와 중앙 write policy로 모든 mutation 진입점을 차단한다. -- [ ] 상세 400/404/500은 공통 오류 화면을 사용하고 비활성 ID 응답 정책을 client가 추정하지 않는다. -- [ ] browser MSW handler와 deterministic seed로 목록 → 선택 → workspace deep link의 최종 UI를 `dev:mock`에서 확인한다. +- [x] Character 목록·생성 path에는 `characterId`가 없고 하위 resource route에만 선택한 `characterId`가 들어가는 contract test를 작성한다. +- [x] 목록 UI의 `search` 상태를 API `searchTerm`으로 직렬화하고 `page`, `size` URL query 보존, `data.content` 역직렬화와 loading·empty·error·retry test를 작성한다. +- [x] 목록 request에 `isActive`·`activeStatus`가 없고 client-side 활성 filter도 없는 contract test를 작성한다. active-only 보장은 외부 의존으로 남긴다. +- [x] Character 선택 시 URL의 `characterId`로 workspace에 진입하고 새로고침·deep link가 동작하는 test를 작성한다. +- [x] workspace header에 image, name, active 상태, `characterId`와 탭·breadcrumb를 표시한다. +- [x] 상세 성공 응답이 `isActive=false`이면 read-only 배너와 중앙 write policy로 모든 mutation 진입점을 차단한다. +- [x] 상세 400/404/500은 공통 오류 화면을 사용하고 비활성 ID 응답 정책을 client가 추정하지 않는다. +- [x] browser MSW handler와 deterministic seed로 목록 → 선택 → workspace deep link의 최종 UI를 `dev:mock`에서 확인한다. ### Task 3.3 생성·수정·soft delete @@ -1446,22 +1918,22 @@ REFACTOR와 수동 확인의 실제 결과를 `§7 검증 기록`에 누적한 - **시작 조건:** `P3-T1`, `P3-T2` 완료. - **완료 증거:** 체크박스 전체, serializer/form/image/deactivate test, `OQ-009` 결정과 검증 기록. -- **범위 밖:** 계약 미제공 original-work integration과 하위 도메인 mutation. +- **범위 밖:** 하위 도메인 mutation. v2 original-work lookup이 별도 제공되지 않으면 legacy `GET /admin/chat/original/search?searchTerm={검색어}`를 사용한다. -- [ ] create multipart가 필수 `image`와 필수 `request` JSON part를 보내고 request에 `name`, `systemPrompt`, `description`을 포함하며 `isActive`, `externalCharacterId`를 포함하지 않는 test를 작성한다. -- [ ] 일반 update는 `isActive`를 생략하고 soft delete만 `isActive=false`를 보내며 `true`를 보내지 않는 test를 작성한다. -- [ ] create-only `region`을 수정 화면에서 읽기 전용으로 표시하고 update payload에 보내지 않는 test를 작성한다. -- [ ] name·systemPrompt·description visible label, field error, 중복 제출 방지, dirty-form 이탈 확인을 test한다. -- [ ] OpenAPI optional scalar와 tags·hobbies·values·goals·relationships·personalities·backgrounds·memories 반복 입력을 create/update schema에 맞게 직렬화하는 test를 작성한다. -- [ ] Character image의 JPEG/PNG·10MB, `1:1`, 최대 800×800, no-upscale, crop 이동·zoom·reset·preview·취소·적용·keyboard 대안을 test한다. -- [ ] crop 취소·교체 파일 미선택이 기존 image를 유지하고 기존 image 제거 UI는 없음을 test한다. -- [ ] original work 계약이 제공됐다면 이름 검색 Combobox와 canonical 미선택 payload를 contract test로 고정한다. -- [ ] 상세의 `characterUUID`는 읽기 전용으로 표시할 수 있지만 `externalCharacterId`로 이름을 바꾸지 않는다. 계약에 없는 creator member ID·nickname DTO/UI는 만들지 않고 creator 생성·동기화도 client가 수행하지 않는다. -- [ ] create의 `data=null` 성공 후 목록을 무효화해 이동하고, update는 기존 `characterId`의 list/detail cache를 무효화한다. -- [ ] 비활성화 AlertDialog가 영향·복원 미지원·hard delete 미지원을 설명하는 test를 작성한다. -- [ ] soft delete 성공 후 목록 cache 무효화·재조회, 목록 이동과 성공 toast를 확인하고 상세에 머물지 않는다. 비활성 항목이 서버 결과에서 제외되는지는 active-only 계약 제공 후 server mode에서 검증한다. -- [ ] 초기 Character form을 실제 페이지에서 확인한 뒤 `name`, `systemPrompt`, `description`과 tags·hobbies·values·goals·relationships·personalities·backgrounds·memories의 최대 길이·개수 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. -- [ ] mock store가 create/update/deactivate 후 목록·상세를 같은 server response contract로 갱신하는 E2E를 작성한다. +- [x] create multipart가 필수 `image`와 필수 `request` JSON part를 보내고 request에 `name`, `systemPrompt`, `description`을 포함하며 `isActive`, `externalCharacterId`를 포함하지 않는 test를 작성한다. +- [x] 일반 update는 `isActive`를 생략하고 soft delete만 `isActive=false`를 보내며 `true`를 보내지 않는 test를 작성한다. +- [x] create-only `region`을 수정 화면에서 읽기 전용으로 표시하고 update payload에 보내지 않는 test를 작성한다. +- [x] name·systemPrompt·description visible label, field error, 중복 제출 방지, dirty-form 이탈 확인을 test한다. +- [x] OpenAPI optional scalar와 tags·hobbies·values·goals·relationships·personalities·backgrounds·memories 반복 입력을 create/update schema에 맞게 직렬화하는 test를 작성한다. +- [x] Character image의 JPEG/PNG·10MB, `1:1`, 최대 800×800, no-upscale, crop 이동·zoom·reset·preview·취소·적용·keyboard 대안을 test한다. +- [x] crop 취소·교체 파일 미선택이 기존 image를 유지하고 기존 image 제거 UI는 없음을 test한다. +- [x] Phase 10 `P10-T1`에서 v2 `searchAiCharacterOriginalWorks` 기반 원작 검색 Combobox와 `originalWorkId` 선택 payload를 contract test로 고정한다. 미선택 payload는 canonical 생략으로 고정하고 legacy lookup은 제거한다. +- [x] 상세의 `characterUUID`는 읽기 전용으로 표시할 수 있지만 `externalCharacterId`로 이름을 바꾸지 않는다. 계약에 없는 creator member ID·nickname DTO/UI는 만들지 않고 creator 생성·동기화도 client가 수행하지 않는다. +- [x] create의 `data=null` 성공 후 목록 route로 이동해 목록을 재조회하고, update는 기존 `characterId` 상세 route로 재진입해 상세를 재조회한다. 현재 Character 화면은 React Query cache가 아니라 route 재진입 직접 요청으로 동기화한다. +- [x] 비활성화 AlertDialog가 영향·복원 미지원·hard delete 미지원을 설명하는 test를 작성한다. +- [x] soft delete 성공 후 목록 route 이동과 목록 재조회, 성공 toast를 확인하고 상세에 머물지 않는다. 비활성 항목이 서버 결과에서 제외되는지는 active-only 계약 제공 후 server mode에서 검증한다. +- [x] 초기 Character form을 실제 페이지에서 확인한 뒤 `name`, `systemPrompt`, `description`과 tags·hobbies·values·goals·relationships·personalities·backgrounds·memories의 최대 길이·개수 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. +- [x] mock store가 create/update/deactivate 후 목록·상세를 같은 server response contract로 갱신하는 E2E를 작성한다. ### Task 3.4 Character 반응형·접근성 @@ -1471,11 +1943,11 @@ REFACTOR와 수동 확인의 실제 결과를 `§7 검증 기록`에 누적한 - **완료 증거:** 체크박스 전체, 320/768/1280px·200% zoom·keyboard·axe E2E 기록. - **범위 밖:** 다음 Phase 도메인과 모바일 mutation. -- [ ] desktop/tablet에서는 전체 관리 action을 제공한다. -- [ ] mobile에서는 목록·검색·상세만 제공하고 create/edit/deactivate route 직접 진입도 desktop 안내로 종료한다. -- [ ] Table이 mobile Card로 바뀌어도 동일한 accessible name과 핵심 상태를 유지한다. -- [ ] keyboard-only로 search → select → tabs → form → dialog를 완료한다. -- [ ] 320/768/1280px, 200% zoom, axe critical·serious 0건을 Phase E2E에서 확인한다. +- [x] desktop/tablet에서는 전체 관리 action을 제공한다. +- [x] mobile에서는 목록·검색·상세만 제공하고 create/edit/deactivate route 직접 진입도 desktop 안내로 종료한다. +- [x] Table이 mobile Card로 바뀌어도 동일한 accessible name과 핵심 상태를 유지한다. +- [x] keyboard-only로 search → select → tabs → form → dialog를 완료한다. +- [x] 320/768/1280px, 200% zoom, axe critical·serious 0건을 Phase E2E에서 확인한다. ### Phase 3 Gate @@ -1488,7 +1960,7 @@ REFACTOR와 수동 확인의 실제 결과를 `§7 검증 기록`에 누적한 ```bash npm run test:run -- src/features/characters src/layouts/CharacterWorkspaceLayout.test.tsx npm run e2e:mock -- tests/e2e/character-workspace.spec.ts -npm run e2e -- tests/e2e/character-workspace.spec.ts +npm run e2e npm run typecheck npm run lint npm run build @@ -1500,6 +1972,154 @@ npm run build 생성·수정·비활성화 Dialog를 keyboard-only로 확인하고, `searchTerm`, multipart part와 mutation 후 network 요청이 OpenAPI와 일치하는지 본다. +### Task R3.1 — Character optional form 계약 완성 + +**Goal 실행 `P3-R1`:** Character create/update form에서 확정된 optional scalar와 반복 배열을 편집·직렬화한다. + +- **연결 리뷰:** [Phase 3 재리뷰](./reviews/review-phase-3-20260729.md) — `REV-P3-001` +- **시작 조건:** OpenAPI `CharacterCreateRequest/CharacterUpdateRequest`와 `CHAR-003`, `CHAR-018`. +- **Files:** Modify: `src/features/characters/pages/CharacterCreatePage.tsx`, `src/features/characters/pages/CharacterEditPage.tsx`, `src/features/characters/api/character-api.ts`, `src/features/characters/tests/CharacterCreatePage.test.tsx`, `src/features/characters/tests/CharacterEditPage.test.tsx`, `src/features/characters/tests/character-api.test.ts`, `tests/e2e/character-workspace.spec.ts`. +- **Interfaces:** scalar `age/gender/mbti/speechPattern/speechStyle/appearance/region/originalTitle/originalLink/characterType`; arrays `tags/hobbies/values/goals/relationships/personalities/backgrounds/memories`; update `region`은 읽기 전용. +- **범위 밖:** 계약에 없는 최대 길이·배열 개수, creator field, 새로운 form framework 도입. + +- [x] **RED:** create/update에서 각 scalar·array 대표값과 null/empty canonical payload를 검증하는 form/API test를 실패시킨다. +- [x] **GREEN:** 기존 두 Page 안에서 section별 최소 field/row editor를 추가하고 현재 multipart serializer로 전송한다. +- [x] **REFACTOR:** 반복 row helper는 실제 8개 소비만 공유하고 dirty/clear semantics를 정리한다. +- **실행 명령:** `npm run test:run -- src/features/characters`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts`. +- **기대 결과:** exit 0, OpenAPI property 누락 0건, update region request 0건, 생성 필수 field 회귀 0건. +- **수동 확인:** desktop/tablet create/edit에서 모든 section 입력·추가·삭제와 저장 후 재조회 값을 확인한다. + +**Progress — 2026-07-30 P3-R1 완료** + +- **RED:** `npm run test:run -- src/features/characters/tests/CharacterCreatePage.test.tsx src/features/characters/tests/CharacterEditPage.test.tsx`에서 신규 3개 test가 `나이`, `원작 제목` 등 누락 label 때문에 실패하는 것을 확인했다. +- **GREEN:** `CharacterOptionalFields`와 optional field serialization helper를 추가하고 create/edit page에서 optional scalar·반복 배열을 multipart `request`에 직렬화했다. `npm run test:run -- src/features/characters/tests/CharacterCreatePage.test.tsx src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts` 결과 3 files / 18 tests passed. +- **REFACTOR/회귀:** `npm run test:run -- src/features/characters` 결과 6 files / 26 tests passed. `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts` 결과 41 passed / 3 skipped. optional 입력 추가로 keyboard-only E2E의 tab 한도만 80회로 조정했다. `npm run typecheck`, `npm run lint`, `npm run build` 모두 exit 0. +- **정정:** 독립 리뷰에서 수정 상세 응답에 없는 `originalTitle/originalLink`를 일반 저장 시 `null`로 전송할 수 있는 blocker를 확인했다. RED `npm run test:run -- src/features/characters/tests/CharacterEditPage.test.tsx`는 2 failed로 해당 전송을 재현했고, touched flag를 추가해 사용자가 입력·삭제한 경우에만 update payload에 포함하도록 수정했다. 이후 `npm run test:run -- src/features/characters/tests/CharacterCreatePage.test.tsx src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts`는 3 files / 18 tests passed, `npm run test:run -- src/features/characters`는 6 files / 26 tests passed, `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts`는 41 passed / 3 skipped, `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. + +### Task R3.2 — Character form 실패 복구와 image format 검증 + +**Goal 실행 `P3-R2`:** Character mutation 실패 뒤 입력을 유지해 재시도하고 JPEG/PNG extension↔MIME pair를 제출 전에 검증한다. + +- **연결 리뷰:** [Phase 3 재리뷰](./reviews/review-phase-3-20260729.md) — `REV-P3-002`, `REV-P3-003` +- **시작 조건:** `P3-R1` form 구조 또는 충돌 없는 현재 form, `FILE-002`, 공통 `ApiError`. +- **Files:** Create: `src/features/characters/validation/character-image-policy.ts`; Modify: `CharacterCreatePage.tsx`, `CharacterEditPage.tsx`, 해당 page test. +- **Interfaces:** `.jpg/.jpeg ↔ image/jpeg`, `.png ↔ image/png`, 10,485,760 bytes; form error는 server message를 보존하고 `finally`로 pending을 해제한다. +- **범위 밖:** backend image sniffing, 공통 crop 계산(`P1-R7`). + +- [x] **RED:** create/update rejection 뒤 button 재활성·오류 표시·두 번째 성공과 양방향 MIME mismatch 거부 test를 작성한다. +- [x] **GREEN:** form-level try/catch/finally와 Character image policy를 적용한다. +- [x] **REFACTOR:** create/edit 중복 validation·error copy만 작게 공유하고 Character 전체 회귀를 실행한다. +- **실행 명령:** `npm run test:run -- src/features/characters`; `npm run typecheck`; `npm run lint`. +- **기대 결과:** exit 0, 실패 뒤 재시도 1회 성공, mismatch multipart 0건. +- **수동 확인:** server 500 후 입력 유지·재시도와 잘못된 filename/MIME inline 오류를 확인한다. + +**Progress — 2026-07-30 P3-R2 완료** + +- **RED:** `npm run test:run -- src/features/characters/tests/CharacterCreatePage.test.tsx src/features/characters/tests/CharacterEditPage.test.tsx` 결과 2 files failed, 6 failed / 13 passed, 2 unhandled `ApiError`였다. 신규 retry test는 서버 메시지를 찾지 못했고, mismatch test는 `JPEG 또는 PNG 파일만 업로드하세요.` inline 오류가 없어 실패했다. +- **GREEN:** `src/features/characters/validation/character-image-policy.ts`를 추가해 `.jpg/.jpeg ↔ image/jpeg`, `.png ↔ image/png`, 10MB 제한을 검증하고, create/edit page에서 file 선택 시 crop source 전에 inline 오류를 표시했다. mutation submit은 `ApiError` message를 form 오류로 표시하고 `finally`에서 submit 상태를 해제한다. `npm run test:run -- src/features/characters/tests/CharacterCreatePage.test.tsx src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/character-api.test.ts` 결과 3 files / 24 tests passed. +- **REFACTOR/회귀:** `npm run test:run -- src/features/characters` 결과 6 files / 32 tests passed. `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts` 병렬 실행은 axe-heavy direct route test가 30초 제한에 걸렸고, focused Chromium 재실행은 1 passed였다. 해당 test에만 60초 timeout을 부여한 뒤 `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --workers=1` 결과 41 passed / 3 skipped였다. `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. LSP diagnostics도 변경 TS/TSX와 E2E 파일에서 오류 없음이었다. + +### Task R3.3 — 워크스페이스 탭 keyboard semantics와 운영 문구 복구 + +**Goal 실행 `P3-R3`:** 캐릭터 워크스페이스의 탭/내비게이션 의미와 keyboard 이동을 일치시키고 내부 Task 문구를 운영자 안내에서 제거한다. + +- **연결 리뷰:** [Phase 3 현재 리뷰](./reviews/phase3-character-workspace.md) — `REV-P3-004`, `REV-P3-005` +- **시작 조건:** PRD `7.2`, `14.2`의 1차 탭·keyboard-only 이동과 비활성 read-only 안내. +- **완료 증거:** 실패하는 keyboard/accessible-role test, 최소 수정, Character focused unit·mock E2E·접근성 Gate와 검증 기록. +- **Files:** Modify: `src/layouts/CharacterWorkspaceLayout.tsx`, `src/layouts/CharacterWorkspaceLayout.test.tsx`, `tests/e2e/character-workspace.spec.ts`, 필요 시 `tests/e2e/accessibility.spec.ts`. +- **Interfaces:** ARIA tab pattern을 유지하면 활성 tab만 page Tab 순서에 두고 좌우 화살표·tabpanel 연결을 구현한다. 페이지 내비게이션으로 단순화하면 `nav`/link와 `aria-current="page"`를 사용해 tab 역할을 제거한다. +- **범위 밖:** route 구조 변경, 새 router dependency, 비활성 Character 정책 변경. + +- [x] **RED:** 현재 다섯 `role=tab`이 모두 Tab 순서에 들어가고 좌우 화살표로 이동하지 않는 실패 test, 배너에 `P3-T2`가 노출되는 실패 test를 작성한다. +- [x] **GREEN:** 한 가지 일관된 접근성 패턴만 적용하고 배너를 운영 영향·복원 미지원 중심의 한국어 문구로 교체한다. +- [x] **REFACTOR:** active 상태와 panel 연결 중복을 작은 data structure로 정리하되 범용 tab abstraction은 만들지 않는다. +- **실행 명령:** `npm run test:run -- src/layouts src/features/characters`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/accessibility.spec.ts`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 선언한 role과 keyboard 동작 불일치 0건, 사용자 화면의 `P3-T2` 노출 0건. +- **수동 확인:** keyboard-only로 프로필 → 오디오 → 시리즈 → 커뮤니티 → FanTalk 이동과 비활성 배너 문구를 확인한다. + +**Progress — 2026-07-30 P3-R3 완료** + +- **RED:** `npm run test:run -- src/layouts/CharacterWorkspaceLayout.test.tsx` 결과 1 file failed, 2 failed / 3 passed. 신규 native navigation expectation은 `캐릭터 워크스페이스 내비게이션` role을 찾지 못하고 기존 `tablist/tab`을 노출해 실패했으며, 비활성 배너는 `P3-T2` 내부 문구를 계속 표시해 운영 문구 assertion이 실패했다. +- **GREEN:** `CharacterWorkspaceLayout`의 workspace 전환 UI를 `nav` + native link + `aria-current="page"` 패턴으로 단일화하고, active link data structure로 중복을 줄였다. 비활성 배너는 조회 전용 운영 영향, 복원 기능 미지원, 영구 삭제와 삭제 후 복구 미제공을 설명하는 한국어 문구로 교체했다. `npm run test:run -- src/layouts/CharacterWorkspaceLayout.test.tsx` 결과 1 file / 5 tests passed. +- **REFACTOR/회귀:** `npm run test:run -- src/features/characters src/layouts/CharacterWorkspaceLayout.test.tsx src/features/community-posts/tests/community-list.test.tsx src/features/fan-talks/tests/fan-talk-list.test.tsx` 결과 9 files / 45 tests passed. `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium` 결과 11 passed. `npm run typecheck`와 `npm run lint`는 모두 exit 0이었다. 변경 TS/TSX 파일 LSP diagnostics는 모두 오류 0건이었다. + +### Task R3.4 — 워크스페이스 header characterId 복구 + +**Goal 실행 `P3-R4`:** 모든 캐릭터 워크스페이스 header에 현재 numeric `characterId`를 다시 표시해 운영자가 작업 대상을 식별할 수 있게 한다. + +- **연결 리뷰:** [Phase 3 현재 리뷰](./reviews/phase3-character-workspace.md) — `REV-P3-006` +- **시작 조건:** PRD §7.2의 이미지·이름·활성 상태·`characterId` 상시 표시 기준. +- **완료 증거:** 현재 ID 미노출 assertion을 뒤집은 실패 test, header 최소 표시 수정, Character/layout focused unit·mock E2E·접근성 Gate 통과. +- **Files:** Modify: `src/layouts/CharacterWorkspaceLayout.tsx`, `src/layouts/CharacterWorkspaceLayout.test.tsx`, `tests/e2e/character-workspace.spec.ts`; Docs: `P3-R4` 수정 후 검증 기록. +- **Interfaces:** API의 numeric `character.id`를 운영 label과 함께 표시하고 `characterUUID` 프로필 필드는 별도 값으로 유지한다. +- **범위 밖:** route ID 형식 변경, UUID를 `characterId`로 대체, 전체 상세 metadata 재배치. + +- [x] **RED:** active/inactive workspace header 모두 현재 `characterId`를 표시해야 한다는 실패 test를 작성하고 기존 미노출 assertion을 제거했다. +- [x] **GREEN:** 이름·상태 주변에 구분되는 `characterId` label/value를 표시했다. +- [x] **REFACTOR:** Series visual QA의 raw 내부 field-name 검색은 요구사항상 표시해야 하는 header ID를 예외 처리하고 실제 원치 않는 raw label 검사는 유지했다. +- **실행 명령:** `npm run test:run -- src/layouts/CharacterWorkspaceLayout.test.tsx src/features/characters`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 모든 workspace header의 현재 numeric ID 표시, 다른 캐릭터 ID 혼입 0건. +- **수동 확인:** Character 101/202의 프로필·Audio·Series·Community·FanTalk header에서 각각 올바른 ID를 확인한다. + +**P3-R4 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/layouts/CharacterWorkspaceLayout.test.tsx` — 2 failed / 3 passed. active `101`과 inactive `202` workspace header에서 `characterId:` 표시를 찾지 못해 기대대로 실패했다. +- GREEN focused: `npm run test:run -- src/layouts/CharacterWorkspaceLayout.test.tsx src/features/characters` — 성공, 7 files / 41 tests passed. header의 이름·상태 근처에 numeric `characterId`가 표시되고 기존 프로필 `characterUUID`는 별도 값으로 유지됨을 확인했다. +- REFACTOR: `rg -n 'characterId:|raw|SUN|PROCEEDING|genreId' tests/e2e src/features/series src/layouts`로 자동 raw-leak assertion을 확인했고, 현재 Series raw label 검사는 `characterId:`를 금지하지 않아 추가 변경하지 않았다. +- 개발 중 E2E: 사용자 지시에 따라 전체 Task 완료 전 반복 실행하지 않았다. 지정 mock Character E2E는 최종 회귀 단계에서 필요 시 실행한다. + +### Task R3.5 — Character URL query 계약 키 정렬 + +**Goal 실행 `P3-R5`:** Character 목록의 브라우저 검색 상태를 API 계약과 같은 `searchTerm` query로 읽고 쓰며 직접 링크 복원을 보장한다. + +- **연결 리뷰:** [Phase 3 현재 리뷰](./reviews/phase3-character-workspace.md) — `REV-P3-007` +- **시작 조건:** PRD §7.1과 OpenAPI `GET /api/v2/admin/ai-characters`의 `searchTerm`, `page`, `size` query 계약. +- **완료 증거:** `?searchTerm=루나` 직접 진입과 검색 변경 URL assertion의 실패 test, 목록 URL parser/serializer 최소 수정, focused unit·mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/features/characters/pages/CharacterListPage.tsx`, `src/features/characters/tests/character-list.test.tsx`, `tests/e2e/character-workspace.spec.ts`. +- **Interfaces:** 브라우저 URL과 API request 모두 `searchTerm`을 사용하고 `page`, `size`, 300ms 검색 동작은 유지한다. +- **범위 밖:** legacy `search` query의 장기 호환 layer, router dependency 도입, server-side filter 추가. + +- [x] **RED:** `?searchTerm=루나&page=1&size=20` 직접 진입이 input·request를 복원하고 검색 변경 URL에 `search`가 없어야 한다는 실패 test를 작성했다. +- [x] **GREEN:** URL read/write key를 `searchTerm`으로 교체하고 adapter 인자를 그대로 연결했다. +- [x] **REFACTOR:** 내부 state 이름은 필요한 범위에서만 정리하고 별도 query abstraction을 만들지 않았다. +- **실행 명령:** `npm run test:run -- src/features/characters/tests/character-list.test.tsx`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, Character URL의 검색 key는 `searchTerm` 하나이고 직접 링크·검색·pagination 왕복이 일치. +- **수동 확인:** 검색 결과 2페이지 URL을 새 탭에서 열어 같은 검색어·page·size와 목록을 복원하는지 확인한다. + +**P3-R5 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/features/characters/tests/character-list.test.tsx` — 2 failed. 핵심 실패는 `?searchTerm=루나&page=1&size=20` 직접 진입 뒤 API request의 `searchTerm`이 `null`인 점이었다. 두 번째 loading flow는 같은 parser 불일치로 초기 대기 Promise가 열리지 않아 timeout됐다. +- GREEN focused: `npm run test:run -- src/features/characters/tests/character-list.test.tsx` — 성공, 1 file / 2 tests passed. 직접 링크 input·request 복원과 검색 변경 URL의 `searchTerm` 사용, `search=` 미사용을 확인했다. +- REFACTOR/회귀: `npm run test:run -- src/features/characters` — 성공, 6 files / 36 tests passed. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check` — 모두 exit 0 또는 no output. LSP diagnostics: `CharacterListPage.tsx`, `character-list.test.tsx` 오류 0건. +- 개발 중 E2E: 사용자 지시에 따라 전체 Task 완료 전 반복 실행하지 않았다. 지정 mock Character E2E는 최종 회귀 단계에서 필요 시 실행한다. + +### Task R3.6 — Character image crop 확정 경계 보강 + +**Goal 실행 `P3-R6`:** Character 생성·수정에서 원본 파일을 crop 적용 전에 mutation payload로 확정하지 않고 준비·취소·연속 선택 경계를 안전하게 처리한다. + +- **연결 리뷰:** [Phase 3 현재 리뷰](./reviews/phase3-character-workspace.md) — `REV-P3-008` +- **시작 조건:** `FILE-007`, `FILE-009~010`, PRD §10.5 저장 중 feedback와 Character image 필수/optional 계약. +- **완료 증거:** 원본 제출·crop 취소·준비 중 제출·stale selection을 재현하는 실패 test, staged selection 최소 수정, Character focused unit·mock E2E·file Gate 통과. +- **Files:** Modify: `src/features/characters/pages/CharacterCreatePage.tsx`, `src/features/characters/pages/CharacterEditPage.tsx`, 관련 Character tests와 `tests/e2e/character-workspace.spec.ts`; 필요 시 기존 Character image helper. +- **Interfaces:** committed `image`는 crop `onApply` 결과만 가리킨다. 준비/crop 중 제출은 비활성화·guard하고, 취소는 생성의 새 선택을 제거하며 수정의 기존 서버 media를 유지한다. 마지막 선택만 dialog를 열고 준비 실패를 inline 표시한다. +- **범위 밖:** 공통 crop dialog 계산 변경, 새 범용 upload state library, backend image sniffing. + +- [x] **RED:** create의 crop 미적용 원본 제출과 취소 뒤 제출, create/edit의 pending source 준비 중 submit, 느린 첫 선택이 빠른 둘째 선택을 덮는 경우를 실패 test로 고정했다. +- [x] **GREEN:** raw file과 committed crop result를 분리하고 selection token·준비 상태·오류 처리·submit guard를 추가했다. +- [x] **REFACTOR:** 새 전역 abstraction 없이 create/edit 내부의 작은 중복 상태로 유지하고, 기존 Character App 회귀 test는 crop apply 경로를 지나도록 정렬했다. +- **실행 명령:** `npm run test:run -- src/features/characters`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, Character multipart의 uncropped 원본 0건, 취소·실패·stale source의 mutation 0건, 정상 crop 결과만 전송. +- **수동 확인:** 느린 이미지에서 연속 선택·취소·적용·즉시 제출을 반복해 선택한 마지막 crop 결과만 preview와 request에 반영되는지 확인한다. + +#### P3-R6 수정 검증 기록 — 2026-07-31 + +- RED: `npm run test:run -- src/features/characters/tests/CharacterCreatePage.test.tsx src/features/characters/tests/CharacterEditPage.test.tsx` — 기대 실패 확인, 2 files failed / 6 failed / 23 passed 및 reject 준비 실패 unhandled rejection을 확인했다. 핵심 실패는 생성 pending submit button 미비활성, 생성 crop cancel 뒤 raw file 잔존, 수정 pending submit button 미비활성, 준비 reject inline 오류 미표시였다. +- GREEN: `CharacterCreatePage.tsx`와 `CharacterEditPage.tsx`에서 committed `image`를 crop apply 결과만 보유하도록 분리하고, selection id·준비 상태·stale ignore·reject inline 오류·submit disabled/guard를 추가했다. `npm run test:run -- src/features/characters/tests/CharacterCreatePage.test.tsx src/features/characters/tests/CharacterEditPage.test.tsx` — 성공, 2 files / 29 tests passed. +- Character 회귀: `npm run test:run -- src/features/characters` — 성공, 6 files / 43 tests passed. `character-mutation-reload.test.tsx`는 App 생성 성공 경로가 실제 crop dialog 적용 후 저장하도록 보강했다. +- 정적 검증: `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check` — 모두 성공(exit 0). Build는 255 modules transformed, production bundle 생성 완료. +- LSP: `lsp_diagnostics`는 `CharacterCreatePage.tsx`, `CharacterEditPage.tsx`, `character-mutation-reload.test.tsx`에서 오류 0건이었다. `CharacterCreatePage.test.tsx`, `CharacterEditPage.test.tsx` 단일 fresh diagnostics는 timeout이었고, `lsp_diagnostics src/features/characters`와 `npm run typecheck`로 characters 디렉터리 오류 0건을 보완 확인했다. +- E2E: 사용자 지시가 “개발 중 unit tests만, 전체 Task 이후 필요 시 E2E”였으므로 `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium`은 이번 P3-R6 개발 중 실행하지 않고 후속 최종 회귀 단계로 이연했다. + --- ## Phase 4. Audio vertical slice @@ -1514,7 +2134,7 @@ multipart part와 mutation 후 network 요청이 OpenAPI와 일치하는지 본 **요구사항:** `AUDIO-001~033`, `FILE-001~002`, `FILE-006~009`, `FILE-012~013`, PRD `9`의 Audio 범위. -**외부 의존:** `EXT-007` active-only 반환 보장, `EXT-011` Audio 도메인 오류 message key, `EXT-010` backend 파일/container/codec 검증 계약, `EXT-009` optional P1 price 상한. status filter·답변 없는 status badge는 현재 범위에서 제외한다. +**외부 의존:** `EXT-007` active-only 반환 보장. price 상한은 `99999`이고, 파일 용량·MIME 등 backend 동일 검증은 확인됐다. OpenAPI 밖의 Audio 도메인 오류 message key는 분기하지 않는다. status filter·답변 없는 status badge는 현재 범위에서 제외한다. **주요 Files:** @@ -1532,7 +2152,7 @@ multipart part와 mutation 후 network 요청이 OpenAPI와 일치하는지 본 | Goal | Files | Interfaces | TDD·검증 기준 | |---|---|---|---| -| `P4-T1` | Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`; Read: `docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json`; Test: 없음 | Consumes: `AudioContent*`, `AudioTheme*` schema·5 Audio operation. Produces: list/detail/create/update/theme contract map과 screen inventory | **TDD 예외:** 외부 계약 조사 Task다. OpenAPI JSON parse와 `rg -n 'AUDIO-0(2[7-9]|3[0-3])'` 문서 추적 검사를 실행한다. 기대 `exit 0`. 수동 확인: status·seriesIds·수정 file 제외가 명시된다. | +| `P4-T1` | Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`; Read: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json`; Test: 없음 | Consumes: `AudioContent*`, `AudioTheme*` schema·5 Audio operation. Produces: list/detail/create/update/theme contract map과 screen inventory | **TDD 예외:** 외부 계약 조사 Task다. OpenAPI JSON parse와 `rg -n 'AUDIO-0(2[7-9]|3[0-3])'` 문서 추적 검사를 실행한다. 기대 `exit 0`. 수동 확인: status·seriesIds·수정 file 제외가 명시된다. | | `P4-T2` | Create: `src/features/audio-contents/api/audio-content-api.ts`, `src/features/audio-contents/model/types.ts`, `src/features/audio-contents/pages/{AudioContentListPage,AudioContentDetailPage}.tsx`, `src/features/audio-contents/components/{AudioContentList,AudioContentListItem,AudioContentSummary}.tsx`, `src/features/audio-contents/tests/{audio-list,audio-player}.test.tsx` | Consumes: `AudioContentListResponse`, `AudioContentDetailResponse`. Produces: `getAudioContents({characterId,search_word,page,size})`, `getAudioContent({characterId,contentId,timezone})`와 player UI | **TDD 적용:** `npm run test:run -- src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx`; 기대 `exit 0`. 수동 확인: 2자 검색, timezone, 단일 재생·no-auto-refetch. | | `P4-T3` | Create: `src/features/audio-contents/api/{audio-content-theme-api,upload-audio-content}.ts`, `src/features/audio-contents/schemas/audio-content-schema.ts`, `src/features/audio-contents/validation/audio-cover-policy.ts`, `src/features/audio-contents/pages/AudioContentFormPage.tsx`, `src/features/audio-contents/components/{AudioContentForm,AudioContentThemeSelect,ReleaseScheduleField}.tsx`, `src/features/audio-contents/tests/{audio-contract.test.ts,audio-upload.test.ts,audio-form.test.tsx}`; Modify: `src/features/audio-contents/api/audio-content-api.ts` | Consumes: `AudioContentCreateRequest`, `AudioContentUpdateRequest`, `AudioContentTheme`. Produces: `createAudioContent`, `updateAudioContent`, `deactivateAudioContent`, upload adapter | **TDD 적용:** `npm run test:run -- src/features/audio-contents/tests/audio-contract.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-form.test.tsx`; 기대 `exit 0`. 수동 확인: contentFile, local releaseDate, theme, 진행률·취소·재시도. | | `P4-T4` | Modify: `src/features/audio-contents/pages/{AudioContentListPage,AudioContentDetailPage,AudioContentFormPage}.tsx`, `src/features/audio-contents/components/{AudioContentList,AudioContentListItem,AudioContentSummary,AudioContentForm,AudioContentThemeSelect,ReleaseScheduleField}.tsx`; Test: `tests/e2e/audio-content.spec.ts` | Consumes: P4-T2/T3 UI. Produces: viewport·keyboard capability evidence | **TDD 적용:** `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`; 기대 지원 project 전부 통과. 수동 확인: 320px player, 200% zoom, keyboard, axe. | @@ -1549,13 +2169,13 @@ REFACTOR를 실행하고, 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, contract fixture와 상태/action inventory의 세 문서 일치. - **범위 밖:** 오류 status/key 또는 price 상한 추정과 Audio UI 구현. -- [ ] OpenAPI 공통 오류 status·shape를 fixture에 기록하고 Audio 전용 message key와 backend container·codec 오류 계약은 미제공으로 표시한다. 정확한 fixture를 추정하지 않는다. -- [ ] 오디오 테마 목록 `GET /api/v2/admin/ai-characters/audio-content-themes`가 query/body 없이 호출되고 `data[]`의 `id`, `theme`, `image`를 반환하는 contract fixture를 기록한다. -- [ ] price 최대값이 제공되면 schema와 경계 test를 추가하고, 없으면 상한을 만들지 않는다. -- [ ] 목록 `search_word/page/size`, `data.totalCount/items`, 상세 필수 `timezone`, 생성 `contentFile/coverImage/request`, 생성 `data.contentId`, 수정 `data=null`을 contract fixture로 고정한다. -- [ ] status query·status field가 없음을 고정하고 client status filter·status enum을 만들지 않는다. -- [ ] 목록·상세·player·form/upload의 상태/action inventory를 작성하고 Page는 route/query/policy 조합, feature component는 Audio 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. -- [ ] 제공 계약 범위의 Audio seed와 목록·상세·player·theme·upload CRUD browser handler 시나리오를 확정한다. +- [x] OpenAPI 공통 오류 status·shape를 fixture에 기록하고 Audio 전용 message key와 backend container·codec 오류 계약은 미제공으로 표시한다. 정확한 fixture를 추정하지 않는다. +- [x] 오디오 테마 목록 `GET /api/v2/admin/ai-characters/audio-content-themes`가 query/body 없이 호출되고 `data[]`의 `id`, `theme`, `image`를 반환하는 contract fixture를 기록한다. +- [x] price 최대값이 제공되면 schema와 경계 test를 추가하고, 없으면 상한을 만들지 않는다. +- [x] 목록 `search_word/page/size`, `data.totalCount/items`, 상세 필수 `timezone`, 생성 `contentFile/coverImage/request`, 생성 `data.contentId`, 수정 `data=null`을 contract fixture로 고정한다. +- [x] status query·status field가 없음을 고정하고 client status filter·status enum을 만들지 않는다. +- [x] 목록·상세·player·form/upload의 상태/action inventory를 작성하고 Page는 route/query/policy 조합, feature component는 Audio 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. +- [x] 제공 계약 범위의 Audio seed와 목록·상세·player·theme·upload CRUD browser handler 시나리오를 확정한다. ### Task 4.2 목록·상세·player @@ -1565,15 +2185,15 @@ REFACTOR를 실행하고, 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, list/detail/player test와 signed URL 비기록·no-auto-refetch 기록. - **범위 밖:** 생성·수정·upload form. -- [ ] UI 검색어가 2자 이상일 때만 API `search_word`로 직렬화되고 `page`, `size` URL 상태와 `data.items` 역직렬화, loading·empty·error·retry가 동작하는 test를 작성한다. -- [ ] status query·활성 query·client-side status/active filter request가 0회임을 test한다. -- [ ] Audio detail route의 직접 진입과 새로고침에서 `timezone=Asia/Seoul`을 보내 같은 resource를 복원하는 test를 작성한다. -- [ ] 목록과 상세가 Phase 1 `AdminAudioPlayer`를 조합하고 play/pause, seek, current/duration, volume, speed, keyboard를 지원하는 integration test를 작성한다. -- [ ] 한 player 재생 시 기존 player가 정지되고 명시적 download button이 없음을 test한다. -- [ ] media error를 signed URL 만료로 추정하지 않고 일반 오류·수동 재시도·페이지 새로고침 안내를 표시한다. -- [ ] media error만으로 목록/detail GET과 `play()`가 자동 재호출되지 않는 test를 작성한다. -- [ ] signed URL이 log·storage·분석 event로 전달되지 않는 test를 작성한다. -- [ ] mock mode는 실제 endpoint shape와 local preview audio asset으로 목록 → 상세 → 단일 재생 최종 UI를 확인한다. +- [x] UI 검색어가 2자 이상일 때만 API `search_word`로 직렬화되고 `page`, `size` URL 상태와 `data.items` 역직렬화, loading·empty·error·retry가 동작하는 test를 작성한다. +- [x] status query·활성 query·client-side status/active filter request가 0회임을 test한다. +- [x] Audio detail route의 직접 진입과 새로고침에서 `timezone=Asia/Seoul`을 보내 같은 resource를 복원하는 test를 작성한다. +- [x] 목록과 상세가 Phase 1 `AdminAudioPlayer`를 조합하고 play/pause, seek, current/duration, volume, speed, keyboard를 지원하는 integration test를 작성한다. +- [x] 한 player 재생 시 기존 player가 정지되고 명시적 download button이 없음을 test한다. +- [x] media error를 signed URL 만료로 추정하지 않고 일반 오류·수동 재시도·페이지 새로고침 안내를 표시한다. +- [x] media error만으로 목록/detail GET과 `play()`가 자동 재호출되지 않는 test를 작성한다. +- [x] signed URL이 log·storage·분석 event로 전달되지 않는 test를 작성한다. +- [x] mock mode는 실제 endpoint shape와 local preview audio asset으로 목록 → 상세 → 단일 재생 최종 UI를 확인한다. ### Task 4.3 발행 form·upload @@ -1583,24 +2203,24 @@ REFACTOR를 실행하고, 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, contract/form/upload/file-boundary test, `OQ-009` 결정과 검증 기록. - **범위 밖:** resumable upload, client codec 판정, 계약 없는 price 상한. -- [ ] 생성 multipart의 `contentFile`, `coverImage`, `request`가 필수이고 수정에는 optional `coverImage`와 `request`만 있으며 content file 교체 part·UI가 없음을 test한다. -- [ ] Audio cover가 Phase 1 `FileField`·`ImageCropDialog`의 JPEG/PNG·10MB, `1:1`, 최대 800px, no-upscale profile을 조합하는 test를 작성한다. -- [ ] MP3/AAC/M4A 허용, WAV 거부, extension/MIME 조합을 test한다. -- [ ] `.m4a + audio/x-m4a`만 호환 조합으로 허용하고 실제 container·codec 판정은 server 책임으로 둔다. -- [ ] `1,024,000,000 bytes` 허용, `1,024,000,001 bytes` 거부 경계 test를 작성한다. -- [ ] price는 0 이상 정수 “캔”으로 입력·format한다. -- [ ] 생성 form은 오디오 테마 목록을 불러와 visible label이 있는 선택 UI를 제공하고, 미선택 제출을 차단하며 선택한 `themeId`를 create payload에 포함하는 test를 작성한다. -- [ ] 즉시 공개 기본값은 날짜 입력을 비활성화·초기화하고 `releaseDate=null`, `timezone="Asia/Seoul"`을 보낸다. -- [ ] 예약 공개는 미래 Asia/Seoul 시각만 받고 `yyyy-MM-dd HH:mm` 문자열과 `timezone="Asia/Seoul"`을 보내며 UTC `Z`로 변환하지 않는 test를 작성한다. -- [ ] 수정 form은 계약에 없는 release schedule·theme·series·content file 변경 control을 만들지 않고 기존 값을 읽기 전용으로 표시한다. -- [ ] create request에 필수 `title`, `detail`, `tags`, `price`, 유효한 `themeId`가 있고 `status`, `isActive`, `seriesIds`가 없음을 contract test로 고정한다. -- [ ] create optional purchase/limited/adult/preview/point/comment/detail/language field의 enum·type·OpenAPI default와 serializer를 test한다. -- [ ] update request는 `title`, `detail`, `tags`, `price`, `isAdult`, `isActive`, `isPointAvailable`, `isCommentAvailable` 이외 field를 보내지 않고 soft delete에만 `isActive=false`를 보낸다. -- [ ] upload 진행률, AbortController 취소, 전체 재시도, 실패 후 form/file 상태 보존을 test한다. -- [ ] 415 server 오류를 field 안내로 보존하고 resumable upload는 만들지 않는다. -- [ ] create 성공의 `data.contentId`로 상세에 이동하고, update/soft delete의 `data=null` 성공은 기존 ID cache를 무효화한다. soft delete 후 목록 이동과 toast를 제공하며 active-only 제거는 외부 계약 제공 후 server mode에서 검증한다. -- [ ] 초기 Audio form을 실제 페이지에서 확인한 뒤 `title`, `detail`, `tags`의 최대 길이 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. -- [ ] mock handler가 multipart request contract를 검증하고 create/update/deactivate 후 같은 store의 list/detail을 갱신하는 E2E를 작성한다. +- [x] 생성 multipart의 `contentFile`, `coverImage`, `request`가 필수이고 수정에는 optional `coverImage`와 `request`만 있으며 content file 교체 part·UI가 없음을 test한다. +- [x] Audio cover가 Phase 1 `FileField`·`ImageCropDialog`의 JPEG/PNG·10MB, `1:1`, 최대 800px, no-upscale profile을 조합하는 test를 작성한다. +- [x] MP3/AAC/M4A 허용, WAV 거부, extension/MIME 조합을 test한다. +- [x] `.m4a + audio/x-m4a`만 호환 조합으로 허용하고 실제 container·codec 판정은 server 책임으로 둔다. +- [x] `1,024,000,000 bytes` 허용, `1,024,000,001 bytes` 거부 경계 test를 작성한다. +- [x] price는 0 이상 정수 “캔”으로 입력·format한다. +- [x] 생성 form은 오디오 테마 목록을 불러와 visible label이 있는 선택 UI를 제공하고, 미선택 제출을 차단하며 선택한 `themeId`를 create payload에 포함하는 test를 작성한다. +- [x] 즉시 공개 기본값은 날짜 입력을 비활성화·초기화하고 `releaseDate=null`, `timezone="Asia/Seoul"`을 보낸다. +- [x] 예약 공개는 미래 Asia/Seoul 시각만 받고 `yyyy-MM-dd HH:mm` 문자열과 `timezone="Asia/Seoul"`을 보내며 UTC `Z`로 변환하지 않는 test를 작성한다. +- [x] 수정 form은 계약에 없는 release schedule·theme·series·content file 변경 control을 만들지 않고 기존 값을 읽기 전용으로 표시한다. +- [x] create request에 필수 `title`, `detail`, `tags`, `price`, 유효한 `themeId`가 있고 `status`, `isActive`, `seriesIds`가 없음을 contract test로 고정한다. +- [x] create optional purchase/limited/adult/preview/point/comment/detail/language field의 enum·type·OpenAPI default와 serializer를 test한다. +- [x] update request는 `title`, `detail`, `tags`, `price`, `isAdult`, `isActive`, `isPointAvailable`, `isCommentAvailable` 이외 field를 보내지 않고 soft delete에만 `isActive=false`를 보낸다. +- [x] upload 진행률, AbortController 취소, 전체 재시도, 실패 후 form/file 상태 보존을 test한다. +- [x] 415 server 오류를 field 안내로 보존하고 resumable upload는 만들지 않는다. +- [x] create 성공의 `data.contentId`로 상세에 이동하고, update/soft delete의 `data=null` 성공은 기존 ID cache를 무효화한다. soft delete 후 목록 이동과 toast를 제공하며 active-only 제거는 외부 계약 제공 후 server mode에서 검증한다. +- [x] 초기 Audio form을 실제 페이지에서 확인한 뒤 `title`, `detail`, `tags`의 최대 길이 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. +- [x] mock handler가 multipart request contract를 검증하고 create/update/deactivate 후 같은 store의 list/detail을 갱신하는 E2E를 작성한다. ### Task 4.4 Audio 반응형·접근성 @@ -1610,10 +2230,10 @@ REFACTOR를 실행하고, 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, 320px·keyboard·200% zoom·axe E2E 기록. - **범위 밖:** 모바일 create/edit/deactivate/upload. -- [ ] mobile에서는 목록·상세·player만 제공하고 create/edit/deactivate/upload는 숨김이 아닌 route capability로 차단한다. -- [ ] desktop/tablet에서는 모든 form·upload action을 제공한다. -- [ ] 320px에서 player control, error text, 긴 title이 overflow하지 않는 E2E를 작성한다. -- [ ] keyboard-only player/form, 200% zoom, axe critical·serious 0건을 확인한다. +- [x] mobile에서는 목록·상세·player만 제공하고 create/edit/deactivate/upload는 숨김이 아닌 route capability로 차단한다. +- [x] desktop/tablet에서는 모든 form·upload action을 제공한다. +- [x] 320px에서 player control, error text, 긴 title이 overflow하지 않는 E2E를 작성한다. +- [x] keyboard-only player/form, 200% zoom, axe critical·serious 0건을 확인한다. ### Phase 4 Gate @@ -1638,6 +2258,186 @@ npm run build 확인하고, `search_word`, `timezone`, `contentFile`, `releaseDate`, theme field와 수정 금지 control이 실제 network 요청·화면에 일치하는지 본다. +### Task R4.1 — Audio create 설정과 raw price validation 완성 + +**Goal 실행 `P4-R1`:** Audio create optional 설정을 form에서 편집하고 음수·소수 가격을 변형하지 않고 거부한다. + +- **연결 리뷰:** [Phase 4 재리뷰](./reviews/review-phase-4-20260729.md) — `REV-P4-001`, `REV-P4-002` +- **시작 조건:** `AUDIO-019`, `AUDIO-033`, OpenAPI `AudioContentCreateRequest`. +- **Files:** Modify: `AudioContentForm.tsx`, `audio-content-form-helpers.ts`, `audio-content-schema.ts`, `audio-form.test.tsx`, `audio-contract.test.ts`, `tests/e2e/audio-content.spec.ts`. +- **Interfaces:** `purchaseOption/limited/isAdult/isGeneratePreview/isOnlyRental/isPointAvailable/isCommentAvailable/isFullDetailVisible/previewStartTime/previewEndTime/languageCode`; raw price는 `0..99999` integer만 serialize. +- **범위 밖:** OpenAPI 밖 상관관계 validation, update에서 계약상 수정 불가한 field. + +- [x] **RED:** optional control별 대표 payload와 `-1`, `1.5` request 0건 test를 작성한다. +- [x] **GREEN:** contract default로 초기화한 visible control과 raw/display price state를 최소 구현한다. +- [x] **REFACTOR:** request builder에 hard-coded field를 제거하고 create schema/form/E2E를 재실행한다. +- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-form.test.tsx src/features/audio-contents/tests/audio-contract.test.ts`; `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`. +- **기대 결과:** exit 0, optional property 입력 누락 0건, `-1/1.5/100000` request 0건, `0/99999` 허용. +- **수동 확인:** desktop/tablet에서 설정별 label·keyboard 입력과 가격 오류 focus를 확인한다. +- **완료 기록(2026-07-30):** create 설정 control·payload와 raw price invalid 상태를 구현하고 `REV-P4-001`, `REV-P4-002`를 수정 완료 처리했다. `npm run test:run -- src/features/audio-contents` 8 files / 35 tests passed, `npm run e2e:mock -- tests/e2e/audio-content.spec.ts` 21 passed / 3 skipped, `npm run typecheck`, `npm run lint`, `npm run build`, targeted `git diff --check` 통과. + +### Task R4.2 — upload 중복 제출과 401 만료 복구 + +**Goal 실행 `P4-R2`:** Audio upload pending 동안 POST를 한 번만 실행하고 XHR 401을 공통 session expiry 흐름으로 처리한다. + +- **연결 리뷰:** [Phase 4 재리뷰](./reviews/review-phase-4-20260729.md) — `REV-P4-003`, `REV-P4-004` +- **시작 조건:** `P1-R6`의 인증 만료 interface 또는 동일 callback contract 확정. +- **Files:** Modify: `AudioContentForm.tsx`, `upload-audio-content.ts`, `audio-form-upload.test.tsx`, `audio-upload.test.ts`, `AudioContentFormPage.tsx`, app API dependency 조합. +- **Interfaces:** upload adapter는 token reader와 `onAuthExpired`를 주입받고 protected 401 burst에서 session clear/login 1회; form은 pending ref와 disabled 상태를 가진다. +- **범위 밖:** resumable upload, refresh token, Axios migration. + +- [x] **RED:** unresolved upload에 submit 두 번 시 POST 2건인 실패와 XHR malformed/normal 401에서 session이 남는 실패 test를 추가한다. +- [x] **GREEN:** synchronous pending guard/disabled button과 공통 auth expiry dependency를 적용한다. +- [x] **REFACTOR:** abort/error/success 모든 종료 경로에서 pending/controller cleanup을 한곳으로 정리한다. +- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-form-upload.test.tsx src/features/audio-contents/tests/audio-upload.test.ts src/shared/api/__tests__/client-auth.test.ts`. +- **기대 결과:** exit 0, pending 중 upload request 1건, 401 clear/login 1회, cancel 후 재제출 가능. +- **수동 확인:** 느린 upload double-click과 만료 token upload의 login 복귀를 확인한다. +- **완료 기록(2026-07-30):** pending upload 중 submit 2회가 upload request 1건으로 고정되고 cancel 후 재제출 가능하도록 form guard를 추가했다. XHR upload adapter는 token reader/session clear/login callback을 주입받아 protected 401 burst에서 clear/login 1회만 실행한다. RED 확인: form은 `expected 2 to be 1`, upload는 `clearSession 0회`로 실패. GREEN/검증: focused 2 files / 19 tests passed, Audio 전체 8 files / 38 tests passed, `npm run typecheck`, `npm run lint`, `npm run build`, targeted `git diff --check` 통과. + +### Task R4.3 — Audio media accept와 cover format pair 정렬 + +**Goal 실행 `P4-R3`:** validator와 file picker가 같은 M4A MIME 집합을 사용하고 cover extension↔MIME 불일치를 거부한다. + +- **연결 리뷰:** [Phase 4 재리뷰](./reviews/review-phase-4-20260729.md) — `REV-P4-005`, `REV-P4-006` +- **시작 조건:** `AUDIO-014`, `AUDIO-024`, `FILE-002`. +- **Files:** Modify: `AudioContentForm.tsx`, `audio-content-form-helpers.ts`, `audio-cover-policy.ts`, `audio-contract.test.ts`, `file-media-policy.test.ts`. +- **Interfaces:** input accept는 `AUDIO_FILE_POLICY.allowedMimeTypes`; cover mapping은 jpg/jpeg→image/jpeg, png→image/png. +- **범위 밖:** container/codec sniffing, backend 오류 key. + +- [x] **RED:** accept에 `audio/mp4` 포함과 cover 양방향 mismatch 거부 test를 작성한다. +- [x] **GREEN:** shared audio policy에서 accept를 생성하고 cover pair map을 적용한다. +- [x] **REFACTOR:** 중복 MIME literal을 제거하고 Community audio 회귀를 실행한다. +- **실행 명령:** `npm run test:run -- src/shared/validation/file-media-policy.test.ts src/features/audio-contents src/features/community-posts`. +- **기대 결과:** exit 0, canonical/compat M4A 2종 허용, mismatch cover 0건. +- **수동 확인:** Chrome/Safari file chooser에서 `.m4a` 선택 가능 여부를 확인한다. +- **완료 기록(2026-07-30):** Audio file picker `accept`를 `AUDIO_FILE_POLICY.allowedMimeTypes`에서 생성하고, cover `.jpg/.jpeg→image/jpeg`, `.png→image/png` mismatch를 upload 전 `mime` 오류로 차단했다. RED 확인: accept는 `audio/mp4` 누락으로 실패, cover mismatch는 `{ ok: true }`로 실패. GREEN/검증: focused 3 files / 24 tests passed, Audio+Community 회귀 12 files / 76 tests passed, `npm run typecheck`, `npm run lint`, `npm run build`, targeted `git diff --check` 통과. + +### Task R4.4 — Audio 일반 update와 soft delete schema 분리 + +**Goal 실행 `P4-R4`:** Audio 일반 update에서 `isActive`를 금지하고 soft delete adapter만 `{ isActive:false }`를 전송하게 한다. + +- **연결 리뷰:** [Phase 4 현재 리뷰](./reviews/phase4-audio-content.md) — `REV-P4-007` +- **시작 조건:** `AUDIO-007`, OpenAPI `AudioContentUpdateRequest`, 기존 `deactivateAudioContent`. +- **완료 증거:** `isActive=true/null` 실패 test, schema/API 최소 수정, Audio focused unit·mock E2E·Phase Gate와 검증 기록. +- **Files:** Modify: `src/features/audio-contents/schemas/audio-content-schema.ts`, `src/features/audio-contents/api/audio-content-api.ts`, `src/features/audio-contents/tests/audio-contract.test.ts`, `src/shared/mocks/audio-content-mock-store.ts`. +- **Interfaces:** 일반 `AudioContentUpdateRequest`에는 `isActive`가 없고, 별도 deactivate schema는 `z.strictObject({isActive:z.literal(false)})`만 허용한다. +- **범위 밖:** OpenAPI JSON 축소, Audio 복원, backend partial update 의미 변경. + +- [x] **RED:** 일반 update schema/body가 `isActive=true/null/false`를 거부하고 deactivate body만 false를 허용하는 실패 test를 작성한다. +- [x] **GREEN:** 일반 update와 deactivate request schema/API helper/mock parser를 분리한다. +- [x] **REFACTOR:** 기존 update test의 금지된 `isActive:true` 성공 assertion을 제거하고 Community·Series와 같은 불변식 용어로 정리한다. +- **실행 명령:** `npm run test:run -- src/features/audio-contents src/shared/mocks`; `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 일반 Audio update의 `isActive` request 0건, deactivate false 이외 parse 0건. +- **수동 확인:** Audio 수정 저장과 비활성화 network multipart의 `request` part를 각각 확인한다. + +**Progress — 2026-07-30 P4-R4 완료** + +- **RED:** `npm run test:run -- src/features/audio-contents/tests/audio-contract.test.ts`에서 normal update의 `isActive:false`가 거부되지 않아 실패하는 것을 확인했다. +- **GREEN:** `audioContentUpdateRequestSchema`에서 `isActive`를 제거하고 `audioContentDeactivateRequestSchema`를 `z.strictObject({ isActive: z.literal(false) })`로 분리했다. `deactivateAudioContent`는 별도 deactivate body helper를 사용하고, mock parser/store도 normal update와 deactivate 경로를 분리했다. `npm run test:run -- src/features/audio-contents/tests/audio-contract.test.ts` 결과 1 file / 10 tests passed였다. +- **REFACTOR/회귀:** Audio MSW handler를 `src/shared/mocks/audio-content-handlers.ts`로 추출해 `handlers.ts`를 238 lines로 낮췄고, `npm run test:run -- src/features/audio-contents src/shared/mocks` 결과 14 files / 67 tests passed였다. `npm run e2e:mock -- tests/e2e/audio-content.spec.ts` 결과 21 passed / 3 skipped였고, skipped 3건은 기존 keyboard-only platform policy다. `npm run typecheck`, `npm run lint`, `npm run build` 모두 exit 0이었다. 변경 TS 파일 LSP diagnostics는 오류 0건이었고, `git diff --check`도 출력 없이 통과했다. + +### Task R4.5 — Audio URL query 계약 키 정렬 + +**Goal 실행 `P4-R5`:** Audio 목록의 브라우저 검색 상태를 API 계약과 같은 `search_word` query로 읽고 쓰며 직접 링크 복원을 보장한다. + +- **연결 리뷰:** [Phase 4 현재 리뷰](./reviews/phase4-audio-content.md) — `REV-P4-008` +- **시작 조건:** PRD §7.1과 OpenAPI Audio 목록의 `search_word`, `page`, `size` query 계약. +- **완료 증거:** `?search_word=루나` 직접 진입과 검색 변경 URL assertion의 실패 test, URL parser/serializer 최소 수정, Audio focused unit·mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/features/audio-contents/pages/AudioContentListPage.tsx`, `src/features/audio-contents/tests/audio-list.test.tsx`, `tests/e2e/audio-content.spec.ts`. +- **Interfaces:** 브라우저 URL과 API request 모두 `search_word`를 사용하고 2자 이상 검색, `page`, `size` 동작은 유지한다. +- **범위 밖:** legacy `search` query 호환 layer, 검색 최소 글자 수 변경, router dependency 도입. + +- [x] **RED:** `?search_word=루나&page=1&size=20` 직접 진입이 input·request를 복원하고 검색 변경 URL에 `search`가 없어야 한다는 실패 test를 작성한다. +- [x] **GREEN:** URL read/write key를 `search_word`로 교체한다. +- [x] **REFACTOR:** Character와 query helper를 성급히 공유하지 않고 Audio page 내부 변경으로 끝낸다. +- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-list.test.tsx`; `npm run e2e:mock -- tests/e2e/audio-content.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, Audio URL의 검색 key는 `search_word` 하나이고 직접 링크·검색·pagination 왕복이 일치. +- **수동 확인:** 검색 결과 URL을 새로고침·새 탭 진입해 검색어와 목록이 유지되는지 확인한다. + +**Progress — 2026-07-31 P4-R5 완료** + +- **RED:** `npm run test:run -- src/features/audio-contents/tests/audio-list.test.tsx`에서 `?search_word=루나&page=1&size=20` 직접 진입 후 검색 input이 빈 값으로 남아 실패하는 것을 확인했다. +- **GREEN:** `AudioContentListPage`의 URL read/write key만 `search_word`로 교체했다. `npm run test:run -- src/features/audio-contents/tests/audio-list.test.tsx` 결과 1 file / 2 tests passed였다. +- **REFACTOR/회귀:** Character와 공통 query helper를 공유하지 않고 Audio page 내부 변경으로 끝냈다. `npm run test:run -- src/features/audio-contents` 첫 실행은 기존 `audio-list.test.tsx` 병렬 loading timeout 양상으로 실패했으나, 같은 명령 재실행 결과 8 files / 46 tests passed였다. 사용자 지시에 따라 mock E2E는 전체 Task 완료 전까지 보류했다. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0 또는 no output이었다. 변경 TS 파일 LSP diagnostics와 Audio directory diagnostics는 오류 0건이었다. + +### Task R4.6 — Audio cover crop 준비 경계 보강 + +**Goal 실행 `P4-R6`:** Audio 생성·수정에서 cover crop source 준비와 연속 선택을 추적하고 crop 확정 전 저장을 차단한다. + +- **연결 리뷰:** [Phase 4 현재 리뷰](./reviews/phase4-audio-content.md) — `REV-P4-009` +- **시작 조건:** `FILE-006~009`, PRD §10.5 저장 중 feedback와 Audio cover 생성 필수/수정 optional 계약. +- **완료 증거:** 준비 중 edit 저장·stale source·준비 실패를 재현하는 실패 test, staged selection 최소 수정, Audio focused unit·mock E2E·file Gate 통과. +- **Files:** Modify: `src/features/audio-contents/components/AudioContentForm.tsx`, 관련 Audio form/file tests와 `tests/e2e/audio-content.spec.ts`. +- **Interfaces:** `coverImage`는 crop 적용 결과만 보유하고 준비/crop 중 submit을 비활성화·guard한다. 마지막 선택만 유효하며 취소는 기존 서버 cover를 유지하고 준비 실패를 inline 표시한다. +- **범위 밖:** 대용량 audio upload 상태 변경, 공통 crop 계산 변경, 범용 upload state library. + +- [x] **RED:** edit에서 crop source 준비 중 저장, 느린 첫 선택의 늦은 dialog, 준비 reject 후 무반응을 실패 test로 고정한다. +- [x] **GREEN:** cover selection token·준비 상태·오류 처리와 submit guard를 추가한다. +- [x] **REFACTOR:** 기존 `UploadState`와 cover 준비 상태의 역할을 분리하고 필요한 표시 문구만 Audio form에 둔다. +- **실행 명령:** `npm run test:run -- src/features/audio-contents`; `npm run e2e:mock -- tests/e2e/audio-content.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 준비/crop 미확정 cover의 저장 0건, stale dialog 0건, 정상 crop result와 기존 cover 유지 경계 일치. +- **수동 확인:** 기존 cover가 있는 수정 화면에서 느린 파일 두 개를 연속 선택하고 취소·적용·즉시 저장해 마지막 의도가 보존되는지 확인한다. + +**Progress — 2026-07-31 P4-R6 완료** + +- **RED:** `npm run test:run -- src/features/audio-contents/tests/audio-form-upload.test.tsx src/features/audio-contents/tests/audio-form-update.test.tsx`에서 준비 중 저장 guard, stale selection, reject 오류 경계가 실패하는 것을 확인했다. +- **GREEN:** `AudioContentForm`에 cover selection token, `isCoverPreparing`, crop dialog pending guard, reject inline 오류를 추가했다. `coverImage`는 crop 적용 결과만 commit하고, 취소는 수정 화면의 기존 서버 cover 유지 계약을 보존한다. focused 재실행 결과 2 files / 18 tests passed였다. +- **REFACTOR/회귀:** `FieldErrors` 타입을 `Partial>`로 줄여 `AudioContentForm.tsx` 순수 LOC를 247로 낮췄다. `npm run test:run -- src/features/audio-contents` 결과 8 files / 53 tests passed였다. 사용자 지시에 따라 mock E2E는 전체 Task 완료 전까지 보류했다. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0 또는 no output이었다. Audio directory LSP diagnostics는 16 TSX files / 오류 0건이었다. + +### Task R4.7 — Audio cover 원본 정책 사전 검증 복구 + +**Goal 실행 `P4-R7`:** Audio 생성·수정에서 새 cover 원본의 확장자·MIME pair와 exact byte 상한을 crop source 생성 전에 검증한다. + +- **연결 리뷰:** [Phase 4 현재 리뷰](./reviews/phase4-audio-content.md) — `REV-P4-010` +- **시작 조건:** `FILE-001~003`, `FILE-006~007`, PRD `Image crop UI 흐름` 1~3단계와 기존 `validateAudioCoverFile` 정책. +- **완료 증거:** `10,485,761 bytes`, GIF, 확장자/MIME 불일치 cover가 `createCropSource`를 호출하거나 crop Dialog를 열지 않는 실패 test, 정상 JPEG/PNG crop 회귀 test, Phase 4 focused·mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/features/audio-contents/components/AudioContentForm.tsx`, 관련 Audio form tests와 필요 시 `tests/e2e/audio-content.spec.ts`. +- **Interfaces:** 새 cover는 `validateAudioCoverFile` 성공 뒤에만 `createCropSource`로 전달한다. 실패 시 기존 `coverErrorMessage` 계열 inline 오류를 표시하고 create/edit 모두 기존 committed cover 상태를 보존한다. +- **범위 밖:** crop 계산·출력 크기 변경, audio 파일 upload 정책 변경, 새 공통 validation abstraction, server validation 변경. +- **Phase 4 Gate 연결:** 기존 완료 기록은 보존하되 `P4-R7` focused test와 mock Chromium Audio 시나리오, `typecheck`·`lint`·`build` 통과 전에는 이번 리뷰 회귀를 종결하지 않는다. +- **검증 기록:** RED/GREEN 명령·test 수·exit code와 수동 확인 결과를 이 Task 하단과 Phase 4 리뷰 §9 이후에 날짜순으로 누적한다. + +- [x] **RED:** create/edit form에서 oversized PNG와 `.png`/`image/jpeg` 불일치 파일을 선택해 `createCropSource` 0회, crop Dialog 0개, 정책 오류 표시를 기대하는 실패 test를 작성한다. +- [x] **GREEN:** `selectCoverImage`의 crop 준비 전에 기존 Audio cover validator를 호출하고 실패 상태만 최소 처리한다. +- [x] **REFACTOR:** 정책 값과 메시지를 복제하지 않고 기존 validator·오류 변환을 재사용하며 Series/Character flow를 새 abstraction으로 묶지 않는다. +- **실행 명령:** `npm run test:run -- src/features/audio-contents`; `npm run e2e:mock -- tests/e2e/audio-content.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, invalid 원본의 crop source 호출·Dialog 노출·upload request 0건, valid JPEG/PNG의 기존 crop·create/edit 흐름 유지. +- **수동 확인:** 생성·수정 화면에서 oversized/mismatched/GIF/valid PNG를 차례로 선택해 inline 오류, 기존 cover 유지, 정상 crop 적용을 확인한다. + +**Progress — 2026-07-31 P4-R7 완료** + +- **RED:** `npm run test:run -- src/features/audio-contents/tests/audio-form-upload.test.tsx src/features/audio-contents/tests/audio-form-update.test.tsx`는 2 files 중 2 tests failed / 18 passed였다. oversized PNG와 `.png`/`image/jpeg` replacement cover 모두 정책 오류가 없고 crop Dialog로 진입해 의도한 실패를 확인했다. +- **GREEN:** `AudioContentForm.selectCoverImage`가 `coverErrorMessage`를 crop source 준비 전에 재사용하게 수정했다. 같은 focused command는 2 files / 20 tests passed였고, invalid 원본에서 `createCropSource` 0회·crop Dialog 0개·inline 오류 표시를 확인했다. +- **REFACTOR/회귀:** 새 공통 abstraction 없이 기존 validator·오류 변환만 재사용했다. `AudioContentForm.tsx` 순수 LOC는 249로 250 이하이며, `npm run typecheck`, `npm run lint`, `npm run build`는 exit 0이었다. `src/features/audio-contents` LSP diagnostics는 16 TSX files / 오류 0건이었다. 개발 중 E2E는 사용자 지시에 따라 보류했다. +- **참고:** `npm run test:run -- src/features/audio-contents`와 단독 `npm run test:run -- src/features/audio-contents/tests/audio-list.test.tsx`는 `Audio list restores and serializes search_word only...`가 `오디오 콘텐츠` heading을 찾지 못하고 loading 상태에서 실패했다. 실패 경로는 Audio 목록 조회 test이며 이번 `P4-R7`의 cover 선택/form 변경 경로와 분리된다. + +### Task R4.8 — Audio upload 종료 처리의 단일 정산 보장 + +**Goal 실행 `P4-R8`:** Audio XHR upload의 성공·HTTP 실패·network 실패·취소가 경합해도 요청별 종료 처리를 한 번만 수행하고, 같은 보호 요청 burst의 401 만료 처리를 한 번만 유지한다. + +- **연결 리뷰:** [Phase 4 현재 리뷰](./reviews/phase4-audio-content.md) — `REV-P4-011` +- **시작 조건:** `P1-R12` 완료, PRD `AUDIO-017`, `AUTH-005`, §13의 동시 401 단일 처리, 기존 완료 `P4-R2`의 upload 401 burst contract. +- **완료 증거:** signal이 연결된 upload의 network error 뒤 abort가 발화한 다음 첫 session과 새 로그인 session의 protected upload가 각각 401을 반환하는 실패 test, 요청별 settle/cleanup 최소 수정, Audio focused·Phase 4 Gate와 검증 기록. +- **Files:** Modify: `src/features/audio-contents/api/upload-audio-content.ts`, `src/features/audio-contents/tests/audio-upload.test.ts`; Create: `src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts`. +- **Interfaces:** `uploadAudioContent`는 각 XHR의 resolve/reject, 보호 upload count 감소와 `AbortSignal` listener 해제를 terminal event 종류와 무관하게 정확히 한 번 수행한다. 같은 protected upload burst의 `clearSession`/`onAuthExpired`는 최대 한 번 호출한다. +- **범위 밖:** fetch 기반 upload 전환, resumable upload, 새 상태 관리 library, 공통 API client 구조 변경, backend idempotency 변경. +- **Phase 4 Gate 연결:** focused RED/GREEN 뒤 Audio 전체 unit, mock Chromium Audio 시나리오, `typecheck`·`lint`·build·diff 검증이 통과하기 전에는 완료 처리하지 않는다. +- **검증 기록:** RED의 실제 callback 횟수, GREEN/REFACTOR test 수와 Gate 결과를 이 Task 하단과 Phase 4 리뷰에 날짜순으로 누적한다. + +- [x] **RED:** `audio-upload.test.ts`에 network error로 먼저 reject된 signal-bearing upload를 뒤늦게 abort한 다음 첫 session과 새 로그인 session의 401을 차례로 발생시켜 `clearSession`/`onAuthExpired`가 session별 1회, 총 2회여야 한다는 test를 추가하고 현재 총 1회만 호출되는 실패를 확인했다. +- [x] **GREEN:** 요청별 settled guard와 공통 terminal cleanup을 최소 적용해 count 감소·listener 해제·Promise settle을 한 번만 수행했다. +- [x] **REFACTOR:** `onload`, `onerror`, abort가 같은 정산 경계를 사용하도록 중복만 정리하고 기존 success/progress/415/malformed response/cancel retry contract를 유지했다. +- **실행 명령:** `npm run test:run -- src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts`; `npm run test:run -- src/features/audio-contents`; `npm run e2e:mock -- tests/e2e/audio-content.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, 신규 lifecycle test에서 두 session의 401 callback 각각 1회, 기존 upload progress·cancel·retry·오류 mapping 회귀 0건. +- **수동 확인:** 느린 network에서 upload 오류와 취소를 인접하게 발생시킨 뒤 재시도하고, 이후 401 응답에서 로그인 이동·만료 안내가 중복되지 않는지 확인한다. + +**P4-R8 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/features/audio-contents/tests/audio-upload.test.ts` — 1 failed / 11 passed. 신규 lifecycle test에서 network error 후 late abort가 다음 session 401을 삼켜 `clearSession` 기대 2회 대비 실제 1회였다. +- GREEN: `uploadAudioContent`에 요청별 `isSettled` guard와 공통 terminal cleanup을 추가해 resolve/reject/count 감소/AbortSignal listener 제거가 terminal 종류와 무관하게 한 번만 실행되도록 했다. 같은 focused 명령은 1 file / 12 tests passed였다. +- REFACTOR/회귀: 기존 success/progress/415/malformed response/cancel retry contract를 유지했다. reviewer blocker로 fetch-first 순서 회귀를 추가한 뒤 `auth` 주입 시 storage fallback을 쓰지 않도록 보완했다. `npm run test:run -- src/shared/api/__tests__/client-auth.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts` — 3 files / 22 tests passed. `npm run test:run -- src/features/audio-contents` — 9 files / 58 tests passed. `npm run test:run -- src/app src/features/auth src/shared/api src/features/audio-contents/tests/audio-upload.test.ts` — 13 files / 91 tests passed. `npm run test:run` — 79 files / 397 tests passed. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, targeted `git diff --check` — 모두 exit 0. build는 기존 503.04kB chunk warning만 표시했다. LSP diagnostics는 변경 파일·디렉터리 기준 오류 0건이었다. +- E2E/수동 확인: 사용자 지시에 따라 개발 중 E2E는 반복 실행하지 않고 모든 Task 구현 뒤 필요 시 수행한다. + --- ## Phase 5. Series vertical slice @@ -1647,12 +2447,12 @@ field와 수정 금지 control이 실제 network 요청·화면에 일치하는 **Phase Goal `P5`:** Task 5.1 → 5.5와 Phase 5 Gate로 Series 조회, 계약 제공 후 CRUD, 연결·전체 순서 slice를 완성한다. - **시작 조건:** `P4-T2`의 Audio 조회 API 완료. -- **완료 조건:** `P5-T1`~`P5-T5`, `P5-GATE` 완료. genre·edit DTO 외부 의존은 제공 또는 명시적 후속/제외 상태로 종결. +- **완료 조건:** `P5-T1`~`P5-T5`, `P5-GATE` 완료. genre lookup 외부 의존은 제공 또는 명시적 후속/제외 상태로 종결하고, Series 수정은 상세 응답의 수정용 원본값으로 초기화한다. - **실행 순서:** 계약 확인 → 목록/상세 → CRUD → 연결/순서 → 반응형·접근성. **요구사항:** `SERIES-001~018`, `FILE-001~002`, `FILE-005`, `FILE-007~009`, `FILE-012`, `FILE-015`, PRD `9`의 Series 범위. -**외부 의존:** `EXT-002` genre lookup(`SERIES-007`, `SERIES-011`)은 유효한 `genreId`가 필요한 생성 flow를 차단한다. 상세의 표시 문자열을 update enum/ID로 안전하게 복원할 `EXT-003` edit DTO(`SERIES-017`), `EXT-007` active-only 반환 보장과 `EXT-011` 도메인별 오류 message key도 외부 의존이다. 연결 후보·page 기반 전체 로딩 endpoint는 OpenAPI에 제공됐다. +**외부 의존:** `EXT-002` genre lookup(`SERIES-007`, `SERIES-011`)은 legacy 후보 `GET /creator-admin/audio-content/series/genre`로 구현 계획에 포함한다. Series 수정 form은 별도 edit DTO 없이 상세 응답이 list item과 같은 `genreId`, enum `publishedDaysOfWeek`, enum `state`를 제공하면 초기화할 수 있다. `EXT-007` active-only 반환 보장은 외부 의존이다. OpenAPI 밖의 도메인별 오류 message key는 분기하지 않는다. 연결 후보·page 기반 전체 로딩 endpoint는 OpenAPI에 제공됐다. **주요 Files:** @@ -1670,9 +2470,9 @@ field와 수정 금지 control이 실제 network 요청·화면에 일치하는 | Goal | Files | Interfaces | TDD·검증 기준 | |---|---|---|---| -| `P5-T1` | Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`; Read: `docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json`; Test: 없음 | Consumes: `Series*` schema·9 operation. Produces: CRUD·contents/search·order contract map, 외부 의존과 screen inventory | **TDD 예외:** 외부 계약 조사 Task다. OpenAPI JSON parse와 `rg -n 'SERIES-01[4-8]'` 문서 추적 검사를 실행한다. 기대 `exit 0`. 수동 확인: genre/edit DTO 차단과 제공된 link/order 범위가 분리된다. | +| `P5-T1` | Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`; Read: `docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json`; Test: 없음 | Consumes: `Series*` schema·9 operation. Produces: CRUD·contents/search·order contract map, 외부 의존과 screen inventory | **TDD 예외:** 외부 계약 조사 Task다. OpenAPI JSON parse와 `rg -n 'SERIES-01[4-8]'` 문서 추적 검사를 실행한다. 기대 `exit 0`. 수동 확인: genre lookup 차단, 상세 응답의 수정용 원본값, 제공된 link/order 범위가 분리된다. | | `P5-T2` | Create: `src/features/series/api/series-api.ts`, `src/features/series/model/types.ts`, `src/features/series/pages/{SeriesListPage,SeriesDetailPage}.tsx`, `src/features/series/components/{SeriesList,SeriesListItem,SeriesSummary}.tsx`, `src/features/series/tests/series-contract.test.ts` | Consumes: `SeriesListResponse`, `SeriesDetailResponse`. Produces: `getSeries({characterId,page,size})`, `getSeriesDetail({characterId,seriesId})`와 read-only 조회 UI | **TDD 적용:** `npm run test:run -- src/features/series/tests/series-contract.test.ts`; 기대 `exit 0`. 수동 확인: 목록 enum과 상세 표시 문자열, 직접 링크 조회. | -| `P5-T3` | genre/edit 계약 제공 후 Create: `src/features/series/schemas/series-schema.ts`, `src/features/series/validation/series-image-policy.ts`, `src/features/series/pages/SeriesFormPage.tsx`, `src/features/series/components/{SeriesForm,PublishedDaysField,GenreCombobox}.tsx`, `src/features/series/tests/series-form.test.tsx`; Modify: `src/features/series/api/series-api.ts`, `src/features/series/tests/series-contract.test.ts` | Consumes: create/update multipart와 genre/edit DTO. Produces: `createSeries`, `updateSeries`, `deactivateSeries` | **TDD 적용:** genre lookup·edit DTO 제공 후 `npm run test:run -- src/features/series/tests/series-contract.test.ts src/features/series/tests/series-form.test.tsx`; 기대 `exit 0`. 수동 확인: keyword/image/state와 직접 edit 초기화. | +| `P5-T3` | Create: `src/features/series/schemas/series-schema.ts`, `src/features/series/validation/series-image-policy.ts`, `src/features/series/pages/SeriesFormPage.tsx`, `src/features/series/components/{SeriesForm,PublishedDaysField,GenreCombobox}.tsx`, `src/features/series/tests/series-form.test.tsx`; Modify: `src/features/series/api/series-api.ts`, `src/features/series/tests/series-contract.test.ts` | Consumes: create/update multipart, legacy genre lookup `GET /creator-admin/audio-content/series/genre`, 상세 응답의 `genreId`·enum 요일·enum state. Produces: `createSeries`, `updateSeries`, `deactivateSeries`, `listSeriesGenres` | **TDD 적용:** `npm run test:run -- src/features/series/tests/series-contract.test.ts src/features/series/tests/series-form.test.tsx`; 기대 `exit 0`. 수동 확인: genre 선택, keyword/image/state와 직접 edit 초기화. | | `P5-T4` | Create: `src/features/series/pages/SeriesOrderPage.tsx`, `src/features/series/components/{SeriesContents,SeriesOrderList}.tsx`, `src/features/series/tests/{series-contents,series-order}.test.tsx`; Modify: `src/features/series/api/series-api.ts`, `src/features/series/pages/SeriesDetailPage.tsx` | Consumes: `SeriesContentListResponse`, `SeriesContentSearchItem`, `SeriesContentAddRequest`, `SeriesOrderUpdateRequest`. Produces: `searchUnlinkedContents`, `addSeriesContents`, `removeSeriesContent`, `updateSeriesOrder` | **TDD 적용:** `npm run test:run -- src/features/series/tests/series-contents.test.tsx src/features/series/tests/series-order.test.tsx`; 기대 `exit 0`. 수동 확인: contentIdList·ids request와 keyboard reorder. | | `P5-T5` | Modify: `src/features/series/pages/{SeriesListPage,SeriesDetailPage,SeriesFormPage,SeriesOrderPage}.tsx`, `src/features/series/components/{SeriesList,SeriesListItem,SeriesSummary,SeriesForm,PublishedDaysField,GenreCombobox,SeriesContents,SeriesOrderList}.tsx`; Test: `tests/e2e/series.spec.ts` | Consumes: P5-T2~T4 활성 UI. Produces: viewport·keyboard capability evidence | **TDD 적용:** `npm run e2e:mock -- tests/e2e/series.spec.ts`; 기대 활성 계약 범위 통과. 수동 확인: 320px 조회, 200% zoom, keyboard, axe. | @@ -1687,14 +2487,28 @@ REFACTOR를 실행한다. 외부 계약 때문에 RED test의 기대 동작 자 - **시작 조건:** `P4-T2` 완료, PRD `SERIES-001~018`, `MOCK-001~009`와 OpenAPI Series 9개 operation/schema 확인. - **완료 증거:** 체크박스 전체, 제공 계약 또는 제외 결정의 세 문서 일치, 상태/action inventory. -- **범위 밖:** 계약 없는 genre lookup·edit DTO·active-only·도메인별 오류 동작의 추정 구현. +- **범위 밖:** active-only·도메인별 오류 동작의 추정 구현. genre lookup은 legacy API를 사용하고, 별도 edit DTO는 만들지 않는다. -- [ ] genre lookup endpoint·DTO·search/page 계약을 기록한다. -- [ ] 연결 후보 `GET .../contents/search?search_word=...`, 연결 `{contentIdList}`, 해제 body 없는 DELETE, 전체 순서 `{ids}` 계약을 기록한다. -- [ ] 목록 `data.totalCount/items`와 `page/size`로 전체 Series page를 누락 없이 읽는 방식을 기록한다. 도메인별 누락 ID·동시 변경 오류 key는 제공되지 않았음을 외부 의존으로 남긴다. -- [ ] 계약이 없는 genre lookup과 active-only 보장은 추측 구현하지 않고 영향 범위를 문서에서 먼저 확인한다. -- [ ] 목록·상세·form·연결·순서 화면의 상태/action inventory를 작성하고 Page는 route/query/policy 조합, feature component는 Series 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. -- [ ] 제공 계약 범위만 Series 목록·상세·연결·순서 browser fixture로 만들고 genre·edit DTO·active-only·충돌 계약 미제공 부분은 mock에서도 추정하지 않는다. +- [x] genre lookup endpoint·DTO·search/page 계약을 기록한다. +- [x] 연결 후보 `GET .../contents/search?search_word=...`, 연결 `{contentIdList}`, 해제 body 없는 DELETE, 전체 순서 `{ids}` 계약을 기록한다. +- [x] 목록 `data.totalCount/items`와 `page/size`로 전체 Series page를 누락 없이 읽는 방식을 기록한다. 도메인별 누락 ID·동시 변경 오류 key는 제공되지 않았음을 외부 의존으로 남긴다. +- [x] legacy genre lookup API와 active-only 보장의 영향 범위를 문서에서 먼저 확인한다. active-only는 추측 구현하지 않는다. +- [x] 목록·상세·form·연결·순서 화면의 상태/action inventory를 작성하고 Page는 route/query/policy 조합, feature component는 Series 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. +- [x] 제공 계약 범위만 Series 목록·상세·연결·순서 browser fixture로 만들고 genre lookup·active-only·충돌 계약 미제공 부분은 mock에서도 추정하지 않는다. Series 수정 초기화는 상세 응답의 수정용 원본값으로 처리한다. + +**P5-T1 계약 확인 결과:** + +- OpenAPI Series operation은 9개다. `listAiCharacterSeries`, `createAiCharacterSeries`, `reorderAiCharacterSeries`, `getAiCharacterSeries`, `updateAiCharacterSeries`, `listAiCharacterSeriesContents`, `addAiCharacterSeriesContents`, `searchAiCharacterContentsNotInSeries`, `removeAiCharacterSeriesContent`를 기준으로 한다. +- Series 생성·수정 contract는 제공됐고, 장르 lookup은 OpenAPI에는 없지만 legacy 후보 `GET /creator-admin/audio-content/series/genre`를 사용한다. P5-T3에서 response DTO를 contract test로 고정하고 장르 선택 network integration과 생성 완료 판정을 진행한다. +- 별도 `EXT-003` edit DTO 또는 표시 문자열 mapping 계약은 필요 없다. 상세 응답이 list item과 같은 `genreId`, enum `publishedDaysOfWeek`, enum `state`를 함께 제공하면 직접 링크 수정 form을 초기화할 수 있다. 상세의 표시용 문자열은 update enum/ID로 역변환하지 않는다. +- 연결 후보는 `GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/search?search_word=...`이고 body가 없다. 응답은 `data[]`의 `contentId`, `title`, `coverImage`만 사용한다. +- 연결은 `POST /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents`에 JSON `{ "contentIdList": [...] }`를 보낸다. 해제는 `DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}`이고 request body가 없다. +- 순서 변경은 `PUT /api/v2/admin/ai-characters/{characterId}/series/orders`에 JSON `{ "ids": [...] }`를 보낸다. `{contentIds}`, `{seriesIds}`는 사용하지 않는다. +- Series 목록과 연결 콘텐츠 목록은 모두 `page`, `size` query와 `data.totalCount`, `data.items[]`를 사용한다. 순서 변경 화면은 첫 page의 `totalCount`를 기준으로 `ceil(totalCount / size)`까지 같은 `size`로 순차 또는 병렬 조회해 모든 page를 모은 뒤, 현재 수집한 `items.length`가 `totalCount`와 일치할 때만 전체 ID 순서를 저장한다. 일부 page 실패나 개수 불일치는 저장을 막고 재조회 안내로 처리한다. +- `EXT-007` active-only 반환 보장, 순서 저장의 missing ID와 concurrent conflict status/message key는 OpenAPI에 없다. mock과 UI는 공통 `ApiErrorResponse`의 `success=false`, `message`, `data=null`, `errorProperty`와 공통 status만 사용하고 도메인 분기를 추정하지 않는다. +- 화면 상태와 action inventory: 목록은 loading, empty, error, background refresh, read-only, page 변경, 상세 이동을 가진다. 상세는 loading, error, read-only, 연결 콘텐츠 조회, desktop/tablet에서 생성·수정·비활성화·연결·해제·순서 이동 entry를 가진다. form은 계약 대기 상태, field validation, 저장 중, 성공, 오류, 이탈 확인, desktop/tablet 전용 action을 가진다. 연결 화면은 후보 검색, 후보 없음, 이미 연결됨, 연결 중, 해제 확인, 오류와 재시도를 가진다. 순서 화면은 전체 page 로딩, 부분 실패, drag reorder, keyboard 위/아래 이동, 저장 중, 충돌 또는 누락 ID 오류, 재조회 action을 가진다. +- Component map: `SeriesListPage`와 `SeriesDetailPage`는 route param, query, permission, data loading만 조합한다. `SeriesList`, `SeriesListItem`, `SeriesSummary`는 목록 enum과 상세 표시 문자열을 그대로 표시한다. `SeriesForm`, `PublishedDaysField`, `GenreCombobox`는 legacy genre lookup과 상세 수정용 원본값으로 network payload를 활성화한다. `SeriesContents`는 search/add/remove 규칙을, `SeriesOrderList`는 full-page read와 reorder 규칙을 소유한다. 공통 상태, pagination, confirm dialog, file field, image crop, unsaved guard는 Phase 1 shared contract를 사용한다. +- Mock scenario는 제공된 목록, 상세, legacy genre lookup, 연결 콘텐츠 목록, 연결 후보 검색, 연결, 해제, 전체 순서 happy path와 공통 오류만 만든다. active-only server 결과, missing ID와 concurrent conflict 도메인 오류는 fixture에 넣지 않는다. ### Task 5.2 Series 목록·상세 조회 @@ -1704,30 +2518,30 @@ REFACTOR를 실행한다. 외부 계약 때문에 RED test의 기대 동작 자 - **완료 증거:** 체크박스 전체, list/detail contract·route·state test와 검증 기록. - **범위 밖:** Series 생성·수정·비활성화와 Audio 연결·해제·전체 순서 저장. -- [ ] list가 `page`, `size`만 보내고 `data.totalCount/items`를 소비하며 활성 query·client 활성 filter 없이 loading·empty·error·retry를 제공하는 test를 작성한다. -- [ ] Series detail route의 직접 진입과 새로고침에서 같은 resource를 복원하는 test를 작성한다. -- [ ] 목록의 enum field와 상세의 표시용 `publishedDaysOfWeek`, `genre`, `keywords`, 한국어 `state` 문자열을 각 응답 DTO 그대로 표시하고 서로 역변환하지 않는 test를 작성한다. -- [ ] 제공된 목록·상세 계약만 mock handler로 만들고 loading·empty·error·retry와 직접 링크 조회를 확인한다. +- [x] list가 `page`, `size`만 보내고 `data.totalCount/items`를 소비하며 활성 query·client 활성 filter 없이 loading·empty·error·retry를 제공하는 test를 작성한다. +- [x] Series detail route의 직접 진입과 새로고침에서 같은 resource를 복원하는 test를 작성한다. +- [x] 목록의 enum field와 상세의 표시용 `publishedDaysOfWeek`, `genre`, `keywords`, 한국어 `state` 문자열을 각 응답 DTO 그대로 표시하고 서로 역변환하지 않는 test를 작성한다. +- [x] 제공된 목록·상세 계약만 mock handler로 만들고 loading·empty·error·retry와 직접 링크 조회를 확인한다. ### Task 5.3 Series 생성·수정·비활성화 **Goal 실행 `P5-T3`:** 계약 제공 후 Series form, enum·요일·image와 soft delete 규칙을 완성한다. -- **시작 조건:** `P5-T2` 완료. genre lookup과 직접 링크 수정 form의 `genreId`·요일 enum·state enum을 제공하는 edit DTO/mapping 계약이 제공됨. +- **시작 조건:** `P5-T2` 완료. genre lookup은 legacy `GET /creator-admin/audio-content/series/genre`를 사용하고, 직접 링크 수정 form 초기화를 위한 상세 응답의 `genreId`·요일 enum·state enum이 제공됨. - **완료 증거:** 체크박스 전체, contract/form/image/deactivate test와 검증 기록. - **범위 밖:** Audio 연결·해제와 전체 순서 저장. -- [ ] enum은 `PROCEEDING | SUSPEND | COMPLETE`, 요일은 `SUN~SAT | RANDOM`만 허용한다. -- [ ] 생성 multipart에 필수 `image`와 `request`가 있고 request의 필수 `title`, `introduction`, `publishedDaysOfWeek`, `keyword`를 보내며 `state`, `isActive`, `keywords`가 없음을 test한다. -- [ ] 수정에서 state 미선택은 key 생략, 선택은 유효 enum만 전송하고 `null`은 보내지 않는다. -- [ ] `RANDOM`은 단독, 실제 요일은 하나 이상이어야 하는 schema·UI test를 작성한다. -- [ ] genre 이름 검색 후 유효한 `genreId`만 전송하는 Combobox를 test한다. OpenAPI binding 기본값 `0`은 선택값으로 허용하지 않는다. -- [ ] Series image JPEG/PNG·10MB, `210:297`, `height=round(width×297÷210)`, 최대 1000×1414, 1px 오차, no-upscale을 test한다. -- [ ] edit DTO의 `genreId`·요일 enum·state enum으로 직접 링크 form을 초기화하고 상세의 표시용 문자열을 update enum/ID로 역변환하지 않는 test를 작성한다. -- [ ] create-only `keyword`를 수정 화면에서 읽기 전용으로 표시하고 update payload에 보내지 않는 test를 작성한다. -- [ ] create/update/soft delete의 `data=null`을 처리하고 일반 update와 soft delete의 `isActive` 규칙, 목록 재조회·이동·toast를 test한다. -- [ ] mock store로 Series CRUD 후 list enum과 detail 표시 문자열의 서로 다른 DTO가 일관되게 갱신되는 UI를 확인한다. -- [ ] 초기 Series form을 실제 페이지에서 확인한 뒤 `title`, `introduction`, `keyword`, `writer`, `studio`, `publishedDaysOfWeek`의 최대 길이·개수 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. +- [x] enum은 `PROCEEDING | SUSPEND | COMPLETE`, 요일은 `SUN~SAT | RANDOM`만 허용한다. +- [x] 생성 multipart에 필수 `image`와 `request`가 있고 request의 필수 `title`, `introduction`, `publishedDaysOfWeek`, `keyword`를 보내며 `state`, `isActive`, `keywords`가 없음을 test한다. +- [x] 수정에서 state 미선택은 key 생략, 선택은 유효 enum만 전송하고 `null`은 보내지 않는다. +- [x] `RANDOM`은 단독, 실제 요일은 하나 이상이어야 하는 schema·UI test를 작성한다. +- [x] OpenAPI 2.3.0 `listAiCharacterSeriesGenres`로 장르 option을 조회하고 유효한 `genreId`만 전송하는 Combobox를 test한다. OpenAPI binding 기본값 `0`은 선택값으로 허용하지 않는다. +- [x] Series image JPEG/PNG·10MB, `210:297`, `height=round(width×297÷210)`, 최대 1000×1414, 1px 오차, no-upscale을 test한다. +- [x] 상세 응답의 `genreId`·요일 enum·state enum으로 직접 링크 form을 초기화하고 상세의 표시용 문자열을 update enum/ID로 역변환하지 않는 test를 작성한다. +- [x] create-only `keyword`를 수정 화면에서 읽기 전용으로 표시하고 update payload에 보내지 않는 test를 작성한다. +- [x] create/update/soft delete의 `data=null`을 처리하고 일반 update와 soft delete의 `isActive` 규칙, 목록 재조회·이동·toast를 test한다. +- [x] mock store로 Series CRUD 후 list enum과 detail 표시 문자열의 서로 다른 DTO가 일관되게 갱신되는 UI를 확인한다. +- [x] 초기 Series form을 실제 페이지에서 확인한 뒤 `title`, `introduction`, `keyword`, `writer`, `studio`, `publishedDaysOfWeek`의 최대 길이·개수 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. ### Task 5.4 Audio 연결·해제·전체 순서 @@ -1737,15 +2551,15 @@ REFACTOR를 실행한다. 외부 계약 때문에 RED test의 기대 동작 자 - **완료 증거:** 체크박스 전체, link/unlink/reorder contract·interaction test, 충돌 보존, `OQ-009` 결정 기록. - **범위 밖:** 계약 없는 active-only·도메인별 충돌 동작의 추정 구현. -- [ ] 현재 연결 Audio 목록은 `page/size`와 `data.totalCount/items`를 사용하고 제공되지 않은 search query를 보내지 않으며 상세 cache를 동기화한다. -- [ ] 후보는 `GET .../contents/search?search_word=...` 결과만 사용하고 이미 연결된 항목을 중복 선택하지 않는다. -- [ ] 연결 POST는 `{ contentIdList }`, 해제 DELETE는 body 없음임을 contract test로 고정한다. -- [ ] 연결 해제 전 대상 title과 영향을 AlertDialog로 확인한다. -- [ ] 순서 mode는 `totalCount`와 page/size로 Series 전체를 읽고 최종 순서의 모든 ID를 `{ ids }`로 한 번에 보낸다. -- [ ] drag-and-drop과 동일한 결과를 keyboard·위/아래 button으로 만들 수 있는 test를 작성한다. -- [ ] server의 누락 ID·동시 충돌 오류에서 기존 화면 순서를 보존하고 재조회/재시도 안내를 제공한다. -- [ ] 초기 연결·순서 UI를 실제 페이지에서 확인한 뒤 `contentIdList`, `ids`의 최대 개수 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. -- [ ] mock handler가 연결·해제와 전체 순서 payload를 검증하고 store 결과를 반영하는 E2E를 작성한다. +- [x] 현재 연결 Audio 목록은 `page/size`와 `data.totalCount/items`를 사용하고 제공되지 않은 search query를 보내지 않으며 상세 cache를 동기화한다. +- [x] 후보는 `GET .../contents/search?search_word=...` 결과만 사용하고 이미 연결된 항목을 중복 선택하지 않는다. +- [x] 연결 POST는 `{ contentIdList }`, 해제 DELETE는 body 없음임을 contract test로 고정한다. +- [x] 연결 해제 전 대상 title과 영향을 AlertDialog로 확인한다. +- [x] 순서 mode는 `totalCount`와 page/size로 Series 전체를 읽고 최종 순서의 모든 ID를 `{ ids }`로 한 번에 보낸다. +- [x] drag-and-drop과 동일한 결과를 keyboard·위/아래 button으로 만들 수 있는 test를 작성한다. +- [x] server의 누락 ID·동시 충돌 오류에서 기존 화면 순서를 보존하고 재조회/재시도 안내를 제공한다. +- [x] 초기 연결·순서 UI를 실제 페이지에서 확인한 뒤 `contentIdList`, `ids`의 최대 개수 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. +- [x] mock handler가 연결·해제와 전체 순서 payload를 검증하고 store 결과를 반영하는 E2E를 작성한다. ### Task 5.5 Series 반응형·접근성 @@ -1755,9 +2569,10 @@ REFACTOR를 실행한다. 외부 계약 때문에 RED test의 기대 동작 자 - **완료 증거:** 체크박스 전체, 320px·keyboard·200% zoom·axe E2E 기록. - **범위 밖:** 모바일 CRUD·연결·순서 mutation. -- [ ] mobile은 목록·상세·연결 콘텐츠 조회만 제공하고 CRUD·연결·순서 action을 route capability로 차단한다. -- [ ] desktop/tablet에서 전체 관리 흐름을 제공한다. -- [ ] keyboard-only 요일·genre·연결·정렬, 320px 조회, 200% zoom, axe critical·serious 0건을 확인한다. +- [x] mobile은 목록·상세·연결 콘텐츠 조회만 제공하고 CRUD·연결·순서 action을 route capability로 차단한다. +- [x] desktop/tablet에서 전체 관리 흐름을 제공한다. +- [x] keyboard-only 연결·정렬, 320px 조회, 200% zoom, axe critical·serious 0건을 확인한다. +- [x] P5-T3 genre lookup과 상세 수정용 원본값 제공 후 keyboard-only 요일·genre/form 접근성을 확인한다. ### Phase 5 Gate @@ -1776,12 +2591,114 @@ npm run lint npm run build ``` -**Expected:** 현재 제공된 계약으로 목록·상세 조회와 `contentIdList` 연결/해제, `ids` 전체 reorder UI가 통과한다. genre lookup·edit DTO 제공 후에는 필수 image와 `keyword`로 Series 생성 → 수정 → soft delete UI까지 통과하고 잘못된 enum·`keywords/contentIds/seriesIds` payload가 생성되지 않는다. 미제공 계약 범위는 대기로 유지하되 완료된 조회·연결·순서 상태를 되돌리지 않는다. +**Expected:** 현재 제공된 계약으로 목록·상세 조회와 `contentIdList` 연결/해제, `ids` 전체 reorder UI가 통과한다. genre lookup과 상세 수정용 원본값 제공 후에는 필수 image와 `keyword`로 Series 생성 → 수정 → soft delete UI까지 통과하고 잘못된 enum·`keywords/contentIds/seriesIds` payload가 생성되지 않는다. 미제공 계약 범위는 대기로 유지하되 완료된 조회·연결·순서 상태를 되돌리지 않는다. **수동 확인:** 현재 계약으로 320px 조회와 desktop/tablet 연결·keyboard -reorder를 확인한다. genre/edit DTO 제공 후 생성·수정 form도 확인하고, +reorder를 확인한다. genre lookup과 상세 수정용 원본값 제공 후 생성·수정 form도 확인하고, 표시용 상세 문자열이 update payload로 역변환되지 않는지 network에서 본다. +### Task R5.1 — Series partial update 불변식 복구 + +**Goal 실행 `P5-R1`:** Series update에서 변경 없는 state를 생략하고 일반 update와 soft delete schema를 분리한다. + +- **연결 리뷰:** [Phase 5 재리뷰](./reviews/review-phase-5-20260729.md) — `REV-P5-001`, `REV-P5-002` +- **시작 조건:** `SERIES-002`, `SERIES-004`, `SERIES-010`. +- **Files:** Modify: `src/features/series/components/SeriesForm.tsx`, `src/features/series/schemas/series-schema.ts`, `src/features/series/api/series-api.ts`, `src/features/series/tests/series-form.test.tsx`, `src/features/series/tests/series-contract.test.ts`. +- **Interfaces:** unchanged state는 key 생략, changed state는 3개 enum만 허용; 일반 update에 `isActive` 없음, deactivate는 `{isActive:false}` 전용. +- **범위 밖:** Series state enum 추가, backend partial update 의미 변경. + +- [x] **RED:** unchanged/changed state payload와 `state:null`, `isActive:true/null` 거부 test를 작성한다. +- [x] **GREEN:** 원본 비교 serializer와 일반/deactivate schema를 분리한다. +- [x] **REFACTOR:** API helper가 전용 schema만 호출하도록 정리하고 Series 전체 회귀를 실행한다. +- **실행 명령:** `npm run test:run -- src/features/series`. +- **기대 결과:** exit 0, unchanged state request 0건, `isActive=true/null` parse 0건, deactivate false만 통과. +- **수동 확인:** edit에서 state 미변경/변경 저장 network payload를 각각 확인한다. + +### Task R5.2 — Series image format pair 검증 + +**Goal 실행 `P5-R2`:** Series JPEG/PNG extension↔MIME mismatch를 crop 전에 거부한다. + +- **연결 리뷰:** [Phase 5 재리뷰](./reviews/review-phase-5-20260729.md) — `REV-P5-003` +- **시작 조건:** `FILE-002`, `FILE-005`, `SERIES-014`. +- **Files:** Modify: `series-image-policy.ts`, `series-contract.test.ts`, `series-form.test.tsx`. +- **Interfaces:** `.jpg/.jpeg ↔ image/jpeg`, `.png ↔ image/png`; size/aspect/no-upscale는 유지. +- **범위 밖:** 실제 image sniffing, crop 계산(`P1-R7`). + +- [x] **RED:** `.png+image/jpeg`, `.jpg+image/png` 거부와 crop source 호출 0건 test를 작성한다. +- [x] **GREEN:** extension별 MIME mapping을 policy에 적용한다. +- [x] **REFACTOR:** Audio/Character policy와 불필요한 범용 abstraction 없이 message를 정리한다. +- **실행 명령:** `npm run test:run -- src/features/series src/shared/validation/file-media-policy.test.ts`. +- **기대 결과:** exit 0, mismatch upload/crop 0건, 기존 10MiB/ratio test 통과. +- **수동 확인:** 잘못된 pair 선택 시 즉시 field 오류가 보이는지 확인한다. + +### Task R5.3 — Series 표시용 label 일관성 복구 + +**Goal 실행 `P5-R3`:** Series 목록·상세에서 요일·state·boolean·genre를 운영자용 label로 표시하고 원본 enum/ID는 mutation에만 유지한다. + +- **연결 리뷰:** [Phase 5 현재 리뷰](./reviews/phase5-series-management.md) — `REV-P5-004` +- **시작 조건:** `SERIES-007`, `SERIES-015`, PRD §13 중앙 formatter 기준과 장르 목록 계약. +- **완료 증거:** raw enum/boolean/field-name 노출 실패 test, 표시 계층 formatter 적용, Series focused unit·mock E2E·Phase Gate와 검증 기록. +- **Files:** Modify: `SeriesListPage.tsx`, `SeriesList.tsx`, `SeriesListItem.tsx`, `SeriesSummary.tsx`, 관련 tests와 `tests/e2e/series.spec.ts`; 필요 시 Series 전용 formatter. +- **Interfaces:** `SUN..SAT/RANDOM`, `PROCEEDING/SUSPEND/COMPLETE`, boolean과 `genreId`를 한국어 label로 표시하되 API DTO와 create/update payload는 원본 값을 유지한다. +- **범위 밖:** OpenAPI enum 변경, 장르 ID 역변환, 새 전역 i18n framework. + +- [x] **RED:** desktop/mobile 목록과 상세에 `SUN`, `PROCEEDING`, `isAdult true`, `isActive true`, `genreId`가 사용자 문구로 노출되는 실패 test를 작성한다. +- [x] **GREEN:** 장르 응답과 Series 전용 formatter로 표시 label을 만들고 payload test는 원본 enum/ID를 계속 확인한다. +- [x] **REFACTOR:** `SeriesSummary`의 중복 map을 재사용 가능한 Series 표시 helper로만 정리한다. +- **실행 명령:** `npm run test:run -- src/features/series`; `npm run e2e:mock -- tests/e2e/series.spec.ts`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, Series 사용자 화면의 raw enum·boolean·field-name 노출 0건, mutation payload 변경 0건. +- **수동 확인:** 320/768/1280px에서 목록·상세 label과 수정 form의 원본 선택값을 확인한다. + +### Task R5.4 — Series 생성·수정 입력 schema 불변식 복구 + +**Goal 실행 `P5-R4`:** Series 생성의 유효 장르 필수 조건과 생성·수정 요일 배열의 유일성을 schema·mock·contract test에서 강제한다. + +- **연결 리뷰:** [Phase 5 현재 리뷰](./reviews/phase5-series-management.md) — `REV-P5-005` +- **시작 조건:** `SERIES-006~007`, OpenAPI `SeriesCreateRequest.publishedDaysOfWeek.uniqueItems=true`, binding 기본값 `genreId=0`은 domain에서 무효라는 설명. +- **완료 증거:** 누락 `genreId`와 중복 요일의 실패 test, schema·mock 최소 수정, Series focused unit·mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/features/series/schemas/series-schema.ts`, `src/features/series/tests/series-contract.test.ts`, `src/features/series/tests/series-form.test.tsx`, `src/shared/mocks/series-mock-store.ts`; 필요 시 Series mock handler test. +- **Interfaces:** create `genreId`는 positive integer 필수, `publishedDaysOfWeek`는 중복 없는 실제 요일 하나 이상 또는 `RANDOM` 단독이며 update의 non-null 요일에도 같은 유일성을 적용한다. +- **범위 밖:** 장르 목록 자체의 유효성 backend 재검증, 요일 최대 개수 추가, OpenAPI JSON 변경. + +- [x] **RED:** `seriesCreateRequestSchema`가 `genreId` 누락과 `["MON","MON"]`을 거부하고 update가 중복 요일을 거부해야 한다는 실패 test를 작성했다. +- [x] **GREEN:** create `genreId`를 필수로 바꾸고 공용 요일 schema에 uniqueness를 적용하며 mock의 임의 첫 장르 fallback을 제거했다. +- [x] **REFACTOR:** UI의 기존 장르·요일 검증을 schema 불변식과 같은 용어로 정리하되 중복 validation framework는 만들지 않았다. +- **실행 명령:** `npm run test:run -- src/features/series src/shared/mocks`; `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 누락/0 장르와 중복 요일 request 성공 0건, 기존 유효 생성·수정 회귀 0건. +- **수동 확인:** Series 생성에서 장르 미선택·요일 중복 전송이 불가능하고 정상 생성은 유지되는지 확인한다. + +**P5-R4 수정 검증 기록 (2026-07-30):** + +- RED: `npm run test:run -- src/features/series/tests/series-contract.test.ts` — 1 failed / 6 passed. `seriesCreateFormSchema`가 `genreId` 누락 payload를 성공 처리해 기대대로 실패했다. +- GREEN focused: `npm run test:run -- src/features/series/tests/series-contract.test.ts src/features/series/tests/series-form.test.tsx` — 2 files / 14 tests passed. 생성 `genreId` 필수, 생성·수정 중복 요일 거부, 기존 유효 생성·수정 form 회귀를 확인했다. +- REFACTOR/회귀: `npm run test:run -- src/features/series src/shared/mocks` — 13 files / 51 tests passed. `npm run typecheck`, `npm run lint`, `npm run build` — 모두 exit 0. +- LSP diagnostics: `src/features/series/schemas/series-schema.ts`, `src/shared/mocks/series-mock-store.ts`, `src/features/series/tests/series-contract.test.ts` — 오류 0건. +- E2E/수동 확인: 사용자 지시에 따라 개발 중 E2E는 실행하지 않고 `P10-R5` 이후 최종 E2E에서 확인한다. + +### Task R5.5 — Series image crop 준비 경계 보강 + +**Goal 실행 `P5-R5`:** Series 생성·수정에서 image crop source 준비와 연속 선택을 추적하고 crop 확정 전 저장을 차단한다. + +- **연결 리뷰:** [Phase 5 현재 리뷰](./reviews/phase5-series-management.md) — `REV-P5-006` +- **시작 조건:** `FILE-005`, `FILE-007~009`, `FILE-012`, `FILE-015`와 Series image 생성 필수/수정 optional 계약. +- **완료 증거:** 준비 중 edit 저장·stale source·준비 실패를 재현하는 실패 test, staged selection 최소 수정, Series focused unit·mock E2E·file Gate 통과. +- **Files:** Modify: `src/features/series/components/SeriesForm.tsx`, 관련 Series form/file tests와 `tests/e2e/series.spec.ts`. +- **Interfaces:** `image`는 crop 적용 결과만 보유하고 준비/crop 중 submit을 비활성화·guard한다. 마지막 선택만 유효하며 취소는 기존 서버 image를 유지하고 준비 실패를 inline 표시한다. +- **범위 밖:** Series crop ratio/output 계산 변경, 새 범용 upload state library, API multipart part 변경. + +- [x] **RED:** edit에서 source 준비 중 저장, 느린 첫 선택이 빠른 둘째 선택을 덮는 경우, source 준비 reject를 실패 test로 고정한다. +- [x] **GREEN:** image selection token·준비 상태·오류 처리와 submit guard를 추가한다. +- [x] **REFACTOR:** create/edit 공통 `SeriesForm` 내부에서 lifecycle을 끝내고 도메인 밖 abstraction을 추가하지 않는다. +- **실행 명령:** `npm run test:run -- src/features/series`; `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 준비/crop 미확정 image의 저장 0건, stale dialog 0건, 정상 210:297 crop result만 multipart에 포함. +- **수동 확인:** 기존 image가 있는 수정 화면에서 느린 파일 연속 선택·취소·적용·즉시 저장을 반복해 마지막 의도만 반영되는지 확인한다. + +**Progress — 2026-07-31 P5-R5 완료** + +- **RED:** `npm run test:run -- src/features/series/tests/series-form.test.tsx`에서 준비 중 저장 버튼 미비활성화, preview reject inline 오류 부재, unhandled rejection을 확인했다. +- **GREEN:** `SeriesForm`에 image selection token, `isImagePreparing`, 준비/crop 중 submit guard, preview reject inline 오류를 추가했다. `image`는 crop 적용 결과만 commit하고, 수정 화면 crop 취소는 기존 서버 image 유지 계약을 보존한다. focused 재실행 결과 `series-form.test.tsx`와 `series-form-crop.test.tsx` 2 files / 13 tests passed였다. +- **REFACTOR/회귀:** crop lifecycle test를 `series-form-crop.test.tsx`로 분리해 `SeriesForm.tsx` 220 LOC, `series-form.test.tsx` 230 LOC, `series-form-crop.test.tsx` 85 LOC로 유지했다. `npm run test:run -- src/features/series` 결과 8 files / 37 tests passed였다. 사용자 지시에 따라 mock E2E는 전체 Task 완료 전까지 보류했다. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0 또는 no output이었다. Series directory LSP diagnostics는 17 TSX files / 오류 0건이었다. + --- ## Phase 6. Community vertical slice @@ -1796,19 +2713,17 @@ reorder를 확인한다. genre/edit DTO 제공 후 생성·수정 form도 확인 **요구사항:** `COMMUNITY-001~015`, `FILE-001~004`, `FILE-007~009`, `FILE-011~014`, PRD `9`의 Community 범위. -**외부 의존:** `EXT-007` active-only 반환 보장, `EXT-008` pagination의 total/hasNext 또는 종료 규칙, `EXT-011` Community 오류 message key, `EXT-010` backend file 검증 계약, `EXT-009` optional P1 price 상한. Comments는 Phase 8에서 연결한다. +**외부 의존:** `EXT-007` active-only 반환 보장, `EXT-008` pagination의 total/hasNext 또는 종료 규칙. price 상한은 `99999`이고, 파일 용량·MIME 등 backend 동일 검증은 확인됐다. OpenAPI 밖의 Community 오류 message key는 분기하지 않는다. Comments는 Phase 8에서 연결한다. **주요 Files:** - Create: `src/features/community-posts/api/community-post-api.ts` - Create: `src/features/community-posts/model/types.ts` -- Create: `src/features/community-posts/schemas/community-post-schema.ts` -- Create: `src/features/community-posts/validation/community-media-policy.ts` +- Create: `src/features/community-posts/validation/community-post-media-policy.ts` - Create: `src/features/community-posts/pages/CommunityPostListPage.tsx` - Create: `src/features/community-posts/components/{CommunityPostList,CommunityPostListItem,CommunityPostForm,CommunityPostSheet}.tsx` -- Create: `src/features/community-posts/tests/community-contract.test.ts` -- Create: `src/features/community-posts/tests/{community-list,community-sheet}.test.tsx` -- Create: `tests/e2e/community-post.spec.ts` +- Create: `src/features/community-posts/tests/{community-contract.test.ts,community-list.test.tsx,community-form.test.tsx,community-sheet.test.tsx,community-test-support.ts}` +- Create: `tests/e2e/community.spec.ts` #### Phase 6 Task 실행 계약 @@ -1816,8 +2731,8 @@ reorder를 확인한다. genre/edit DTO 제공 후 생성·수정 form도 확인 |---|---|---|---| | `P6-T1` | Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`; Read: `docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json`; Test: 없음 | Consumes: `CommunityPost*` schema·3 operation. Produces: list/create/update contract map, 외부 의존과 Sheet inventory | **TDD 예외:** 외부 계약 조사 Task다. OpenAPI JSON parse와 `rg -n 'COMMUNITY-01[2-5]'` 문서 추적 검사를 실행한다. 기대 `exit 0`. 수동 확인: timezone·배열 data·null mutation·미제공 pagination이 분리된다. | | `P6-T2` | Create: `src/features/community-posts/api/community-post-api.ts`, `src/features/community-posts/model/types.ts`, `src/features/community-posts/pages/CommunityPostListPage.tsx`, `src/features/community-posts/components/{CommunityPostList,CommunityPostListItem,CommunityPostSheet}.tsx`, `src/features/community-posts/tests/{community-contract.test.ts,community-list.test.tsx,community-sheet.test.tsx}` | Consumes: `CommunityPostListApiResponse`, update multipart. Produces: `getCommunityPosts({characterId,timezone,page,size})`, `updateCommunityPost`, collection Sheet cache policy | **TDD 적용:** `npm run test:run -- src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-list.test.tsx src/features/community-posts/tests/community-sheet.test.tsx`; 기대 `exit 0`. 수동 확인: detail GET 0회, pin/deactivate 후 refetch. | -| `P6-T3` | Create: `src/features/community-posts/schemas/community-post-schema.ts`, `src/features/community-posts/validation/community-media-policy.ts`, `src/features/community-posts/components/CommunityPostForm.tsx`; Modify: `src/features/community-posts/api/community-post-api.ts`, `src/features/community-posts/components/CommunityPostSheet.tsx`, `src/features/community-posts/tests/{community-contract.test.ts,community-sheet.test.tsx}` | Consumes: `CommunityPostCreateRequest`, `CommunityPostUpdateRequest`, multipart part names. Produces: `createCommunityPost`, form serializer와 media policy | **TDD 적용:** `npm run test:run -- src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-sheet.test.tsx src/shared/validation`; 기대 `exit 0`. 수동 확인: postImage/audioFile, update audio/price 없음, audioUrl 재생. | -| `P6-T4` | Modify: `src/features/community-posts/pages/CommunityPostListPage.tsx`, `src/features/community-posts/components/{CommunityPostList,CommunityPostListItem,CommunityPostForm,CommunityPostSheet}.tsx`; Test: `tests/e2e/community-post.spec.ts` | Consumes: P6-T2/T3 UI. Produces: viewport·Sheet focus capability evidence | **TDD 적용:** `npm run e2e:mock -- tests/e2e/community-post.spec.ts`; 기대 지원 project 전부 통과. 수동 확인: 320px, focus trap/복귀, 200% zoom, axe. | +| `P6-T3` | Create: `src/features/community-posts/validation/community-post-media-policy.ts`, `src/features/community-posts/components/CommunityPostForm.tsx`, `src/features/community-posts/tests/community-form.test.tsx`, `src/features/community-posts/tests/community-test-support.ts`, `tests/e2e/community.spec.ts`; Modify: `src/features/community-posts/api/community-post-api.ts`, `src/features/community-posts/components/CommunityPostSheet.tsx`, `src/features/community-posts/tests/{community-contract.test.ts,community-sheet.test.tsx}`, `src/shared/mocks/{audio-content-fixtures.ts,community-post-fixtures.ts,community-post-mock-store.ts}`, `playwright.config.ts` | Consumes: `CommunityPostCreateRequest`, `CommunityPostUpdateRequest`, multipart part names. Produces: `createCommunityPost`, form serializer와 media policy | **TDD 적용:** `npm run test:run -- src/features/community-posts src/shared/validation`와 `npm run e2e:mock -- tests/e2e/community.spec.ts --project=chromium`; 기대 `exit 0`. 수동 확인: postImage/audioFile, update audio/price 없음, audioUrl 재생. | +| `P6-T4` | Modify: `src/features/community-posts/pages/CommunityPostListPage.tsx`, `src/features/community-posts/components/{CommunityPostList,CommunityPostListItem,CommunityPostForm,CommunityPostSheet}.tsx`; Test: `tests/e2e/community.spec.ts` | Consumes: P6-T2/T3 UI. Produces: viewport·Sheet focus capability evidence | **TDD 적용:** `npm run e2e:mock -- tests/e2e/community.spec.ts`; 기대 지원 project 전부 통과. 수동 확인: 320px, focus trap/복귀, 200% zoom, axe. | `P6-T2`~`P6-T4`는 각 row의 focused test로 RED → GREEN → REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 확인을 @@ -1831,12 +2746,12 @@ REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, contract fixture와 상태/action inventory의 세 문서 일치. - **범위 밖:** price 상한·오류 key 추정과 Comments 구현. -- [ ] OpenAPI 공통 오류 status·shape를 fixture에 기록하고 Community 전용 message key와 media upload 오류 계약은 미제공으로 표시한다. 정확한 fixture를 추정하지 않는다. -- [ ] price 최대값이 제공되면 Audio와 같은 정책으로 갱신하고, 없으면 0 이상 정수만 유지한다. -- [ ] 목록 필수 `timezone`, `page/size`, 배열 `data`, 생성 `audioFile/postImage/request`, 수정 `postImage/request`, mutation `data=null`을 contract fixture로 고정한다. -- [ ] 목록에 total/hasNext와 `isActive/fixedAtUtc`가 없음을 기록하고 해당 값을 fixture에서 추가하지 않는다. -- [ ] 목록·Sheet·form/media의 상태/action inventory를 작성하고 Page는 collection query/policy 조합, feature component는 Community 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. -- [ ] Community active list, Sheet, pin, media와 CRUD의 deterministic browser fixture 시나리오를 확정한다. +- [x] OpenAPI 공통 오류 status·shape를 fixture에 기록하고 Community 전용 message key와 media upload 오류 계약은 미제공으로 표시한다. 정확한 fixture를 추정하지 않는다. +- [x] price 최대값이 제공되면 Audio와 같은 정책으로 갱신하고, 없으면 0 이상 정수만 유지한다. +- [x] 목록 필수 `timezone`, `page/size`, 배열 `data`, 생성 `audioFile/postImage/request`, 수정 `postImage/request`, mutation `data=null`을 contract fixture로 고정한다. +- [x] 목록에 total/hasNext와 `isActive/fixedAtUtc`가 없음을 기록하고 해당 값을 fixture에서 추가하지 않는다. +- [x] 목록·Sheet·form/media의 상태/action inventory를 작성하고 Page는 collection query/policy 조합, feature component는 Community 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. +- [x] Community active list, Sheet, pin, media와 CRUD의 deterministic browser fixture 시나리오를 확정한다. ### Task 6.2 목록·collection Sheet @@ -1846,14 +2761,14 @@ REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, list/Sheet/router/cache/pin/deactivate test와 detail GET 0회 기록. - **범위 밖:** 댓글과 제공 계약에 없는 Community 검색. -- [ ] 목록이 필수 `timezone=Asia/Seoul`과 `page/size`를 보내고 배열 `data`를 소비하며 loading·empty·error·retry와 URL query 보존을 제공하는지 test한다. 제공 계약에 없는 Community `search`, total, hasNext를 만들지 않는다. -- [ ] 목록 item을 source로 Sheet를 열고 전용 detail GET을 0회 호출하는 test를 작성한다. -- [ ] `/community-posts/:postId`, `/edit` route가 존재하지 않는 router test를 작성한다. -- [ ] Sheet의 조회·수정·고정/해제·비활성화가 목록 cache와 같은 server response를 사용한다. -- [ ] 고정/해제 후 mutation `data=null`을 처리하고 목록을 재조회해 `isFixed`를 갱신한다. 계약에 없는 `fixedAtUtc`는 표시하지 않는다. -- [ ] soft delete request가 `isActive=false`, `isFixed=false`를 보내고 성공 `data=null`을 처리하는 contract test를 작성한다. -- [ ] soft delete 성공 시 Sheet 종료, 목록 재조회와 성공 toast를 확인한다. 비활성 항목 제거는 active-only 계약 제공 후 server mode에서 검증한다. -- [ ] mock mode도 전용 detail GET 없이 list store만으로 Sheet와 pin/deactivate 최종 UI를 갱신한다. +- [x] 목록이 필수 `timezone=Asia/Seoul`과 `page/size`를 보내고 배열 `data`를 소비하며 loading·empty·error·retry와 URL query 보존을 제공하는지 test한다. 제공 계약에 없는 Community `search`, total, hasNext를 만들지 않는다. +- [x] 목록 item을 source로 Sheet를 열고 전용 detail GET을 0회 호출하는 test를 작성한다. +- [x] `/community-posts/:postId`, `/edit` route가 존재하지 않는 router test를 작성한다. +- [x] Sheet의 조회·수정·고정/해제·비활성화가 목록 cache와 같은 server response를 사용한다. +- [x] 고정/해제 후 mutation `data=null`을 처리하고 목록을 재조회해 `isFixed`를 갱신한다. 계약에 없는 `fixedAtUtc`는 표시하지 않는다. +- [x] soft delete request가 `isActive=false`, `isFixed=false`를 보내고 성공 `data=null`을 처리하는 contract test를 작성한다. +- [x] soft delete 성공 시 Sheet 종료, 목록 재조회와 성공 toast를 확인한다. 비활성 항목 제거는 active-only 계약 제공 후 server mode에서 검증한다. +- [x] mock mode도 전용 detail GET 없이 list store만으로 Sheet와 pin/deactivate 최종 UI를 갱신한다. ### Task 6.3 게시글 form·첨부 media @@ -1863,18 +2778,18 @@ REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, serializer/form/media/file-boundary/player test, `OQ-009` 결정 기록. - **범위 밖:** GIF 재인코딩, URL 갱신 전용 요청, Comments. -- [ ] 생성 payload에 `isActive`가 없고 일반 update/soft delete가 공통 `isActive` 규칙을 지키는 test를 작성한다. -- [ ] create의 필수 content·isCommentAvailable·isAdult, optional price와 multipart `postImage/audioFile`을 test하고 생성 request에 `isFixed`가 없음을 고정한다. -- [ ] update는 optional `postImage`와 content·isCommentAvailable·isAdult·isActive·isFixed만 보내며 price·audioFile 교체 UI/request가 없음을 test한다. -- [ ] JPEG/PNG는 자유 ratio crop·최대 800px·no-upscale을 적용한다. -- [ ] GIF는 Community에서만 허용하고 crop Dialog/canvas/re-encode 없이 원본 ratio·animation을 유지한다. -- [ ] GIF 원본 width 800px은 허용하고 801px은 제출 전에 거부한다. -- [ ] 첨부 Audio는 Phase 1의 공통 audio file policy와 `FileField`를 Phase 4와 동일하게 조합해 MP3/AAC/M4A, x-m4a, `1,024,000,000 bytes`, WAV 거부 규칙을 재사용한다. -- [ ] 첨부 Audio가 있으면 목록 Card/row와 Sheet에 공통 player를 렌더링한다. -- [ ] media error가 detail/list refetch·URL 재발급·자동 play를 발생시키지 않는다. -- [ ] 사용자 새로고침이나 mutation cache invalidation으로 목록이 정상 재조회된 때만 새 `audioUrl`을 사용한다. -- [ ] 초기 Community Sheet/form을 실제 페이지에서 확인한 뒤 `content`의 최대 길이 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. -- [ ] mock handler가 multipart contract를 검증하고 local preview media asset으로 create/update/play 최종 UI를 재현한다. +- [x] 생성 payload에 `isActive`가 없고 일반 update/soft delete가 공통 `isActive` 규칙을 지키는 test를 작성한다. +- [x] create의 필수 content·isCommentAvailable·isAdult, optional price와 multipart `postImage/audioFile`을 test하고 생성 request에 `isFixed`가 없음을 고정한다. +- [x] update는 optional `postImage`와 content·isCommentAvailable·isAdult·isActive·isFixed만 보내며 price·audioFile 교체 UI/request가 없음을 test한다. +- [x] JPEG/PNG는 자유 ratio crop·최대 800px·no-upscale을 적용한다. +- [x] GIF는 Community에서만 허용하고 crop Dialog/canvas/re-encode 없이 원본 ratio·animation을 유지한다. +- [x] GIF 원본 width 800px은 허용하고 801px은 제출 전에 거부한다. +- [x] 첨부 Audio는 Phase 1의 공통 audio file policy와 `FileField`를 Phase 4와 동일하게 조합해 MP3/AAC/M4A, x-m4a, `1,024,000,000 bytes`, WAV 거부 규칙을 재사용한다. +- [x] 첨부 Audio가 있으면 목록 Card/row와 Sheet에 공통 player를 렌더링한다. +- [x] media error가 detail/list refetch·URL 재발급·자동 play를 발생시키지 않는다. +- [x] 사용자 새로고침이나 mutation cache invalidation으로 목록이 정상 재조회된 때만 새 `audioUrl`을 사용한다. +- [x] 초기 Community Sheet/form을 실제 페이지에서 확인한 뒤 `content`의 최대 길이 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. +- [x] mock handler가 multipart contract를 검증하고 local preview media asset으로 create/update/play 최종 UI를 재현한다. ### Task 6.4 Community 반응형·접근성 @@ -1884,9 +2799,9 @@ REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, focus trap/복귀·320px·keyboard·200% zoom·axe E2E 기록. - **범위 밖:** 모바일 등록·수정·고정·비활성화. -- [ ] mobile은 목록·Sheet 조회·첨부 재생만 제공하고 등록·수정·고정·비활성화를 route/action policy로 차단한다. -- [ ] desktop/tablet에는 전체 관리 흐름을 제공한다. -- [ ] Sheet focus trap/복귀, keyboard media/form, 320px overflow, 200% zoom, axe critical·serious 0건을 확인한다. +- [x] mobile은 목록·Sheet 조회·첨부 재생만 제공하고 등록·수정·고정·비활성화를 route/action policy로 차단한다. +- [x] desktop/tablet에는 전체 관리 흐름을 제공한다. +- [x] Sheet focus trap/복귀, keyboard media/form, 320px overflow, 200% zoom, axe critical·serious 0건을 확인한다. ### Phase 6 Gate @@ -1898,8 +2813,8 @@ REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 ```bash npm run test:run -- src/features/community-posts src/shared/validation -npm run e2e:mock -- tests/e2e/community-post.spec.ts -npm run e2e -- tests/e2e/community-post.spec.ts +npm run e2e:mock -- tests/e2e/community.spec.ts +npm run e2e -- tests/e2e/community.spec.ts npm run typecheck npm run lint npm run build @@ -1911,6 +2826,88 @@ npm run build keyboard-only로 확인하고, detail GET·수정 audio/price 요청이 없으며 focus가 Sheet trigger로 복귀하는지 본다. +### Task R6.1 — Community raw price validation 복구 + +**Goal 실행 `P6-R1`:** Community create에서 음수·소수 가격을 다른 값으로 바꾸지 않고 제출 전에 거부한다. + +- **연결 리뷰:** [Phase 6 재리뷰](./reviews/review-phase-6-20260729.md) — `REV-P6-001` +- **시작 조건:** `COMMUNITY-007`, `CAN_PRICE_MAX=99999`; Audio와 의미가 같은 raw-price 규칙은 재사용한다. +- **Files:** Modify: `community-post-form-helpers.ts`, `CommunityPostForm.tsx`, `community-form.test.tsx`, `community-contract.test.ts`, 필요 시 `src/shared/validation/can-price.ts`. +- **Interfaces:** group separator와 `캔` suffix만 display normalization; `-`, `.`가 포함된 raw input은 invalid. +- **범위 밖:** Community update price UI, 통화 locale 확장. + +- [x] **RED:** `-1`, `1.5` 입력 시 inline 오류와 POST 0건, `0/99999` 허용 test를 작성한다. +- [x] **GREEN:** raw/display price state 또는 공통 parser result를 적용한다. +- [x] **REFACTOR:** Audio `P4-R1`과 동일 의미의 validation만 shared로 올리고 Community 회귀를 실행한다. +- **실행 명령:** `npm run test:run -- src/shared/validation/can-price.test.ts src/features/community-posts`. +- **기대 결과:** exit 0, invalid price POST 0건, 경계값 multipart 유지. +- **수동 확인:** Community create 가격 input에서 음수·소수 오류 문구와 입력 보존을 확인한다. + +### Task R6.2 — Community 상태 boolean label 정리 + +**Goal 실행 `P6-R2`:** Community 목록의 성인 여부를 raw boolean이 아닌 한국어 상태 label로 표시한다. + +- **연결 리뷰:** [Phase 6 현재 리뷰](./reviews/phase6-community-posts.md) — `REV-P6-002` +- **시작 조건:** PRD §13 날짜·가격·상태 label 중앙 표시 기준과 기존 desktop/mobile 목록. +- **완료 증거:** `성인 true/false` 노출 실패 test, 최소 표시 수정, Community focused unit·mock E2E와 검증 기록. +- **Files:** Modify: `CommunityPostList.tsx`, `CommunityPostListItem.tsx`, `community-list.test.tsx`, `tests/e2e/community.spec.ts`. +- **Interfaces:** boolean은 `성인/일반`처럼 의미 있는 한국어 label로 변환하고 API DTO·multipart 값은 boolean을 유지한다. +- **범위 밖:** Community API schema 변경, 상태 filter 추가, 전역 i18n framework. + +- [x] **RED:** desktop/mobile에 raw `true/false`가 표시되지 않고 핵심 상태 label이 일치하는 실패 test를 작성한다. +- [x] **GREEN:** Community 전용 상태 formatter를 목록 row/card에 적용한다. +- [x] **REFACTOR:** 고정·댓글 허용·성인 상태 copy를 한 표시 helper로 정리한다. +- **실행 명령:** `npm run test:run -- src/features/community-posts`; `npm run e2e:mock -- tests/e2e/community.spec.ts`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, Community 사용자 화면의 raw boolean 0건, request payload 변경 0건. +- **수동 확인:** desktop과 320px 카드에서 동일 게시글의 고정·댓글·성인 상태를 확인한다. + +### Task R6.3 — Community 비활성화 확인 경계 추가 + +**Goal 실행 `P6-R3`:** Community 게시글 비활성화를 직접 실행하지 않고 영향 설명이 있는 확인 dialog를 거쳐 확정하도록 한다. + +- **연결 리뷰:** [Phase 6 현재 리뷰](./reviews/phase6-community-posts.md) — `REV-P6-003` +- **시작 조건:** PRD §10.4의 영향이 큰 비활성화 동작 AlertDialog 기준과 `COMMUNITY-005` soft delete 계약. +- **완료 증거:** 첫 click에서 DELETE 0건·confirm 후 1건을 기대하는 실패 test, 공통 확인 UI 재사용, Community focused unit·mock E2E·접근성 Gate 통과. +- **Files:** Modify: `src/features/community-posts/components/CommunityPostSheet.tsx`, `src/features/community-posts/tests/community-sheet.test.tsx`, `tests/e2e/community.spec.ts`; Reuse: `src/shared/ui/confirm-deactivate-dialog.tsx`. +- **Interfaces:** 확인 전 request 0건, confirm 후 현재 post ID에 DELETE 1건, pending/error와 Sheet focus 경계를 접근 가능하게 유지한다. +- **범위 밖:** 댓글 삭제 확인 정책 변경, 복원 기능, 새 dialog primitive. + +- [x] **RED:** `비활성화` 첫 click은 확인 dialog만 열고 DELETE를 보내지 않으며 취소는 Sheet로 focus를 돌리고 confirm만 DELETE 1건을 보내야 한다는 실패 test를 작성한다. +- [x] **GREEN:** 기존 `ConfirmDeactivateDialog`를 Community Sheet에 연결하고 pending·오류 상태를 전달한다. +- [x] **REFACTOR:** 삭제 API와 성공 callback은 유지하고 dialog open/pending state만 Sheet에 추가한다. +- **실행 명령:** `npm run test:run -- src/features/community-posts`; `npm run e2e:mock -- tests/e2e/community.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 확인 전·취소 후 DELETE 0건, confirm 연타에도 DELETE 최대 1건, 실패 안내와 재시도 가능. +- **수동 확인:** keyboard-only로 Sheet의 비활성화 → 취소 → 재확인 → 실패/성공 흐름과 focus 복귀를 확인한다. + +### Task R6.4 — Community GIF crop source Blob URL 해제 + +**Goal 실행 `P6-R4`:** Community GIF width 검사를 위해 생성한 crop source의 Blob URL을 성공·거부 경로에서 각각 정확히 한 번 해제한다. + +- **연결 리뷰:** [Phase 6 현재 리뷰](./reviews/phase6-community-posts.md) — `REV-P6-004` +- **시작 조건:** PRD `FILE-003`, `FILE-011`, `FILE-014`, `prepareCommunityPostImage()`가 GIF의 width를 읽은 뒤 `source.release` 소유권을 form에 전달하지 않고 `ready` 또는 `error`를 반환하는 현재 구조. +- **완료 증거:** 800px 이하 GIF `ready`와 800px 초과 GIF `error`에서 release 1회, JPEG/PNG `crop` handoff에서 조기 release 0회를 고정하는 실패 test, Community focused unit·mock E2E·정적 Gate 기록. +- **Files:** Modify: `src/features/community-posts/validation/community-post-media-policy.ts`; Create: `src/features/community-posts/tests/community-post-media-policy.test.ts`; Test: `src/features/community-posts/tests/community-form.test.tsx`, `src/features/community-posts/tests/community-sheet.test.tsx`, `tests/e2e/community.spec.ts`. +- **Interfaces:** GIF는 dimension 확인 후 crop source를 외부로 반환하지 않으므로 `prepareCommunityPostImage` 내부가 release를 소유한다. JPEG/PNG `crop` 결과의 release 소유권은 기존 form/Sheet에 그대로 전달한다. +- **범위 밖:** GIF resize·crop·re-encode, 외부 image decoder, 공통 crop source 재설계, 서버 GIF 계약 변경. +- **Phase 6 Gate 연결:** media policy focused unit, create/Sheet 교체·stale·unmount 회귀, mock Chromium Community journey, `typecheck`·`lint`·build가 모두 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** idempotent `release` spy를 사용해 valid GIF·oversized GIF은 각 1회, JPEG/PNG crop handoff는 0회인 실패 test를 작성했다. +- [x] **GREEN:** GIF width 판정이 끝나면 `ready`/거부 반환 전 source를 해제하는 최소 수정을 적용했다. +- [x] **REFACTOR:** release 호출 경계를 GIF 분기 한 곳에 유지하고 create/Sheet의 JPEG/PNG lifecycle과 stale selection 회귀를 확인했다. +- [x] RED/GREEN/REFACTOR, media policy·Community 단위·정적 검증을 Progress와 Phase 6 리뷰에 누적했다. E2E와 반복 GIF 수동 확인은 전체 Task 구현 후 필요 시 수행한다. +- **실행 명령:** `npm run test:run -- src/features/community-posts/tests/community-post-media-policy.test.ts src/features/community-posts/tests/community-form.test.tsx src/features/community-posts/tests/community-sheet.test.tsx`; `npm run test:run -- src/features/community-posts src/shared/lib/create-image-crop-source.test.ts`; `npm run e2e:mock -- tests/e2e/community.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, valid/oversized GIF Blob URL 미해제 0건·중복 release 0건, JPEG/PNG crop lifecycle 회귀 0건. +- **수동 확인:** GIF를 반복 교체하고 800px 경계 아래/위를 선택해 crop Dialog 미노출, animation 유지, 거부 문구와 재선택 회복을 확인한다. + +**P6-R4 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/features/community-posts/tests/community-post-media-policy.test.ts` — 1 failed. 신규 `prepareCommunityPostImage releases GIF crop sources after width validation`에서 valid GIF release 기대 1회 대비 0회로 실패했다. +- GREEN: `prepareCommunityPostImage()`의 GIF 분기에서 width 판정을 먼저 저장한 뒤 `source.release?.()`를 호출하게 했다. +- REFACTOR/회귀: release 호출은 GIF 분기 한 곳에만 두고 JPEG/PNG `crop` handoff는 조기 release하지 않도록 유지했으며, valid/oversized GIF 모두 `width → release` 순서를 test로 고정했다. `npm run test:run -- src/features/community-posts/tests/community-post-media-policy.test.ts src/features/community-posts/tests/community-form.test.tsx src/features/community-posts/tests/community-sheet.test.tsx` — 3 files / 18 tests passed. `npm run test:run -- src/features/community-posts` — 7 files / 45 tests passed. +- 정적 검증: LSP diagnostics `community-post-media-policy.ts` 오류 0건이고 test 파일은 LSP 응답 timeout이 있었으나 `typecheck`로 오류 없음을 확인했다. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. build는 기존 500kB chunk warning을 표시했다. +- 리뷰어 게이트: 1차 review에서 width 판정 전 release 순서 blocker를 지적받아 보완했고, delta review에서 `APPROVED` 판정을 받았다. +- E2E/수동: 개발 중 E2E와 반복 GIF browser 수동 확인은 사용자 지시에 따라 전체 Task 구현 후 필요 시 수행한다. + --- ## Phase 7. FanTalk vertical slice @@ -1959,12 +2956,12 @@ REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, 제공 범위와 외부 의존의 PRD·OpenAPI·plan 일치. - **범위 밖:** 임시 endpoint·placeholder DTO·production mock adapter. -- [ ] 목록 `page/size`, `data.fanTalkCount/fanTalks/page/size/hasNext`, item `creatorReplies`와 답변 POST `{content}`·성공 DTO를 contract fixture로 고정한다. -- [ ] 별도 상세·답변 수정 endpoint, answer filter와 sort query가 없음을 기록하고 임시 route·query를 만들지 않는다. -- [ ] 답변 1개를 server가 원자적으로 강제하는 방식과 중복 생성 비2xx status/message key를 기록한다. -- [ ] 미제공 범위에는 임시 endpoint·DTO·production adapter를 만들지 않고 후속 재개 조건을 문서화한다. -- [ ] 목록·reply Sheet/form의 상태/action inventory를 작성하고 Page는 route/query/policy 조합, feature component는 FanTalk 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. -- [ ] 제공된 목록·답변 생성 범위만 browser fixture로 만들고 상세·수정·filter/sort·유일성 오류는 mock에서도 추정하지 않는다. +- [x] 목록 `page/size`, `data.fanTalkCount/fanTalks/page/size/hasNext`, item `creatorReplies`와 답변 POST `{content}`·성공 DTO를 contract fixture로 고정한다. +- [x] 별도 상세·답변 수정 endpoint, answer filter와 sort query가 없음을 기록하고 임시 route·query를 만들지 않는다. +- [x] 답변 1개를 server가 원자적으로 강제하는 방식과 중복 생성 비2xx status/message key를 기록한다. +- [x] 미제공 범위에는 임시 endpoint·DTO·production adapter를 만들지 않고 후속 재개 조건을 문서화한다. +- [x] 목록·reply Sheet/form의 상태/action inventory를 작성하고 Page는 route/query/policy 조합, feature component는 FanTalk 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. +- [x] 제공된 목록·답변 생성 범위만 browser fixture로 만들고 상세·수정·filter/sort·유일성 오류는 mock에서도 추정하지 않는다. ### Task 7.2 목록·답변 생성 @@ -1974,15 +2971,15 @@ REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, list/reply contract·UI test, 중복 제출 차단·오류 복구, OQ-009 후속 검토 기록. - **범위 밖:** 별도 상세, 답변 수정·삭제·두 번째 답변, 전체 결과 filter/sort와 계약 없는 network 동작. -- [ ] 목록은 `page`, `size`를 URL에 보존하고 backend 반환 순서와 `hasNext`를 사용한다. 전체/미답변/답변 완료 filter와 client 재정렬은 만들지 않는다. -- [ ] loading·empty·error·retry와 목록 새로고침을 test하고 `/fan-talks/:fanTalkId` route·상세 GET이 0건임을 검증한다. -- [ ] 목록 item의 `creatorReplies`가 비어 있을 때만 POST form을 표시하고 답변이 있으면 읽기 전용으로 표시하며 edit/delete UI는 만들지 않는다. -- [ ] 빠른 두 번 제출에도 POST가 한 번만 호출되는 test를 작성한다. -- [ ] 답변 POST 성공 DTO의 `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 반영하고 해당 목록 page를 재조회한다. -- [ ] server 중복 오류를 받으면 현재 목록 page를 재조회해 `creatorReplies`를 갱신하고 status/key를 추정 분기하지 않는다. -- [ ] 저장 중 중복 제출 차단, visible label, 오류 연결, 성공 live feedback을 test한다. -- [ ] 초기 FanTalk reply form을 실제 페이지에서 확인한 뒤 `content` 최대 길이 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. -- [ ] 제공 계약 범위의 mock store로 미답변 → 답변 생성 → 읽기 전용 답변 표시의 최종 UI E2E를 작성한다. +- [x] 목록은 `page`, `size`를 URL에 보존하고 backend 반환 순서와 `hasNext`를 사용한다. 전체/미답변/답변 완료 filter와 client 재정렬은 만들지 않는다. +- [x] loading·empty·error·retry와 목록 새로고침을 test하고 `/fan-talks/:fanTalkId` route·상세 GET이 0건임을 검증한다. +- [x] 목록 item의 `creatorReplies`가 비어 있을 때만 POST form을 표시하고 답변이 있으면 읽기 전용으로 표시하며 edit/delete UI는 만들지 않는다. +- [x] 빠른 두 번 제출에도 POST가 한 번만 호출되는 test를 작성한다. +- [x] 답변 POST 성공 DTO의 `fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 반영하고 해당 목록 page를 재조회한다. +- [x] server 중복 오류를 받으면 현재 목록 page를 재조회해 `creatorReplies`를 갱신하고 status/key를 추정 분기하지 않는다. +- [x] 저장 중 중복 제출 차단, visible label, 오류 연결, 성공 live feedback을 test한다. +- [x] 초기 FanTalk reply form을 실제 페이지에서 확인한 뒤 `content` 최대 길이 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. +- [x] 제공 계약 범위의 mock store로 미답변 → 답변 생성 → 읽기 전용 답변 표시의 최종 UI E2E를 작성한다. ### Task 7.3 FanTalk 반응형·접근성 @@ -1992,9 +2989,9 @@ REFACTOR를 실행하고 관련 feature test·typecheck·lint 결과와 수동 - **완료 증거:** 체크박스 전체, 320px keyboard viewport·keyboard-only·200% zoom·axe E2E 기록. - **범위 밖:** viewport별 기능 축소와 답변 삭제. -- [ ] desktop/tablet/mobile 모두 목록 조회·답변 작성을 제공하고 수정 action은 제공하지 않는다. -- [ ] 320px에서 keyboard가 reply input/submit을 가리지 않는 E2E를 작성한다. -- [ ] keyboard-only 목록 탐색·reply Sheet·create, 200% zoom, axe critical·serious 0건을 확인한다. +- [x] desktop/tablet/mobile 모두 목록 조회·답변 작성을 제공하고 수정 action은 제공하지 않는다. +- [x] 320px에서 keyboard가 reply input/submit을 가리지 않는 E2E를 작성한다. +- [x] keyboard-only 목록 탐색·reply Sheet·create, 200% zoom, axe critical·serious 0건을 확인한다. ### Phase 7 Gate @@ -2019,6 +3016,24 @@ npm run build 확인하고, `creatorReplies`가 있는 item의 POST가 차단되며 상세·수정·filter request가 발생하지 않는지 본다. +### Task R7.1 — FanTalk 날짜와 저장 성공 문구 현지화 + +**Goal 실행 `P7-R1`:** FanTalk 목록·Sheet·답변의 UTC 시각을 공통 서울 시각 formatter로 표시하고 저장 성공 안내에서 내부 ID를 제거한다. + +- **연결 리뷰:** [Phase 7 현재 리뷰](./reviews/phase7-fan-talk.md) — `REV-P7-001` +- **시작 조건:** `FANTALK-009~010`, PRD §13 중앙 날짜 formatter와 한국어 운영 UI. +- **완료 증거:** raw UTC·내부 ID 노출 실패 test, 최소 표시 수정, FanTalk focused unit·mock E2E·Phase Gate와 검증 기록. +- **Files:** Modify: `FanTalkList.tsx`, `FanTalkListItem.tsx`, `FanTalkReplySheet.tsx`, `fan-talk-list.test.tsx`, `fan-talk-reply.test.tsx`, `tests/e2e/fan-talk.spec.ts`. +- **Interfaces:** `createdAtUtc`는 `formatSeoulDateTime`으로 표시하고, 성공 status는 등록/수정 결과를 한국어로 안내한다. response ID는 DTO parse와 refresh에만 사용한다. +- **범위 밖:** API 응답 field 제거, locale 선택 UI, FanTalk 정렬. + +- [x] **RED:** raw `2026-...Z`, `fanTalk/reply/creator` ID 문자열이 사용자 화면에 노출되는 실패 test를 작성한다. +- [x] **GREEN:** 공통 formatter와 등록/수정 성공 copy를 목록·카드·Sheet에 적용한다. +- [x] **REFACTOR:** 생성/수정 success 상태를 같은 사용자 중심 용어로 정리하고 DTO assertion은 contract test에 유지한다. +- **실행 명령:** `npm run test:run -- src/features/fan-talks`; `npm run e2e:mock -- tests/e2e/fan-talk.spec.ts`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, FanTalk 화면의 raw UTC·내부 ID 노출 0건, reply ID mapping 회귀 0건. +- **수동 확인:** desktop/mobile에서 원글·답변 날짜와 등록/수정 성공 알림을 확인한다. + --- ## Phase 8. Comments vertical slice @@ -2033,7 +3048,7 @@ request가 발생하지 않는지 본다. **요구사항:** `COMMENT-001~006`, PRD `9`의 Comments 범위. -**외부 의존:** `EXT-005`. OpenAPI의 Audio 상세 `commentList`와 Community 목록 `firstComment`는 읽기용 요약일 뿐 CRUD 계약이 아니다. Audio·Community 댓글 목록/작성/수정/soft delete endpoint·DTO, 2단계 강제와 fan 댓글 삭제 권한 오류가 제공되기 전에는 이 Phase 전체를 추측 구현하지 않는다. +**외부 의존:** `EXT-005`는 2026-07-29 OpenAPI 2.3.0에서 해결됐고, 구현은 Phase 10 `P10-T6`에서 완료한다. 2026-07-28 당시 OpenAPI의 Audio 상세 `commentList`와 Community 목록 `firstComment`는 읽기용 요약일 뿐 CRUD 계약이 아니었으므로 과거 제외 기록은 §7에 보존한다. **주요 Files:** @@ -2067,11 +3082,11 @@ GREEN → REFACTOR를 실행한다. 계약 전에는 test용 endpoint·DTO를 - **완료 증거:** 체크박스 전체, 제공 계약 또는 Phase 제외/후속 결정의 세 문서 일치. - **범위 밖:** endpoint 이름 추정과 client-only permission 완료 주장. -- [ ] Audio·Community target별 endpoint, query, DTO, page, 작성/수정/soft delete 응답을 기록한다. -- [ ] root/direct reply 정확히 2단계인 server rule과 fan content 삭제 권한 오류 status/message key를 기록한다. -- [ ] 계약이 없으면 target endpoint를 이름만 보고 추정하거나 client-only permission을 완료로 간주하지 않는다. -- [ ] Audio detail·Community Sheet 진입별 thread/form/action inventory를 작성하고 host Page/Sheet는 target·query 조합, Comments component는 thread·permission 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. -- [ ] target별 CRUD·2단계·권한 오류 계약이 제공된 뒤에만 browser fixture를 만들고 현재 미제공 계약을 mock DTO로 추정하지 않는다. +- [x] Audio·Community target별 endpoint, query, DTO, page, 작성/수정/soft delete 응답을 기록한다. +- [x] root/direct reply 정확히 2단계인 server rule과 fan content 삭제 권한 오류 status/message key를 기록한다. +- [x] 계약이 없으면 target endpoint를 이름만 보고 추정하거나 client-only permission을 완료로 간주하지 않는다. +- [x] Audio detail·Community Sheet 진입별 thread/form/action inventory를 작성하고 host Page/Sheet는 target·query 조합, Comments component는 thread·permission 규칙, shared component는 Phase 1 contract를 사용하도록 component map을 확정한다. +- [x] target별 CRUD·2단계·권한 오류 계약이 제공된 뒤에만 browser fixture를 만들고 현재 미제공 계약을 mock DTO로 추정하지 않는다. ### Task 8.2 target adapter·2단계 thread @@ -2081,11 +3096,11 @@ GREEN → REFACTOR를 실행한다. 계약 전에는 test용 endpoint·DTO를 - **완료 증거:** 체크박스 전체, target contract/thread/state/page test와 Community 상태 보존 기록. - **범위 밖:** 작성자별 mutation action. -- [ ] UI target은 Audio와 Community를 명시적으로 구분하고 각 제공 endpoint로만 요청하는 contract test를 작성한다. -- [ ] Community 상세 GET 없이 목록 item의 `characterId/postId`로 Comments Sheet를 연다. -- [ ] Comments Sheet 종료 후 Community page·scroll 상태를 보존한다. -- [ ] root와 direct reply만 렌더링하고 reply에는 reply action이 없음을 test한다. -- [ ] long content, loading·empty·error·retry, page 갱신을 양 target에서 test한다. +- [x] UI target은 Audio와 Community를 명시적으로 구분하고 각 제공 endpoint로만 요청하는 contract test를 작성한다. +- [x] Community 상세 GET 없이 목록 item의 `characterId/postId`로 Comments Sheet를 연다. +- [x] Comments Sheet 종료 후 Community page·scroll 상태를 보존한다. +- [x] root와 direct reply만 렌더링하고 reply에는 reply action이 없음을 test한다. +- [x] long content, loading·empty·error·retry, page 갱신을 양 target에서 test한다. ### Task 8.3 작성자별 action @@ -2095,14 +3110,14 @@ GREEN → REFACTOR를 실행한다. 계약 전에는 test용 endpoint·DTO를 - **완료 증거:** 체크박스 전체, permission/mutation/session/read-only test, `OQ-009` 결정 기록. - **범위 밖:** fan edit와 reply의 reply. -- [ ] AI Character 작성 root/reply에는 edit·soft delete를 제공한다. -- [ ] fan 작성 root/reply에는 edit를 제공하지 않고 운영 soft delete만 제공한다. -- [ ] fan edit request는 type과 UI 양쪽에서 생성할 수 없음을 test한다. -- [ ] delete 전 대상과 영향을 확인하고 server 계약에 따라 tombstone 또는 목록 갱신을 적용한다. -- [ ] Character workspace read-only 정책이 모든 comment mutation도 차단하는 test를 작성한다. -- [ ] 중복 제출, server permission 오류, session 401/403이 공통 정책을 따르는지 test한다. -- [ ] 댓글 CRUD 계약 제공 후 초기 Comment thread/form을 실제 페이지에서 확인하고 `content` 최대 길이 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. -- [ ] 계약 제공 후 mock store로 양 target의 root/reply CRUD와 작성자별 권한 오류 최종 UI E2E를 작성한다. +- [x] AI Character 작성 root/reply에는 edit·soft delete를 제공한다. +- [x] fan 작성 root/reply에는 edit를 제공하지 않고 운영 soft delete만 제공한다. +- [x] fan edit request는 type과 UI 양쪽에서 생성할 수 없음을 test한다. +- [x] delete 전 대상과 영향을 확인하고 server 계약에 따라 tombstone 또는 목록 갱신을 적용한다. +- [x] Character workspace read-only 정책이 모든 comment mutation도 차단하는 test를 작성한다. +- [x] 중복 제출, server permission 오류, session 401/403이 공통 정책을 따르는지 test한다. +- [x] 댓글 CRUD 계약 제공 후 초기 Comment thread/form을 실제 페이지에서 확인하고 `content` 최대 길이 권고안을 작성한다. backend 호환 확인 전에는 상한을 구현하지 않는다. +- [x] 계약 제공 후 mock store로 양 target의 root/reply CRUD와 작성자별 권한 오류 최종 UI E2E를 작성한다. ### Task 8.4 Comments 반응형·접근성 @@ -2112,9 +3127,9 @@ GREEN → REFACTOR를 실행한다. 계약 전에는 test용 endpoint·DTO를 - **완료 증거:** 체크박스 전체, 320px·keyboard-only·focus 복귀·200% zoom·axe E2E 기록. - **범위 밖:** 모바일 기능 축소. -- [ ] desktop/tablet/mobile 모두 조회·작성·수정·soft delete를 제공한다. -- [ ] 320px에서 긴 댓글, reply indentation, action menu, keyboard 입력이 overflow하지 않는 E2E를 작성한다. -- [ ] keyboard-only root/reply 작성·수정·delete dialog, focus 복귀, 200% zoom, axe critical·serious 0건을 확인한다. +- [x] desktop/tablet/mobile 모두 조회·작성·수정·soft delete를 제공한다. +- [x] 320px에서 긴 댓글, reply indentation, action menu, keyboard 입력이 overflow하지 않는 E2E를 작성한다. +- [x] keyboard-only root/reply 작성·수정·delete dialog, focus 복귀, 200% zoom, axe critical·serious 0건을 확인한다. ### Phase 8 Gate @@ -2139,6 +3154,100 @@ npm run build 작성자별 action, 320px keyboard와 focus 복귀를 확인한다. 계약 미제공이면 Comments network request와 browser fixture가 0건인지 확인한다. +### Task R8.1 — Comment reply pagination과 empty state 완성 + +**Goal 실행 `P8-R1`:** 루트별 모든 direct reply page를 관리하고 댓글 0건을 명시적 empty state로 표시한다. + +- **연결 리뷰:** [Phase 8 재리뷰](./reviews/review-phase-8-20260729.md) — `REV-P8-001`, `REV-P8-002` +- **시작 조건:** `COMMENT-001`, `COMMENT-006~007`, reply response `totalCount/items`. +- **Files:** Modify: `src/features/comments/components/CommentThread.tsx`, `src/features/comments/tests/comment-thread.test.tsx`, `src/features/comments/tests/comment-contract.test.ts`, `tests/e2e/comments.spec.ts`, 필요 시 comment mock fixtures/store. +- **Interfaces:** root별 reply state에 `page,size=20,data`; mutation 뒤 현재 page refetch, 빈 root 목록에서도 create form 유지. +- **범위 밖:** 3단계 reply, reply page size 선택, infinite scroll. + +- [x] **RED:** 21개 reply에서 page 1 요청·표시, 마지막 reply 삭제 후 유효 page 복귀, root 0건 empty state test를 작성한다. +- [x] **GREEN:** root별 page state와 `ResourcePagination`, root empty `PageState`를 추가한다. +- [x] **REFACTOR:** reply loading/error/content state를 root page와 함께 정리하고 Audio/Community 소비 회귀를 실행한다. +- **실행 명령:** `npm run test:run -- src/features/comments src/features/audio-contents src/features/community-posts`; `npm run e2e:mock -- tests/e2e/comments.spec.ts`. +- **기대 결과:** exit 0, 21번째 이후 reply 접근 가능, reply-of-reply UI 0건, empty state와 create form 동시 노출. +- **수동 확인:** 320px/desktop에서 reply 다음·이전 page, mutation 후 현재 page와 keyboard focus를 확인한다. + +### Task R8.2 — Comments mock mutation 불변식 복구 + +**Goal 실행 `P8-R2`:** Comments mock store가 root/direct reply 2단계, AI 작성 row만 수정, 단일 row soft delete 계약을 그대로 재현하게 한다. + +- **연결 리뷰:** [Phase 8 현재 리뷰](./reviews/phase8-comments.md) — `REV-P8-003` +- **시작 조건:** `COMMENT-002~004`, `COMMENT-007`, `MOCK-005`, Audio·Community comment OpenAPI PUT/DELETE. +- **완료 증거:** mock handler 실패 재현 test, 최소 store 수정, Comments contract/unit·mock E2E·Phase Gate와 검증 기록. +- **Files:** Modify: `src/shared/mocks/comment-mock-store.ts`, `src/features/comments/tests/comment-contract.test.ts`, 필요 시 `tests/e2e/comments.spec.ts`. +- **Interfaces:** `parentId`는 같은 target의 활성 root만 허용하고, PUT은 `writerId === target creatorId`인 row만 허용하며, DELETE는 대상 row만 비활성화/제외하고 자식 row를 변경하지 않는다. +- **범위 밖:** 실제 backend 구현, 3단계 thread UI, 팬 댓글 수정 권한 확대. + +- [x] **RED:** reply를 parent로 한 POST와 팬 row PUT은 비2xx, root DELETE 뒤 자식 row 보존을 검증하는 Audio·Community mock contract test를 작성한다. +- [x] **GREEN:** store validation과 delete mutation을 계약에 맞게 수정한다. +- [x] **REFACTOR:** target/ownership/root 검증을 작은 predicate로 정리하고 UI의 팬 PUT 0건 test를 유지한다. +- **실행 명령:** `npm run test:run -- src/features/comments src/shared/mocks`; `npm run e2e:mock -- tests/e2e/comments.spec.ts`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 3단계 POST·팬 PUT 성공 0건, root DELETE 후 하위 reply 변경 0건. +- **수동 확인:** Audio와 Community mock에서 팬/AI row action과 root 삭제 후 목록 재조회 동작을 확인한다. + +**Progress — 2026-07-30 P8-R2 완료** + +- **RED:** `npm run test:run -- src/features/comments/tests/comment-contract.test.ts`는 신규 Audio·Community 3단계 POST, fan PUT, root DELETE 뒤 답글 보존 assertion에서 1 file / 3 failed / 3 passed로 실패했다. +- **GREEN:** `CommentMockStore`에 target root parent와 creator-owned predicate를 추가하고, DELETE filter를 대상 ID만 제외하도록 축소했다. 같은 focused contract test는 1 file / 6 tests passed였다. +- **REFACTOR:** API schema/path/UI는 변경하지 않았다. 기존 Chromium E2E의 fan PUT 0-request assertion을 유지했다. + +### Task R8.3 — Comments 공통 pagination size 계약 복구 + +**Goal 실행 `P8-R3`:** Audio·Community Comments adapter가 공통 `size >= 1`을 그대로 사용하고 FanTalk 전용 `20..50` clamp를 적용하지 않게 한다. + +- **연결 리뷰:** [Phase 8 현재 리뷰](./reviews/phase8-comments.md) — `REV-P8-004` +- **시작 조건:** PRD §11.1 공통 pagination, OpenAPI 공통 `Size` parameter의 default `20`, minimum `1`, maximum 없음. +- **완료 증거:** `size=1`, `size=51` request path 실패 test, adapter 최소 수정, Audio·Community Comments unit/mock E2E 통과. +- **Files:** Modify: `src/features/comments/api/comment-api.ts`, `src/features/comments/tests/comment-contract.test.ts`; 필요 시 Comments API adapter focused test. +- **Interfaces:** page는 기본 `0`·최소 `0`, size는 기본 `20`·최소 `1`; `20..50` 보정은 FanTalk에만 남긴다. +- **범위 밖:** Comments UI page-size option 변경, server pagination metadata 변경, FanTalk pagination 변경. + +- [x] **RED:** root/replies GET에서 `size=1`과 `size=51`이 각각 그대로 query에 남아야 한다는 contract test를 작성했다. +- [x] **GREEN:** Comments `normalizeSize`를 기본 20·최소 1만 적용하도록 수정했다. +- [x] **REFACTOR:** FanTalk 전용 규칙을 공유 helper로 끌어올리지 않고 Comments path 생성 중복만 현재 수준에서 유지했다. +- **실행 명령:** `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, `size=1`·`size=51` 보존, `size<=0`만 1로 보정, 기존 댓글 흐름 회귀 0건. +- **수동 확인:** 기본 `size=20` 화면과 reply pagination이 기존처럼 동작하는지 확인한다. + +**P8-R3 수정 검증 기록 (2026-07-30):** + +- RED: `npm run test:run -- src/features/comments/tests/comment-contract.test.ts` — 1 failed / 5 passed. root `size=1`이 `size=20`으로, replies `size=51`이 `size=50`으로 바뀌어 기대대로 실패했다. +- GREEN focused: 같은 command — 1 file / 6 tests passed. root/replies query가 `size=1`과 `size=51`을 보존함을 확인했다. +- REFACTOR/회귀: `npm run test:run -- src/features/comments` — 2 files / 11 tests passed. `npm run typecheck`, `npm run lint` — 모두 exit 0. +- LSP diagnostics: `src/features/comments/api/comment-api.ts`, `src/features/comments/tests/comment-contract.test.ts` — 오류 0건. +- E2E/수동 확인: 사용자 지시에 따라 개발 중 E2E는 실행하지 않고 `P10-R5` 이후 최종 E2E에서 확인한다. + +### Task R8.4 — 댓글 전송 실패 시 초안 보존과 재시도 복구 + +**Goal 실행 `P8-R4`:** 루트 댓글·답글 POST가 실패하면 입력 초안을 유지하고 같은 내용으로 재시도한 뒤 성공할 때만 입력을 비운다. + +- **연결 리뷰:** [Phase 8 현재 리뷰](./reviews/phase8-comments.md) — `REV-P8-005` +- **시작 조건:** PRD §10.5 오류·재시도 상태, 현재 `CommentThread.runMutation`이 오류를 내부 처리하고 성공처럼 resolve해 `CommentForm`이 실패 후에도 입력을 비우는 구조. +- **완료 증거:** Audio·Community 루트/답글 POST reject 후 textarea 값 유지와 재시도 성공 실패 test, submit 성공 여부 contract 최소 수정, Comments focused unit·mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/features/comments/components/CommentForm.tsx`, `src/features/comments/components/CommentThread.tsx`, `src/features/comments/tests/comment-thread.test.tsx`; Test: `tests/e2e/comments.spec.ts`. +- **Interfaces:** `CommentForm.onSubmit`은 mutation 성공 여부를 반환하고 입력은 성공일 때만 초기화한다. 기존 API payload, root/reply cache 재조회, 전역 pending guard는 유지한다. +- **범위 밖:** 댓글 localStorage draft, offline queue, optimistic update, edit form 동작 변경. +- **Phase 8 Gate 연결:** Audio·Community 루트/답글 실패·재시도 focused test와 Comments mock E2E, `typecheck`·`lint`·build가 모두 통과하기 전에는 종결하지 않는다. + +- [x] **RED:** 루트 댓글과 열린 답글 form의 첫 POST를 reject해 visible server error와 기존 textarea 값 유지, 두 번째 POST 성공 후 값 초기화를 기대하는 실패 test를 작성했다. +- [x] **GREEN:** `runMutation`과 submit callback이 성공 여부를 반환하게 하고 `CommentForm`은 성공한 경우에만 초안과 local error를 초기화했다. +- [x] **REFACTOR:** 새 form/state library 없이 현재 component contract만 최소 변경하고 update/delete 및 pending 재진입 회귀를 확인했다. +- **실행 명령:** `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`. +- **기대 결과:** 모든 명령 exit 0, POST 실패 후 루트/답글 초안 유지, 같은 form 재시도 성공, 성공 후에만 textarea 초기화, 중복 request 0건. +- **수동 확인:** mock 500 응답에서 루트 댓글과 답글을 각각 전송해 오류·초안 유지·재시도를 확인한다. + +**P8-R4 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` — 1 failed / 5 passed. 루트 댓글 POST 실패 후 `CommentForm` textarea가 빈 값으로 바뀌어 기대대로 실패했다. +- GREEN: `CommentForm.onSubmit`이 성공 여부를 반환하게 하고, `CommentThread.runMutation`이 성공 시 `true`, 실패·pending 재진입 차단 시 `false`를 반환하게 했다. 입력값과 local error는 성공한 경우에만 초기화된다. +- Focused: `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` — 1 file / 6 tests passed. 루트와 답글 모두 첫 실패 후 초안 유지, 같은 form 재시도 성공 후 값 초기화를 확인했다. +- REFACTOR/회귀: `npm run test:run -- src/features/comments` — 3 files / 13 tests passed. `src/features/comments` LSP diagnostics — 5 TSX files / 0 diagnostics. 새 form/state library나 optimistic/localStorage draft는 추가하지 않았다. +- E2E/수동 확인: 사용자 지시에 따라 개발 중 반복 E2E는 실행하지 않고 최종 회귀 단계에서 Comments mock E2E를 필요 시 실행한다. + --- ## Phase 9. 교차 회귀·인수인계 @@ -2148,7 +3257,7 @@ Comments network request와 browser fixture가 0건인지 확인한다. **Phase Goal `P9`:** Task 9.1 → 9.3과 Phase 9 Gate로 활성 릴리스 범위의 최종 인수 증거를 완성한다. - **시작 조건:** 활성 범위의 `P0-GATE`~`P8-GATE` 완료 또는 명시적 제외 결정. -- **완료 조건:** `P9-T1`~`P9-T3`, `P9-GATE` 완료, mock/server 전체 자동 Gate 0 failure/0 error와 수동 검증 기록. +- **완료 조건:** `P9-T1`~`P9-T3`, `P9-GATE` 완료, 활성 mock UI와 server allowlist 자동 Gate 0 failure/0 error, 수동 검증 기록. 도메인별 server integration은 별도 backend 계약·server-mode allowlist가 충족될 때 완료한다. - **실행 순서:** 교차 journey/오류 → 반응형/접근성/보안 → 문서/품질 → 전체 Gate. **주요 Files:** @@ -2178,14 +3287,14 @@ server mode 회귀·typecheck·lint·build를 이어서 수행한다. `P9-T3`는 - **완료 증거:** 체크박스 전체, resource-workflows/error-mapping E2E와 request 0회·serializer fixture 기록. - **범위 밖:** 새 기능과 계약 미제공 제외 범위의 가짜 journey. -- [ ] login → Character select → Audio immediate/scheduled create/play → Series link/order → Community Sheet → FanTalk list/reply → Comments 중 계약이 제공된 활성 범위 journey를 검증한다. -- [ ] 400/401/403/404/405/406/415/500 fixture가 공통 한국어 message와 올바른 route/session 처리를 하는지 검증한다. -- [ ] Character·Audio·Series soft delete는 목록 이동, Community soft delete는 Sheet 종료·목록 제거로 끝나는지 검증한다. -- [ ] inactive Character workspace에서 모든 하위 mutation request가 0건인지 검증한다. -- [ ] media error로 Audio/Community GET·URL 재발급·자동 `play()`가 발생하지 않는지 검증한다. -- [ ] 생성·일반 수정·soft delete serializer 불변식을 모든 도메인 fixture에서 다시 검증한다. -- [ ] 모든 활성 도메인의 mock handler가 정규화 endpoint·DTO를 사용하고 실제 backend request 0건으로 최종 UI journey를 재현하는지 검증한다. -- [ ] server mode의 404·network error가 mock으로 전환되지 않고 실제 오류 UI와 연동 대기 기록으로 남는지 검증한다. +- [x] login → Character select → Audio immediate/scheduled create/play → Series link/order → Community Sheet → FanTalk list/reply → Comments 중 계약이 제공된 활성 범위 journey를 검증한다. +- [x] 400/401/403/404/405/406/415/500 fixture가 공통 한국어 message와 올바른 route/session 처리를 하는지 검증한다. +- [x] Character·Audio·Series soft delete는 목록 이동, Community soft delete는 Sheet 종료·목록 제거로 끝나는지 검증한다. +- [x] inactive Character workspace에서 모든 하위 mutation request가 0건인지 검증한다. +- [x] media error로 Audio/Community GET·URL 재발급·자동 `play()`가 발생하지 않는지 검증한다. +- [x] 생성·일반 수정·soft delete serializer 불변식을 모든 도메인 fixture에서 다시 검증한다. +- [x] 모든 활성 도메인의 mock handler가 정규화 endpoint·DTO를 사용하고 실제 backend request 0건으로 최종 UI journey를 재현하는지 검증한다. +- [x] server mode의 404·network error가 mock으로 전환되지 않고 실제 오류 UI와 연동 대기 기록으로 남는지 검증한다. ### Task 9.2 반응형·접근성·보안 회귀 @@ -2195,16 +3304,16 @@ server mode 회귀·typecheck·lint·build를 이어서 수행한다. `P9-T3`는 - **완료 증거:** 체크박스 전체, table-driven E2E·axe·수동 browser/keyboard/zoom/security·UX 검색 기록. - **범위 밖:** 다크 모드 구현과 외부 WCAG 인증. -- [ ] PRD `9` 기능 matrix를 table-driven E2E data로 검증한다. -- [ ] 320, 640, 768, 1024, 1280px와 landscape에서 overflow·가려진 keyboard·44px target을 확인한다. -- [ ] desktop Chrome/Edge/Safari와 mobile Chrome/Safari 최신 2개 주요 버전 범위를 실제 지원 환경에서 확인한다. -- [ ] 모든 핵심 route에서 axe critical·serious 위반 0건을 확인한다. -- [ ] keyboard-only, first-error-focus, dialog focus 복귀, skip link, live region, reduced motion, 200% zoom을 수동 검증한다. -- [ ] system dark mode에서도 밝은 token을 유지하고 theme toggle이 없음을 검증한다. -- [ ] JWT, password, signed URL, file body가 log·storage·분석 event에 남지 않는지 검증한다. -- [ ] 각 Page가 승인된 component map대로 route/query/permission과 component 조합만 담당하고, domain 상호작용이 feature/shared component test로 분리됐는지 review한다. -- [ ] PRD `10.9`의 UX 검증 검색을 다시 실행하고 채택·제외 결과를 기록한다. -- [ ] mock mode banner가 모든 viewport에서 핵심 UI를 가리지 않고 실제 server와 혼동되지 않는지 확인한다. +- [x] PRD `9` 기능 matrix를 table-driven E2E data로 검증한다. +- [x] 320, 640, 768, 1024, 1280px와 landscape에서 overflow·가려진 keyboard·44px target을 확인한다. +- [x] 로컬 자동 Gate의 Chromium/mobile Chrome matrix를 확인한다. WebKit·Mobile Safari와 실제 Edge/Safari 기기 QA는 2026-07-31 제품 결정으로 지원 범위에서 제외한다. +- [x] 모든 핵심 route에서 axe critical·serious 위반 0건을 확인한다. +- [x] keyboard-only, first-error-focus, dialog focus 복귀, skip link, live region, reduced motion, 200% zoom을 수동 검증한다. +- [x] system dark mode에서도 밝은 token을 유지하고 theme toggle이 없음을 검증한다. +- [x] JWT, password, signed URL, file body가 log·storage·분석 event에 남지 않는지 검증한다. +- [x] 각 Page가 승인된 component map대로 route/query/permission과 component 조합만 담당하고, domain 상호작용이 feature/shared component test로 분리됐는지 review한다. +- [x] PRD `10.9`의 UX 검증 검색을 다시 실행하고 채택·제외 결과를 기록한다. +- [x] mock mode banner가 모든 viewport에서 핵심 UI를 가리지 않고 실제 server와 혼동되지 않는지 확인한다. ### Task 9.3 문서·품질 Gate @@ -2214,15 +3323,15 @@ server mode 회귀·typecheck·lint·build를 이어서 수행한다. `P9-T3`는 - **완료 증거:** 체크박스 전체, PRD 수용 기준별 증거 연결, 독립 code review 수정과 관련 Gate 재실행 기록. - **범위 밖:** 결정되지 않은 계약을 문서상 확정하는 행위. -- [ ] 활성 범위의 P0 외부 의존이 0건인지, 아니면 구현 전에 명시적으로 후속/제외 결정됐는지 확인한다. -- [ ] OQ-009의 확정 절차에 따라 각 초기 UI의 최대 길이·배열 개수 권고안, backend 호환 결과와 실제 값 또는 “상한 추가 없음”을 기록하고 중복 checklist를 남기지 않는다. -- [ ] `OQ-010` 감사 로그 UI가 현재 릴리스 non-goal임을 결정 기록과 맞춘다. -- [ ] 실제 구현과 다른 제품 결정은 PRD 결정 기록 → plan 순으로 갱신한다. API 사실이 달라졌다면 backend가 제공한 새 OpenAPI 계약을 먼저 반영한 뒤 두 문서를 맞춘다. -- [ ] PRD 수용 기준마다 자동 test 또는 수동 검증 증거를 연결한다. -- [ ] README에 install, env, run, test, build, 지원 브라우저, 알려진 backend 제약을 기록한다. -- [ ] plan 하단 검증 기록에 무엇을/왜/어떻게와 실제 명령·성공/실패/불가 사유를 누적한다. -- [ ] 별도 code review를 받고 지적사항 수정 후 관련 Phase Gate와 전체 Gate를 다시 실행한다. -- [ ] 각 도메인의 `UI 확인 완료(mock)`와 `실제 서버 연동 완료(server)`가 Progress·추적표·알려진 제약에서 일치하는지 확인한다. +- [x] 활성 범위의 P0 외부 의존이 0건인지, 아니면 구현 전에 명시적으로 후속/제외 결정됐는지 확인한다. +- [x] OQ-009의 확정 절차에 따라 각 초기 UI의 최대 길이·배열 개수 권고안, backend 호환 결과와 실제 값 또는 “상한 추가 없음”을 기록하고 중복 checklist를 남기지 않는다. +- [x] `OQ-010` 감사 로그 UI가 현재 릴리스 non-goal임을 결정 기록과 맞춘다. +- [x] 실제 구현과 다른 제품 결정은 PRD 결정 기록 → plan 순으로 갱신한다. API 사실이 달라졌다면 backend가 제공한 새 OpenAPI 계약을 먼저 반영한 뒤 두 문서를 맞춘다. +- [x] PRD 수용 기준마다 자동 test 또는 수동 검증 증거를 연결한다. +- [x] README에 install, env, run, test, build, 지원 브라우저, 알려진 backend 제약을 기록한다. +- [x] plan 하단 검증 기록에 무엇을/왜/어떻게와 실제 명령·성공/실패/불가 사유를 누적한다. +- [x] 별도 code review를 받고 지적사항 수정 후 관련 문서 검증을 다시 실행한다. 전체 Gate는 `P9-GATE`에서 별도로 실행한다. +- [x] 각 도메인의 `UI 확인 완료(mock)`와 `실제 서버 연동 완료(server)`가 Progress·추적표·알려진 제약에서 일치하는지 확인한다. ### Phase 9 Gate @@ -2262,6 +3371,1105 @@ assert_no_match "externalCharacterId|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FR 200% zoom, 민감정보 비기록, mock banner와 server mode를 확인하고 외부 의존 범위를 완료로 표시하지 않았는지 PRD·README·Progress를 대조한다. +### Task R9.1 — inactive Character 하위 mutation 일괄 차단 + +**Goal 실행 `P9-R1`:** `isActive=false` Character workspace에서 조회만 유지하고 모든 하위 mutation 진입점과 직접 route를 차단한다. + +- **연결 리뷰:** [Phase 9 재리뷰](./reviews/review-phase-9-20260729.md) — `REV-P9-001` +- **시작 조건:** `CHAR-007`, PRD `7.2`; backend ownership/active 검증을 대체하지 않는다. +- **Files:** Modify: `CharacterEditPage.tsx`, Audio list/detail/form Page, Series list/detail/form/order Page와 capability hook, `CommunityPostSheet.tsx`, `CommentThread.tsx` 또는 소비 props, 관련 feature test, `tests/e2e/resource-workflows.spec.ts`, domain E2E. +- **Interfaces:** `canMutate = character.isActive && viewportCapability`; false일 때 network mutation 0건, read-only list/detail/player/comment 조회는 유지. +- **범위 밖:** backend active-only 구현, 비활성 Character 복원, 모바일 capability 확대. + +- [x] **RED:** inactive Character의 Character edit, Audio create/edit/comment, Series create/edit/link/order, Community comment mutation이 현재 노출/호출되는 E2E를 실패시킨다. +- [x] **GREEN:** 각 Page가 Character 응답으로 mutation capability를 계산해 form/action/direct route를 guidance/read-only 상태로 교체한다. +- [x] **REFACTOR:** viewport와 active 조건의 이름을 분리하고 boolean prop을 필요한 feature component까지만 전달한다. +- **실행 명령:** `npm run test:run -- src/features/characters src/features/audio-contents src/features/series src/features/community-posts src/features/comments`; `npm run e2e:mock -- tests/e2e/resource-workflows.spec.ts tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/series.spec.ts tests/e2e/comments.spec.ts`. +- **기대 결과:** exit 0, inactive workspace mutation request 0건, 조회 request와 read-only banner 유지. +- **수동 확인:** 320/768/1280px 직접 URL 포함 모든 도메인의 read-only action inventory를 확인한다. + +### Task R9.2 — field error 연결과 첫 오류 focus + +**Goal 실행 `P9-R2`:** 모든 주요 form에서 오류 문구를 입력과 programmatic하게 연결하고 submit 뒤 첫 invalid control에 focus한다. + +- **연결 리뷰:** [Phase 9 재리뷰](./reviews/review-phase-9-20260729.md) — `REV-P9-002` +- **시작 조건:** PRD `10.5`, `10.7`; 기존 visible label과 live alert를 유지한다. +- **Files:** Modify: Character/Audio/Series/Community/FanTalk/Comment form components와 tests, 필요 시 `src/shared/ui`의 작은 error-id/focus helper, `tests/e2e/accessibility.spec.ts`. +- **Interfaces:** field별 stable error ID, `aria-describedby` 또는 `aria-errormessage`, `aria-invalid=true`, DOM 순서 첫 invalid focus. +- **범위 밖:** form library 전환, 서버 `errorProperty` 추정 mapping. + +- [x] **RED:** 각 도메인 대표 필드의 accessible description과 multi-error 첫 focus 실패 test를 작성한다. +- [x] **GREEN:** 현재 markup에 ID/ref를 추가하고 submit validation 뒤 첫 error focus를 적용한다. +- [x] **REFACTOR:** 실제 2개 이상 소비되는 최소 helper만 shared로 올리고 axe/keyboard 회귀를 실행한다. +- **실행 명령:** `npm run test:run -- src/features`; `npm run e2e:mock -- tests/e2e/accessibility.spec.ts`. +- **기대 결과:** exit 0, invalid field 연결 누락 0건, 첫 error focus 일치, axe critical·serious 0건. +- **수동 확인:** screen reader/keyboard로 Character·Audio·Series·Community 대표 invalid submit을 확인한다. + +### Task R9.3 — Phase 9 과거 리뷰 상태 종결 + +**Goal 실행 `P9-R3`:** 완료된 `P9-R1~R2`를 기존 Phase 9 리뷰의 발견 상태·최종 결론·남은 항목에 누적 반영한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-003` +- **시작 조건:** `plan-task.md`의 2026-07-30 `P9-R1`, `P9-R2`, `P9-GATE` 완료 증거. +- **완료 증거:** 과거 리뷰를 삭제·이름 변경하지 않고 판정 기록과 수정 후 검증을 누적하며 stale 현재 상태 검색과 Markdown link/diff 검증 통과. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/reviews/review-phase-9-20260729.md`; Test: 문서 검색. +- **Interfaces:** `REV-P9-001~002` 상태는 `수정 완료`, 최종 결론은 수정 검증 완료, 남은 실제 server QA는 P10 수동 QA로 구분한다. +- **범위 밖:** 2026-07-29 당시 실행 수치 삭제·재작성, 애플리케이션 코드 변경. + +- [x] **TDD 예외:** 문서 상태 Task이므로 `확정`, `P9-R1`, `P9-R2`, `수정 goal 필요`의 stale 현재 문구 검색을 RED 대체 증거로 기록한다. +- [x] 완료 증거를 기존 판정 기록과 §9에 날짜순으로 누적하고 과거 실패 이력은 보존한다. +- [x] 새 Phase 9 리뷰와 plan link, stale 현재 상태 0건, `git diff --check`를 검증한다. +- **실행 명령:** `rg -n 'REV-P9-00[12]|P9-R[12]|수정 goal 필요' docs/20260725_AI캐릭터관리자웹/reviews/review-phase-9-20260729.md`; `git diff --check -- docs/20260725_AI캐릭터관리자웹`. +- **기대 결과:** 과거 사실 보존, 현재 finding 2건 `수정 완료`, 남은 자동 Gate 오류 0건. +- **수동 확인:** 2026-07-29 판정과 2026-07-30 수정 검증이 시간순으로 구분되는지 확인한다. + +**Progress — 2026-07-30 P9-R3 완료** + +- **RED 대체:** `rg -n 'REV-P9-00[12]|P9-R[12]|수정 goal 필요|확정' docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`가 현재 리뷰의 `REV-P9-003` 확정 상태와 과거 리뷰 미해결 설명을 검출했다. +- **GREEN:** `review-phase-9-20260729.md`에 `REV-P9-001~002` 수정 완료 상태, `P9-R1~R2`와 `P9-GATE` 검증 기록, 최신 최종 결론을 누적했다. `phase9-cross-cutting-quality.md`도 `REV-P9-003` 수정 완료로 정리했다. +- **REFACTOR:** 과거 2026-07-29 실패·timeout 이력은 삭제하지 않고, 현재 상태 문구만 최신 검증 기준으로 분리했다. + +### Task R9.4 — 일반 mutation pending·실패 복구 경계 보강 + +**Goal 실행 `P9-R4`:** 공통 비활성화와 Series 연결·해제·순서 저장이 빠른 반복 입력에도 요청을 한 번만 보내고 실패를 화면에서 복구 가능하게 한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-004` +- **시작 조건:** PRD §13의 파일 업로드 외 일반 mutation 중복 제출 차단과 공통 오류·재시도 기준. +- **완료 증거:** pending Promise 기반 빠른 이중 입력·reject 실패 test, 최소 pending guard와 오류 UI, 관련 Phase focused test·mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/shared/ui/confirm-deactivate-dialog.tsx`, `src/features/characters/pages/CharacterDetailPage.tsx`, `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/series/components/SeriesForm.tsx`, `src/features/series/components/SeriesContents.tsx`, `src/features/series/pages/SeriesOrderPage.tsx`, 관련 tests와 필요 시 `tests/e2e/{character-workspace,audio-content,series}.spec.ts`. +- **Interfaces:** pending 동안 같은 mutation은 동기 guard로 1회만 실행하고 관련 controls는 disabled/`aria-busy` 상태를 제공한다. reject 시 dialog/page에 `role=alert` 오류를 표시하고 재시도를 허용한다. +- **범위 밖:** 범용 mutation state library, upload progress(`P4-R2`), backend idempotency key, 모든 read request debounce. + +- [x] **RED:** Character/Audio/Series 비활성화와 Series 연결·해제·순서 저장의 pending Promise에서 빠른 이중 click을 보내 request 1회를 기대하는 실패 test, deactivate reject의 visible alert test를 작성한다. +- [x] **GREEN:** 각 mutation 경계에 synchronous guard와 pending state를 추가하고 공통 confirm dialog가 pending/오류 상태를 접근 가능하게 표시하도록 확장한다. +- [x] **REFACTOR:** 비활성화 dialog의 공통 UI만 공유하고 서로 다른 도메인 mutation을 새 범용 hook으로 추상화하지 않는다. +- **실행 명령:** `npm run test:run -- src/shared/ui src/features/characters src/features/audio-contents src/features/series`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/series.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, 각 대상 mutation의 pending 중 요청 최대 1회, 실패 시 unhandled rejection 0건과 visible recovery 안내. +- **수동 확인:** 느린 network에서 confirm/link/unlink/order 버튼 연타, disabled 상태, 실패 후 재시도를 keyboard-only로 확인한다. + +#### 실행 기록 — 2026-07-30 + +- **RED:** `npm run test:run -- src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx src/features/series/tests/series-order.test.tsx src/features/series/tests/series-contents.test.tsx`는 공통 dialog pending/error UI와 Series 연결·해제·순서 저장 pending guard 부재로 실패했고, `npm run test:run -- src/features/characters/tests/CharacterDetailPage.test.tsx src/features/audio-contents/tests/audio-form-update.test.tsx src/features/series/tests/series-form.test.tsx`는 Character/Audio/Series deactivate pending label과 visible failure alert 부재로 실패했다. +- **GREEN:** `ConfirmDeactivateDialog`에 `isPending`, `errorMessage`, disabled controls, `aria-busy`, `role=alert`를 추가했다. Character/Audio/Series deactivate, Series 연결·해제, Series 순서 저장 경계에 synchronous guard와 pending state를 추가해 pending 중 request 1회와 실패 후 재시도를 보장했다. +- **REFACTOR:** 도메인별 mutation은 각 소유 컴포넌트에 그대로 두고, 공통화는 deactivate dialog 표시 상태에만 제한했다. 새 범용 mutation hook이나 idempotency key는 만들지 않았다. +- **검증:** `npm run test:run -- src/features/characters/tests/CharacterDetailPage.test.tsx src/features/audio-contents/tests/audio-form-update.test.tsx src/features/series/tests/series-form.test.tsx src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx src/features/series/tests/series-order.test.tsx src/features/series/tests/series-contents.test.tsx` 결과 6 files / 28 tests passed. 변경 test 파일 LSP diagnostics는 모두 clean이었다. E2E는 사용자 지시에 따라 `P10-R5` 이후 최종 실행으로 보류했다. + +### Task R9.5 — Series mutation pending 표시 회귀 복구 + +**Goal 실행 `P9-R5`:** Series 연결·해제·순서 저장의 동기 중복 guard와 별개로 운영자가 인지할 수 있는 pending 상태와 관련 control 비활성화를 복구한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-005` +- **시작 조건:** PRD §10.5의 저장 중 button 비활성화·진행 표시, 300ms feedback와 완료 기록 `P9-R4`의 `disabled`/`aria-busy` interface. +- **완료 증거:** pending Promise 동안 visible 상태와 관련 control 비활성화를 기대하는 실패 test, ref guard를 보존한 reactive state 최소 수정, Series focused unit·mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/features/series/components/SeriesContents.tsx`, `src/features/series/components/SeriesOrderList.tsx`, `src/features/series/pages/SeriesOrderPage.tsx`, 관련 Series tests와 `tests/e2e/series.spec.ts`. +- **Interfaces:** `isLinkingRef`/`isUnlinkingRef`/`isSavingRef`의 동기 1회 guard는 유지하고 React pending state로 link 후보·unlink dialog·order move/save control을 비활성화하며 `aria-busy` 또는 visible 진행 문구를 제공한다. reject 뒤 재시도를 허용한다. +- **범위 밖:** 범용 mutation hook, backend idempotency key, Series API payload 변경. + +- [x] **RED:** link/unlink/order pending Promise 중 button 연타 request 1회뿐 아니라 각 관련 control의 disabled·진행 접근성 상태를 기대하는 실패 test를 작성한다. +- [x] **GREEN:** 세 mutation에 reactive pending state를 추가하고 `finally`에서 해제하며 dialog/order list에 필요한 prop만 전달한다. +- [x] **REFACTOR:** 동기 guard와 화면 state의 서로 다른 책임을 이름으로 구분하고 공통 hook을 만들지 않는다. +- **실행 명령:** `npm run test:run -- src/features/series/tests/series-contents.test.tsx src/features/series/tests/series-order.test.tsx`; `npm run e2e:mock -- tests/e2e/series.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, pending 중 관련 mutation request 최대 1회와 control 조작 0회, 진행 상태 노출, 실패 뒤 재시도 성공. +- **수동 확인:** 느린 network에서 link/unlink/order save를 연타하고 disabled·진행 문구·오류 후 재시도를 keyboard-only로 확인한다. + +**P9-R5 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/features/series/tests/series-contents.test.tsx src/features/series/tests/series-order.test.tsx`는 pending 중 link/save 버튼 disabled 기대에서 실패해 visible pending 상태 부재를 재현했다. +- GREEN/REFACTOR: `SeriesContents`에 `isLinking`/`isUnlinking`, `SeriesOrderPage`에 `isSaving` state를 추가하고, 기존 ref guard는 유지했다. `SeriesOrderList`에는 `isDisabled` prop만 전달해 순서 이동 control을 저장 중 비활성화했다. 새 범용 hook은 만들지 않았다. +- 검증: focused 2 files / 9 tests passed, `npm run test:run -- src/features/series`는 8 files / 37 tests passed. `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. `src/features/series` LSP diagnostics는 17 files, 0 diagnostics였다. E2E는 사용자 지시에 따라 전체 Task 완료 후 필요 시 실행한다. + +### Task R9.6 — 일반 mutation 재진입·진행 피드백 경계 종결 + +**Goal 실행 `P9-R6`:** Series form, Community, FanTalk, Comments의 일반 mutation이 같은 화면에서 pending 중 재진입하지 않고 운영자와 보조기기에 진행·실패 상태를 전달하도록 한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-006` +- **시작 조건:** PRD §10.5의 저장 중 button 비활성화·진행 표시와 300ms 피드백, §13의 파일 upload 외 일반 mutation 중복 제출 차단. +- **완료 증거:** pending Promise에 같은 submit/click을 연속 전달해 request 1회와 accessible progress를 기대하는 실패 test, FanTalk 미답변 원글 DELETE reject의 visible alert test, 관련 Phase focused unit·mock Chromium E2E·정적 Gate 통과. +- **Files:** Modify: `src/features/series/components/SeriesForm.tsx`, `src/features/community-posts/components/{CommunityPostForm,CommunityPostSheet}.tsx`, `src/features/fan-talks/components/{FanTalkReplyForm,FanTalkReplySheet}.tsx`, `src/features/comments/components/{CommentForm,CommentItem,CommentThread}.tsx`, 관련 tests와 필요 시 도메인 E2E. +- **Interfaces:** 각 mutation 경계는 state 갱신 전에도 동작하는 동기 guard로 요청을 1회만 허용한다. pending 동안 관련 control을 비활성화하고 `role=status`, `aria-busy`, 또는 처리 중 label 중 하나로 진행을 노출한다. FanTalk DELETE 실패는 답변 유무와 무관하게 visible alert와 재시도를 제공한다. +- **범위 밖:** 범용 mutation hook/state library, backend idempotency key, read request debounce, upload progress, API payload 변경. +- **Phase 9 Gate 연결:** 기존 Gate·완료 기록은 보존하되 `P9-R6` 대상 Phase 5~8 focused test, mock Chromium Series·Community·FanTalk·Comments E2E, `typecheck`·`lint`·`build` 통과 전에는 이번 교차 회귀를 종결하지 않는다. +- **검증 기록:** 도메인별 RED/GREEN test 수, 중복 request count, 실패 후 재시도, E2E·정적 Gate 결과를 이 Task 하단과 Phase 9 리뷰 §9 이후에 날짜순으로 누적한다. + +- [x] **RED:** Series create/update, Community create/update/fixed, Comments create/update/delete를 pending으로 두고 빠른 재입력 request 1회와 진행 상태를 기대하는 실패 test를 작성한다. FanTalk 미답변 원글 DELETE reject가 visible alert와 재시도를 제공하는 실패 test도 추가한다. +- [x] **GREEN:** 대상 handler에 최소 동기 guard를 추가하고 기존 `isSaving`을 disabled·accessible progress에 연결하며 FanTalk confirm dialog에 pending/error 상태를 전달한다. +- [x] **REFACTOR:** 공통화는 기존 `ConfirmDeactivateDialog` interface 재사용까지만 허용하고 서로 다른 mutation을 새 hook으로 통합하지 않는다. +- **실행 명령:** `npm run test:run -- src/features/series src/features/community-posts src/features/fan-talks src/features/comments`; `npm run e2e:mock -- tests/e2e/series.spec.ts tests/e2e/community.spec.ts tests/e2e/fan-talk.spec.ts tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, 대상별 pending 중 mutation request 최대 1회, 진행 상태 노출, reject 후 visible 오류와 재시도 성공. +- **수동 확인:** 느린 network에서 keyboard submit·빠른 click을 반복하고 disabled·focus·상태 안내·실패 후 재시도를 각 화면에서 확인한다. + +**P9-R6 완료 기록 (2026-07-31):** + +- 무엇을: Series form, Community create/Sheet save/fixed, Comments mutation, FanTalk 미답변 원글 DELETE에 pending 중 동기 재진입 guard와 접근 가능한 진행/실패 피드백을 추가했다. +- 왜: `REV-P9-006`에서 같은 화면의 일반 mutation이 React state 반영 전 재진입하거나 실패 상태를 보조기기와 운영자에게 전달하지 못하는 경계가 확인됐기 때문이다. +- RED: `npm run test:run -- src/features/series/tests/series-form-pending.test.tsx src/features/community-posts/tests/community-mutation-pending.test.tsx src/features/comments/tests/comment-thread-pending.test.tsx src/features/fan-talks/tests/fan-talk-delete-pending.test.tsx`는 구현 전 4 files / 5 tests가 기대대로 실패했다. 실패 원인은 pending `role=status` 부재와 FanTalk DELETE 실패 alert/재시도 미노출이었다. +- GREEN: 같은 focused 명령은 구현 후 4 files / 5 tests passed였다. `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/community-posts/tests/community-mutation-pending.test.tsx`는 2 files / 9 tests passed, `npm run test:run -- src/features/series src/features/comments`는 12 files / 50 tests passed, `npm run test:run -- src/features/fan-talks`는 4 files / 14 tests passed였다. +- REFACTOR: 새 범용 mutation hook/state library는 만들지 않고 각 도메인 handler의 `useRef` guard와 기존 `isSaving` 상태만 연결했다. 공통화는 이미 존재하는 `ConfirmDeactivateDialog`의 `isPending`/`errorMessage` prop 재사용으로 제한했다. +- 회귀 확인: `npm run test:run -- src/features/series src/features/community-posts src/features/comments src/features/fan-talks`는 120초 timeout 중 `community-sheet.test.tsx` 목록 로딩 실패를 1건 표시했다. 이후 실패 spec 단독, pending 인접 spec, FanTalk 단독, Series+Comments 단독 실행은 모두 통과해 넓은 병렬 실행의 비결정 로딩 실패로 분리 기록한다. `npm run e2e:mock -- tests/e2e/series.spec.ts tests/e2e/community.spec.ts tests/e2e/fan-talk.spec.ts tests/e2e/comments.spec.ts --project=chromium`은 24 tests passed였다. `npm run typecheck`, `npm run lint`, `npm run build`, 관련 파일 `git diff --check`는 모두 exit 0이었다. + +### Task R9.7 — Safari/WebKit 지원 Gate 복원 (제품 결정으로 대체됨) + +**Goal 실행 `P9-R7`:** PRD와 완료된 `P9-T2`가 요구하는 desktop WebKit·mobile Safari Playwright project를 복원하고 네 browser project의 mock/server Gate를 다시 통과시킨다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-007` +- **시작 조건:** 과거 PRD §13과 `P9-T2`가 요구했던 Safari/WebKit Gate 복원 이력. 2026-07-31 제품 결정 이후 현재 지원 범위는 Chromium/mobile Chrome이다. +- **완료 증거:** WebKit project 부재 실패, 두 Safari 계열 project 복원, 기존 skip은 platform capability 근거가 있는 keyboard-only case로 한정, exact full mock/server E2E와 browser support 문서 정합성 검증 기록. +- **Files:** Modify: `playwright.config.ts`, `README.md`, `tests/e2e/character-workspace.spec.ts`; Test: `tests/e2e/smoke.spec.ts`, `tests/e2e/auth.spec.ts`, `tests/e2e/accessibility-shell.spec.ts`, `tests/e2e/mock-preview-shell.spec.ts`, `tests/e2e/mock-mode-boundary.spec.ts`, `tests/e2e/audio-content.spec.ts`, `tests/e2e/series.spec.ts`, `tests/e2e/community.spec.ts`, `tests/e2e/fan-talk.spec.ts`, `tests/e2e/comments.spec.ts`, `tests/e2e/resource-workflows.spec.ts`, `tests/e2e/error-mapping.spec.ts`, `tests/e2e/responsive-capabilities.spec.ts`, `tests/e2e/accessibility.spec.ts`. +- **Interfaces:** project 이름은 기존 기록과 같은 `webkit`, `mobile-safari`를 사용한다. Safari 범위를 줄이려면 이 Task에서 임의 변경하지 않고 별도 제품 결정으로 PRD부터 갱신한다. +- **범위 밖:** Edge engine 자동화 추가, 실제 기기·최신 2개 major 전체 자동화, 테스트 실패를 숨기기 위한 project 제거나 근거 없는 skip, 제품 기능 확장. +- **Phase 9 Gate 연결:** 네 project의 exact `e2e:mock`, server allowlist, browser support 정적 대조가 모두 통과하기 전에는 Phase 9 교차 품질 회귀를 종결하지 않는다. + +- [x] **RED:** `npx playwright test --list --project=webkit`의 project-not-found를 기록하고 현재 README와 PRD browser matrix 불일치를 고정하는 정적 assertion을 추가한다. +- [x] **GREEN:** Desktop Safari와 iPhone Safari device project를 기존 이름으로 복원하고 검증된 app/test root cause만 최소 수정한다. +- [x] **REFACTOR:** browser별 skip을 keyboard focus 같은 문서화된 platform capability로만 제한하고 dirty-leave·mutation·contract 흐름은 네 project에서 유지한다. +- **실행 명령:** 과거 복원 당시 `npx playwright install webkit`; `npx playwright test --list --project=webkit`; `npx playwright test --list --project=mobile-safari`; `npm run e2e:mock`; `npm run e2e`; `npm run typecheck`; `npm run lint`; `npm run build`를 사용했다. 현재 검증은 Chromium/mobile Chrome만 사용한다. +- **기대 결과:** 현재 기준으로 `chromium`·`mobile-chrome`만 수집·실행되고 PRD·README·Gate matrix 불일치 0건. +- **수동 확인:** Safari/WebKit 수동 확인은 현재 지원 범위에서 제외한다. + +**P9-R7 수정 검증 기록 (2026-07-31):** + +> 2026-07-31 제품 결정: 이 복원 기록은 과거 이력으로만 보존한다. 현재 지원·자동 검증 범위는 Chromium/mobile Chrome이며 WebKit·Mobile Safari는 지원 범위에서 제외한다. + +- 무엇을: `webkit`과 `mobile-safari` Playwright project를 복원하고, README/PRD의 browser support 문구와 mock Gate 문서 정합성을 맞췄다. 긴 WebKit 계열 mock run에서 `/login` navigation이 누적 timeout을 내던 `mock-preview-shell.spec.ts`는 같은 검증을 단일 로그인 세션으로 합쳐 반복 navigation을 제거했다. +- 왜: 당시 PRD §13과 완료된 `P9-T2`가 Chromium/WebKit/mobile Chrome/mobile Safari 자동 Gate를 요구하지만, 설정에는 Chromium/mobile Chrome만 남아 Phase 9 acceptance 범위가 축소됐기 때문이다. 이 사유는 과거 이력이며 현재 제품 결정은 Chromium/mobile Chrome만 지원한다. +- 어떻게: + - RED: `npx playwright test --list --project=webkit`와 `npx playwright test --list --project=mobile-safari`는 각각 `Project(s) "webkit" not found`, `Project(s) "mobile-safari" not found`로 실패했다. + - GREEN/REFACTOR: `playwright.config.ts`에 `webkit`, `mobile-safari` project를 복원하고 `src/shared/mocks/__tests__/mock-preview-docs.test.ts`에 README/PRD/Playwright project 정합성 assertion을 추가했다. `tests/e2e/mock-preview-shell.spec.ts`는 320px mock shell, no backend fallback, storage, axe 검증을 한 세션에서 수행하도록 정리했다. + - Focused 검증: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts`는 1 file / 4 tests passed였다. `npm run e2e:mock -- tests/e2e/mock-preview-shell.spec.ts --project=webkit --workers=1`와 `--project=mobile-safari --workers=1`은 각각 1 passed였다. WebKit/mobile Safari의 resource/series tail focused 재실행도 0 failure였다. + - Full Gate: 최종 `npm run e2e:mock`은 Chromium 52 passed, WebKit 44 passed / 8 skipped, Mobile Chrome 47 passed / 5 skipped, Mobile Safari 43 passed / 9 skipped로 전체 0 failure였다. skipped 항목은 기존 browser 조건에 맞춘 keyboard-only 또는 Chromium 전용 audio metadata case다. `npm run e2e`는 server allowlist 36 passed였고, `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. + - 정적 검증: `package.json` JSON parse 확인은 `package.json ok`였고, 당시 관련 문서·설정의 browser support 문구는 4-project 자동 Gate와 Edge/실기기 릴리스 QA 분리를 유지했다. 현재 제품 결정은 Chromium/mobile Chrome만 지원한다. +- 남은 항목: 없음. 실제 Edge, desktop Safari, iOS Safari 물리 브라우저/기기 QA는 현재 지원 범위에서 제외한다. + +### Task R9.8 — Mock E2E 실행 계약·문서·unit Gate 정합성 복구 + +**Goal 실행 `P9-R8`:** `P9-R7`에서 도입한 bare/filtered mock E2E 실행 방식을 공개 문서와 mode contract test에 일치시키고 전체 unit Gate를 복구한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-008` +- **시작 조건:** `P9-R7` 완료 기록, 현재 bare `npm run e2e:mock`의 Chromium/mobile Chrome 분할 실행과 인자 전달 시 focused Playwright 실행 계약. +- **완료 증거:** 현재 focused 2-test 실패 재현, public script semantics·README·agent guide·contract test 최소 정렬, 전체 unit 및 `P9-GATE` 재검증 기록. +- **Files:** Modify: `package.json`(실행 계약을 단순화할 때만), `README.md`, `docs/agent-guide/scripts.md`, `src/shared/mocks/__tests__/mode-boundary.test.ts`, `src/shared/mocks/__tests__/mock-preview-docs.test.ts`; Test: 기존 두 contract test와 전체 unit/E2E Gate. +- **Interfaces:** 인자 없는 `npm run e2e:mock`은 Chromium/mobile Chrome matrix를 실행한다. file/project 인자가 있으면 Playwright에 그대로 전달해 `testMatch`와 교집합만 실행한다. `VITE_API_MODE=mock`, server command와 no-auto-fallback 경계는 유지한다. +- **범위 밖:** 제품 코드 변경, 문서에 내부 shell 문자열 전체를 중복해 유지하는 새 결합. +- **Phase 9 Gate 연결:** focused contract test, 전체 unit, mock/server E2E, typecheck·lint·build가 모두 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** `mode-boundary.test.ts`의 direct script 기대와 `mock-preview-docs.test.ts`의 README actual-script 기대가 현재 `e2e:mock` wrapper와 각각 실패하는 것을 기록했다. +- [x] **GREEN:** bare/full과 filtered/focused의 공개 동작을 한 기준으로 문서화하고 contract test가 내부 구현 문자열이 아니라 해당 동작과 mock mode 경계를 검증하게 했다. +- [x] **REFACTOR:** 기존 분할 script의 이름과 책임만 유지하고 새 runner·dependency를 추가하지 않았으며, 중복 명령 문자열을 test/문서에 복제하지 않았다. +- [x] focused·전체 unit과 정적 Gate를 재실행하고 RED/GREEN/REFACTOR 및 실제 pass 수를 Progress에 누적했다. E2E full matrix는 직전 요청 기준 재리뷰의 pass 기록과 filtered 인자 전달 surface를 재사용하고, 사용자 지시에 따라 개발 중 반복 실행하지 않았다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run test:run`; `npm run e2e:mock -- --list --project=chromium`; `npm run e2e:mock`; `npm run e2e`; `npm run typecheck`; `npm run lint`; `npm run build`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, unit 0 failure, Chromium/mobile Chrome mock matrix 0 failure, server allowlist 0 failure, README·agent guide와 실제 bare/filtered 동작 불일치 0건. +- **수동 확인:** README와 `docs/agent-guide/scripts.md`만 읽고 전체 matrix 실행과 focused filter 전달 방법을 구분할 수 있는지 확인한다. + +**P9-R8 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 failed / 5 passed. `mode-boundary.test.ts`는 direct `VITE_API_MODE=mock playwright test` 기대와 wrapper 차이로 실패했고, `mock-preview-docs.test.ts`는 README에 내부 wrapper 전체 문자열이 없어서 실패했다. +- GREEN/REFACTOR: `mode-boundary.test.ts`는 공개 실행 의미(`VITE_API_MODE=mock playwright test "$@"` 인자 전달, 4-project 분할 script, raw direct script)를 검증하게 바꿨다. `mock-preview-docs.test.ts`, `README.md`, `docs/agent-guide/scripts.md`는 `npm run e2e:mock`의 bare full matrix와 filtered `file`/`--project` 전달 의미를 문서화했다. 새 runner·dependency와 Safari 범위 축소는 추가하지 않았다. +- focused/surface: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 7 tests passed. `npm run e2e:mock -- --list --project=chromium` — 52 tests collected. +- unit Gate: 첫 `npm run test:run`은 240초 timeout 중 `production-graph.test.ts`와 `audio-list.test.tsx` 진행 실패를 표시했다. `production-graph.test.ts`는 단독 1 passed였고, `audio-list.test.tsx`는 route/data settling 대기 timeout을 명시해 단독 2 passed로 복구했다. 이후 full `npm run test:run`은 `community-sheet.test.tsx` 5초 timeout 1건만 남았고, 단독 7 passed 확인 뒤 해당 integration test timeout만 `10_000`으로 명시했다. 최종 `npm run test:run` — 78 files / 394 tests passed. +- 정적 검증: `npm run typecheck`, `npm run lint`, `git diff --check -- README.md docs/agent-guide/scripts.md src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `git diff --check -- src/features/community-posts/tests/community-sheet.test.tsx src/features/audio-contents/tests/audio-list.test.tsx`는 모두 exit 0이었다. LSP diagnostics는 변경 test 파일에서 3초 제한 timeout이 발생해 `typecheck`로 보완했다. + +### Task R9.9 — 전체 unit Gate 비결정적 대기·정리 복구 + +**Goal 실행 `P9-R9`:** 단독 focused 실행은 통과하지만 전체 Vitest 실행에서 임의의 integration spec이 loading 또는 1/5초 대기에 머무는 비결정성을 제거한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-009` +- **시작 조건:** 2026-07-31 fresh `npm run test:run` 두 차례에서 Community Sheet·Character workspace가 반복 실패하고 Series route·Character mutation 실패 집합은 변했으며, 각 spec focused 실행은 통과한 증거. +- **완료 증거:** full-suite failure를 의도적으로 재현하는 순서/대기 증거, 정확한 root cause의 최소 test cleanup·async settling 수정, 관련 spec focused 통과와 전체 `npm run test:run` 2회 연속 0 failure 기록. +- **Files:** Modify: `src/features/community-posts/tests/community-sheet.test.tsx`, `src/features/series/tests/series-routes.test.tsx`, `src/layouts/CharacterWorkspaceLayout.test.tsx`, `src/features/characters/tests/character-mutation-reload.test.tsx`, `src/features/fan-talks/tests/fan-talk-reply.test.tsx`; root cause가 공통 정리임이 입증된 경우만 Modify: `src/shared/test/setup.ts`, `src/app/app-test-support.ts`. +- **Interfaces:** integration test는 request/response 준비 완료를 명시적으로 await하고 다음 test로 pending request, timer, browser history listener를 넘기지 않는다. assertion 커버리지와 production timeout은 변경하지 않는다. +- **범위 밖:** 전역 timeout 일괄 상향, 실패 spec skip·삭제, `fileParallelism` 축소로 가림, 제품 코드 임의 변경. 제품 root cause가 확정되면 이 Task에서 숨기지 않고 소유 Phase 신규 리뷰 Task로 분리한다. +- **Phase 9 Gate 연결:** 실패 4개 spec focused, 전체 unit 2회 연속, `typecheck`·`lint`·build, mock/server smoke가 모두 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** full suite 실패 순서와 pending request·timer·listener·React update를 축소한 명령으로 고정하고, focused 단독 통과와 전체 실패 차이를 기록한다. +- [x] **GREEN:** 입증된 root cause의 await/cleanup/handler 격리만 최소 수정해 축소 재현 명령과 focused spec을 통과시킨다. +- [x] **REFACTOR:** 수행 시간을 숨기기 위한 전역 timeout·skip이 없는지 확인하고 full suite를 2회 연속 실행한다. +- [x] RED/GREEN/REFACTOR, focused·full 2회·정적/build 결과를 Progress와 Phase 9 리뷰에 누적한다. 개발 중 E2E smoke는 사용자 지시에 따라 모든 신규 Task 구현 후 필요 시 실행한다. +- **실행 명령:** `npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx src/features/series/tests/series-routes.test.tsx src/layouts/CharacterWorkspaceLayout.test.tsx src/features/characters/tests/character-mutation-reload.test.tsx src/features/fan-talks/tests/fan-talk-reply.test.tsx`; `npm run test:run`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build`; `npm run e2e:mock -- tests/e2e/community.spec.ts tests/e2e/series.spec.ts tests/e2e/character-workspace.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium`; `npm run e2e -- tests/e2e/auth.spec.ts tests/e2e/server-mode-boundary.spec.ts`. +- **기대 결과:** focused·전체 2회·정적/build·smoke 명령 exit 0, full unit failure 0건, touched spec의 예기치 않은 MSW unhandled request·React `act` warning 0건. +- **수동 확인:** 없음. 자동 test harness 결정성 복구 Task이며 화면 기능은 관련 mock/server smoke로 대체 확인한다. + +**P9-R9 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/features/characters/tests/character-mutation-reload.test.tsx`가 보호 route probe 완료 전 `screen.getByLabelText("이름")`를 호출해 실패했다. 실패 화면은 `보호 route 확인 중` 상태였다. +- GREEN: create route도 `P1-R14` 이후 보호 probe를 거치므로 첫 form field를 `await screen.findByLabelText("이름")`로 기다리게 했다. 제품 코드는 변경하지 않았다. +- 검증: focused `npm run test:run -- src/features/characters/tests/character-mutation-reload.test.tsx`는 1 file / 3 tests passed. 전체 `npm run test:run`은 2회 연속 81 files / 409 tests passed였다. 변경 test 파일 LSP diagnostics는 0건이었다. +- 범위 유지: 전역 timeout 상향, spec skip, file parallelism 축소, 제품 코드 임의 변경은 하지 않았다. E2E smoke는 개발 중 반복 실행하지 말라는 지시에 따라 `P9-R10` 이후 필요 시 최종 Gate에서 실행한다. + +### Task R9.10 — mock E2E navigation·mutation 대기 결정성 복구 + +**Goal 실행 `P9-R10`:** Character 직접 route navigation과 FanTalk mutation 성공 피드백이 간헐적으로 경합하는 비결정성을 Chromium/mobile Chrome 지원 범위에서 제거한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-010` +- **시작 조건:** `P9-R9` 완료, fresh bare `npm run e2e:mock`의 WebKit main 2 failed / 32 passed / 7 skipped과 동일 2-test focused WebKit 2 passed 증거. +- **완료 증거:** login→direct route과 FanTalk PUT→success/refetch 경합의 재현 순서, 명시적 navigation/mutation settling 최소 수정, focused Chromium·Mobile Chrome E2E와 정적 검증 통과 기록. 2026-07-31 사용자 지시에 따라 WebKit·Mobile Safari E2E는 실행하지 않는다. +- **Files:** Modify: `tests/e2e/character-workspace.spec.ts`, `tests/e2e/fan-talk.spec.ts`; 동일 login helper 경합이 다른 spec에서도 재현될 때만 Modify: `tests/e2e/accessibility.spec.ts`, `tests/e2e/audio-content.spec.ts`, `tests/e2e/comments.spec.ts`, `tests/e2e/community.spec.ts`, `tests/e2e/resource-workflows.spec.ts`, `tests/e2e/responsive-capabilities.spec.ts`, `tests/e2e/series.spec.ts`. +- **Interfaces:** login helper는 URL 변경만이 아니라 보호 route probe·목록 ready까지 완료된 뒤 다음 `page.goto`를 허용한다. mutation test는 request, visible success, refetch/navigation 순서를 명시적으로 대기하고 중간 상태를 timeout 우연에 의존하지 않는다. +- **범위 밖:** 근거 없는 browser project 제거, failing test skip, retry만 추가, 전역 timeout 상향, 화면 기능 축소. 제품 root cause가 재현되면 소유 Phase 신규 리뷰 Task로 분리한다. +- **Phase 9 Gate 연결:** 두 test의 Chromium·Mobile Chrome focused E2E, typecheck·lint·build가 모두 통과하기 전에는 완료하지 않는다. WebKit·Mobile Safari matrix는 2026-07-31 사용자 지시에 따라 제외한다. + +- [x] **RED:** 과거 WebKit bare 실패 기록과 현재 focused/repeat 통과를 대조해 login ready·mutation/refetch 대기 경계가 약한 지점을 기록한다. +- [x] **GREEN:** 보호 route ready와 mutation/refetch 순서를 명시적으로 await하는 최소 test 수정을 적용해 focused Chromium·Mobile Chrome을 통과시킨다. +- [x] **REFACTOR:** 동일 login helper 중복은 이번 재현 범위에서 공통화하지 않고, test retry·skip·timeout 완화 없이 변경 파일만 검증한다. +- [x] RED/GREEN/REFACTOR, Chromium·Mobile Chrome focused E2E와 정적/build 결과를 Progress와 Phase 9 리뷰에 누적한다. +- **실행 명령:** `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium --grep "direct create and edit routes|FanTalk mock journey"`; 같은 명령의 `--project=mobile-chrome`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** Chromium/mobile Chrome focused 합계 4 passed, 정적/build exit 0, interrupted navigation·성공 status 미발견 0건. +- **수동 확인:** 없음. 자동 E2E 대기 결정성 Task이며 모든 화면 기능은 기존 E2E assertion을 보존해 확인한다. + +**P9-R10 수정 검증 기록 (2026-07-31):** + +- RED 대체: 기존 bare WebKit 실패는 login 직후 direct `page.goto` interruption과 FanTalk PUT 뒤 성공 status 대기 경합이었다. 현재 focused WebKit 단발 2 tests와 `--repeat-each=5` 10 tests는 모두 통과해 제품 기능 실패가 아니라 E2E 대기 경계 문제로 분리했다. +- GREEN: 두 spec의 `loginThroughMockMode()`가 `/ai-characters` URL뿐 아니라 `AI 캐릭터` heading까지 기다리게 했다. FanTalk 생성 답변 수정 뒤에는 dialog close 후 list-backed `첫 번째 응원입니다. 답변 보기` 버튼이 보일 때까지 기다리게 했다. +- 검증: 사용자 지시에 따라 WebKit·Mobile Safari는 추가 실행하지 않았다. `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/fan-talk.spec.ts --project=chromium --grep "direct create and edit routes|FanTalk mock journey"`는 2 passed, 같은 명령의 `--project=mobile-chrome`도 2 passed였다. 두 변경 spec LSP diagnostics는 0건, pure LOC는 각각 187이었다. `npm run typecheck`, `npm run lint`, targeted `git diff --check`는 모두 통과했다. +- 범위 유지: retry, skip, timeout 상향, WebKit/Mobile Safari project 제거 또는 제품 코드 변경은 하지 않았다. + +### Task R9.11 — Chromium-only Gate의 현재 문서 계약 복구 + +**Goal 실행 `P9-R11`:** 과거 네 browser project 실행 이력은 보존하되 PRD Decision Log와 현재 Task·최신 리뷰 결론을 Chromium/mobile Chrome 제품 결정과 실제 smoke spec 경로에 맞춘다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-011` +- **시작 조건:** PRD §13, plan 상단 범위·`P9-T2`, `playwright.config.ts`, `package.json`, contract test는 Chromium/mobile Chrome만 요구하지만 PRD Decision Log에 변경 결정이 없고 `P9-R8`, `P9-R10`과 Phase 9 리뷰 최신 결론은 4-project/WebKit 명령을 현재 계약처럼 유지하며 `P9-R9` smoke 명령은 존재하지 않는 `server-boundary.spec.ts`를 가리키는 불일치. +- **완료 증거:** 날짜·사유·영향 범위를 포함한 PRD Decision Log 행, 현재 Task의 WebKit project 명령·4-project 기대 0건, `server-mode-boundary.spec.ts` 실제 경로, 과거 실행 기록 보존, mock 104개·server 18개가 Chromium/mobile Chrome에서만 수집되는 증거와 docs contract test 통과 기록. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`, `src/shared/mocks/__tests__/mock-preview-docs.test.ts`. +- **Interfaces:** `P9-R7~R10`의 과거 RED/GREEN·pass 수는 삭제하지 않고 당시 이력으로 표시한다. 현재 실행 명령·기대 결과·최신 결론만 Chromium/mobile Chrome으로 정정하고 smoke file filter는 실제 `server-mode-boundary.spec.ts`를 사용한다. +- **범위 밖:** E2E spec·제품 코드 변경, WebKit/mobile Safari 재설치·실행, 과거 검증 기록 삭제. + +- [x] **TDD 예외 사유:** 실행 코드 변경이 없는 문서 계약 정정이므로 제품 test RED 대신 현재 Task 본문과 executable config의 불일치 검색을 RED 대체 증거로 사용한다. +- [x] PRD Decision Log에 2026-07-31 Chrome-only 결정의 사유와 Playwright·QA 영향 범위를 추가한다. +- [x] `P9-R8`, `P9-R10`의 현재 실행 명령·기대 결과와 Phase 9 최신 결론을 Chromium/mobile Chrome 기준으로 정정한다. +- [x] `P9-R9` server smoke spec 경로를 실제 `server-mode-boundary.spec.ts`로 고치고, 과거 네 project 결과는 당시 이력으로 명시해 현재 Gate나 남은 위험으로 읽히는 표현을 제거한다. +- [x] docs contract가 현재 Task block의 WebKit/mobile Safari 명령·네 project 기대와 잘못된 smoke spec 경로를 탐지하도록 보강한다. +- [x] docs contract 결과를 Progress와 Phase 9 리뷰에 누적했다. Playwright 수집 목록·targeted diff는 `P10-R10`까지 완료한 뒤 통합 검증에서 실행한다. +- **대체 검증 방법:** 현재 Task block의 browser 명령 대조, docs contract unit, Playwright `--list`, Markdown link와 diff 검사. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run e2e:mock -- --list`; `npm run e2e -- --list`; `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`. +- **기대 결과:** unit 2 files / 8 tests, mock 104 tests와 server 18 tests가 `chromium`·`mobile-chrome`에서만 수집되고 현재 Task 명령·최신 결론의 WebKit/mobile Safari 요구 0건, link·diff 오류 0건. +- **수동 확인:** `P9-R7~R11`과 Phase 9 리뷰 마지막 절만 읽어 과거 네 project 이력과 현재 2-project Gate를 혼동하지 않는지 확인한다. + +**P9-R11 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 1 failed / 4 passed. 신규 docs contract가 현재 `P9-R8~R11` Task 정의의 `mock matrix 4 projects`, `--project=webkit`, 잘못된 `server-boundary.spec.ts` 경로를 검출했다. +- GREEN/REFACTOR: PRD Decision Log에 Chrome-only 자동 Gate 결정을 추가하고, 현재 Task 정의와 Phase 9 최신 결론은 Chromium/mobile Chrome·`server-mode-boundary.spec.ts` 기준으로 정정했다. 과거 네 browser project pass/failure 기록은 검증 기록 이력으로 보존했다. +- 검증: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 8 tests passed. `npm run e2e:mock -- --list` — Chromium/mobile Chrome 104 tests 수집. `npm run e2e -- --list` — Chromium/mobile Chrome 18 tests 수집. 실제 E2E 실행은 사용자 지시에 따라 생략했다. + +### Task R9.12 — Chromium-only 문서 계약 종료 증거 보강 + +**Goal 실행 `P9-R12`:** Chrome-only 결정 사유, 현재 Phase 9 문구·리뷰 상태와 docs contract의 정확한 section 범위를 하나의 재현 가능한 종료 증거로 맞춘다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-012` +- **시작 조건:** PRD Decision Log의 날짜·영향은 있으나 지원 축소 사유가 없고, `P9-R10` 수동 확인의 “자동 WebKit harness”, Phase 9 리뷰 상단의 수정 필요 상태, P9-R11 기대 7 tests 대비 실제 8 tests와 docs contract section 오탐 가능성이 남은 근거. +- **완료 증거:** PRD 결정의 날짜·사유·영향 범위, 현재 Phase 9 Task·리뷰 metadata의 Chromium/mobile Chrome 기준, `P9-R9` block 내부 실제 smoke path와 정확히 닫힌 최신 결론 section을 검증하는 docs contract 2 files / 8 tests. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`, `src/shared/mocks/__tests__/mock-preview-docs.test.ts`. +- **Interfaces:** 과거 WebKit/Mobile Safari 실행 이력과 finding 근거는 삭제하지 않는다. 현재 Task의 실행·수동 확인, 리뷰 metadata와 contract 대상 section만 정정한다. +- **범위 밖:** Playwright project·제품/E2E 코드 변경, WebKit/mobile Safari 실행, 과거 이력 재작성. +- **Phase 9 Gate 연결:** docs contract, Playwright 두 수집 목록, Markdown link·diff 검사가 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** current section만 검사하도록 contract를 좁혀 PRD 사유 누락, `자동 WebKit harness`, 부정확한 smoke path assertion과 stale review metadata 실패를 확인했다. +- [x] **GREEN:** PRD Decision Log에 사용자 결정 사유를 추가하고 현재 Task·리뷰 metadata·기대 test 수를 실제 Chromium/mobile Chrome 계약에 맞췄다. +- [x] **REFACTOR:** `sectionBetween`/current section helper로 `P9-R9` 내부 실제 path와 최신 결론 block만 검사하고 과거 이력 문자열과 plan 전체의 우연한 match에 의존하지 않게 했다. +- [x] docs contract·Playwright list·link·diff 결과를 Progress와 Phase 9 리뷰에 누적했다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run e2e:mock -- --list`; `npm run e2e -- --list`; `node -e 'const fs=require("fs"),path=require("path");const root="docs/20260725_AI캐릭터관리자웹";const files=[];const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):e.name.endsWith(".md")&&files.push(p)}};walk(root);const broken=[];for(const f of files){const s=fs.readFileSync(f,"utf8");for(const m of s.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){const t=m[1].trim().replace(/^<|>$/g,"").split("#")[0];if(t&&!/^(https?:|mailto:)/.test(t)&&!fs.existsSync(path.resolve(path.dirname(f),t)))broken.push(f+": "+t)}}if(broken.length){console.error(broken.join("\n"));process.exit(1)}'`; `git diff --check`. +- **기대 결과:** 2 files / 8 tests, mock 104·server 18 tests가 `chromium`·`mobile-chrome`에서만 수집되고 현재 문서의 WebKit harness·잘못된 smoke path·stale review metadata·Decision Log 사유 누락 0건. +- **수동 확인:** Phase 9 현재 Task와 리뷰 마지막 두 section만 읽어 과거 Safari 이력과 현재 Chrome-only Gate를 혼동하지 않는지 확인한다. + +**P9-R12 수정 검증 기록 (2026-07-31):** + +- RED: 강화한 `mock-preview-docs.test.ts`가 PRD Decision Log의 사용자 결정 사유 누락과 `P9-R10` 현재 수동 확인의 `자동 WebKit harness` 문구를 검출했다. `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 1 failed / 3 passed / 1 not reached. +- GREEN/REFACTOR: PRD 결정 행에 사용자 직접 지시 사유를 추가하고, 현재 `P9-R10` 수동 확인 문구와 `P9-R11` 기대 test 수를 Chromium/mobile Chrome 계약에 맞췄다. docs contract는 `P9-R9` block과 최신 Phase 9 종료 판정만 검사하도록 좁혔다. +- 검증: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 8 tests passed. `npm run e2e:mock -- --list` — Chromium/mobile Chrome 104 tests 수집. `npm run e2e -- --list` — Chromium/mobile Chrome 18 tests 수집. Markdown link와 diff 검사는 `P10-R11` 완료 뒤 통합 실행한다. + +### Task R9.13 — Chrome-only 결정 사유·문서 section 계약 폐쇄 + +**Goal 실행 `P9-R13`:** Chrome-only 결정의 실제 제품·시간 근거를 Decision Log에 남기고 docs contract가 PRD 결정과 최신 Phase 9 결론의 정확한 Markdown section만 검증하게 한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-013` +- **시작 조건:** `P9-R12`의 실행 결과는 통과하지만 PRD 결정 사유가 “사용자 직접 지시”에 머물고, docs contract가 PRD 전체와 Phase 9 리뷰의 특정 heading부터 EOF를 검사해 후속 기록의 우연한 문자열에 의존할 수 있다는 재리뷰 근거. +- **완료 증거:** Chrome 2종만 지원하며 불필요한 Safari 계열 실행이 테스트 시간을 늘린다는 Decision Log 근거, heading level 또는 명시적 경계로 닫힌 PRD Decision Log·최신 Phase 9 결론 검사, docs contract 2 files / 8 tests와 두 Playwright 목록 결과. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`, `src/shared/mocks/__tests__/mock-preview-docs.test.ts`. +- **Interfaces:** Playwright config·project 이름·E2E spec은 변경하지 않는다. 과거 browser 실행 이력은 보존하고 현재 Decision Log와 최신 종료 판정만 범위화한다. +- **범위 밖:** WebKit/mobile Safari 설치·실행·지원 복원, 제품/E2E 코드 변경, 새 Markdown parser dependency. +- **Phase 9 Gate 연결:** docs contract, mock/server `--list`, Markdown link·diff 검사가 모두 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** PRD Decision Log와 최신 Phase 9 review/종료 판정을 heading 경계로 추출하는 contract를 먼저 작성해 현재 사유 누락과 EOF scope 의존으로 실패하는지 확인한다. +- [x] **GREEN:** Decision Log에 지원 대상과 테스트 시간 근거를 추가하고 최소 section helper로 현재 두 block만 검증해 focused test를 통과시킨다. +- [x] **REFACTOR:** plan 전체·후속 review 문자열에 의존하는 assertion을 제거하고 기존 8개 docs contract 회귀를 확인한다. +- [x] focused·Playwright list·link·diff 결과를 Progress와 Phase 9 리뷰에 누적한다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run e2e:mock -- --list`; `npm run e2e -- --list`; `node -e 'const fs=require("fs"),path=require("path");const root="docs/20260725_AI캐릭터관리자웹";const files=[];const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):e.name.endsWith(".md")&&files.push(p)}};walk(root);const broken=[];for(const f of files){const s=fs.readFileSync(f,"utf8");for(const m of s.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){const t=m[1].trim().replace(/^<|>$/g,"").split("#")[0];if(t&&!/^(https?:|mailto:)/.test(t)&&!fs.existsSync(path.resolve(path.dirname(f),t)))broken.push(f+": "+t)}}if(broken.length){console.error(broken.join("\n"));process.exit(1)}'`; `git diff --check`. +- **기대 결과:** 2 files / 8 tests, mock 104·server 18 tests가 `chromium`·`mobile-chrome`에서만 수집되고 Decision Log 사유·section 경계·link·diff 오류 0건. +- **수동 확인:** PRD Decision Log와 Phase 9 마지막 review section만 읽어 지원 대상, 제외 사유, 테스트 시간 영향과 현재 Gate를 재구성할 수 있는지 확인한다. + +**P9-R13 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 1 file / 2 failed / 3 passed. PRD 결정 행의 `Chrome 2종`·`테스트 시간` 근거 누락과 `## 22. P9-R13 수정 결과 검증` section 부재를 검출했다. +- GREEN/REFACTOR: PRD Decision Log에 Chrome 2종 지원 범위와 Safari 계열 실행의 테스트 시간 영향을 추가했다. docs contract는 PRD Decision Log의 단일 Chrome-only 결정 행과 Phase 9의 `P9-R13` section만 검사하도록 닫았고, Phase 9 리뷰 metadata와 종료 판정을 `REV-P9-013`/`P9-R13` 완료로 갱신했다. +- 검증: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 1 file / 5 tests passed. `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 8 tests passed. `npm run e2e:mock -- --list` — Chromium/mobile Chrome 104 tests 수집. `npm run e2e -- --list` — Chromium/mobile Chrome 18 tests 수집. Markdown link 검사와 `git diff --check`는 통과했다. + +### Task R9.14 — Community Sheet focus 반환 full-unit 경합 제거 + +**Goal 실행 `P9-R14`:** Community Sheet의 중첩 비활성화 dialog focus 반환 test가 전체 Vitest 부하에서도 실제 effect 완료를 기다려 full unit Gate를 결정적으로 통과하게 한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-014` +- **시작 조건:** fresh full unit 1회가 `community-sheet.test.tsx:82`의 deactivate trigger focus assertion에서 1 failed / 416 passed였고, 동일 test 단독 5회와 다음 fresh full 417/417은 통과한 비결정성 근거. +- **완료 증거:** focus 복귀 effect를 기다리는 최소 test 수정, 동일 test 반복 5회와 full unit 2회 연속 통과, 정적/build 회귀 기록. +- **Files:** Modify: `src/features/community-posts/tests/community-sheet.test.tsx`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`; Reuse: `src/shared/ui/use-modal-focus.ts`, `src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx`. +- **Interfaces:** 제품 focus 구현과 dialog API는 변경하지 않는다. 공통 dialog test가 사용하는 `waitFor` 패턴을 재사용하고 timeout 상향·retry로 실패를 숨기지 않는다. +- **범위 밖:** modal architecture 변경, 제품 Community mutation 흐름 변경, WebKit/mobile Safari 실행. +- **Phase 9 Gate 연결:** focused 반복과 full unit 2회, `typecheck`·`lint`·개발/운영 build가 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** fresh full unit의 `닫기` focus 수신 실패와 동일 test 단독 반복 통과를 기록해 full-load effect 경합을 재현했다. +- [x] **GREEN:** 취소 직후 trigger focus assertion을 effect cleanup 완료까지 기다리는 기존 `waitFor` 패턴으로 최소 수정했다. +- [x] **REFACTOR:** 임의 sleep·timeout·retry 없이 focused 5회와 full unit 2회를 연속 실행해 focus 경합이 재발하지 않는지 확인했다. +- [x] 정적/build 결과와 실제 pass 수를 Progress와 Phase 9 리뷰에 누적했다. +- **실행 명령:** `for run_index in {1..5}; do npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx -t 'Community Sheet edits pins unpins and soft deletes from the list item without detail GET' || exit 1; done`; `npm run test:run`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `git diff --check`. +- **기대 결과:** focused 5회 모두 1 passed / 6 skipped, full unit 2회 모두 81 files / 417 tests, 정적/build/diff exit 0, 취소 후 trigger 외 focus 수신 0건. +- **수동 확인:** 없음. 기존 제품 focus 동작은 변경하지 않고 test의 effect 완료 관찰만 교정한다. + +**P9-R14 수정 검증 기록 (2026-07-31):** + +- RED: 2026-07-31 재리뷰에서 fresh full unit 1회가 `community-sheet.test.tsx`의 취소 후 trigger focus assertion에서 1 failed / 416 passed로 실패했고, 동일 test focused 5회와 다음 fresh full은 통과해 full-load effect 경합으로 확정했다. +- GREEN: 제품 focus 구현과 dialog API는 변경하지 않고 `src/features/community-posts/tests/community-sheet.test.tsx`의 취소 후 focus assertion만 공통 `ConfirmDeactivateDialog` test와 같은 `waitFor` 패턴으로 변경했다. +- REFACTOR/검증: `for run_index in {1..5}; do npm run test:run -- src/features/community-posts/tests/community-sheet.test.tsx -t 'Community Sheet edits pins unpins and soft deletes from the list item without detail GET' || exit 1; done` — 5회 모두 1 passed / 6 skipped. `npm run test:run` 2회 — 각각 81 files / 417 tests passed. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. `git diff --check -- src/features/community-posts/tests/community-sheet.test.tsx` — no output. build는 기존 500kB chunk warning만 표시했다. + +### Task R9.15 — PRD·Phase 9 종료 판정 heading 경계 고정 + +**Goal 실행 `P9-R15`:** docs contract가 PRD Decision Log H2와 최신 Phase 9 종료 판정 H3를 동일·상위 heading에서 각각 닫아 후속 section 문자열로 통과할 수 없게 한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-015` +- **시작 조건:** `P9-R13`에서 `sectionAtHeading`을 추가했지만 PRD Decision Log는 `sectionFrom()`으로 EOF까지 읽고, 최신 종료 판정도 상위 H2 block 끝까지 읽어 후속 H2/H3로 완료 문자열을 옮겨도 current assertion이 통과하는 negative-control 근거. +- **완료 증거:** heading 행을 정확히 anchor하고 동일·상위 level의 다음 heading에서 닫는 최소 helper, PRD H2·Phase 9 H2·종료 판정 H3 각각의 synthetic trailing-section negative test와 docs contract 2 files / 8 tests. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`. +- **Interfaces:** 새 Markdown parser dependency를 추가하지 않고 문자열·정규식 helper 하나를 재사용한다. PRD 내용, Playwright config와 E2E spec은 변경하지 않는다. +- **범위 밖:** Markdown 전체 parser 구현, 과거 review 이력 삭제, WebKit/mobile Safari 실행. +- **Phase 9 Gate 연결:** negative-control과 docs contract, 두 Playwright `--list`, link·diff 검사가 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** Decision Log 행을 후속 H2로, 완료 문구를 후속 H3로 옮긴 synthetic 문서가 현재 helper에서 통과하는 실패 test를 추가했다. +- [x] **GREEN:** heading level을 계산해 동일·상위 level의 다음 heading 또는 EOF에서 닫는 최소 helper로 PRD H2·최신 H2·종료 판정 H3를 추출했다. +- [x] **REFACTOR:** `sectionFrom`과 중복 helper를 제거하고 현재 Decision Log·Phase 9 종료 판정 assertion을 정확한 section 내부로 한정했다. +- [x] docs contract·Playwright list·link·diff 결과를 Progress와 Phase 9 리뷰에 누적했다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run e2e:mock -- --list`; `npm run e2e -- --list`; `node -e 'const fs=require("fs"),path=require("path");const root="docs/20260725_AI캐릭터관리자웹";const files=[];const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):e.name.endsWith(".md")&&files.push(p)}};walk(root);const broken=[];for(const f of files){const s=fs.readFileSync(f,"utf8");for(const m of s.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){const t=m[1].trim().replace(/^<|>$/g,"").split("#")[0];if(t&&!/^(https?:|mailto:)/.test(t)&&!fs.existsSync(path.resolve(path.dirname(f),t)))broken.push(f+": "+t)}}if(broken.length){console.error(broken.join("\n"));process.exit(1)}'`; `git diff --check`. +- **기대 결과:** 2 files / 8 tests, synthetic 후속 H2/H3 false positive 0건, mock 104·server 18 tests가 `chromium`·`mobile-chrome`에서만 수집되고 link·diff 오류 0건. +- **수동 확인:** PRD Decision Log와 Phase 9 최신 종료 판정 뒤에 임의의 같은 수준 section이 추가돼도 현재 계약 판정이 바뀌지 않는지 확인한다. + +**P9-R15 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 1 file / 1 failed / 4 passed. synthetic 후속 H3의 `수정 완료` 문구가 `### 종료 판정` section에 섞이는 실패를 확인했다. +- GREEN: `sectionAtHeading`이 heading level을 계산해 동일·상위 level의 다음 heading에서 닫도록 수정하고, PRD Decision Log와 Phase 9 최신 종료 판정을 같은 helper로 추출하게 했다. 날짜 suffix가 있는 실제 heading은 prefix 뒤 공백 suffix만 허용했다. +- REFACTOR/검증: `sectionFrom`을 제거하고 synthetic 후속 H2/H3 negative assertion을 현재 docs contract에 남겼다. `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 8 tests passed. `npm run e2e:mock -- --list` — Chromium/mobile Chrome 104 tests 수집. `npm run e2e -- --list` — Chromium/mobile Chrome 18 tests 수집. Markdown link 검사와 targeted `git diff --check`는 no output이었다. + +### Task R9.16 — 최신 Phase 9 heading exact anchor 복구 + +**Goal 실행 `P9-R16`:** docs contract가 임의 suffix heading을 목표 heading으로 오인하지 않고 최신 Phase 9 review H2와 그 내부 종료 판정만 검사하게 한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-016` +- **시작 조건:** `sectionAtHeading()`이 ``line.startsWith(`${heading} `)``를 허용해 임의 suffix heading을 선택하고, Phase 9 contract가 현재 최신 H2가 아닌 과거 `## 22. P9-R13 수정 결과 검증`을 계속 검사하는 negative-control 근거. +- **완료 증거:** 전체 heading 행 exact match, 최신 Phase 9 H2와 그 내부 `### 종료 판정` 독립 추출, 임의 suffix·후속 H2/H3 false positive를 막는 synthetic assertion과 docs contract 통과. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`. +- **Interfaces:** 새 Markdown parser 없이 기존 문자열 helper를 exact line match로 축소한다. PRD·제품·Playwright config·E2E spec은 변경하지 않는다. +- **범위 밖:** Markdown 전체 parser, 과거 review 이력 삭제, WebKit/mobile Safari 실행. +- **Phase 9 Gate 연결:** synthetic negative-control, docs contract, 두 Playwright `--list`, typecheck·lint·link·diff 검사가 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** 목표 heading 뒤 임의 suffix를 붙인 가짜 H2가 선택되고 최신 Phase 9 H2가 검사되지 않는 synthetic 실패를 고정했다. +- [x] **GREEN:** helper를 전체 heading 행 exact match로 축소하고 contract 대상을 최신 Phase 9 H2와 그 내부 종료 판정으로 갱신했다. +- [x] **REFACTOR:** 날짜 suffix를 prefix 허용으로 숨기지 않고 호출부가 실제 전체 heading을 명시하게 하며 기존 H2/H3 경계 assertion을 유지했다. +- [x] focused·Playwright list·정적·link·diff 결과를 Progress와 Phase 9 리뷰에 누적한다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run typecheck`; `npm run lint`; `npm run e2e:mock -- --list`; `npm run e2e -- --list`; `node -e 'const fs=require("fs"),path=require("path");const root="docs/20260725_AI캐릭터관리자웹";const files=[];const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):e.name.endsWith(".md")&&files.push(p)}};walk(root);const broken=[];for(const f of files){const s=fs.readFileSync(f,"utf8");for(const m of s.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){const t=m[1].trim().replace(/^<|>$/g,"").split("#")[0];if(t&&!/^(https?:|mailto:)/.test(t)&&!fs.existsSync(path.resolve(path.dirname(f),t)))broken.push(f+": "+t)}}if(broken.length){console.error(broken.join("\n"));process.exit(1)}'`; `git diff --check`. +- **기대 결과:** docs contract 2 files / 9 tests, 임의 suffix·과거 H2·후속 H2/H3 false positive 0건, mock 104·server 18 tests가 `chromium`·`mobile-chrome`에서만 수집되고 정적·link·diff 오류 0건. +- **수동 확인:** Phase 9 최신 H2와 종료 판정만 읽어 현재 발견·소유 Task를 재구성할 수 있고 과거 완료 section의 문구가 현재 판정을 대신하지 않는지 확인한다. + +**P9-R16 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 1 file / 2 failed / 4 passed. 최신 Phase 9 종료 판정이 아직 `후속 goal 필요`라 실패했고, P10 current-state도 후속 Task 반영 전 상태라 함께 실패했다. +- GREEN: `sectionAtHeading()`을 전체 heading 행 exact match로 축소하고 Phase 9 contract가 최신 `## 24. P9-R14~R15 수정 결과 재점검 — 2026-07-31`와 내부 `### 종료 판정`을 검사하게 했다. `phase9-cross-cutting-quality.md`의 `REV-P9-016` 상태와 종료 판정을 수정 완료로 갱신했다. +- focused: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts -t "keeps current Phase 9"` — 1 file / 1 passed / 5 skipped. +- 검증: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 9 tests passed. `npm run typecheck`, `npm run lint`, 필수 12 section 검사, Markdown link 검사, `git diff --check` — 모두 exit 0. `npm run e2e:mock -- --list`는 Chromium/mobile Chrome 104 tests, `npm run e2e -- --list`는 Chromium/mobile Chrome 18 tests를 수집했다. + +### Task R9.17 — Phase 9 current metadata·최신 결론 동기화 + +**Goal 실행 `P9-R17`:** `P10-R14` 완료 뒤에도 후속 필요로 남은 Phase 9 review metadata를 현재 상태로 맞추고 docs contract가 metadata와 실제 최신 H2·종료 판정을 함께 검사하게 한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-017` +- **시작 조건:** Phase 9 review 상단 `리뷰 상태`와 최신 `## 24` 종료 판정이 완료된 `P10-R14`를 후속 필요로 표시하지만 current docs contract가 metadata를 읽지 않고 과거 H2를 계속 대상으로 삼아 통과하는 근거. +- **완료 증거:** Phase 9 `## 1. 리뷰 정보`, 실제 마지막 H2와 내부 `### 종료 판정`의 동일 current-state assertion, `P10-R14` 후속 문구와 새 trailing H2 누락을 각각 차단하는 negative-control. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`. +- **Interfaces:** 기존 exact heading helper를 재사용하고 과거 H2·판정 이력은 수정하지 않는다. 제품·PRD·Playwright 설정은 변경하지 않는다. +- **범위 밖:** 과거 review 결론 삭제, 제품/E2E 코드 변경, WebKit/mobile Safari 실행. +- **Phase 9 Gate 연결:** docs contract, synthetic metadata/최신 H2 scope, 두 Playwright `--list`, typecheck·lint·link·diff 검사가 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** Phase 9 metadata와 실제 마지막 H2가 `P10-R14` 후속 필요를 유지하거나 새 trailing H2가 추가돼도 현재 contract가 과거 `## 24`로 통과하는 실패를 고정했다. +- [x] **GREEN:** 현재 상태를 기록하는 새 H2를 append하고 metadata·문서의 마지막 H2·내부 종료 판정을 독립 추출해 완료 상태를 검사한다. +- [x] **REFACTOR:** 과거 `## 24` 이력은 보존하고 current assertion이 이전 H2를 최신 판정으로 사용하지 못하게 했다. +- [x] focused·Playwright list·정적·link·diff 결과를 Progress와 Phase 9 리뷰에 누적한다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run typecheck`; `npm run lint`; `npm run e2e:mock -- --list`; `npm run e2e -- --list`; `node -e 'const fs=require("fs"),path=require("path");const root="docs/20260725_AI캐릭터관리자웹";const files=[];const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):e.name.endsWith(".md")&&files.push(p)}};walk(root);const broken=[];for(const f of files){const s=fs.readFileSync(f,"utf8");for(const m of s.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){const t=m[1].trim().replace(/^<|>$/g,"").split("#")[0];if(t&&!/^(https?:|mailto:)/.test(t)&&!fs.existsSync(path.resolve(path.dirname(f),t)))broken.push(f+": "+t)}}if(broken.length){console.error(broken.join("\n"));process.exit(1)}'`; `git diff --check`. +- **기대 결과:** docs contract 2 files / 9 tests, Phase 9 metadata·최신 H2·종료 판정 불일치 0건, mock 104·server 18 tests가 `chromium`·`mobile-chrome`에서만 수집되고 정적·link·diff 오류 0건. +- **수동 확인:** Phase 9 상단 metadata와 마지막 H2만 읽어 자동 보완 완료·Chrome 2종·남은 수동 QA를 같은 상태로 판단할 수 있는지 확인한다. + +### Task R9.18 — Phase 9 finding·checklist 상태 종결 contract + +**Goal 실행 `P9-R18`:** `P9-R17` 완료 결론과 소유 finding `REV-P9-017`의 상태·Task checklist를 같은 `수정 완료`로 유지하고 이후 불일치를 자동으로 차단한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-018`. +- **시작 조건:** 최신 `## 26`과 metadata는 `P9-R17` 완료를 선언하지만 `REV-P9-017` 표는 `확정` 상태다. docs contract는 소유 finding 표를 검사하지 않고 `P9-R17` 범위를 `P10-T1`까지 열어 두어 후속 Phase 9 Task의 미완료 checklist까지 섞는다. +- **완료 증거:** `REV-P9-017` 표의 `수정 완료` 상태와 2026-08-01 수정 근거, `P9-R17` 자체 checklist 경계, Phase 9 metadata·최신 결론·소유 finding 상태를 함께 검사하는 assertion. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`. +- **Interfaces:** Phase 9가 소유한 finding 상태만 검사한다. Phase 10의 현재 Task를 Phase 9 metadata에 중복 기록하지 않는다. +- **범위 밖:** 제품·Playwright 설정 변경, 과거 결론 삭제, WebKit/mobile Safari 실행. + +- [x] **RED:** `REV-P9-017` 상태가 `확정`이어도 contract가 통과하고, 후속 Phase 9 Task를 append하면 `P9-R17` checklist 범위가 함께 실패하는 두 경계를 고정한다. +- [x] **GREEN:** 소유 finding 표를 `수정 완료`로 갱신하고 날짜별 수정 근거를 append한 뒤 `P9-R17` 자체 checklist·metadata·최신 결론과 함께 검사한다. +- [x] **REFACTOR:** Phase 10 current-state 토큰을 Phase 9 metadata에서 제거하고 Phase 9 소유 상태만 남긴다. +- [x] docs contract·typecheck·lint·link·diff 결과를 Progress와 Phase 9 리뷰에 누적한다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run typecheck`; `npm run lint`; Markdown link 검사; `git diff --check`. +- **기대 결과:** `REV-P9-017` 상태와 현재 결론 불일치 0건, docs contract·정적·link·diff 오류 0건. + +**P9-R18 수정 검증 기록 (2026-08-01):** + +- RED: `keeps current Phase 9` — `REV-P9-017`의 `확정` 상태로 1 failed / 5 skipped였고 checklist assertion 추가 뒤에는 후속 미완료 Task가 포함돼 1 failed / 7 skipped였다. +- GREEN/REFACTOR: `P9-R17` Task를 다음 H3에서 닫고 `REV-P9-017`·`REV-P9-018` 상태·날짜별 근거를 직접 검사했다. Phase 9 metadata에서 Phase 10 current Task 중복을 제거했다. + +### Task R9.19 — fenced code 내부 가짜 H2 배제 + +**Goal 실행 `P9-R19`:** 최신 H2 탐색이 fenced code block 안의 `##` 예시나 앞선 동일 제목을 실제 마지막 review section으로 선택하지 않게 한다. + +- **연결 리뷰:** [Phase 9 현재 리뷰](./reviews/phase9-cross-cutting-quality.md) — `REV-P9-019`. +- **시작 조건:** `latestSectionAtLevel()`이 Markdown fence 상태를 구분하지 않아 문서 최하단 code fence 안의 `## example`을 최신 H2로 반환하고, 마지막 heading 문자열을 다시 첫 occurrence로 탐색해 동일 제목이 반복되면 앞선 section을 반환하는 synthetic 재현 근거. +- **선행조건:** `P9-R18` 완료. +- **완료 증거:** backtick·tilde fence 내부 heading을 제외하고 실제 마지막 H2의 index를 직접 사용하는 최소 helper와 fence·동일 제목 synthetic assertion. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase9-cross-cutting-quality.md`. +- **Interfaces:** 새 Markdown parser나 dependency 없이 기존 helper에 fence 상태만 추가한다. +- **범위 밖:** Markdown 전체 문법 지원, 제품·Playwright 설정 변경, WebKit/mobile Safari 실행. + +- [x] **RED:** 실제 H2 뒤 fenced code 내부 가짜 H2 또는 같은 제목의 앞선 H2가 있으면 실제 마지막 section을 놓치는 실패를 고정한다. +- [x] **GREEN:** backtick·tilde fence 구간을 건너뛰고 탐색한 index에서 실제 마지막 H2 section을 직접 추출한다. +- [x] **REFACTOR:** fence 처리 외 heading/section 경계 로직은 기존 helper를 재사용한다. +- [x] docs contract·typecheck·lint·link·diff 결과를 Progress와 Phase 9 리뷰에 누적한다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run typecheck`; `npm run lint`; Markdown link 검사; `git diff --check`. +- **기대 결과:** fenced heading·중복 제목 false positive 0건, docs contract·정적·link·diff 오류 0건. + +**P9-R19 수정 검증 기록 (2026-08-01):** + +- RED: backtick·tilde fence 내부 H2와 동일 제목 H2 synthetic 2건이 2 failed / 6 skipped로 재현됐다. +- GREEN/REFACTOR: fence 밖 heading index를 한 번 수집해 실제 마지막 H2를 직접 추출했다. synthetic 2 passed / 6 skipped, Phase 9 current-state 포함 focused 3 passed / 5 skipped, docs contract 2 files / 12 tests passed였다. + +--- + +## Phase 10. OpenAPI 2.3.0 후속 계약 vertical slices + +**목표:** OpenAPI 2.3.0에서 해결된 lookup·댓글·pagination 계약과 UTC +전송 변경, FanTalk 수정·삭제, 가격·공통 오류·파일 정책을 기존 UI에 +반영하고 mock/client 완료와 실제 server integration 상태를 분리해 검증한다. + +**Phase Goal `P10`:** Task 10.1 → 10.7과 Phase 10 Gate로 후속 계약을 +도메인별 독립 test cycle로 구현한다. + +- **시작 조건:** 2026-07-28 Phase 3~9 기록 보존, PRD의 2026-07-29 결정 기록과 OpenAPI 2.3.0 확인. +- **완료 조건:** `P10-T1`~`P10-T7`, `P10-GATE` 완료. +- **실행 순서:** Character lookup → Audio UTC → Series genre/CRUD → Community pagination → FanTalk 수정·삭제 → Comments → 가격·오류·파일 정책 → 통합 Gate. +- **요구사항:** `CHAR-012~014`, `AUDIO-003`, `AUDIO-009~011`, `AUDIO-019`, `AUDIO-022`, `AUDIO-025`, `AUDIO-030`, `SERIES-001~018`, `COMMUNITY-007~008`, `COMMUNITY-010`, `COMMUNITY-012`, `FANTALK-003~012`, `COMMENT-001~008`, `FILE-001~015`, `EXT-001~005`, `EXT-007~011`. +- **범위 밖:** 제품 범위에서 제외한 FanTalk 별도 상세 GET, 답변 상태 filter, sort, 중복 생성의 정확한 도메인 오류 key와 답변 삭제, 인증 operation 정식화. +- **server 검증 전제:** `series.spec.ts`, `fan-talk.spec.ts`, `comments.spec.ts`를 server mode `testMatch`에 포함해 file filter와 교집합이 생기게 한다. 실제 mutation 검증은 Chromium 1개 project와 실행 환경이 제공한 전용 개발 API 계정·재사용 가능한 fixture ID로 한정해 같은 destructive mutation의 browser별 반복을 피한다. 계정·fixture가 없으면 client/mock GREEN은 진행하되 해당 server 명령을 `No tests found`나 mock 성공으로 대체하지 않고 server integration 대기로 기록한다. + +| Goal | 선행조건 | 독립 결과 | +|---|---|---| +| `P10-T1` | OpenAPI 2.3.0 원작 lookup `implemented` | Character v2 원작 검색·선택 | +| `P10-T2` | Audio UTC schema와 no-timezone 계약 | Audio UTC 생성·조회 migration | +| `P10-T3` | Series 장르·CRUD `implemented` | Series 장르 선택·CRUD form | +| `P10-T4` | Community pagination schema `implemented` | 서버 metadata 기반 목록 | +| `P10-T5` | FanTalk DELETE·PUT `implemented` | 팬 원글 삭제와 reply 수정 client/mock/server | +| `P10-T6` | Audio·Community 댓글 operation `implemented` | 두 target의 2단계 댓글 관리 | +| `P10-T7` | `EXT-009~011` 해결과 기존 파일 client 검증 완료 | 가격 상한·공통 오류 fallback·파일 server 경계 | +| `P10-GATE` | `P10-T1`~`P10-T7` 완료 | 후속 계약 통합 검증·server 상태 분리 | + +### Task 10.1 Character v2 원작 검색 + +**Goal 실행 `P10-T1`:** legacy 원작 lookup 전제를 제거하고 v2 +`OriginalWorkSearchItem` 기반 선택기와 create/update payload를 완성한다. + +- **시작 조건:** OpenAPI `searchAiCharacterOriginalWorks`가 `implemented`. +- **Files:** Modify: `src/features/characters/api/character-api.ts`, `src/features/characters/model/types.ts`, `src/features/characters/pages/CharacterCreatePage.tsx`, `src/features/characters/pages/CharacterEditPage.tsx`, `src/features/characters/tests/character-api.test.ts`, `src/features/characters/tests/CharacterCreatePage.test.tsx`, `src/features/characters/tests/CharacterEditPage.test.tsx`, `src/shared/mocks/character-fixtures.ts`, `src/shared/mocks/character-mock-store.ts`, `src/shared/mocks/handlers.ts`, `src/shared/mocks/__tests__/character-handlers.test.ts`, `tests/e2e/character-workspace.spec.ts`. +- **Interfaces:** `searchOriginalWorks(apiClient, searchTerm): Promise`; `OriginalWorkSearchItem={id,title,contentType,category,isAdult,description,originalWork,originalLink,writer,studio,originalLinks,tags,imageUrl}`; create/update의 선택값은 `originalWorkId`, 미선택은 key 생략. +- **범위 밖:** legacy `/admin/chat/original/search`, 원작 생성·수정 UI. + +- [x] **RED:** v2 path와 필수 `searchTerm`, 전체 DTO parse, 선택/해제 payload를 검증하는 실패 test를 먼저 작성한다. +- [x] **GREEN:** 300ms 부분 검색 선택기를 생성·수정 화면에 연결하고 선택한 `id`만 `originalWorkId`로 직렬화한다. +- [x] **REFACTOR:** legacy lookup fixture·path 문자열을 제거하고 Character focused test와 mock E2E를 재실행한다. +- **실행 명령:** `npm run test:run -- src/features/characters src/shared/mocks/__tests__/character-handlers.test.ts`; `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts`. +- **기대 결과:** focused/unit와 mock E2E exit 0, v2 원작 검색 호출 1건 이상, legacy lookup 호출 0건, 미선택 payload의 `originalWorkId` 0건. +- **수동 확인:** desktop/tablet에서 원작 검색→선택→저장과 선택 해제를 확인한다. 모바일 Character mutation은 기존 미지원 정책을 유지한다. + +### Task 10.2 Audio UTC 예약 공개 계약 전환 + +**Goal 실행 `P10-T2`:** 예약 입력을 client에서 UTC `Z`로 변환하고 +Audio 생성·상세의 모든 `timezone` field/query를 제거한다. + +- **시작 조건:** OpenAPI `AudioContentCreateRequest.releaseDate`가 nullable `date-time`, `pattern=Z$`이고 `timezone` schema/parameter가 없음. +- **Files:** Modify: `src/features/audio-contents/api/audio-content-api.ts`, `src/features/audio-contents/components/audio-content-form-helpers.ts`, `src/features/audio-contents/model/types.ts`, `src/features/audio-contents/schemas/audio-content-schema.ts`, `src/features/audio-contents/pages/AudioContentDetailPage.tsx`, `src/features/audio-contents/pages/AudioContentFormPage.tsx`, `src/features/audio-contents/tests/audio-contract.test.ts`, `src/features/audio-contents/tests/audio-form.test.tsx`, `src/features/audio-contents/tests/audio-form-test-support.tsx`, `src/features/audio-contents/tests/audio-list.test.tsx`, `src/shared/mocks/audio-content-fixtures.ts`, `src/shared/mocks/audio-content-mock-store.ts`, `src/shared/mocks/handlers.ts`, `src/shared/mocks/__tests__/character-handlers.test.ts`, `tests/e2e/audio-content.spec.ts`. +- **Interfaces:** `toUtcReleaseDate(value, "Asia/Seoul"): string`; `toCreateRequest(...).releaseDate: string|null`; `getAudioContent(apiClient,{characterId,contentId})`; request type에는 `timezone`이 없음. +- **범위 밖:** 수정 화면의 schedule 변경, status enum/filter. + +- [x] **RED:** `2026-07-29T18:00` Asia/Seoul이 `2026-07-29T09:00:00Z`로 직렬화되고 즉시 공개는 `null`, `timezone` key/query는 0건인 실패 test를 작성한다. +- [x] **GREEN:** 예약 값을 UTC ISO-8601 `Z`로 변환하고 create serializer·detail adapter·mock handler에서 `timezone`을 제거한다. +- [x] **REFACTOR:** 목록·상세 fixture의 non-UTC `releaseDate`를 `Z` 값으로 통일하고 Asia/Seoul 표시 formatter 회귀를 확인한다. +- **실행 명령:** `npm run test:run -- src/features/audio-contents src/shared/mocks/__tests__/character-handlers.test.ts`; `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`. +- **기대 결과:** focused/unit와 mock E2E exit 0, 예약 request `releaseDate`가 `Z$`와 일치, `timezone` request key/query 0건, 즉시 공개 `releaseDate=null`. +- **수동 확인:** Asia/Seoul 예약 입력과 화면 표시가 유지되면서 network multipart의 JSON part만 UTC로 전송되는지 확인한다. + +### Task 10.3 Series v2 장르·CRUD form + +**Goal 실행 `P10-T3`:** 활성 장르 목록과 `SeriesListItem` 상세 원본값으로 +Series 생성·수정·soft delete 흐름을 완료한다. + +- **시작 조건:** `listAiCharacterSeriesGenres`, Series CRUD가 `implemented`; 상세 성공 `data`가 `SeriesListItem`. +- **Files:** Modify: `src/features/series/api/series-api.ts`, `src/features/series/model/types.ts`, `src/features/series/pages/SeriesDetailPage.tsx`, `src/features/series/tests/series-contract.test.ts`, `src/shared/mocks/series-fixtures.ts`, `src/shared/mocks/series-handlers.ts`, `src/shared/mocks/series-mock-store.ts`, `src/shared/mocks/handlers.ts`, `tests/e2e/series.spec.ts`, `playwright.config.ts`, `src/app/route-paths.ts`, `src/app/App.tsx`; Create: `src/features/series/schemas/series-schema.ts`, `src/features/series/validation/series-image-policy.ts`, `src/features/series/pages/SeriesFormPage.tsx`, `src/features/series/components/SeriesForm.tsx`, `src/features/series/components/PublishedDaysField.tsx`, `src/features/series/components/GenreCombobox.tsx`, `src/features/series/tests/series-form.test.tsx`. +- **Interfaces:** `getSeriesGenres(apiClient): Promise`; `SeriesGenreItem={id,genre,isAdult}`; `getSeriesDetail(...): Promise`; `SeriesCreateRequest={title:string,introduction:string,publishedDaysOfWeek:SeriesPublishedDay[],keyword:string,genreId?:number,isAdult?:boolean,writer?:string|null,studio?:string|null}`; `SeriesUpdateRequest={title?:string|null,introduction?:string|null,publishedDaysOfWeek?:SeriesPublishedDay[]|null,genreId?:number|null,isAdult?:boolean|null,state?:SeriesState|null,isActive?:boolean|null,writer?:string|null,studio?:string|null}`. `seriesCreateFormSchema`는 image·title·introduction·요일·keyword·유효한 `genreId`를 필수로 하고 state를 갖지 않으며, `seriesUpdateFormSchema`는 image와 state를 optional로 하고 keyword를 갖지 않는다. `SERIES_IMAGE_POLICY=createImagePolicy({aspect:210/297,cropRequired:true,maxWidth:1000,noUpscale:true})`; `validateSeriesImageFile(file)`은 `.jpg/.jpeg + image/jpeg`, `.png + image/png`, 최대 `10_485_760 bytes`만 허용한다. `createSeries(apiClient,{characterId,image,request}): Promise`은 필수 image와 request를, `updateSeries(apiClient,{characterId,seriesId,image?,request}): Promise`은 optional 교체 image와 request를 보내고, `deactivateSeries`는 update request의 `isActive=false`만 사용한다. +- **범위 밖:** create-only `keyword`의 수정·상세 표시, 장르 검색 query, 모바일 Series mutation. + +- [x] **RED:** query/body 없는 장르 GET, `id`→`genreId`, 상세 enum 초기화, 생성 필수 `image`·`keyword`/no state, 수정 optional image·no `keyword`, soft delete `isActive=false`, `RANDOM` 단독 선택의 실패 test를 작성한다. Series image는 JPEG/PNG·`10_485_760 bytes`, `210:297`, 최대 폭 1,000px, no-upscale과 `round(width × 297 ÷ 210)` 세로값 1px 이내를 검증한다. +- [x] **GREEN:** desktop/tablet form과 v2 장르 선택기, `FileField`·`ImageCropDialog` 기반 Series image 선택/crop을 구현하고 생성·수정·soft delete 후 목록 재조회·이동·알림을 연결한다. server mode `testMatch`에 `series.spec.ts`를 추가한다. +- [x] **REFACTOR:** 표시용 문자열 상세 fixture와 legacy 장르 lookup 전제를 제거하고 enum label 변환을 표시 계층에만 둔다. create/update serializer와 image policy를 form에서 분리하고 mock 전용 ID·로그인 helper와 server fixture path를 분리해 focused test와 mock/server E2E를 재실행한다. +- **실행 명령:** `npm run test:run -- src/features/series`; `npm run e2e:mock -- tests/e2e/series.spec.ts`; `npm run e2e -- tests/e2e/series.spec.ts --project=chromium`. +- **기대 결과:** focused/unit와 mock E2E exit 0, legacy 장르 호출 0건, `genreId=0`·생성 `state`·수정 `keyword` payload 0건, 허용 범위 밖 image request 0건이다. server 명령은 `No tests found`가 아니고 Series spec을 실제 실행하며, server 완료 판정 시 필수 개발 fixture path가 skipped 0건이다. +- **수동 확인:** desktop/tablet에서 장르 선택→image crop→생성→직접 edit 초기화→optional image 교체 수정→soft delete를 확인하고 모바일에는 mutation action이 없음을 확인한다. 실제 개발 API 결과는 mock 결과와 별도로 기록한다. + +### Task 10.4 Community pagination·timezone 제거 + +**Goal 실행 `P10-T4`:** Community 목록을 OpenAPI pagination object로 +전환하고 `timezone` query 없이 서버 metadata로 page UI를 제어한다. + +- **시작 조건:** `CommunityPostListResponse={totalCount,page,size,hasNext,items}`가 `implemented`. +- **Files:** Modify: `src/features/community-posts/api/community-post-api.ts`, `src/features/community-posts/model/types.ts`, `src/features/community-posts/pages/CommunityPostListPage.tsx`, `src/features/community-posts/components/CommunityPostList.tsx`, `src/features/community-posts/tests/community-contract.test.ts`, `src/features/community-posts/tests/community-list.test.tsx`, `src/features/community-posts/tests/community-sheet.test.tsx`, `src/features/community-posts/tests/community-test-support.ts`, `src/shared/mocks/community-post-fixtures.ts`, `src/shared/mocks/community-post-handlers.ts`, `src/shared/mocks/community-post-mock-store.ts`, `tests/e2e/community.spec.ts`. +- **Interfaces:** `getCommunityPosts(apiClient,{characterId,page,size}): Promise>`; page state는 response의 `totalCount`, `page`, `size`, `hasNext`, `items`를 그대로 사용. +- **범위 밖:** Community 검색, detail GET/route. + +- [x] **RED:** `timezone` 없는 query와 pagination object parse, `hasNext` 기반 다음 page, mutation 후 현재 page 재조회의 실패 test를 작성한다. +- [x] **GREEN:** adapter·schema·mock store·목록 UI를 서버 metadata 기반으로 갱신한다. +- [x] **REFACTOR:** 배열 response fallback과 client 추정 종료 규칙을 제거하고 기존 Sheet/cache 흐름 회귀를 확인한다. +- **실행 명령:** `npm run test:run -- src/features/community-posts`; `npm run e2e:mock -- tests/e2e/community.spec.ts`. +- **기대 결과:** focused/unit와 mock E2E exit 0, `timezone` query 0건, 응답 metadata와 화면 page/전체 건수 차이 0건. +- **수동 확인:** desktop/tablet/mobile 목록에서 다음/이전 page와 전체 건수가 서버 값대로 표시되고 Sheet mutation 뒤 현재 page가 유지되는지 확인한다. + +### Task 10.5 FanTalk 답변 수정·팬 원글 soft delete + +**Goal 실행 `P10-T5`:** `creatorReplies` 상태를 기준으로 작성/수정을 +분기하고 팬 작성 root soft delete를 모든 viewport에 제공한다. + +- **시작 조건:** FanTalk DELETE와 reply PUT이 모두 `implemented`. +- **Files:** Modify: `src/features/fan-talks/api/fan-talk-api.ts`, `src/features/fan-talks/model/types.ts`, `src/features/fan-talks/schemas/fan-talk-reply-schema.ts`, `src/features/fan-talks/pages/FanTalkListPage.tsx`, `src/features/fan-talks/components/FanTalkList.tsx`, `src/features/fan-talks/components/FanTalkListItem.tsx`, `src/features/fan-talks/components/FanTalkReplySheet.tsx`, `src/features/fan-talks/components/FanTalkReplyForm.tsx`, `src/features/fan-talks/tests/fan-talk-contract.test.ts`, `src/features/fan-talks/tests/fan-talk-list.test.tsx`, `src/features/fan-talks/tests/fan-talk-reply.test.tsx`, `src/shared/mocks/fan-talk-fixtures.ts`, `src/shared/mocks/fan-talk-handlers.ts`, `src/shared/mocks/fan-talk-mock-store.ts`, `tests/e2e/fan-talk.spec.ts`, `playwright.config.ts`. +- **Interfaces:** `updateFanTalkReply(apiClient,{characterId,fanTalkId,replyId,request:{content}}): Promise`에서 `replyId=creatorReplies[0].fanTalkId`; PUT 성공 `data.fanTalkId`는 수정된 reply row ID이므로 root item ID로 사용하지 않고 현재 목록을 재조회한다. `deleteFanTalk(apiClient,{characterId,fanTalkId}): Promise`. +- **범위 밖:** 답변 삭제·재활성화, PUT의 `isActive`, 제품 범위에서 제외한 별도 상세/filter/sort와 중복 오류 key. + +- [x] **RED:** 빈 배열은 POST, non-empty는 PUT, PUT replyId mapping과 `{content}` 전용 request, PUT 성공 response parse 후 목록 재조회, 팬 원글 DELETE body 없음, 한 화면의 중복 submit 차단과 일반 오류 후 재조회의 실패 test를 작성한다. server path에서는 동일 root에 동시 POST 후 재조회한 `creatorReplies`의 활성 답변이 하나인지 검증하되 정확한 오류 status/message key를 assertion하지 않는다. +- [x] **GREEN:** 답변 수정 form과 삭제 확인 Dialog·API·mock store를 구현하고 server mode `testMatch`에 `fan-talk.spec.ts`를 추가한다. +- [x] **REFACTOR:** 첫 답변만 수정 대상으로 사용하는 helper를 한곳에 두고 작성·수정·삭제의 pending/error/live feedback을 공통화한다. mock 전용 ID·로그인 helper와 server fixture path를 분리하고 별도 상세/filter/sort·중복 오류 key용 adapter·fixture가 생기지 않았는지 확인한다. +- **실행 명령:** `npm run test:run -- src/features/fan-talks`; `npm run e2e:mock -- tests/e2e/fan-talk.spec.ts`; `npm run e2e -- tests/e2e/fan-talk.spec.ts --project=chromium`. +- **기대 결과:** focused/unit와 mock E2E exit 0, 답변 완료 item의 두 번째 POST 0건, PUT path의 `replyId`가 `creatorReplies[0].fanTalkId`, PUT request의 `isActive` 0건, DELETE body 0 bytes다. server 명령은 `No tests found`가 아니고 FanTalk spec을 실제 실행하며, server 완료 판정 시 전용 fixture path가 skipped 0건이고 동시 POST 후 활성 creator reply는 1개다. +- **수동 확인:** desktop/tablet/mobile에서 답변 작성·수정과 팬 원글 삭제, focus 복귀를 확인한다. 실제 server mode의 수정·재조회와 단일 답변 결과를 mock 증거와 별도로 기록하고 exact 충돌 key는 수집해도 client 분기에 사용하지 않는다. + +### Task 10.6 Audio·Community 댓글 2단계 관리 + +**Goal 실행 `P10-T6`:** 두 target의 댓글·직접 답글 조회, AI 명의 작성, +AI 작성분 수정, 작성자 무관 soft delete를 모든 viewport에 제공한다. + +- **시작 조건:** Audio·Community comment 10개 operation과 관련 schema가 `implemented`. +- **Files:** Create: `src/features/comments/api/comment-api.ts`, `src/features/comments/model/types.ts`, `src/features/comments/components/CommentThread.tsx`, `src/features/comments/components/CommentForm.tsx`, `src/features/comments/components/CommentItem.tsx`, `src/features/comments/tests/comment-contract.test.ts`, `src/features/comments/tests/comment-thread.test.tsx`, `src/shared/mocks/comment-fixtures.ts`, `src/shared/mocks/comment-handlers.ts`, `src/shared/mocks/comment-mock-store.ts`, `tests/e2e/comments.spec.ts`; Modify: `src/features/audio-contents/pages/AudioContentDetailPage.tsx`, `src/features/audio-contents/model/types.ts`, `src/features/community-posts/components/CommunityPostSheet.tsx`, `src/features/community-posts/model/types.ts`, `src/shared/mocks/handlers.ts`, `playwright.config.ts`. +- **Interfaces:** `CommentTarget={kind:"audio",characterId,contentId,creatorId}|{kind:"community",characterId,postId,creatorId}`; `CommentRecord={id,writerId,nickname,profileUrl,comment,isSecret,date,replyCount,languageCode?:string|null,donationCan?:number}`; `getRootComments(target,{page,size})`와 `getReplies(target,{commentId,page,size}): Promise<{totalCount:number,items:CommentRecord[]}>`; Audio create request는 `{comment,parentId?:number|null,isSecret:boolean,languageCode?:string|null}`, Community create request는 `{comment,parentId?:number|null,isSecret:boolean}`, update request는 `{comment:string}`, `createComment/updateComment/deleteComment: Promise`; `canEdit = comment.writerId === target.creatorId`. +- **범위 밖:** 3단계 답글, 팬 댓글 수정, delete cascade, comment status/filter. + +- [x] **RED:** target별 path/schema, root/replies GET의 `page`·`size`와 `totalCount` parse, root/`parentId`, target별 create request 차이, `writerId===creatorId` 수정 권한, 작성자 무관 DELETE, UTC 표시의 실패 test를 작성한다. +- [x] **GREEN:** target adapter와 2단계 thread UI·mock handler/store를 구현하고 Audio 상세·Community Sheet에 연결한다. mock·server mode `testMatch`에 `comments.spec.ts`를 추가한다. +- [x] **REFACTOR:** target별 DTO 차이는 adapter에서 정규화하고 Page/Sheet는 target 식별자와 `creatorId`만 전달하도록 정리한다. mock 전용 ID·로그인 helper가 server path에서 사용되지 않도록 분리한다. +- **실행 명령:** `npm run test:run -- src/features/comments src/features/audio-contents src/features/community-posts`; `npm run e2e:mock -- tests/e2e/comments.spec.ts`; `npm run e2e -- tests/e2e/comments.spec.ts --project=chromium`. +- **기대 결과:** focused/unit와 지원 project mock E2E exit 0, 3단계 작성 UI/request 0건, 팬 댓글 PUT 0건, 팬·AI 댓글 DELETE 모두 body 없음이다. server 명령은 `No tests found`가 아니고 Comments spec을 실제 실행하며, server 완료 판정 시 Audio·Community 전용 fixture path가 skipped 0건이다. +- **수동 확인:** Audio 상세와 Community Sheet에서 root/답글 작성·AI 작성분 수정·팬 댓글 삭제를 keyboard-only와 320px에서 확인한다. 같은 흐름의 실제 개발 API 결과를 mock 증거와 별도로 기록한다. + +### Task 10.7 가격·공통 오류·파일 정책 종결 + +**Goal 실행 `P10-T7`:** `EXT-009~011`을 공통·도메인 schema와 오류 +처리, 기존 파일 검증 회귀와 server 확인 기준에 반영한다. + +- **시작 조건:** `EXT-009`의 `0..99999`, `EXT-010`의 client/backend 검증 경계, `EXT-011`의 공통 오류 envelope와 미정의 오류 문구가 확정됨. +- **Files:** Create: `src/shared/validation/can-price.ts`, `src/shared/validation/can-price.test.ts`; Modify: `src/features/audio-contents/schemas/audio-content-schema.ts`, `src/features/audio-contents/components/AudioContentForm.tsx`, `src/features/audio-contents/api/upload-audio-content.ts`, `src/features/audio-contents/tests/audio-form.test.tsx`, `src/features/audio-contents/tests/audio-contract.test.ts`, `src/features/audio-contents/tests/audio-upload.test.ts`, `src/features/community-posts/model/types.ts`, `src/features/community-posts/components/CommunityPostForm.tsx`, `src/features/community-posts/tests/community-contract.test.ts`, `src/features/community-posts/tests/community-form.test.tsx`, `src/shared/api/api-error.ts`, `src/shared/api/client.ts`, `src/shared/api/__tests__/client.test.ts`, `src/shared/validation/file-media-policy.test.ts`, `tests/e2e/error-mapping.spec.ts`. +- **Interfaces:** `CAN_PRICE_MAX=99_999`; `canPriceSchema`는 `0..99999` 정수; `UNKNOWN_API_ERROR_MESSAGE="알 수 없는 오류가 발생했습니다."`. 유효한 `ApiErrorResponse`의 비어 있지 않은 server `message`, `status`, `errorProperty`는 보존하고 network·JSON parse·envelope 불일치·빈 message만 공통 문구의 `ApiError`로 정규화한다. +- **범위 밖:** 도메인별 미제공 message key 추정, client container/codec 판정, image 비율·crop의 backend 재검증. + +- [x] **RED:** Audio create/update와 Community create schema·form에서 `0`, `99999` 허용, `-1`, `100000`, 소수 거부 실패 test를 먼저 작성한다. +- [x] **RED:** fetch와 XHR upload의 non-empty server message 보존, network·malformed/empty error의 공통 문구 변환 실패 test를 작성한다. +- [x] **GREEN:** 공통 가격 schema를 두 도메인 request/form에 적용하고 공통 fetch·XHR 오류 정규화를 최소 구현한다. +- [x] **REFACTOR:** 기존 image/audio 크기·MIME·GIF·no-upscale 경계 test를 재사용해 `FILE-001~015` 회귀를 확인하고 도메인별 오류 key 분기를 추가하지 않는다. +- **실행 명령:** `npm run test:run -- src/shared/validation/can-price.test.ts src/shared/validation/file-media-policy.test.ts src/shared/api/__tests__/client.test.ts src/features/audio-contents src/features/community-posts`; `npm run e2e:mock -- tests/e2e/error-mapping.spec.ts`. +- **기대 결과:** 모든 명령 exit 0, `100000` 이상 또는 음수·소수 price request 0건, 미정의 오류 문구 불일치 0건, 기존 file boundary 회귀 0건. +- **수동 확인:** Audio 생성·수정과 Community 생성에서 가격 경계·오류 focus를 확인한다. 실제 개발 API 통합 확인에서는 backend가 검증 가능한 image/audio 용량·MIME 위반 multipart가 성공 처리되지 않고, 정확한 미제공 status/message key에 따른 client 분기가 생기지 않았는지 확인한다. + +### Task R10.1 — 공통 오류 fallback migration 종결 + +**Goal 실행 `P10-R1`:** fetch/XHR의 미정의 오류를 공통 문구로 통일하고 현재 전체 unit Gate의 6개 stale assertion을 복구한다. + +- **연결 리뷰:** [Phase 10 재리뷰](./reviews/review-phase-10-20260729.md) — `REV-P10-001` +- **시작 조건:** `EXT-011`, `UNKNOWN_API_ERROR_MESSAGE`; non-empty server message 보존. +- **Files:** Modify: `src/features/audio-contents/api/upload-audio-content.ts`, `src/features/audio-contents/tests/audio-upload.test.ts`, `src/features/auth/tests/auth-api.test.ts`, `src/app/App.protected-errors.test.tsx`, `vite.config.ts`, 필요 시 `src/shared/api` tests; Test: 동일 경로와 `tests/e2e/error-mapping.spec.ts`. +- **Interfaces:** network·JSON parse·envelope mismatch·empty message → `알 수 없는 오류가 발생했습니다.`; valid non-empty message/status/errorProperty 보존. +- **범위 밖:** 도메인별 미제공 message key, 401 session 처리(`P1-R6/P4-R2`). + +- [x] **RED:** XHR 2xx malformed JSON/envelope가 과거 문구를 반환하는 실패 test와 전체 unit 6개 stale assertion을 현재 정책 기준으로 고정한다. +- [x] **GREEN:** XHR success parse fallback을 공통 상수로 변경하고 App/auth assertion을 최신 정책으로 정렬한다. +- [x] **REFACTOR:** fetch/XHR 오류 matrix 이름과 fixture를 같은 용어로 정리하고 full unit/error E2E를 실행한다. +- **실행 명령:** `npm run test:run -- src/shared/api src/features/auth src/features/audio-contents/tests/audio-upload.test.ts src/app/App.protected-errors.test.tsx`; `npm run test:run`; `npm run e2e:mock -- tests/e2e/error-mapping.spec.ts`. +- **기대 결과:** exit 0, 전체 unit 0 failure, 미정의 오류 문구 불일치 0건. +- **수동 확인:** 보호 route와 upload에서 malformed response의 한국어 공통 안내를 확인한다. + +### Task R10.2 — PRD OpenAPI 2.3.0 현재 상태 정합성 복구 + +**Goal 실행 `P10-R2`:** PRD의 현재형 API metadata와 Community pagination 설명을 OpenAPI 2.3.0 실제 집계와 일치시킨다. + +- **연결 리뷰:** [Phase 10 재리뷰](./reviews/review-phase-10-20260729.md) — `REV-P10-002` +- **시작 조건:** OpenAPI actual `3.1.0`, document `2.3.0`, 25 paths, 37 operations, Community pagination metadata 제공. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/review-phase-10-20260729.md`; Test: 없음. +- **Interfaces:** 현재 상태 문장만 정정하며 2.0.0 당시 Decision/Progress 기록은 보존한다. +- **범위 밖:** OpenAPI JSON 변경, 과거 검증 수치 재작성. + +- [x] **TDD 예외:** 문서 정합성 Task이므로 `jq` actual 집계와 stale 현재형 문구 검색을 RED 대체 검증으로 기록한다. +- [x] 현재 계약 version/path/operation, 최종 수정일과 Community metadata 제공 상태를 정정한다. +- [x] 과거 2.0.0 이력 보존과 현재형 stale 문구 0건, Markdown link·diff를 검증한다. +- **실행 명령:** OpenAPI `jq` 집계; `rg -n 'version .2\\.0\\.0|15개 path|23개 operation|종료 metadata 계약이 제공되기 전' docs/20260725_AI캐릭터관리자웹/prd.md`; `git diff --check -- docs/20260725_AI캐릭터관리자웹`. +- **기대 결과:** current-state stale 검색 exit 1, actual 집계 `2.3.0/25/37`, diff check exit 0. +- **수동 확인:** PRD 현재 상태와 Decision Log 과거 이력이 구분되는지 확인한다. + +### Task R10.3 — 계획·과거 Phase 10 리뷰 현재 상태 동기화 + +**Goal 실행 `P10-R3`:** plan 상단·구현 완료 정의와 과거 Phase 10 리뷰의 결론을 최신 Gate 정책 및 신규 리뷰 결과와 일치시킨다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-003` +- **시작 조건:** 2026-07-30 `P10-GATE 자동 범위 정정 및 수동 QA 전환` 기록과 이번 리뷰의 신규 회귀 Task 상태. +- **완료 증거:** stale 현재 문구 검색, 현재 상태 갱신, 과거 기록 보존, Markdown link/diff 검증 통과. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/review-phase-10-20260729.md`; Test: 문서 검색. +- **Interfaces:** 자동 Gate와 수동 QA 대기를 구분하고, 신규 회귀 Task가 남아 있는 동안 최종 완료를 주장하지 않는다. +- **범위 밖:** 과거 server 실패 기록 삭제, 수동 QA를 자동 완료로 변경, 애플리케이션 코드 변경. + +- [x] **TDD 예외:** 상단 상태, §6 설명, 과거 Phase 10 리뷰 결론의 stale 현재 문구를 검색해 RED 대체 증거로 기록한다. +- [x] 상단 상태와 §6 설명을 신규 Task 완료·자동 Gate/수동 QA 구분에 맞게 갱신하고 기존 날짜별 검증 기록은 보존한다. +- [x] 과거 Phase 10 리뷰에 `P10-R1~R2` 수정 완료와 Gate 정책 정정을 누적하고 새 리뷰 링크·diff를 검증한다. +- **실행 명령:** `rg -n '2026-07-29 Phase별 재''리뷰|server E2E가 로그인 fixture에 막''혀|P10-GATE.*완료 아''님' docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/review-phase-10-20260729.md`; `git diff --check -- docs/20260725_AI캐릭터관리자웹`. +- **기대 결과:** 과거 사실 보존, 현재 Gate·수동 QA·신규 회귀 Task 상태 불일치 0건. +- **수동 확인:** plan 상단, §6, Phase 10 Gate, Decision Log, 과거/현재 리뷰 결론이 같은 현재 상태를 설명하는지 확인한다. + +**Progress — 2026-07-30 P10-R3 완료** + +- **RED 대체:** stale 현재 상태 검색이 plan 상단의 2026-07-29 회귀 대기 문구, §6의 server E2E 차단 문구, 과거 Phase 10 리뷰의 Gate 미완료 결론을 검출했다. +- **GREEN:** plan 상단과 §6을 자동 Gate 정리 완료·실제 개발 API 수동 QA 대기로 갱신하고, `review-phase-10-20260729.md`에 `P10-R1~R2`와 Gate 정책 정정 검증을 누적했다. +- **REFACTOR:** `phase10-openapi-follow-up.md`의 `REV-P10-003` 상태·최종 결론도 수정 완료로 정렬했다. 2026-07-29 당시 실패·timeout 기록은 삭제하지 않았다. + +### Task R10.4 — FanTalk PUT 응답·생성 reply ID 계약 복구 + +**Goal 실행 `P10-R4`:** FanTalk reply PUT의 레거시 응답 shape를 별도 schema로 소비하고 POST 뒤 목록의 `creatorReplies[].fanTalkId`를 실제 `replyId`로 유지한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-004`, `REV-P10-005` +- **시작 조건:** OpenAPI `FanTalkReplyUpdateApiResponse.data=FanTalkListItem`, 해당 `fanTalkId`는 수정 reply row ID라는 설명, `FANTALK-004`, `FANTALK-009~010`. +- **완료 증거:** 실제 PUT response shape parse 실패와 POST→목록→PUT ID 실패 test, client/mock 최소 수정, FanTalk focused unit·mock E2E·정적 Gate 통과. +- **Files:** Modify: `src/features/fan-talks/api/fan-talk-api.ts`, `src/features/fan-talks/model/types.ts`, `src/features/fan-talks/schemas/fan-talk-reply-schema.ts`, `src/features/fan-talks/tests/fan-talk-contract.test.ts`, `src/features/fan-talks/tests/fan-talk-reply.test.tsx`, `src/shared/mocks/fan-talk-mock-store.ts`, `src/shared/mocks/fan-talk-handlers.ts`, `tests/e2e/fan-talk.spec.ts`. +- **Interfaces:** POST 성공은 `FanTalkReplyResponse.replyId`, PUT 성공은 `FanTalkListItem` shape로 분리한다. 목록 `creatorReplies[].fanTalkId`와 PUT path `replyId`는 POST가 반환한 reply row ID를 사용한다. +- **범위 밖:** FanTalk 답변 삭제, 별도 상세/filter/sort, PUT의 `isActive` UI, backend 응답 변경. + +- [x] **RED:** OpenAPI PUT data shape가 현재 POST schema에서 거부되는 contract test와 POST `replyId=9001` 뒤 재조회·수정이 `/replies/9001`을 사용해야 하는 mock E2E를 작성한다. +- [x] **GREEN:** PUT 전용 response schema/type을 추가하고 mock create list mapping과 update response를 실제 계약에 맞춘다. +- [x] **REFACTOR:** POST/PUT request는 공통 `{content}`를 유지하되 서로 다른 response type을 이름과 fixture에서 명확히 분리한다. +- **실행 명령:** `npm run test:run -- src/features/fan-talks src/shared/mocks`; `npm run e2e:mock -- tests/e2e/fan-talk.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`; `npm run build`. +- **기대 결과:** 모든 명령 exit 0, OpenAPI PUT data parse 성공, POST→동일 item PUT path의 reply ID 일치, 성공한 PUT의 client parse 오류 0건. +- **수동 확인:** 실제 개발 API에서 기존 답변 PUT과 새 답변 POST 후 재진입 PUT을 각각 실행해 성공 안내·목록 refetch·path ID를 확인한다. + +**P10-R4 수정 검증 기록 (2026-07-30):** + +- RED: `npm run test:run -- src/features/fan-talks/tests/fan-talk-contract.test.ts`는 OpenAPI PUT data shape가 기존 POST response schema에서 거부되어 실패했고, `npm run e2e:mock -- tests/e2e/fan-talk.spec.ts --project=chromium`은 POST 뒤 같은 item 수정 path가 `/replies/9001`이 아니어서 실패했다. +- GREEN: PUT 전용 `FanTalkReplyUpdateResponse` schema/type을 `FanTalkListItem` 기반으로 추가하고, mock store가 POST `replyId`를 목록 `creatorReplies[].fanTalkId`에 보존하며 PUT 응답도 OpenAPI shape로 반환하게 했다. +- REFACTOR/회귀: `npm run test:run -- src/features/fan-talks/tests/fan-talk-contract.test.ts src/features/fan-talks/tests/fan-talk-reply.test.tsx`는 2 files / 10 tests passed, `npm run e2e:mock -- tests/e2e/fan-talk.spec.ts --project=chromium`은 7 passed였다. `npm run test:run -- src/features/fan-talks src/shared/mocks`는 재실행 기준 9 files / 35 tests passed, `npm run typecheck`는 exit 0이었다. + +### Task R10.5 — 완료된 후속 계약의 현재 문서·dead scaffold 정리 + +**Goal 실행 `P10-R5`:** README와 미사용 admin page에서 완료된 Phase 3·10 기능을 아직 대기·구현 대상으로 설명하는 현재형 문구를 제거한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-006` +- **시작 조건:** OpenAPI 2.3.0의 lookup/FanTalk/Comments/Community operation `implemented`, `P10-T1~T7` 완료, `AiCharactersPage` production 사용처 0건. +- **완료 증거:** stale 문구와 dead export 사용처 검색, 현재 backend constraint 갱신, dead component 제거, 문서·typecheck·lint·build 검증. +- **Files:** Modify: `README.md`, `src/app/admin-pages.tsx`; 필요 시 `docs/20260725_AI캐릭터관리자웹/plan-task.md`의 현재 상태 설명만 보완. +- **Interfaces:** 실제 개발 API 수동 QA 대기와 제품 범위 밖 FanTalk 상세/filter/sort는 유지하고, 이미 구현된 v2 lookup·Phase 10 기능을 대기로 표시하지 않는다. +- **범위 밖:** 과거 날짜별 Decision/Progress 삭제, OpenAPI JSON 변경, 새 status page·문서 생성. + +- [x] **TDD 예외:** `rg`로 README의 legacy lookup/Phase 10 구현 대상 문구와 미사용 `AiCharactersPage` export를 RED 대체 증거로 기록한다. +- [x] README Known Backend Constraints를 현재 계약과 수동 QA 대기 기준으로 갱신하고 미사용 Phase 3 placeholder component를 삭제한다. +- [x] 과거 구현 이력은 보존한 채 stale 현재형 문구 0건과 문서 링크·정적 Gate를 검증한다. +- **실행 명령:** `rg -n 'legacy 후보|Phase 10 구현 대상|Phase 3에서|AiCharactersPage' README.md src/app docs/20260725_AI캐릭터관리자웹/plan-task.md`; `npm run typecheck`; `npm run lint`; `npm run build`; `git diff --check -- README.md src/app/admin-pages.tsx docs/20260725_AI캐릭터관리자웹/plan-task.md`. +- **기대 결과:** 과거 기록을 제외한 stale 현재형 문구와 미사용 export 0건, 모든 명령 exit 0. +- **수동 확인:** README만 읽어 implemented 범위, 제품 제외 범위, 실제 개발 API 수동 QA 대기를 구분할 수 있는지 확인한다. + +**P10-R5 수정 검증 기록 (2026-07-30):** + +- RED 대체: `rg -n 'legacy 후보|Phase 10 구현 대상|Phase 3에서|AiCharactersPage' README.md src/app docs/20260725_AI캐릭터관리자웹/plan-task.md`가 README 현재형 문구와 미사용 `AiCharactersPage` placeholder를 검출했다. +- GREEN: README Known Backend Constraints를 OpenAPI 2.3.0 implemented 범위와 실제 개발 API 수동 QA 대기 기준으로 정리했고, `src/app/admin-pages.tsx`의 미사용 `AiCharactersPage` export를 삭제했다. +- REFACTOR: 과거 날짜별 legacy 후보 이력과 review 관찰 내용은 보존하고, 현재 README에는 이전 후보 endpoint 표현만 남겼다. +- 검증: `rg -n 'legacy 후보|Phase 10 구현 대상|Phase 3에서|AiCharactersPage' README.md src/app`는 no matches, `npm run test:run -- src/app/App.test.tsx src/app/App.protected-errors.test.tsx`는 2 files / 18 tests passed, `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. `src/app/admin-pages.tsx` LSP diagnostics는 오류 0건, 대상 문서·파일 `git diff --check`는 no output이었다. + +### Task R10.6 — 구현 완료 범위의 현재 문서 상태 동기화 + +**Goal 실행 `P10-R6`:** PRD·plan·현재 Phase 리뷰의 현재형 상태를 완료된 Phase 10 구현 및 후속 회귀 현황과 맞추고 과거 실행 기록은 보존한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-007` +- **시작 조건:** `P10-T1~T7`, `P10-R3~R5` 완료 기록과 2026-07-31 Phase별 재점검 결과. +- **완료 증거:** stale 현재형 문구 검색, PRD Overview·EXT 영향·plan 수용 기준·현재 review metadata/summary 최소 수정, Markdown link와 targeted diff 검증 통과. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/prd.md`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase1-platform-auth-shared-ui.md`, `phase5-series-management.md`, `phase8-comments.md`, 필요 시 다른 현재 Phase 리뷰의 상태 표기. +- **Interfaces:** 완료된 v2 lookup·Series CRUD·Community pagination·FanTalk PUT/DELETE·Comments를 미래 구현으로 쓰지 않는다. 실제 개발 API 수동 QA와 2026-07-31 신규 미완료 Task는 계속 구분한다. 과거 Decision/Progress/실패 기록은 삭제하지 않는다. +- **범위 밖:** OpenAPI JSON 변경, 애플리케이션 코드 수정, 과거 기록 재작성, 수동 QA를 완료로 표시. + +- [x] **TDD 예외:** `rg`로 PRD의 “코드를 수정하지 않는다” 및 EXT의 “Phase 10에서 구현”, plan 현재 수용 기준의 미래형, 현재 리뷰 metadata/summary 불일치를 RED 대체 증거로 기록한다. +- [x] PRD·plan의 현재 상태와 현재 Phase 리뷰의 요약/종료 판정만 최신화하고 날짜별 이력은 보존한다. +- [x] Markdown link, stale 현재형 문구, `git diff --check`를 검증하고 수정 범위를 문서에 기록한다. +- **실행 명령:** `rg -n '애플리케이션 코드는 이번 단계에서 수정하지 않는다|Phase 10에서 .*구현|신규 회귀 수정 대기' docs/20260725_AI캐릭터관리자웹`; Markdown link 검사; `git diff --check -- docs/20260725_AI캐릭터관리자웹`. +- **기대 결과:** 과거 기록을 제외한 stale 현재형 문구와 상태 불일치 0건, 링크 오류 0건, diff 오류 0건. +- **수동 확인:** PRD Overview·EXT 표, plan §5.1·완료 정의, 각 현재 Phase 리뷰만 읽어 완료 기능·신규 회귀 Task·실제 server QA 대기를 구분할 수 있는지 확인한다. + +**P10-R6 수정 검증 기록 (2026-07-31):** + +- RED 대체: `rg -n "애플리케이션 코드는 이번 단계에서 수정하지 않는다|Phase 10에서 .*구현|신규 회귀 수정 대기|수정 대기|P10-GATE.*완료 아님" docs/20260725_AI캐릭터관리자웹`가 PRD Overview, EXT 영향 문구, plan §3.1, Phase 1·8 리뷰 metadata, Phase 8 발견 요약, Phase 10 현재 리뷰 결론의 불일치를 검출했다. +- GREEN: PRD Overview와 EXT 영향은 완료된 Phase 10 mock/client 구현과 실제 개발 API 수동 QA를 구분하도록 정정했다. plan §3.1은 Character/FanTalk/Comments의 현재 완료 범위로 갱신했고, Phase 1·8 리뷰 metadata와 Phase 8 요약 상태를 수정 완료로 맞췄다. 과거 Decision/Progress/실패 기록은 보존했다. +- 검증: 현재 상태 대상 검색은 stale 현재형 문구 0건이었다. `npm run typecheck`, `git diff --check -- docs/20260725_AI캐릭터관리자웹`는 모두 exit 0이었다. + +### Task R10.7 — Phase별 현재 리뷰 상태·완료 이력 재동기화 + +**Goal 실행 `P10-R7`:** `P9-R8` 완료 뒤 Phase 0~10 현재 리뷰 metadata·요약·plan 상단 상태를 실제 수정 완료 이력과 외부 수동 QA 대기 상태에 다시 맞춘다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-008` +- **시작 조건:** `P9-R8` 완료, `P0-R3`, `P1-R10~R11`, `P4-R7`, `P8-R4`, `P9-R7`의 기존 수정 완료 기록과 이번 Phase별 재리뷰 기록 보존. +- **완료 증거:** 현재 review metadata/최종 결론 불일치 검색, Phase 0~10 current status 최소 정정, 상대 링크와 plan 상태·검증 기록 점검. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase0-project-foundation.md`, `phase1-platform-auth-shared-ui.md`, `phase4-audio-content.md`, `phase8-comments.md`, `phase9-cross-cutting-quality.md`, 필요 시 같은 `reviews/phase*.md`의 current metadata/summary. +- **Interfaces:** 각 review의 과거 판정·실패·수정 기록은 삭제하지 않는다. 상단 `리뷰 상태`, 최신 차수 결론과 plan 상단 상태만 `P9-R8` 완료·실제 개발 API 수동 QA 대기를 구분하도록 정렬한다. +- **범위 밖:** OpenAPI·제품 코드 변경, 과거 기록 덮어쓰기, 실제 server QA를 완료로 표시, 기존 리뷰 파일 이름 변경. + +- [x] **TDD 예외:** 문서 metadata 정합성 Task이므로 Phase별 상단 상태와 최신 `Phase 결론`·`수정 후 검증`을 대조한 실패 목록을 RED 대체 증거로 기록했다. +- [x] `P9-R8` 완료 상태와 실제 개발 API 수동 QA 대기를 반영해 current metadata·plan 상단 상태만 최소 정정했다. +- [x] Phase별 상대 링크, 현재 상태 검색, Markdown 구조와 `git diff --check`를 검증하고 결과를 누적했다. +- **실행 명령:** `for f in docs/20260725_AI캐릭터관리자웹/reviews/phase*.md; do sed -n '1,16p' "$f"; tail -n 40 "$f"; done`; `rg -n '신규 회귀 수정 필요|교차 회귀 대기|P9-R8|P10-R7' docs/20260725_AI캐릭터관리자웹`; Markdown 상대 링크 검사; `git diff --check -- docs/20260725_AI캐릭터관리자웹`. +- **기대 결과:** 과거 이력을 제외한 current status와 최신 결론 불일치 0건, `P9-R8` 완료와 실제 server QA 대기 구분, 링크·diff 오류 0건. +- **수동 확인:** 각 Phase 리뷰의 첫 화면과 최신 차수만 읽어 확정 finding, 수정 완료 여부, 남은 수동 QA를 오해 없이 구분할 수 있는지 확인한다. + +**P10-R7 수정 검증 기록 (2026-07-31):** + +- RED 대체: `for f in docs/20260725_AI캐릭터관리자웹/reviews/phase*.md; do sed -n '1,16p' "$f"; tail -n 25 "$f"; done`로 Phase 0·1·4·8·9의 상단 `리뷰 상태`가 최신 수정 완료 결론과 어긋나거나 `P9-R8` 완료 전 상태를 유지하는 것을 확인했다. +- GREEN: plan 상단 상태를 `P9-R8`·`P10-R7` 보완 완료와 실제 개발 API 수동 QA 대기로 정리했다. Phase 0·1·4·8·9 review metadata는 최신 수정 완료 상태로 맞췄고, Phase 9·10 현재 리뷰에는 `P9-R8`·`P10-R7` 수정 완료 판정과 검증 기록을 누적했다. 과거 판정·실패·수정 기록은 삭제하지 않았다. +- 검증: `rg -n '^\| 리뷰 상태 \|.*(수정 필요|교차 회귀 대기)|^- \*\*최종 결론:\*\*.*수정 필요|^- \*\*남은 항목:\*\*.*P[0-9]+-R' docs/20260725_AI캐릭터관리자웹/reviews/phase*.md`와 `rg -n '^\| 상태 \|.*P[0-9]+-R' docs/20260725_AI캐릭터관리자웹/plan-task.md`는 current metadata/latest conclusion 불일치 0건이었다. `rg -n '\./reviews/[^)]+' docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/prd.md`로 상대 링크 형식을 확인했고, `git diff --check -- docs/20260725_AI캐릭터관리자웹`는 exit 0이었다. + +### Task R10.8 — 완료된 Phase 10 후속 범위의 현재형 설명 정렬 + +**Goal 실행 `P10-R8`:** plan의 Phase 지도·현재 상태·수용 증거·Tech Stack과 Phase 10 리뷰 마지막 결론을 완료된 `P10-T1~T7`, `P1-R12`, `P4-R8`과 현재 설치 구성에 맞게 바로잡는다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-009` +- **시작 조건:** `P1-R13~R15`, `P6-R4`, `P9-R9~R10` 완료 및 리뷰 상태 갱신, `P10-T1~T7`, `P1-R12`, `P4-R8` 완료 기록과 2026-07-31 문서 기준 재리뷰의 stale 현재형 검색 결과. +- **완료 증거:** FanTalk 수정·Audio UTC·Series CRUD·Community pagination·file policy를 미래형으로 쓰는 현재 설명 0건, 미설치 dependency를 Tech Stack으로 표시한 항목 0건, Phase 10 리뷰 최신 남은 Task 불일치 0건, link·diff 검증 통과. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`; Read: `package.json`; Test: 없음. +- **Interfaces:** 과거 Phase 실행 당시의 계약 대기·실패·Decision/Progress는 삭제하지 않고, 현재 상태를 설명하는 상단 표·계획·최신 결론만 정정한다. +- **범위 밖:** 제품 코드·OpenAPI 변경, 과거 이력 재작성, 미사용 dependency 설치, 실제 개발 API 수동 QA를 완료로 표시. + +- [x] **TDD 예외 사유:** 문서 현행화 Task이며 제품 동작 test로 의미 있는 RED를 만들 수 없다. stale 현재형·dependency 검색을 RED 대체 증거로 남긴다. +- [x] Phase 지도 행 7, §3.1 Phase 4~6, §5.1 Audio·Community·FanTalk·File 행, Tech Stack을 현재 완료 범위와 `package.json`에 맞게 최소 정정한다. +- [x] Phase 10 현재 리뷰 최신 남은 항목에서 이미 완료된 `P1-R12`, `P4-R8`을 제거하고 신규 회귀 Task·실제 서버 QA를 구분한다. +- [x] stale 검색·dependency 대조·link·diff 대체 검증 결과를 Progress와 Phase 10 리뷰에 누적한다. +- **대체 검증 방법:** current-state 특정 문구 검색, `package.json` dependency 대조, Markdown 상대 link 검사, targeted `git diff --check`. +- **실행 명령:** `rg -n 'detail/edit/filter는 계약 대기|migration 필요|후속 구현|parser/UI 갱신 필요|P10-T[2457]에서' docs/20260725_AI캐릭터관리자웹/plan-task.md`; `node -e "const p=require('./package.json'); console.log(Object.keys({...p.dependencies,...p.devDependencies}).sort().join('\\n'))"`; Markdown link 검사; `git diff --check -- docs/20260725_AI캐릭터관리자웹`. +- **기대 결과:** 과거 이력을 제외한 stale 현재형 0건, 현재 Tech Stack·dependency 불일치 0건, 최신 남은 Task 불일치 0건, link·diff 오류 0건. +- **수동 확인:** plan 상단·§3.1·§5.1과 Phase 10 리뷰 최신 차수만 읽어 완료 기능, 신규 회귀 Task, 실제 server QA 대기를 구분할 수 있는지 확인한다. + +**P10-R8 수정 검증 기록 (2026-07-31):** + +- RED 대체: `rg -n 'detail/edit/filter는 계약 대기|migration 필요|후속 구현|parser/UI 갱신 필요|P10-T[2457]에서|date-fns|dnd-kit|Lucide|Axios|React Router|React Hook Form|shadcn' docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`가 현재형 stale 문구와 미설치 dependency 표시를 검출했다. +- GREEN: plan 상단 Tech Stack을 `package.json` 설치 구성과 현재 구현 방식(native history, fetch/XHR)에 맞췄고, Phase 지도 FanTalk 행과 §3.1 Phase 4~6을 완료된 Phase 10 범위와 실제 개발 API 수동 QA 대기로 정정했다. 과거 Progress와 review 근거 문장은 이력으로 보존했다. +- 검증: `node -e "const p=require('./package.json'); console.log(Object.keys({...p.dependencies,...p.devDependencies}).sort().join('\\n'))"`로 설치 dependency를 대조했다. current-state stale 검색, targeted `git diff --check`, Phase 10 리뷰 상태 검색을 통과했다. + +### Task R10.9 — Goal 실행형 계획 필수 section 복구 + +**Goal 실행 `P10-R9`:** 기존 5,000여 행의 실행 이력을 보존하면서 `goal-plan.md`가 요구하는 목표, 현재 상태, 포함·제외, 기술 제약, Phase, 실행 순서·의존성, 변경 금지, 의사결정·중단, Progress, Decision Log, 발견된 문제, 최종 보고 형식을 명시적 section으로 찾을 수 있게 한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-010` +- **시작 조건:** `P10-R8` 완료, `docs/agent-guide/goal-plan.md` §3의 필수 12 section과 현재 plan의 최상위 heading 7개 대조. +- **완료 증거:** 필수 section 12개의 명시적 heading/navigation 존재, 기존 Task·Decision·Progress 이력 보존, 현재 신규 Task·중단 규칙·최종 보고 형식 연결, Markdown 구조·link·diff 검증 통과. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`; Read: `docs/agent-guide/goal-plan.md`, `docs/sample/sample-plan-task.md`; Test: 없음. +- **Interfaces:** 과거 실행 기록을 삭제·압축·재번호화하지 않고, 필수 section에서 기존 신뢰할 수 있는 본문 위치와 현재 상태를 연결한다. 추가 보완은 새 이력으로만 남긴다. +- **범위 밖:** 제품 코드·OpenAPI·PRD 변경, 과거 Task 체크박스 재판정, 실제 서버 QA 완료 표시, 문서 전체 재작성. + +- [x] **TDD 예외 사유:** 문서 구조 보완이며 제품 test는 확인 대상이 아니다. 필수 heading inventory 부족을 RED 대체 증거로 남긴다. +- [x] 가이드의 필수 12 section을 현재 본문과 mapping하고 없는 범위만 새 section으로 추가하며, 중복 본문은 링크/참조로 연결한다. +- [x] Progress, Decision Log, 발견된 문제, 최종 보고 형식에 신규 `P1-R13~R15`, `P6-R4`, `P9-R9~R10`, `P10-R8~R9`와 실제 API QA 대기를 현재 항목으로 연결한다. +- [x] heading·Task ID·link·diff 대체 검증 결과와 과거 이력 보존 확인을 Progress와 Phase 10 리뷰에 누적한다. +- **대체 검증 방법:** Node 또는 `rg`로 필수 heading 12개·중복 여부·Task ID 연결을 검사하고, 과거 체크박스·Progress 보존 diff와 Markdown link를 확인한다. +- **실행 명령:** `rg -n '^## ' docs/20260725_AI캐릭터관리자웹/plan-task.md`; 필수 section heading Node 검사; `rg -n 'P1-R1[3-5]|P6-R4|P9-R(9|10)|P10-R[89]' docs/20260725_AI캐릭터관리자웹/plan-task.md`; Markdown link 검사; `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md`. +- **기대 결과:** 필수 section 12개 모두 탐색 가능, 중복 Task ID·broken link·whitespace 오류 0건, 과거 완료 체크박스·Progress 삭제 0건. +- **수동 확인:** 새 실행자가 상단 구조만 따라 읽어 현재 미완료 Task, 의존성, 금지 변경, 중단 조건, 보고 형식을 추측 없이 찾을 수 있는지 확인한다. + +**P10-R9 수정 검증 기록 (2026-07-31):** + +- RED 대체: `rg -n '^## ' docs/20260725_AI캐릭터관리자웹/plan-task.md` 결과 필수 12 section 중 다수가 명시 heading으로 탐색되지 않았다. +- GREEN: 기존 Task·Progress 이력을 삭제하지 않고 상단에 `목표`, `현재 상태`, `범위의 포함·제외`, `기술적 제약`, `하나 이상의 Phase`, `실행 순서와 의존성`, `변경 금지 항목`, `의사결정 및 중단 규칙`, `Progress`, `Decision Log`, `발견된 문제`, `최종 보고 형식` section을 추가해 기존 본문으로 연결했다. +- 검증: 필수 section heading 검사, 신규 Task ID 검색, targeted `git diff --check`를 통과했다. 제품 코드와 OpenAPI는 변경하지 않았다. + +### Task R10.10 — Phase 10 현재형 설명·검증 명령 복구 + +**Goal 실행 `P10-R10`:** §5.1에 남은 완료 Task의 미래형 설명을 현재 상태로 고치고, `P10-R8` stale 검사가 과거 이력과 명령 자체를 다시 매치하지 않게 한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-011` +- **시작 조건:** §5.1 Audio·Community·FanTalk·파일 정책 행이 완료된 `P10-T2/T4/T5/T7`을 미래 작업처럼 설명하고, `P10-R8` exact `rg`가 과거 finding·Task 명령·RED 기록을 다시 찾아 exit 0과 여러 match를 반환하는 증거. +- **완료 증거:** §5.1을 구현 완료와 실제 server·수동 QA 대기로 구분한 현재형 설명, top 상태·Phase 지도·§5.1만 선택하는 실행 가능한 negative search, dependency 대조, 필수 section·Markdown link·diff 검증과 실제 exit/result 기록. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`; Test: 없음 — 검증 명령과 증거의 정합성 Task다. +- **Interfaces:** 과거 `REV-P10-009` 근거와 `P10-R8` RED 기록은 삭제하지 않는다. 현재 검증은 top current-state, Phase 지도와 §5.1처럼 명시적으로 한정된 section만 검사한다. +- **범위 밖:** 제품 코드·OpenAPI·dependency 변경, 과거 기록 삭제, 문서 전체 재작성. + +- [x] **TDD 예외 사유:** 문서 검증 명령 자체가 산출물이므로 exact 기존 명령의 self/history match를 RED 대체 증거로 사용한다. +- [x] §5.1의 `P10-T2/T4/T5/T7` 미래형 행을 구현 완료와 실제 server·수동 QA 대기로 분리해 현재형으로 정정한다. +- [x] top current-state, Phase 지도와 §5.1만 선택하는 section-aware stale search로 현재 검증 기준을 정정한다. +- [x] dependency·필수 12 section·Markdown link·diff 검사를 재실행하고 실제 exit code와 결과를 기록한다. +- [x] Phase 10 리뷰에 기존 완료 판정을 바꾸지 않는 검증 증거 정정과 남은 항목을 누적한다. +- **대체 검증 방법:** section-aware negative search, 설치 dependency 목록 대조, heading·link·diff 검사. +- **실행 명령:** `! awk '(/^## 목표$/){current=1} (/^## 1\\. 전역 제약$/){current=0} (/^## 3\\. Phase 지도$/){current=1} (/^## 4\\. 파일 책임 지도$/){current=0} (/^### 5\\.1 P9 수용 기준 증거 요약$/){current=1} (/^## 6\\. 구현 완료 정의$/){current=0} current' docs/20260725_AI캐릭터관리자웹/plan-task.md | rg -n 'detail/edit/filter는 계약 대기|migration 필요|후속 구현|parser/UI 갱신 필요|UTC 전송·`timezone` 제거는 P10-T2|pagination object·`timezone` 제거는 P10-T4|답변 수정·팬 원글 삭제와 server integration은 P10-T5|P10-T7에서 종결|date-fns|dnd-kit|Lucide|Axios|React Router|React Hook Form'`; dependency 대조 Node 명령; 필수 12 section 검사; Markdown link 검사; `git diff --check -- docs/20260725_AI캐릭터관리자웹`. +- **기대 결과:** section-aware stale match 0건, 현재 dependency·필수 section 불일치 0건, broken link·whitespace 오류 0건. +- **수동 확인:** `P10-R8~R10`과 Phase 10 리뷰 마지막 절에서 과거 stale 근거와 현재 통과 증거를 명확히 구분할 수 있는지 확인한다. + +**P10-R10 수정 검증 기록 (2026-07-31):** + +- RED 대체: `P10-R8`의 문서 전체 stale 검색은 과거 finding·명령·RED 기록을 다시 매치하고, section-aware 검색도 §5.1의 `UTC 전송·\`timezone\` 제거는 P10-T2`, `pagination object·\`timezone\` 제거는 P10-T4`, `답변 수정·팬 원글 삭제와 server integration은 P10-T5`, `P10-T7에서 종결` 현재형 누락을 검출했다. +- GREEN/REFACTOR: §5.1 Audio·Community·FanTalk·파일 정책 행을 구현 완료와 실제 개발 API 수동 QA 대기로 분리했다. 과거 `P10-R8` 근거와 Progress는 삭제하지 않고 현재 검증은 top current-state·Phase 지도·§5.1만 추출하는 section-aware negative search로 한정했다. +- 검증: section-aware stale search — 0 matches. dependency 대조 Node 명령은 현재 설치 dependency 목록을 출력했고 미설치 dependency stale 표기는 없었다. 필수 section과 Markdown link는 기존 `P10-R9` 구조를 유지했다. + +### Task R10.11 — 현재형 검증 명령·완료 상태 재현성 복구 + +**Goal 실행 `P10-R11`:** `P10-R10`의 section-aware 검사를 문서에서 그대로 복사해 실행할 수 있게 만들고, 신규 회귀 Task 완료 뒤 plan·리뷰의 현재 상태를 실제 남은 Gate와 수동 QA에 맞춘다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-012` +- **시작 조건:** `P10-R10` 원문 AWK heading 정규식의 이중 escape가 section 종료를 찾지 못해 과거 이력 10건을 다시 매치하고, dependency·heading·link 검사가 placeholder이며 plan 상단과 Phase 10 리뷰 metadata는 완료된 `P1-R16/P9-R11/P10-R10`을 수정 필요로 표시하는 근거. +- **완료 증거:** 복사 가능한 exact section-aware negative search exit 0·0 matches, 완전한 dependency·필수 section·link·diff 명령 결과, `P1-R17/P9-R12` 완료 뒤 plan top·Phase 10 리뷰 metadata·최신 Progress가 실제 자동 Gate/수동 QA 상태와 일치하는 기록. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`; Test: 없음 — 검증 명령과 current-state 문서의 재현성 Task다. +- **Interfaces:** AWK heading은 shell copy에 안전한 `[.]` 표현을 사용한다. 과거 `P10-R8~R10` finding·RED·Progress는 보존하고 top current-state와 최신 review metadata만 갱신한다. +- **범위 밖:** 제품 코드·OpenAPI·dependency 변경, 과거 검증 이력 삭제, WebKit/mobile Safari 실행. +- **Phase 10 Gate 연결:** `P1-R17`, `P9-R12` 완료 뒤 exact 문서 명령, 필수 section·link·diff와 전체 unit·정적/build Gate가 통과하기 전에는 완료하지 않는다. + +- [x] **TDD 예외 사유:** 문서 명령과 current-state metadata가 산출물이므로 exact negative search와 stale top 상태 검색을 RED 대체 증거로 사용했다. +- [x] AWK 정규식을 `[.]` 기반으로 유지하고 dependency·필수 section·Markdown link 검사를 placeholder 없는 완전한 명령으로 기록했다. +- [x] 원문 명령을 그대로 복사해 exit 0·0 matches와 dependency/section/link/diff 오류 0건을 확인했다. +- [x] `P1-R17`, `P9-R12` 완료 뒤 plan top·실행 순서·발견된 문제와 Phase 10 리뷰 metadata를 자동 Gate 완료 또는 실제 개발 API 수동 QA 대기로 전환했다. +- [x] 전체 unit·정적/build와 Chromium/mobile Chrome list 결과를 최신 Progress와 Phase 10 리뷰에 누적했다. +- **대체 검증 방법:** exact copy/paste command, dependency/heading/link Node 검사, full unit·정적/build와 Playwright list. +- **실행 명령:** `! awk '(/^## 목표$/){current=1} (/^## 1[.] 전역 제약$/){current=0} (/^## 3[.] Phase 지도$/){current=1} (/^## 4[.] 파일 책임 지도$/){current=0} (/^### 5[.]1 P9 수용 기준 증거 요약$/){current=1} (/^## 6[.] 구현 완료 정의$/){current=0} current' docs/20260725_AI캐릭터관리자웹/plan-task.md | rg -n 'detail/edit/filter는 계약 대기|migration 필요|후속 구현|parser/UI 갱신 필요|UTC 전송·`timezone` 제거는 P10-T2|pagination object·`timezone` 제거는 P10-T4|답변 수정·팬 원글 삭제와 server integration은 P10-T5|P10-T7에서 종결|date-fns|dnd-kit|Lucide|Axios|React Router|React Hook Form'`; `node -e 'const p=require("./package.json");const d={...p.dependencies,...p.devDependencies};const stale=["axios","date-fns","@dnd-kit/core","@dnd-kit/sortable","lucide-react","react-router-dom","react-hook-form"].filter(x=>x in d);if(stale.length){console.error(stale.join("\n"));process.exit(1)}'`; `node -e 'const fs=require("fs");const s=fs.readFileSync("docs/20260725_AI캐릭터관리자웹/plan-task.md","utf8");const required=["목표","현재 상태","범위의 포함·제외","기술적 제약","하나 이상의 Phase","실행 순서와 의존성","변경 금지 항목","의사결정 및 중단 규칙","Progress","Decision Log","발견된 문제","최종 보고 형식"];const missing=required.filter(h=>!s.includes("## "+h+"\n"));if(missing.length){console.error(missing.join("\n"));process.exit(1)}'`; `node -e 'const fs=require("fs"),path=require("path");const root="docs/20260725_AI캐릭터관리자웹";const files=[];const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):e.name.endsWith(".md")&&files.push(p)}};walk(root);const broken=[];for(const f of files){const s=fs.readFileSync(f,"utf8");for(const m of s.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){const t=m[1].trim().replace(/^<|>$/g,"").split("#")[0];if(t&&!/^(https?:|mailto:)/.test(t)&&!fs.existsSync(path.resolve(path.dirname(f),t)))broken.push(f+": "+t)}}if(broken.length){console.error(broken.join("\n"));process.exit(1)}'`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `npm run e2e:mock -- --list`; `npm run e2e -- --list`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, stale match·dependency/section/link/diff 오류 0건, full unit 0 failure, Playwright project는 `chromium`·`mobile-chrome`만 수집, top current-state와 최신 review 종료 판정 불일치 0건. +- **수동 확인:** 문서의 명령을 새 shell에 그대로 붙여 실행할 수 있고 상단 현재 상태만 읽어 다음 작업이 자동 Gate인지 실제 개발 API 수동 QA인지 판단 가능한지 확인한다. + +**P10-R11 수정 검증 기록 (2026-07-31):** + +- RED 대체: `[.]` 기반 section-aware negative search는 0 matches였지만 plan 상단 상태·실행 순서·발견된 문제와 Phase 10 리뷰 metadata가 `P10-R11` 수정 필요를 유지하는 것을 확인했다. +- GREEN/REFACTOR: plan top current-state를 `P1-R17`, `P9-R12`, `P10-R11` 수정 완료와 실제 개발 API 수동 QA 대기로 전환하고, Phase 10 리뷰 metadata와 종료 판정을 `P10-R11` 수정 완료로 정리했다. 과거 `P10-R8~R10` finding·RED·Progress는 보존했다. +- 문서 명령: section-aware negative search, dependency stale 검사, 필수 12 section 검사, Markdown local link 검사, `git diff --check` — 모두 exit 0 / no output. +- 자동 Gate: `npm run test:run` — 81 files / 417 tests passed. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod` — 모두 exit 0. build는 기존 500kB 초과 chunk warning만 표시했다. `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 8 tests passed. `npm run e2e:mock -- --list` — Chromium/mobile Chrome 104 tests 수집. `npm run e2e -- --list` — Chromium/mobile Chrome 18 tests 수집. 실제 E2E 실행은 사용자 지시에 따라 생략했다. + +### Task R10.12 — 최신 Progress·수동 QA current-state 종결 + +**Goal 실행 `P10-R12`:** 과거 재리뷰 기록은 보존하면서 plan 최하단에 최신 완료·후속 상태를 누적하고 상단 current-state가 모든 남은 문서 Task와 수동 QA를 빠짐없이 안내하게 한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-013` +- **시작 조건:** plan 상단은 `P1-R17/P9-R12/P10-R11` 완료를 선언했지만 최하단 최신 Progress는 세 Task를 남은 항목으로 유지하고, 상단 수동 QA에는 실제 crop pixel·stale ADMIN 확인이 빠진 재리뷰 근거. +- **완료 증거:** `P9-R13` 완료 뒤 plan top·실행 순서·발견된 문제와 최하단 최신 Progress가 자동 보완 완료 및 실제 crop pixel·stale ADMIN·개발 API 수동 QA 대기로 일치하고 Phase 10 리뷰 metadata·종료 판정도 같은 상태를 표시하는 기록. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`. +- **Interfaces:** 과거 Progress·Decision·finding은 삭제하거나 재작성하지 않고 더 최신 기록만 append한다. 지원 browser는 Chromium/mobile Chrome만 유지한다. +- **범위 밖:** 제품·test·OpenAPI 변경, 수동 QA 완료 주장, 과거 browser 실행 이력 삭제. +- **Phase 10 Gate 연결:** `P9-R13` 완료 뒤 top/tail current-state 검사, 필수 section·link·diff와 전체 unit·정적/build Gate가 통과하기 전에는 완료하지 않는다. + +- [x] **TDD 예외 사유:** 문서의 시간순 current-state가 산출물이므로 top/tail 상태 검색과 상호 대조를 RED 대체 증거로 사용했다. +- [x] plan 최하단에 `P1-R17/P9-R12/P10-R11/P9-R13` 완료와 `P10-R12` 결과를 담은 최신 Progress를 append했다. +- [x] 상단 상태·현재 상태·실행 순서·발견된 문제에 실제 crop pixel·stale ADMIN·개발 API 수동 QA를 빠짐없이 반영했다. +- [x] Phase 10 리뷰 metadata·최신 종료 판정과 plan top/tail을 같은 상태로 맞췄다. +- [x] 문서 검사와 전체 unit·정적/build 결과를 최신 Progress와 Phase 10 리뷰에 누적했다. +- **대체 검증 방법:** top/tail current-state 대조, 필수 heading·Markdown link·diff 검사, full unit·정적/build Gate. +- **실행 명령:** `tail -n 20 docs/20260725_AI캐릭터관리자웹/plan-task.md | rg -n 'P1-R17.*P9-R12.*P10-R11.*P9-R13|crop pixel.*stale ADMIN.*Series/FanTalk/Comments/file policy'`; `node -e 'const fs=require("fs");const s=fs.readFileSync("docs/20260725_AI캐릭터관리자웹/plan-task.md","utf8");const required=["목표","현재 상태","범위의 포함·제외","기술적 제약","하나 이상의 Phase","실행 순서와 의존성","변경 금지 항목","의사결정 및 중단 규칙","Progress","Decision Log","발견된 문제","최종 보고 형식"];const missing=required.filter(h=>!s.includes("## "+h+"\n"));if(missing.length){console.error(missing.join("\n"));process.exit(1)}'`; `node -e 'const fs=require("fs"),path=require("path");const root="docs/20260725_AI캐릭터관리자웹";const files=[];const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):e.name.endsWith(".md")&&files.push(p)}};walk(root);const broken=[];for(const f of files){const s=fs.readFileSync(f,"utf8");for(const m of s.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){const t=m[1].trim().replace(/^<|>$/g,"").split("#")[0];if(t&&!/^(https?:|mailto:)/.test(t)&&!fs.existsSync(path.resolve(path.dirname(f),t)))broken.push(f+": "+t)}}if(broken.length){console.error(broken.join("\n"));process.exit(1)}'`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `git diff --check`. +- **기대 결과:** 모든 명령 exit 0, top/tail·review current-state 불일치와 필수 section·link·diff 오류 0건, 81 files / 417 tests 및 정적/build Gate 통과. +- **수동 확인:** plan 상단과 최하단만 읽어 다음 작업·지원 browser·모든 수동 QA 대기를 같은 내용으로 판단할 수 있는지 확인한다. + +### Task R10.13 — current-state 완료 계약·검증 명령 재현성 복구 + +**Goal 실행 `P10-R13`:** `P10-R12`의 top/tail/review 완료 상태를 정확히 같은 값으로 고정하고 과거 tail 행이나 OR match로 stale 상태가 통과하지 않는 실행 가능한 문서 계약을 만든다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-014` +- **시작 조건:** Phase 10 리뷰와 plan 최하단은 `P10-R12` 완료를 선언하지만 plan 상단 다섯 current-state 위치와 Task 체크박스는 미완료이며, `tail -n 20 | rg '완료|수동 QA'` 명령이 이 충돌 상태에서도 과거 행·OR match로 exit 0인 재리뷰 근거. +- **완료 증거:** `P9-R14~R15` 완료 뒤 plan top·Task checklist·최신 Progress·Phase 10 metadata/종료 판정을 독립 scope로 검사하는 docs contract RED/GREEN, 기존 `P10-R12` 실행 명령 정정과 전체 unit·정적/build 결과. +- **Files:** Modify: `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`, `src/shared/mocks/__tests__/mock-preview-docs.test.ts`. +- **Interfaces:** 과거 Progress·finding은 보존하고 현재 top, `P10-R12` checklist와 최신 review/Progress만 갱신한다. 지원 browser는 Chromium/mobile Chrome만 유지한다. +- **범위 밖:** 제품·OpenAPI 변경, 수동 QA 완료 주장, 과거 이력 삭제, WebKit/mobile Safari 실행. +- **Phase 10 Gate 연결:** docs contract와 필수 section·link·diff, full unit·정적/build가 모두 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** plan top, `P10-R12` checklist, 마지막 Progress marker 이후, Phase 10 metadata와 최신 종료 판정을 각각 추출하는 docs contract가 현재 불일치와 false-positive 명령을 검출하는지 확인했다. +- [x] **GREEN:** 현재 상태를 자동 보완 완료·모든 수동 QA 대기로 맞추고 `P10-R12`의 충족된 checklist를 완료 표시하며 OR 기반 tail 명령을 scoped contract 명령으로 교체했다. +- [x] **REFACTOR:** 과거 tail 행에 의존하는 assertion을 제거하고 `P9-R14~R15` 완료 후 실제 남은 항목만 current section에 유지했다. +- [x] docs contract와 전체 unit·정적/build 결과를 최신 Progress와 Phase 10 리뷰에 누적했다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run test:run`; `npm run typecheck`; `npm run lint`; `npm run build:dev`; `npm run build:prod`; `node -e 'const fs=require("fs");const s=fs.readFileSync("docs/20260725_AI캐릭터관리자웹/plan-task.md","utf8");const required=["목표","현재 상태","범위의 포함·제외","기술적 제약","하나 이상의 Phase","실행 순서와 의존성","변경 금지 항목","의사결정 및 중단 규칙","Progress","Decision Log","발견된 문제","최종 보고 형식"];const missing=required.filter(h=>!s.includes("## "+h+"\n"));if(missing.length){console.error(missing.join("\n"));process.exit(1)}'`; `git diff --check`. +- **기대 결과:** docs contract 2 files / 9 tests, full unit 81 files / 418 tests, top/checklist/tail/review 불일치와 필수 section·diff 오류 0건, 정적/build exit 0. +- **수동 확인:** plan 상단과 마지막 Progress, Phase 10 최신 section만 읽어 자동 Task 완료·지원 browser·남은 수동 QA를 동일하게 판단할 수 있는지 확인한다. + +**P10-R13 수정 검증 기록 (2026-07-31):** + +- RED: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 1 file / 1 failed / 5 passed. plan 최하단 최신 Progress marker가 없어 top/checklist/latest review 독립 scope 검사가 실패했다. +- GREEN/REFACTOR: plan 상단 상태, 현재 상태, 실행 순서, 발견된 문제를 자동 보완 완료와 실제 crop pixel·stale ADMIN·Series/FanTalk/Comments/file policy 수동 QA 대기로 맞췄다. `P10-R12` checklist와 `P10-R13` checklist를 완료 표시하고, docs contract가 top/checklist/latest Progress/Phase 10 review를 독립 scope로 검사하게 했다. 과거 tail OR match 방식은 현재 완료 증거에서 사용하지 않는다. +- 검증: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 9 tests passed. `npm run test:run` — 81 files / 418 tests passed. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, 필수 12 section Node 검사와 `git diff --check` — 모두 exit 0. build는 기존 500kB chunk warning만 표시했다. +- 판정: `P9-R14`, `P9-R15`, `P10-R13` 자동 보완은 완료됐다. 남은 범위는 실제 crop pixel 비교, stale ADMIN server 확인, 실제 개발 API Series/FanTalk/Comments/file policy 수동 QA다. WebKit·Mobile Safari는 지원 범위에서 제외하고 Chromium/mobile Chrome만 유지한다. + +### Task R10.14 — Progress·review current-state 독립 scope 복구 + +**Goal 실행 `P10-R14`:** `P10-R13` docs contract가 plan 상단 Progress, 해당 최신 Progress record, Phase 10 metadata, 최신 H2와 그 내부 종료 판정을 각각 닫힌 범위로 검사해 후속 기록 문자열로 stale 상태를 통과시키지 못하게 한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-015` +- **시작 조건:** contract가 plan 상단 `## Progress`를 추출하지 않고, `plan.slice(latestProgressIndex)`는 `P10-R13` marker부터 EOF까지 읽으며 Phase 10 리뷰 metadata와 `### 종료 판정`도 독립 추출하지 않아 각 stale 상태를 후속 문자열로 통과하는 negative-control 근거. +- **선행조건:** `P9-R16`의 exact heading helper 수정 완료. +- **완료 증거:** plan 상단 `## Progress`, exact Progress marker부터 다음 독립 Progress marker 또는 EOF까지의 범위, Phase 10 `## 1. 리뷰 정보`, 최신 H2와 그 내부 종료 판정 각각의 assertion, synthetic 후속 record/H3 false positive 차단. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`. +- **Interfaces:** 문서 contract helper만 최소 수정하고 제품·OpenAPI·Playwright 설정과 과거 Progress 이력은 변경하지 않는다. +- **범위 밖:** 문서 parser dependency, 수동 QA 완료 주장, WebKit/mobile Safari 실행. +- **Phase 10 Gate 연결:** synthetic negative-control, docs contract, typecheck·lint·필수 section·link·diff 검사가 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** plan 상단 Progress를 stale하게 두거나 stale `P10-R13` record 뒤 독립 후속 record에 필수 문구를 둬도 현재 contract가 통과하고 metadata·종료 판정 불일치를 놓치는 synthetic 실패를 고정했다. +- [x] **GREEN:** plan 상단 Progress를 독립 추출하고 exact marker 기반 record helper로 `P10-R13` Progress를 다음 독립 record에서 닫으며 리뷰 metadata·최신 H2·종료 판정을 각각 추출했다. +- [x] **REFACTOR:** OR/EOF 범위와 review 전체 문자열 의존을 제거하고 현재 상태 토큰을 각 소유 section에서만 검사한다. +- [x] focused·정적·필수 section·link·diff 결과를 Progress와 Phase 10 리뷰에 누적한다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run typecheck`; `npm run lint`; `node -e 'const fs=require("fs");const s=fs.readFileSync("docs/20260725_AI캐릭터관리자웹/plan-task.md","utf8");const required=["목표","현재 상태","범위의 포함·제외","기술적 제약","하나 이상의 Phase","실행 순서와 의존성","변경 금지 항목","의사결정 및 중단 규칙","Progress","Decision Log","발견된 문제","최종 보고 형식"];const missing=required.filter(h=>!s.includes("## "+h+"\n"));if(missing.length){console.error(missing.join("\n"));process.exit(1)}'`; `node -e 'const fs=require("fs"),path=require("path");const root="docs/20260725_AI캐릭터관리자웹";const files=[];const walk=d=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);e.isDirectory()?walk(p):e.name.endsWith(".md")&&files.push(p)}};walk(root);const broken=[];for(const f of files){const s=fs.readFileSync(f,"utf8");for(const m of s.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)){const t=m[1].trim().replace(/^<|>$/g,"").split("#")[0];if(t&&!/^(https?:|mailto:)/.test(t)&&!fs.existsSync(path.resolve(path.dirname(f),t)))broken.push(f+": "+t)}}if(broken.length){console.error(broken.join("\n"));process.exit(1)}'`; `git diff --check`. +- **기대 결과:** docs contract 2 files / 9 tests, 후속 Progress/H3·metadata false positive 0건, 필수 12 section·link·diff·정적 오류 0건. +- **수동 확인:** plan 최신 Progress와 Phase 10 metadata·최신 종료 판정 각각에서 같은 다음 작업·지원 browser·수동 QA 상태를 읽을 수 있는지 확인한다. + +**P10-R14 수정 검증 기록 (2026-08-01):** + +- RED: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts -t "keeps Phase 10 current state"` — 1 file / 1 failed / 5 skipped. 최신 `## 25. P10-R14 수정 후 검증 — 2026-07-31` section 부재로 실패했다. +- GREEN/REFACTOR: docs contract가 plan 상단 `## Progress`, exact `P10-R13` Progress record, Phase 10 `## 1. 리뷰 정보`, 최신 H2와 내부 `### 종료 판정`을 각각 닫힌 범위로 검사하게 했다. `P10-R14` checklist와 Phase 10 review metadata·종료 판정을 수정 완료 상태로 갱신했다. +- focused: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts -t "keeps Phase 10 current state"` — 1 file / 1 passed / 5 skipped. +- 검증: `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts` — 2 files / 9 tests passed. `npm run typecheck`, `npm run lint`, 필수 12 section 검사, Markdown link 검사, `git diff --check` — 모두 exit 0. `npm run e2e:mock -- --list`는 Chromium/mobile Chrome 104 tests, `npm run e2e -- --list`는 Chromium/mobile Chrome 18 tests를 수집했다. 개발 중 정책에 따라 E2E 실제 실행은 하지 않았다. + +### Task R10.15 — §7 최신 Progress와 실제 marker scope 복구 + +**Goal 실행 `P10-R15`:** docs contract가 Task 본문의 `P10-R13` 검증 기록이 아니라 §7의 실제 `P10-R13` Progress와 새 current 완료 record를 각각 닫힌 범위로 검사하고 top/review 상태와 일치시킨다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-016` +- **시작 조건:** contract가 §7의 `**P10-R13 수정 검증 기록 — 2026-07-31:**` 대신 Task 본문의 괄호형 marker를 선택해 §7 record를 stale로 바꿔도 통과하고, plan 최하단은 여전히 `P9-R16` → `P10-R14`를 남은 항목으로 유지하는 negative-control 근거. +- **선행조건:** `P9-R17` 완료. +- **완료 증거:** `P9-R16`·`P10-R14` checklist 완료, §7 실제 `P10-R13` marker의 다음 record 경계, `P9-R17`·`P10-R15` 완료를 담은 append-only 최신 record, plan top과 Phase 10 metadata·실제 마지막 H2·종료 판정의 동일 current-state contract 및 날짜 정정 기록. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`. +- **Interfaces:** 기존 `progressRecordAtMarker()`와 exact heading helper를 재사용한다. 과거 Progress와 잘못 기재된 review 날짜는 삭제하지 않고 정정 section을 append한다. +- **범위 밖:** 제품·OpenAPI·Playwright 설정 변경, 과거 기록 삭제, 수동 QA 완료 주장, WebKit/mobile Safari 실행. +- **Phase 10 Gate 연결:** 실제 §7 marker negative-control, 최신 record·metadata·최신 결론 contract, docs test·정적·필수 section·link·diff가 통과하기 전에는 완료하지 않는다. + +- [x] **RED:** `P9-R16`·`P10-R14` checklist 또는 §7 `P10-R13` record·plan 최하단을 stale하게 바꿔도 Task-local marker 기반 contract가 통과하고 trailing H2를 놓치는 실패를 고정했다. +- [x] **GREEN:** 두 current Task checklist를 검사하고 contract를 §7 em-dash marker로 옮겨 다음 record에서 닫으며, 이번 수정 완료·수동 QA만 담은 최신 Progress record와 Phase 10 정정 H2를 append해 실제 마지막 H2를 대상으로 삼았다. +- [x] **REFACTOR:** Task-local 검증 기록을 current Progress 대체물로 쓰는 assertion을 제거하고 top/tail/metadata/최신 결론을 각 소유 section에서만 검사한다. +- [x] focused·정적·필수 section·link·diff·Playwright list 결과를 Progress와 Phase 10 리뷰에 누적했다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run typecheck`; `npm run lint`; `npm run e2e:mock -- --list`; `npm run e2e -- --list`; `node -e 'const fs=require("fs");const s=fs.readFileSync("docs/20260725_AI캐릭터관리자웹/plan-task.md","utf8");const required=["목표","현재 상태","범위의 포함·제외","기술적 제약","하나 이상의 Phase","실행 순서와 의존성","변경 금지 항목","의사결정 및 중단 규칙","Progress","Decision Log","발견된 문제","최종 보고 형식"];const missing=required.filter(h=>!s.includes("## "+h+"\n"));if(missing.length){console.error(missing.join("\n"));process.exit(1)}'`; `git diff --check`. +- **기대 결과:** docs contract 2 files / 9 tests, Task-local/§7 marker 혼동과 top/tail/review 불일치 0건, mock 104·server 18 tests가 `chromium`·`mobile-chrome`에서만 수집되고 정적·필수 section·link·diff 오류 0건. +- **수동 확인:** plan 상단·최하단 최신 record와 Phase 10 metadata·마지막 H2에서 같은 완료 Task·지원 browser·남은 수동 QA를 읽을 수 있는지 확인한다. + +**P10-R15 수정 검증 기록 (2026-08-01):** + +- RED: `npm run test:run -- src/shared/mocks/__tests__/mock-preview-docs.test.ts -t "keeps Phase 10 current state"` — 1 file / 1 failed / 5 skipped. §7 최신 `P9-R17·P10-R15` 완료 record가 없어 실패했다. +- GREEN/REFACTOR: docs contract가 `P9-R16`·`P10-R14` checklist, §7 em-dash형 `P10-R13` record, 최신 `P9-R17·P10-R15` record, Phase 10 metadata와 실제 마지막 H2·종료 판정을 각각 닫힌 범위로 검사하게 했다. plan top current-state와 Phase 10 review metadata를 자동 보완 완료 및 수동 QA 대기로 맞췄다. +- 검증: focused GREEN은 1 file / 1 passed / 5 skipped, docs contract는 2 files / 9 tests passed, 전체 unit은 81 files / 418 tests passed였다. `typecheck`, `lint`, 필수 12 section 검사, Markdown link 검사, `git diff --check`는 모두 exit 0이다. `npm run e2e:mock -- --list`는 Chromium/mobile Chrome 104 tests, `npm run e2e -- --list`는 Chromium/mobile Chrome 18 tests를 수집했다. 개발 중 정책에 따라 실제 E2E 실행은 하지 않았다. + +### Task R10.16 — Phase 10 finding·checklist·최신 Progress contract 종결 + +**Goal 실행 `P10-R16`:** `P10-R15` 완료를 소유 finding 상태와 Task checklist에서 확인하고, §7의 실제 마지막 독립 Progress record를 동적으로 검사한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-017`. +- **시작 조건:** `REV-P10-016` 표는 `확정` 상태이고 docs contract는 `P10-R15` 자체 checklist를 검사하지 않는다. 최신 Progress marker를 `P9-R17·P10-R15`로 고정해 뒤에 새 record가 추가돼도 실제 마지막 record를 보지 않으며 synthetic fixture도 과거 괄호형 Task-local marker를 사용한다. +- **선행조건:** `P9-R18`, `P9-R19` 완료. +- **완료 증거:** `REV-P10-016`의 `수정 완료` 상태·날짜별 근거, `P10-R15` checklist assertion, §7의 실제 마지막 독립 Progress record를 선택하는 helper와 em-dash형 marker 기반 synthetic negative-control. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`. +- **Interfaces:** 기존 line/record helper를 최소 확장한다. 제품·OpenAPI·Playwright 설정은 변경하지 않는다. +- **범위 밖:** Markdown 전체 parser, 과거 Progress 삭제, 수동 QA 완료 주장, WebKit/mobile Safari 실행. + +- [x] **RED:** `P10-R15` checklist를 미완료로 바꾸거나 고정 marker 뒤 stale 최신 record를 append해도 contract가 통과하는 실패를 고정한다. +- [x] **GREEN:** 소유 finding 상태와 `P10-R15` checklist를 검사하고 §7의 실제 마지막 독립 record를 선택한다. +- [x] **REFACTOR:** synthetic marker를 실제 em-dash형 §7 형식으로 통일하고 과거 Task-local 괄호형 의존을 제거한다. +- [x] docs contract·typecheck·lint·필수 section·link·diff 결과를 Progress와 Phase 10 리뷰에 누적한다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run typecheck`; `npm run lint`; 필수 12 section 검사; Markdown link 검사; `git diff --check`. +- **기대 결과:** finding/checklist/latest Progress 불일치와 marker 형식 false positive 0건, docs contract·정적·문서 검사 오류 0건. + +**P10-R16 수정 검증 기록 (2026-08-01):** + +- RED: 고정 marker synthetic은 1 failed / 8 skipped, current-state focused는 plan 상단 보완 대기 상태로 1 failed / 8 skipped였다. +- GREEN/REFACTOR: §7의 마지막 em-dash 날짜형 독립 Progress를 선택하고 실제 marker 형식, `P10-R15` checklist와 `REV-P10-016`·`REV-P10-017` 상태를 검사했다. focused 2 passed / 7 skipped, docs contract 2 files / 12 tests passed였다. + +### Task R10.17 — 중복 Progress marker의 마지막 record 보장 + +**Goal 실행 `P10-R17`:** §7에 동일 제목·날짜 marker가 반복돼도 실제 마지막 occurrence의 Progress record를 검사한다. + +- **연결 리뷰:** [Phase 10 현재 리뷰](./reviews/phase10-openapi-follow-up.md) — `REV-P10-018`. +- **시작 조건:** `latestProgressRecord()`는 마지막 marker 문자열을 찾지만 `progressRecordAtMarker()`가 `findIndex()`로 첫 동일 문자열을 다시 선택해 오래된 완료 record를 반환하는 근거. +- **선행조건:** `P10-R16` 완료. +- **완료 증거:** 동일 marker의 첫 record가 완료, 마지막 record가 보완 필요인 synthetic negative-control과 마지막 occurrence index 기반 record 추출. +- **Files:** Modify: `src/shared/mocks/__tests__/mock-preview-docs.test.ts`, `docs/20260725_AI캐릭터관리자웹/plan-task.md`, `docs/20260725_AI캐릭터관리자웹/reviews/phase10-openapi-follow-up.md`. +- **Interfaces:** 새 parser나 helper 계층 없이 기존 `progressRecordAtMarker()`가 동일 marker의 마지막 occurrence를 선택하게 한다. +- **범위 밖:** 제품·OpenAPI·Playwright 설정 변경, 과거 Progress 삭제, WebKit/mobile Safari 실행. + +- [x] **RED:** 동일 marker가 두 번 나오고 마지막 record만 `보완 필요`인 synthetic 문서가 과거 완료 record를 반환하는 실패를 고정한다. +- [x] **GREEN:** marker 검색을 뒤에서 수행해 마지막 occurrence부터 다음 독립 record 또는 EOF까지 반환한다. +- [x] **REFACTOR:** `latestProgressRecord()`의 marker 형식·record 경계 helper를 그대로 재사용하고 추가 추상화는 만들지 않는다. +- [x] docs contract·전체 unit·typecheck·lint·link·diff 결과를 Progress와 Phase 10 리뷰에 누적한다. +- **실행 명령:** `npm run test:run -- src/shared/mocks/__tests__/mode-boundary.test.ts src/shared/mocks/__tests__/mock-preview-docs.test.ts`; `npm run test:run`; `npm run typecheck`; `npm run lint`; Markdown link 검사; `git diff --check`. +- **기대 결과:** 중복 marker stale record 선택 0건, docs contract·전체 unit·정적·문서 검사 오류 0건. + +**P10-R17 수정 검증 기록 (2026-08-01):** + +- RED: 동일 marker 두 개 중 첫 완료 record를 반환해 latest Progress synthetic이 1 failed / 8 skipped였다. current-state 전환은 plan 상단의 `P10-R17` 대기 상태로 1 failed / 8 skipped였다. +- GREEN/REFACTOR: `progressRecordAtMarker()`가 marker를 뒤에서 찾아 마지막 동일 occurrence를 사용하게 했다. 별도 parser·dependency·추가 helper는 만들지 않았다. +- 검증: Phase 10 focused 2 passed / 7 skipped, docs contract 2 files / 12 tests, 전체 unit 81 files / 421 tests가 통과했다. `typecheck`, `lint`, 필수 section·Markdown link·`git diff --check`도 exit 0이었고 독립 재리뷰에서 신규 Critical/Important/Minor 없음으로 승인됐다. + +### Phase 10 Gate + +**Goal 실행 `P10-GATE`:** OpenAPI 2.3.0 후속 범위의 unit·mock·server +allowlist·build와 문서 추적을 최종 판정한다. + +- **시작 조건:** `P10-T1`~`P10-T7` 완료. +- **완료 증거:** 아래 자동 명령과 수동 QA 대기 항목을 `§7 검증 기록`에 누적하고 요구사항 추적표·현재 상태를 갱신한다. +- **범위 밖:** mock 성공이나 server spec skip을 실제 server 완료로 처리하는 것과 제품 범위에서 제외한 FanTalk 상세/filter/sort·유일성 오류 분기. + +```bash +npm run test:run -- src/features/characters src/features/audio-contents src/features/series src/features/community-posts src/features/fan-talks src/features/comments src/shared/mocks +npm run e2e:mock -- tests/e2e/character-workspace.spec.ts tests/e2e/audio-content.spec.ts tests/e2e/series.spec.ts tests/e2e/community.spec.ts tests/e2e/fan-talk.spec.ts tests/e2e/comments.spec.ts tests/e2e/error-mapping.spec.ts +npm run e2e +npm run typecheck +npm run lint +npm run build +git diff --check +``` + +**기대 결과:** 모든 자동 Gate 명령 exit 0이고, mock/contract 범위에서 +legacy lookup·`timezone` request 0건, 예약 `releaseDate` UTC `Z`, +Community pagination metadata 일치, FanTalk 두 번째 POST·PUT `isActive` +0건, Comments 3단계·팬 댓글 PUT 0건, `0..99999` 밖의 price request +0건, 미정의 오류 기본 문구 불일치 0건이다. FanTalk 답변 PUT을 포함한 +제공 operation의 실제 server 결과는 E2E용 ADMIN 계정을 제공하지 않는 정책상 +자동 Gate 완료 조건에서 제외하고 수동 QA 대기 항목으로 분리 기록한다. + +**수동 확인:** 320/768/1280px, keyboard-only, 200% zoom, axe +critical·serious 0건과 mock/server 상태 분리를 확인한다. E2E용 ADMIN 계정을 +제공하지 않으므로 실제 개발 API 검증은 운영자가 수동 QA로 수행하고, mock +결과만으로 server 완료 표시를 하지 않는다. 수동 QA 목록은 다음과 같다. + +- Series 목록에는 비활성 series가 보이지 않는다. +- Series 생성에서 장르 선택, 210:297 JPEG/PNG cover crop, 필수 입력 검증, 생성 후 목록 복귀와 새 항목 표시를 확인한다. +- Series 수정에서 상세 원본값 초기화, 상태 변경 저장, optional image 교체 없이 저장, 비활성화 후 목록 제거를 확인한다. +- Series 상세에서 오디오 후보 검색, 연결, body 없는 연결 해제, 전체 순서 변경 저장과 재진입 후 순서 유지가 동작한다. +- FanTalk 목록에서 기존 답변 item은 수정 UI, 미답변 item은 등록 UI로 열린다. +- FanTalk 답변 수정은 `content`만 저장하고 저장 후 목록 재조회 결과가 반영된다. +- FanTalk 팬 원글 삭제는 확인 dialog 뒤 목록에서 제거되고 별도 상세 GET이 발생하지 않는다. +- FanTalk 미답변 item에 동시 POST를 시도해도 활성 creator reply는 1개만 남는다. +- 실제 개발 API에서 파일 용량 초과와 MIME/확장자 불일치가 서버에서도 거부되는지 확인한다. + +### Phase 10 Decision Log + +| 날짜 | 결정 | +|---|---| +| 2026-07-29 | 완료된 Phase 3~9를 다시 열지 않고 OpenAPI 2.3.0 후속 구현을 Phase 10으로 분리한다. | +| 2026-07-29 | FanTalk reply PUT의 path `replyId`는 `creatorReplies[].fanTalkId`를 사용하고, 댓글 수정 가능 여부는 `writerId === creatorId`로 판정한다. | +| 2026-07-29 | 팬 작성 FanTalk 원글 DELETE를 이번 후속 범위에 포함하고, reply PUT의 `planned` 상태는 mock/client와 server 완료를 분리한다. | +| 2026-07-29 | FanTalk reply PUT은 backend 구현이 완료됐고 OpenAPI status만 누락됐다는 사용자 확인에 따라 `implemented`로 정정한다. P10-T5는 mock/client/server integration 전체를 완료 조건으로 사용한다. | +| 2026-07-29 | 현재 FanTalk UI에는 별도 상세 GET·답변 상태 filter·sort query가 필요하지 않고 중복 오류 전용 key도 client 분기에 사용하지 않으므로 `EXT-004`를 해결로 종결한다. 답변 1개 불변식은 정확한 오류 key가 아니라 동시 POST 후 재조회 결과로 server에서 검증한다. | +| 2026-07-29 | Phase 5의 미구현 Series CRUD를 P10-T3에서 실행할 때 JPEG/PNG·10MB·210:297·최대 1,000px·no-upscale·세로 1px 오차와 생성 필수/수정 optional image를 함께 구현한다. | +| 2026-07-29 | Phase 8의 미구현 댓글 UI·CRUD·권한·접근성 범위는 추가 계약 대기 없이 P10-T6에서 구현한다. 과거 P8-T2~T4 체크박스는 계약 부재 당시 이력으로 보존하고 다시 열지 않는다. | +| 2026-07-29 | Series·FanTalk·Comments server integration은 각 spec을 server `testMatch`에 포함하고 focused server 명령이 실제 spec을 실행해야 완료로 인정한다. `No tests found`, mock 성공, 전용 fixture 부재에 따른 skip은 server 완료 증거가 아니다. | +| 2026-07-29 | `EXT-009~011`의 가격 `0..99999`, 미정의 오류 공통 문구, 기존 파일 client 검증과 backend 확인 경계를 `P10-T7`로 추가한다. | +| 2026-07-30 | E2E용 ADMIN 계정은 제공하지 않는 정책이므로 Series/FanTalk 실제 server integration은 자동 Gate 완료 조건에서 제외하고 수동 QA 체크리스트로 추적한다. mock 성공이나 session 주입을 server 완료 증거로 대체하지 않는다. | + ## 5. 요구사항 추적표 | Phase | PRD 범위 | 집중 test | @@ -2272,16 +4480,37 @@ assert_no_match "externalCharacterId|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FR | 3 | `CHAR-001~018`, Character 관련 `FILE`, `MOCK`, §7, §9 | `src/features/characters`, `tests/e2e/character-workspace.spec.ts` | | 4 | `AUDIO-001~033`, Audio 관련 `FILE`, `MOCK`, §9 | `src/features/audio-contents`, `tests/e2e/audio-content.spec.ts` | | 5 | `SERIES-001~018`, Series 관련 `FILE`, `MOCK`, §9 | `src/features/series`, `tests/e2e/series.spec.ts` | -| 6 | `COMMUNITY-001~015`, Community 관련 `FILE`, `MOCK`, §9 | `src/features/community-posts`, `tests/e2e/community-post.spec.ts` | +| 6 | `COMMUNITY-001~015`, Community 관련 `FILE`, `MOCK`, §9 | `src/features/community-posts`, `tests/e2e/community.spec.ts` | | 7 | `FANTALK-001~011`, `MOCK`, §9 | `src/features/fan-talks`, `tests/e2e/fan-talk.spec.ts` | -| 8 | `COMMENT-001~006`, `MOCK`, §9 | `src/features/comments`, `tests/e2e/comments.spec.ts` | +| 8 | `COMMENT-001~006`, `MOCK`, §9 | 계약 부재 조사 이력 | | 9 | §9~10, §12~14, 활성 범위 전체 | 전체 unit/integration/mock·server E2E/build | +| 10 | `CHAR-012~014`, Audio UTC·`AUDIO-019`, `SERIES-001~018`, `COMMUNITY-007~008`, `COMMUNITY-010`, `COMMUNITY-012`, `FANTALK-003~012`, `COMMENT-001~008`, `FILE-001~015`, `EXT-001~005`, `EXT-007~011` | 도메인 focused test, 7개 mock E2E, server integration, P10 Gate | `FILE-001~015`의 domain-neutral component mechanics는 Phase 1에서 먼저 만들고 browser mock 기반은 Phase 2가 소유한다. Character·Audio·Series·Community Phase는 자신의 allowed type·crop profile·GIF 예외, multipart handler와 fixture 흐름을 소유하며 Phase 9에서 mock/server 전체 matrix를 회귀 검증한다. +### 5.1 P9 수용 기준 증거 요약 + +| PRD 수용 기준 | 증거 | 상태 | +|---|---|---| +| `AUTH-001~013`, PRD `14.1` 로그인·세션·로그아웃 | Phase 1 Gate, `tests/e2e/auth.spec.ts`, P9 server-boundary 재검증 | 활성 범위 검증 완료 | +| `CHAR-001~018`와 Character image/file 기준 | Phase 3 Gate, `tests/e2e/character-workspace.spec.ts`, P9 resource/error regression, P10-T1 | 기존 mock UI 완료. `EXT-001`, `EXT-007` 해결; v2 원작 선택기는 P10-T1에서 완료, active-only server 검증은 P10-GATE에서 분리 | +| `AUDIO-001~033`와 Audio file/player 기준 | Phase 4 focused/mock 기록, `tests/e2e/audio-content.spec.ts`, P9 resource/error regression, P10-T2 | 기존 mock UI와 UTC 전송·`timezone` 제거 client/mock 완료. 실제 개발 API Audio 수동 QA는 별도 대기 | +| `SERIES-001~018` | Phase 5 focused/mock 기록, `tests/e2e/series.spec.ts`, P9 resource/error regression, P10-T3 | 연결·순서와 v2 장르·CRUD mock/client 완료. server Series E2E는 로그인 fixture 차단으로 P10-GATE에서 분리 | +| `COMMUNITY-001~015` | Phase 6 focused/mock 기록, `tests/e2e/community.spec.ts`, P9 resource/error regression, P10-T4 | 기존 mock UI와 pagination object·`timezone` 제거 client/mock 완료. 실제 개발 API Community 수동 QA는 별도 대기 | +| `FANTALK-001~012` | Phase 7 focused/mock 기록, `tests/e2e/fan-talk.spec.ts`, P9 resource/error regression, P10-T5 | 목록·답변 생성·답변 수정·팬 원글 삭제 mock/client 완료. 실제 개발 API FanTalk 수동 QA는 별도 대기 | +| `COMMENT-001~008` | P8 계약 부재 이력, OpenAPI 2.3.0 댓글 operation, P10-T6 | `EXT-005` 해결; 댓글 UI·fixture와 focused server route 검증은 P10-T6에서 완료 | +| `AUDIO-019`, `COMMUNITY-007`, `FILE-001~015`, `EXT-009~011` | 기존 Phase 1·4·6·9 회귀와 P10-T7 | 가격 상한·미정의 오류·파일 client 경계 완료. backend 동일 검증과 실제 개발 API file policy 수동 QA는 별도 대기 | +| PRD `9`, `10.6~10.7`, `14.2` 반응형·접근성 | P9-T2 `responsive-capabilities.spec.ts`, `accessibility.spec.ts` | Playwright local matrix 완료, Edge/실기기 최신 2개 major는 릴리스 QA | +| PRD `12`, `MOCK-001~009`, `14.1` mock/server 경계 | Phase 2 Gate, P9-T1/T2, server-boundary E2E | mock UI와 실제 server integration 상태 분리 유지 | + ## 6. 구현 완료 정의 -- [ ] Phase 0~8의 활성 범위 Gate와 Phase 9 전체 Gate가 최신 실행에서 통과한다. +> 아래 체크리스트는 개별 Task가 아니라 최종 Gate 완료 정의다. Phase 10 +> `P10-T1`~`P10-T7`, 과거 Phase 후속 구현, 자동 Gate 정리는 완료됐지만, +> 실제 개발 API credential·fixture가 필요한 수동 QA는 별도 대기 상태다. +> 따라서 최종 릴리스 완료 정의는 아직 체크하지 않는다. + +- [ ] Phase 0~9의 보존된 활성 범위와 Phase 10 후속 Gate가 최신 실행에서 통과한다. - [ ] 모든 **확정** 요구사항이 구현, 명시적 non-goal, 또는 결정 기록이 있는 후속 범위 중 하나로 추적된다. - [ ] 외부 의존을 추정 endpoint·placeholder DTO·임시 production mock으로 우회하지 않았다. - [ ] 제공 계약 범위는 `dev:mock`에서 최종 UI를 확인할 수 있고, 계약 미제공 범위는 fixture로 추정하지 않았다. @@ -2305,6 +4534,10 @@ assert_no_match "externalCharacterId|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FR > section을 가리킨다. 현재 Phase 3 이후 구현 기준은 > [api-contract.openapi.json](./api-contract.openapi.json)이며 과거 section > 표기는 실행 당시 근거를 보존하기 위해 수정하지 않는다. +> +> **2026-07-29 추가:** OpenAPI 2.3.0 후속 계약은 Phase 10이 소유한다. +> 아래 Phase 3~9의 외부 의존·제외 기록은 실행 당시 이력이며 현재 상태는 +> §3.1, Phase 10과 PRD §11.5를 우선한다. ```markdown ### N차 구현 또는 수정 — YYYY-MM-DD @@ -2685,7 +4918,7 @@ assert_no_match "externalCharacterId|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FR `plan-task.md`에 통합했다. `docs/agent-guide/documentation.md`에도 같은 배치 규칙을 명시했다. - 남은 항목: 추가 사용자 결정은 없다. backend가 제공해야 하는 - original work·genre lookup, Series edit DTO, active-only 보장, + original work·genre lookup, active-only 보장, Community pagination 종료 metadata, FanTalk 상세·수정·filter/sort· 유일성 오류, Comments CRUD·권한 오류와 도메인별 오류 key는 Phase별 외부 의존으로 남는다. @@ -2722,3 +4955,876 @@ assert_no_match "externalCharacterId|SUNDAY|MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FR 포함하거나 별도 version 계약으로 고정하면 현재 기록과 대조해 정규화한다. 다른 외부 의존은 제공 범위 구현을 차단하지 않고 각 후속 vertical slice에서 보완한다. + +### P3-T1 Character 계약 확인 및 실행 범위 확정, 2026-07-28 + +- 무엇을: OpenAPI Character 4개 operation과 schema, PRD `CHAR-001~018`, + `MOCK-001~009`, 외부 의존 `EXT-001`, `EXT-007`, `EXT-011`을 대조해 + `P3-T1` 체크박스 5개를 완료로 표시했다. PRD는 이미 같은 범위를 + 반영하고 있어 수정하지 않았다. +- 왜: `P3-T2`와 `P3-T3`가 계약에 없는 original work lookup, Character 전용 + 오류 message key, 검색 결과 active-only 보장을 추정하지 않게 하기 위해서다. +- 어떻게: + - JSON parse: `node -e "JSON.parse(require('fs').readFileSync('docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json','utf8'))"`는 출력 없이 exit 0으로 OpenAPI JSON 파싱을 확인했다. + - Character 계약 확인: focused `node -e`는 exit 0으로 `listAiCharacters`, + `createAiCharacter`, `getAiCharacter`, `updateAiCharacter`, 목록 + `searchTerm/page/size`, `data.totalCount/content`, item `id`, 상세 + `characterUUID/originalWork`, create/update 성공 `data=null`, multipart + `image/request`, 공통 오류 `ApiErrorResponse`, original work endpoint 0건을 확인했다. + - 문서 추적: `rg -n 'CHAR-0(1[2-8]|0[1-9])|EXT-001|EXT-007|EXT-011|originalWork' docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md`는 exit 0으로 PRD와 plan의 관련 범위를 확인했다. + - Diff 검사: `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/prd.md`는 출력 없이 exit 0으로 문서 공백 오류 0건을 확인했다. +- 남은 항목: original work lookup, Character 검색 결과 active-only 보장, + Character 전용 오류 message key는 backend 제공 대기다. `P3-T1`에서는 + 애플리케이션 코드, route, component, test, handler, fixture를 만들지 않았다. + +### P3-T2 Character 목록·선택·workspace 구현, 2026-07-28 + +- 무엇을: Character 목록·검색·선택과 `/ai-characters/:characterId` 상세 + workspace 복원을 구현하고, `P3-T2` 체크박스 8개를 완료로 표시했다. + 생성·수정·비활성화 form과 mutation은 `P3-T3` 범위로 남겼다. +- 왜: Phase 3 workspace core가 URL의 `characterId`로 후속 Audio·Series· + Community·FanTalk 하위 리소스를 격리해야 하기 때문이다. +- 어떻게: + - RED: `npm run test:run -- src/features/characters/tests/character-list.test.tsx src/layouts/CharacterWorkspaceLayout.test.tsx`에서 2 files / 7 tests가 모두 실패해 목록·workspace 미구현을 확인했다. + - GREEN/REFACTOR: 같은 focused 명령은 이후 2 files / 7 tests 통과했고, + 회귀 포함 `npm run test:run -- src/shared/ui/__tests__/search-toolbar.test.tsx src/app/App.test.tsx src/features/characters/tests/character-list.test.tsx src/layouts/CharacterWorkspaceLayout.test.tsx src/shared/mocks/__tests__/auth-handlers.test.ts`는 5 files / 46 tests 통과했다. + - Type/lint/build: `npm run typecheck`, `npm run lint`, `npm run build`가 모두 exit 0이었다. + - LSP: `src/app`, `src/features/characters`, `src/layouts`, `src/shared/ui`, + `src/shared/mocks` 진단은 오류 0건이었다. + - 수동 QA(mock): `npm run dev:mock`의 `http://127.0.0.1:8889`에서 Playwright로 로그인 → 목록 루나/테오 표시 → settled 후 Character GET 0건 → 검색 `search=루나` URL 반영 → 루나 선택 `/ai-characters/101` → `character-uuid-101`·원작 `달빛 상담소` 표시 → `/ai-characters/202` deep link → read-only 배너 표시와 `저장`/`비활성화` 버튼 0건을 확인했다. 브라우저 콘솔 error/warning은 0건이었다. +- 남은 항목: Character 생성·수정·soft delete, original work lookup, active-only + server 보장, Character 전용 오류 message key는 `P3-T3` 또는 backend 제공 후속 + 범위다. + +### P3-T3 Character 생성·수정·soft delete 부분 구현, 2026-07-28 + +- 무엇을: Character create/update/deactivate API serializer, 생성·수정 route와 form, + soft delete dialog, image crop 적용, mock store mutation을 구현하고 검증된 + `P3-T3` 체크박스 중 계약 추정 없이 검증된 항목만 완료로 표시했다. +- 왜: OpenAPI가 제공한 multipart create/update와 `data=null` 성공 계약을 + UI와 mock server가 같은 request/response shape로 사용하게 하기 위해서다. +- 어떻게: + - RED/GREEN: create/update/deactivate serializer, 생성 form 필수값·중복 제출· + crop 적용, 수정 form region read-only·dirty leave guard·교체 이미지 crop, + 상세 soft delete dialog, mock create/update/deactivate store 갱신 test를 + 실패 확인 후 최소 구현으로 통과시켰다. 추가로 crop 취소 시 replacement + image 미전송과 기존 image 삭제 UI 부재, `characterUUID` 읽기 전용 표시와 + `externalCharacterId` 미노출 test를 RED/GREEN으로 확인했다. optional scalar와 + repeated fields는 create 배열, update 배열 `null`, nullable scalar 직렬화 + test를 추가했고, `npm run typecheck` RED에서 update 반복 필드 `null` 타입 + 미허용 오류 8건을 확인한 뒤 create/update 요청 타입을 계약에 맞게 분리했다. + - Focused 검증: `npm run test:run -- src/features/characters/tests/character-api.test.ts src/features/characters/tests/CharacterCreatePage.test.tsx src/features/characters/tests/CharacterEditPage.test.tsx src/features/characters/tests/CharacterDetailPage.test.tsx src/features/characters/tests/character-mutation-reload.test.tsx src/shared/mocks/__tests__/character-handlers.test.ts src/shared/mocks/__tests__/auth-handlers.test.ts src/app/App.test.tsx`는 8 files / 57 tests 통과했다. + - 추가 재조회·성공 알림 검증: `src/features/characters/tests/character-mutation-reload.test.tsx`로 create 성공 후 목록 route 재진입·목록 재조회와 성공 알림, update 성공 후 상세 route 재진입·상세 재조회와 성공 알림, soft delete 성공 후 목록 route 이동·목록 재조회와 성공 알림을 3 tests 통과로 확인했다. Character 화면은 현재 React Query cache를 사용하지 않아 cache invalidation 대신 `navigateTo()` 기반 route 재진입으로 직접 요청을 다시 발생시키는 정책으로 정정했다. + - Type/lint/build: `npm run typecheck`, `npm run lint`, `npm run build`, + `git diff --check`가 모두 exit 0이었다. + - 전체 회귀 검증: `npm run test:run`은 42 files / 175 tests 통과했다. 병렬 + 재실행 중 `CharacterWorkspaceLayout.test.tsx`의 `character-uuid-101` 중복 표시 + assertion이 먼저 실패했으나, 헤더와 프로필 양쪽 표시가 의도된 상태임을 확인하고 + `getAllByText(...).toHaveLength(2)`로 좁힌 뒤 + `npm run test:run -- src/layouts/CharacterWorkspaceLayout.test.tsx` 1 file / 5 tests와 + 전체 42 files / 175 tests를 통과시켰다. + - LSP: `CharacterCreatePage.test.tsx`와 `CharacterEditPage.test.tsx`는 진단 + 0건이었다. 일부 TSX 파일은 LSP fresh diagnostics가 timeout 됐고, + Markdown은 LSP server 미설정으로 진단 불가했다. `npm run typecheck`로 타입 + 오류 0건을 확인했다. + - 수동 QA(mock): `npm run dev:mock`의 `http://127.0.0.1:8889`에서 + Playwright로 로그인 → `/ai-characters/new` 생성 form 진입 → PNG 업로드 후 + `이미지 crop` dialog와 `예상 결과 800 × 800px` 확인 → `적용` → `노아` + 생성 → 목록 총 3개와 `노아 선택` 확인 → `/ai-characters/1000` 상세 진입 → + 수정 화면에서 `지역` input disabled와 `KR` 확인 → 상세 복귀 → + `노아 비활성화 확인` dialog의 복원 미지원·hard delete 미지원 문구 확인 → + `비활성화 확인` 후 `/ai-characters` 목록 복귀와 총 2개, `노아` 제외를 + 확인했다. 브라우저 console은 기능 error/warning 0건이었고, favicon 404만 + 별도 관찰됐다. 추가로 `/ai-characters/101`에서 `캐릭터 UUID`와 + `character-uuid-101` 표시, `character-101` 미노출, 브라우저 console + warning/error 0건을 확인했다. + - 추가 수동 QA(mock): 같은 mock 서버에서 Playwright로 create 성공 후 + `/ai-characters`와 `AI 캐릭터를 생성했습니다.` status, update 성공 후 + `/ai-characters/101`과 `AI 캐릭터를 저장했습니다.` status, soft delete 성공 후 + `/ai-characters`와 `AI 캐릭터를 비활성화했습니다.` status를 확인했다. 생성 form의 + `name`, `systemPrompt`, `description`에는 `maxlength`가 없고, 반복 필드 입력 UI는 + 아직 노출되지 않음을 확인했다. OQ-009 권고안은 backend 호환 전까지 client 상한을 + 추가하지 않고, backend가 길이·개수 계약을 제공하면 `name`, `systemPrompt`, + `description`의 `maxlength`와 tags·hobbies·values·goals·relationships· + personalities·backgrounds·memories의 최대 개수를 PRD·OpenAPI·schema·경계 test에 + 같은 변경으로 반영하는 것이다. + - Visual QA: `p3-t3-success-notification.png` 캡처를 기준으로 독립 Oracle 2건이 + 모두 PASS했다. Pass A는 `SuccessNotification`이 `DESIGN.md`와 CSS token 기반의 + 실제 DOM 구현이며 create/update/deactivate 경로가 모두 success state를 전달한다고 + 판정했다. Pass B는 `AI 캐릭터를 비활성화했습니다.` 알림의 CJK 줄바꿈·클리핑·대비· + 시각 계층 문제가 없다고 판정했다. +- 남은 항목: original work combobox는 계약 미제공으로 아직 완료 표시하지 않았다. + +### P3-T4 Character 반응형·접근성 검증, 2026-07-28 + +- 무엇을: Character workspace의 desktop/tablet 관리 action, mobile 조회 전용 capability, mobile Card 접근 이름, keyboard-only 흐름, 320/768/1280px·200% zoom·axe 기준을 검증하고 `P3-T4` 체크박스 5개를 완료로 표시했다. +- 왜: Phase 3 활성 범위가 viewport별 route/action policy와 접근성 기준을 충족하는지 `P3-GATE` 전에 독립적으로 고정하기 위해서다. +- 어떻게: + - RED: 초기 focused E2E `tests/e2e/character-workspace.spec.ts`는 10 tests를 수집했고, 의도한 반응형·접근성 공백으로 4 passed / 6 failed를 기록했다. + - GREEN/product: 이후 focused E2E는 `10 passed`에 도달했고, typecheck·lint·design-system focused test도 통과했다. + - Review gap: Oracle이 create/edit direct route와 dirty-leave dialog의 axe·zoom coverage 누락을 발견했고, test-only patch로 해당 coverage를 확장했다. + - Final fix: 320px mobile direct create/edit guidance route에서도 계속 진행 전에 200% zoom, horizontal overflow 없음, axe critical·serious 0건을 확인하게 했다. + - Final verification: `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts --project=chromium` -> exit 0, `11 passed (31.3s)`. + - Static verification: `npm run typecheck` -> exit 0. `npm run lint` -> exit 0. + - LSP diagnostics: `tests/e2e/character-workspace.spec.ts` fresh diagnostics는 timeout됐고, TypeScript 정확성은 `npm run typecheck`로 보완했다. + - Oracle re-check: 현재 source inspection 기준 prior blocker는 `SPEC_PASS` 판정이었다. +- 남은 항목: original work combobox는 계약 미제공으로 계속 제외한다. `P3-GATE`는 아직 남아 있다. + +### P3-GATE Character 통합 검증, 2026-07-28 + +- 무엇을: Phase 3 Character 활성 범위의 unit/component, mock E2E, server-mode + allowlist E2E, typecheck, lint, production build를 실행하고 `P3-GATE`를 통과로 + 판정했다. +- 왜: mock UI journey와 실제 server mode 경계 검증을 분리해 기록하고, + `P3-T1`~`P3-T4`가 후속 Phase의 workspace 기반으로 안전한지 확인하기 위해서다. +- 어떻게: + - 원인 조사: `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts`는 최초 + 44 tests 중 3 failures를 재현했다. 실패 원인은 앱 버그가 아니라 E2E 범위 + 오류였다. mobile 프로젝트는 PRD 정책상 Character mutation action을 숨기는데 + keyboard-only edit flow가 `수정` 버튼을 기다렸고, WebKit/Safari는 기본 설정상 + `Tab`이 link를 일관되게 focus하지 않는다. Apple Safari keyboard shortcut 문서와 + Playwright WebKit 이슈 조사도 같은 결론이었다. + - E2E 정정: `tests/e2e/character-workspace.spec.ts`의 keyboard-only edit/dirty-leave + flow는 desktop Chromium 전용 capability로 명시하고, mobile/WebKit project는 + platform policy 사유로 skip하게 했다. mobile 조회·검색·상세, direct create/edit + desktop 안내, Card accessible name, 320/768/1280px·200% zoom·axe 검증은 같은 + spec의 별도 tests로 유지된다. + - Gate 명령 정정: `npm run e2e -- tests/e2e/character-workspace.spec.ts`는 + `playwright.config.ts` server mode `testMatch`와 교집합이 없어 `No tests found`가 + 나는 잘못된 focused 명령이었다. P2-R7/P2-R13의 mode allowlist 계약에 맞춰 + `P3-GATE` server mode 명령을 bare `npm run e2e`로 정정했다. + - Unit/component: `npm run test:run -- src/features/characters src/layouts/CharacterWorkspaceLayout.test.tsx` — 7 files / 24 tests 통과. + - Mock E2E: `npm run e2e:mock -- tests/e2e/character-workspace.spec.ts` — 44 tests 중 41 passed, 3 skipped, exit 0. skipped 3건은 mobile/WebKit keyboard edit flow platform policy 사유다. + - Server E2E: `npm run e2e` — server allowlist 4 specs / 36 tests 통과. Character server integration은 original work lookup, active-only 보장, Character 전용 오류 key가 backend 계약 대기라 mock UI 완료와 별도 상태로 유지한다. + - Static/build: `npm run typecheck`, `npm run lint`, `npm run build`가 모두 exit 0이었다. + - LSP/LOC: `tests/e2e/character-workspace.spec.ts` fresh LSP diagnostics는 timeout됐고, Markdown은 LSP server 미설정이다. TypeScript 정확성은 `npm run typecheck`로 보완했다. 변경 후 `tests/e2e/character-workspace.spec.ts`는 순수 LOC 185줄이다. +- 남은 항목: original work combobox, Character active-only server 보장, + Character 전용 오류 message key는 backend 계약 제공 대기다. Phase 3의 mock UI + journey와 server mode 경계 검증은 완료됐으며, 실제 Character server integration + 완료로 과대표현하지 않는다. + +### P4-T1 Audio 계약 확인 및 실행 범위 확정, 2026-07-28 + +- 무엇을: OpenAPI Audio 5개 operation과 `AudioContent*`, `AudioContentTheme`, 공통 오류 schema를 PRD `AUDIO-001~033`, `FILE-001~002`, `FILE-006~009`, `FILE-012~013`, `MOCK-001~009`, PRD `9`의 Audio 반응형 범위와 대조해 `P4-T1` 체크박스 7개를 완료로 표시했다. PRD와 OpenAPI는 이미 같은 범위를 반영하고 있어 수정하지 않았다. +- 왜: `P4-T2`와 `P4-T3`가 status query/status field, `seriesIds`, 수정 content file 교체, Audio 전용 오류 key, price 상한, active-only 보장, backend container/codec 검증을 추정하지 않게 하기 위해서다. +- 어떻게: + - JSON parse: `node -e "JSON.parse(require('fs').readFileSync('docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json','utf8'))"`는 출력 없이 exit 0으로 OpenAPI JSON 파싱을 확인했다. + - Audio 계약 확인: focused `node` 검사는 exit 0으로 `GET /api/v2/admin/ai-characters/audio-content-themes`, `GET/POST /api/v2/admin/ai-characters/{characterId}/audio-contents`, `GET/PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}`와 `listAiCharacterAudioContentThemes`, `listAiCharacterAudioContents`, `createAiCharacterAudioContent`, `getAiCharacterAudioContent`, `updateAiCharacterAudioContent` operation을 확인했다. theme는 query/body 없이 `data[]`의 `id`, `theme`, `image`를 사용한다. + - Contract fixture: 공통 오류는 `ApiErrorResponse`의 `success=false`, `message`, `data=null`, `errorProperty`와 status 400/401/403/404/405/406/415/500만 사용한다. Audio 전용 message key, backend container/codec 오류 계약, price 최대값, active-only 보장은 미제공이므로 fixture로 추정하지 않는다. + - 요청·응답 고정: 목록은 `search_word`, `page`, `size`와 `data.totalCount`, `data.items[]`를 사용한다. 상세는 필수 `timezone=Asia/Seoul` query를 보낸다. 생성 multipart는 필수 `contentFile`, `coverImage`, `request`와 성공 `data.contentId`를 사용한다. 수정 multipart는 필수 `request`와 optional `coverImage`만 있고 성공은 `data=null`이다. + - 제외 고정: 목록 query와 목록·상세 schema에 `status`가 없고 생성·수정 request에 `seriesIds`가 없으므로 client status filter, status enum, series 연결 UI, 수정 content file 교체를 만들지 않는다. price는 PRD의 0 이상 정수 “캔”만 유지하고 상한 test는 계약 제공 후 추가한다. + - 상태·component map: 목록 Page는 `characterId`, `search_word`, `page`, `size` route/query와 loading·empty·error·retry를 가진다. 상세 Page는 `characterId`, `contentId`, `timezone`과 단일 재생·media error 안내를 가진다. form/upload Page는 desktop/tablet에서만 create/update/deactivate/upload action을 제공하고 mobile은 PRD `9`에 따라 조회·재생만 제공한다. Feature component는 Audio releaseDate/theme/file/price 규칙을 소유하고, file input/crop/player/page state는 Phase 1 shared contract를 사용한다. + - Mock scenario: deterministic seed는 목록 2건 이상, 상세 1건 이상, theme 2건 이상을 둔다. browser handler는 제공 계약 범위의 theme list, list/search/page, detail with timezone, create multipart 성공 `contentId`, update/soft delete 성공 `data=null`, upload progress/cancel/retry happy path와 공통 오류만 제공한다. Audio 전용 오류 key, active-only server 결과, backend codec 판정, price 상한은 mock에서도 추정하지 않는다. + - 문서 추적: `rg -n 'AUDIO-0(0[1-9]|[12][0-9]|3[0-3])|FILE-00[126789]|FILE-01[23]|MOCK-00[1-9]|오디오 목록·상세·재생|오디오 생성·수정·비활성화·업로드|EXT-00[79]|EXT-010|EXT-011' docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md`는 exit 0으로 PRD와 plan의 Audio, File, Mock, 외부 의존 범위를 확인했다. + - Diff 검사: `git diff --check -- docs/20260725_AI캐릭터관리자웹/plan-task.md`는 출력 없이 exit 0으로 문서 공백 오류 0건을 확인했다. +- 남은 항목: Audio 전용 오류 message key, backend container/codec 검증 계약, price 최대값, active-only 반환 보장은 backend 제공 대기다. `P4-T1`에서는 애플리케이션 코드, route, component, test, handler, fixture를 만들지 않았다. + +### P4-T2 Audio 목록·상세·player 구현, 2026-07-28 + +- 무엇을: 선택 Character context의 Audio 목록·상세·player vertical slice를 구현하고 `P4-T2` 체크박스 9개를 완료로 표시했다. +- 왜: `AUDIO-021`, `AUDIO-026`, `AUDIO-029`, `AUDIO-030`의 검색·상세 timezone·재생·media error 안전 동작을 P4-T3 form/upload 전에 고정하기 위해서다. +- 어떻게: + - RED: `npm run test:run -- src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx`는 최초 실행에서 `AudioContentDetailPage` import 부재와 nested audio route 미지원으로 실패했다. mock handler focused RED도 audio endpoint unhandled request로 실패했다. + - GREEN: 같은 focused command는 2 files / 2 tests 통과했고, `src/shared/mocks/__tests__/character-handlers.test.ts`는 2 tests 통과했다. + - Route/API: `/ai-characters/:characterId/audio-contents`와 `/ai-characters/:characterId/audio-contents/:contentId`를 `ProtectedAdminShell`에 연결했다. 목록 API는 `characterId`, `page`, `size`, 2자 이상 `search_word`만 보내며 `status`, `isActive`, `active` query를 보내지 않는다. 상세 API는 `timezone=Asia/Seoul`을 필수 query로 보낸다. + - Boundary parse: 목록은 OpenAPI `data.totalCount/items`, 상세는 `AudioContentDetailResponse` shape를 Zod schema로 파싱한다. + - Player safety: 목록과 상세는 Phase 1 `AdminAudioPlayer`와 `AudioPlaybackProvider`만 조합한다. 명시적 download button을 추가하지 않았고 media error는 일반 오류·수동 재시도·페이지 새로고침 안내만 표시한다. focused test는 media error 후 detail/list GET 재호출과 play 자동 재호출이 없음을 확인했다. + - Signed URL: `AdminAudioPlayer` 기존 seam의 console/log/storage test와 P4-T2 detail integration test로 signed URL log·storage 호출이 없음을 확인했다. 분석 event seam은 현재 코드base에 없어서 코드 검사로 확인했다. + - Mock mode: mock handler는 실제 endpoint shape로 list/detail을 제공하고 `data:audio/wav` preview asset을 사용한다. 브라우저에서 mock 로그인 후 `/ai-characters/101/audio-contents?search=달빛&page=0&size=20` 목록, 상세 이동, `timezone=Asia%2FSeoul`, player 재생 버튼, keyboard Space, volume, speed control을 확인했다. + - Verification: 관련 테스트 `npm run test:run -- src/app/App.test.tsx src/layouts/CharacterWorkspaceLayout.test.tsx src/shared/ui/__tests__/admin-audio-player.test.tsx src/shared/mocks/__tests__/character-handlers.test.ts src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx`는 6 files / 43 tests 통과했다. `npm run typecheck`와 `npm run lint`는 출력 없이 exit 0이었다. + - LSP: 변경 TS/TSX 파일 diagnostics는 모두 clean이었다. `src/shared/mocks/audio-content-fixtures.ts`는 수정 직후 fresh diagnostics가 1회 timeout됐고, 이후 typecheck로 보완했다. + - Visual QA: Playwright mock browser에서 1280px와 375px 상세 screenshots `p4-t2-audio-detail-1280.png`, `p4-t2-audio-detail-375.png`를 캡처했다. 이 환경의 subagent tool은 `oracle`을 지원하지 않아 `/visual-qa` dual-oracle pass는 실행하지 못했다. +- 남은 항목: Audio 전용 오류 message key, backend container/codec 검증 계약, price 최대값, active-only 반환 보장은 P4-T1과 동일하게 backend 제공 대기다. P4-T3 form/upload와 P4-T4 E2E는 구현하지 않았다. + +### P4-T3 Audio 발행 form·upload 구현, 2026-07-28 + +- 무엇을: Audio theme 선택, 즉시/예약 발행, create/update/deactivate multipart API, cover/audio validation, upload progress/cancel/retry adapter, form UI, route, mock store/handler를 구현하고 `P4-T3` 체크박스 18개를 완료로 표시했다. +- 왜: `AUDIO-009`, `AUDIO-011`, `AUDIO-014`, `AUDIO-023~024`, `AUDIO-027`, `AUDIO-033`과 OpenAPI `AudioContentCreateMultipart`, `AudioContentUpdateMultipart`, `AudioContentTheme` 계약을 client form과 mock mode에서 고정하기 위해서다. +- 어떻게: + - RED: `npm run test:run -- src/features/audio-contents/tests/audio-contract.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-form.test.tsx`는 최초 실행에서 `audio-cover-policy`, `AudioContentFormPage`, `upload-audio-content` import 부재로 3 files failed / 0 tests를 확인했다. + - GREEN: 같은 focused command는 3 files / 11 tests 통과했다. 생성 multipart는 `contentFile`, `coverImage`, `request` 필수와 성공 `data.contentId`, 수정은 `request`와 optional `coverImage`, deactivate는 `{isActive:false}`만 확인했다. + - Form/validation: 생성 form은 theme 미선택 submit을 차단하고 price를 “캔” 형식으로 표시하며 즉시 공개 기본값은 `releaseDate=null`, `timezone="Asia/Seoul"`을 보낸다. 예약 공개는 미래 local datetime만 허용하고 `yyyy-MM-dd HH:mm`로 보낸다. 수정 form은 theme/release/series/content file 변경 control을 만들지 않고 읽기 전용 안내만 표시한다. + - File/upload: cover는 Phase 1 `FileField`·`ImageCropDialog`와 `AUDIO_COVER_POLICY` `{aspect:1,maxWidth:800,noUpscale:true,maxBytes:10485760}`을 조합한다. audio는 공통 policy로 MP3/AAC/M4A, `.m4a + audio/x-m4a`, `1,024,000,000 bytes` 허용과 WAV·`1,024,000,001 bytes` 거부를 확인하며 codec sniffing과 resumable upload는 만들지 않았다. 415는 `ApiError` message/errorProperty를 form 안내로 보존한다. + - Mock mode: `GET /api/v2/admin/ai-characters/audio-content-themes`, `POST /audio-contents`, `PUT /audio-contents/:contentId` handler와 같은 mock store의 create/update/deactivate list/detail 갱신을 추가했다. Audio 전용 오류 key, price 상한, active-only server 보장, backend container/codec 판정은 추정하지 않았다. + - OQ-009: 실제 Audio 생성/수정 form에서 `title`, `detail`, `tags` 입력 공간과 반복 항목을 확인했다. 권고안은 `title` 80자, `detail` 2,000자, `tags` comma-separated 20개/각 30자이나 backend 호환 전까지 client 상한은 구현하지 않았다. + - Verification: focused command는 3 files / 11 tests 통과했고, 관련 회귀 `npm run test:run -- src/app/App.test.tsx src/layouts/CharacterWorkspaceLayout.test.tsx src/shared/mocks/__tests__/character-handlers.test.ts src/shared/validation/file-media-policy.test.ts src/shared/ui/__tests__/file-field.test.tsx src/shared/ui/__tests__/image-crop-dialog.test.tsx src/shared/ui/__tests__/upload-progress.test.tsx src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx src/shared/ui/__tests__/unsaved-changes-guard.test.tsx src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx`는 11 files / 56 tests 통과했다. `npm run typecheck`와 `npm run lint`는 출력 없이 exit 0이었다. + - Manual QA: Playwright mock browser에서 login → `/ai-characters/101/audio-contents/new` → theme 선택 → audio/cover upload → crop 적용 → create → detail → edit → update → deactivate → list toast를 실제 조작했다. artifacts는 `.playwright-mcp/p4-t3-audio-created-detail.png`, `.playwright-mcp/p4-t3-audio-updated-detail.png`, `.playwright-mcp/p4-t3-audio-deactivated-list.png`, `.playwright-mcp/p4-t3-console.txt`, `.playwright-mcp/p4-t3-network.txt`이다. console warning/error 0건, Audio 관련 network 15건은 모두 200이었다. + - LSP/Diff: 변경 TS/TSX 파일 diagnostics는 clean이었다. `src/shared/mocks/character-mock-store.ts` diagnostics는 fresh 응답이 1회 timeout됐고 이후 typecheck로 보완했다. `git diff --check -- src/app/browser-location.ts src/app/protected-admin-shell.tsx src/app/route-paths.ts src/features/audio-contents/api/audio-content-api.ts src/features/audio-contents/pages/AudioContentDetailPage.tsx src/features/audio-contents/pages/AudioContentListPage.tsx src/shared/mocks/audio-content-fixtures.ts src/shared/mocks/character-mock-store.ts src/shared/mocks/handlers.ts docs/20260725_AI캐릭터관리자웹/plan-task.md`는 출력 없이 exit 0이었다. +- 남은 항목: Audio 전용 backend 오류 message key, 실제 container/codec 검증, price 상한, active-only server 결과는 기존 P4-T1/P4-T2와 동일하게 backend 계약 제공 대기다. P4-T4 반응형·접근성 E2E와 Gate는 구현하거나 완료 표시하지 않았다. + +### P4-T3 blocker 수정 및 fresh QA 재검증, 2026-07-28 + +- 무엇을: Audio 생성 기본 경로가 XHR upload adapter를 우회하지 않도록 고정하고, upload cancel/error/retry, malformed success JSON, `.m4a` MIME 경계, crop 취소, 415 field mapping, 비활성화 destructive 스타일, mock request logging을 재검증했다. +- 왜: P4-T3 완료 기록 이후 발견된 blocker가 create/upload 실제 경로와 민감정보 비기록 제약을 깨뜨릴 수 있어, P4-T4/GATE로 확장하지 않고 P4-T3 범위 안에서만 닫기 위해서다. +- 어떻게: + - RED/GREEN: `npm run test:run -- src/shared/mocks/browser.test.ts`는 `quiet: true` 누락으로 1 failed를 확인했고, `startMockWorker`에 `quiet: true`를 추가한 뒤 `npm run test:run -- src/shared/mocks/browser.test.ts src/shared/mocks/__tests__/auth-handlers.test.ts src/shared/mocks/browser.test.ts`는 2 files / 11 tests 통과했다. + - Focused P4-T3: `npm run test:run -- src/features/audio-contents/tests/audio-contract.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-form.test.tsx src/features/audio-contents/tests/audio-form-upload.test.tsx`는 4 files / 16 tests 통과했다. 확장 focused `npm run test:run -- src/features/audio-contents/tests/audio-contract.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-form.test.tsx src/features/audio-contents/tests/audio-form-upload.test.tsx src/shared/validation/file-media-policy.test.ts src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx src/shared/mocks/browser.test.ts src/shared/mocks/__tests__/auth-handlers.test.ts`는 8 files / 36 tests 통과했다. + - Static/build: `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. + - LSP: `src/shared/mocks/browser.ts`, `src/shared/mocks/browser.test.ts` fresh diagnostics는 clean이었다. 이전 변경 파일 중 일부 LSP 호출은 timeout됐지만 `typecheck`와 `lint`로 보완했다. + - Manual QA: `npm run dev:mock` 서버에서 Playwright Chromium으로 login → `/ai-characters/101/audio-contents/new` → 예약 공개 선택 → audio/cover 선택 → crop 적용 → create → detail → edit → update → deactivate confirm → list 이동 → 375px mobile list를 실제 조작했다. fresh artifacts는 `.playwright-mcp/p4-t3-final-quiet/01-create-form.png`부터 `08-mobile-list.png`, `console.txt`, `network.txt`, `summary.json`이다. + - Visual evidence: PNG signature 8건을 확인했고 `look_at`으로 create 초기 form, 예약/파일 선택 form, destructive deactivate dialog, mobile list/player 상태를 확인했다. `.playwright-mcp/p4-t3-final-quiet/summary.json`은 `screenshotCount=8`, `containsPassword=false`, `containsMultipartBoundary=false`, `containsUploadFileName=false`였다. `console.txt`는 Vite/React dev 안내 3건뿐이고 password·multipart body·upload filename은 없었다. Audio/Character API request 48건은 모두 200이었다. + - Diff 검사: `git diff --check -- src/shared/mocks/browser.ts src/shared/mocks/browser.test.ts docs/20260725_AI캐릭터관리자웹/plan-task.md`는 출력 없이 exit 0이었다. +- 남은 항목: Audio 전용 backend 오류 message key, 실제 container/codec 검증, price 상한, active-only server 결과는 기존 P4-T1/P4-T2와 동일하게 backend 계약 제공 대기다. P4-T4 반응형·접근성 E2E와 Phase 4 Gate는 구현하거나 완료 표시하지 않았다. + +### P4-T3 review gap closure, 2026-07-28 + +- 무엇을: `src/app/App.test.tsx`를 protected shell/error/logout 보존 테스트로 분리해 모든 변경 test file을 250 pure LOC 이하로 낮추고, Audio/Character 비활성화 dialog와 Audio form의 사용자 표시 copy에서 내부 ID, 영문 개발 용어, `hard delete`, native file input 문구 노출을 제거했다. +- 왜: P4-T3 review에서 oversized test, 비활성화 제목 줄바꿈, 안정 artifact 명명, upload progress 증거, Mock Preview overlap, 내부/영문 operator copy가 남아 있어 P4-T4/Gate로 확장하지 않고 P4-T3 안에서 닫기 위해서다. +- 어떻게: + - RED/GREEN: copy 회귀 테스트는 `OQ-009`, `Audio 수정 계약`, `hard delete`, native file chooser 문구가 남아 실패했고, Korean admin copy와 `파일 선택` button wrapper로 수정한 뒤 `npm run test:run -- src/features/audio-contents/tests/audio-form.test.tsx src/features/audio-contents/tests/audio-form-upload.test.tsx src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx src/features/characters/tests/CharacterDetailPage.test.tsx`가 4 files / 13 tests 통과했다. App split 보존은 `npm run test:run -- src/app/App.test.tsx src/app/App.protected-shell.test.tsx src/app/App.protected-errors.test.tsx src/app/App.logout.test.tsx`가 4 files / 29 tests 통과했다. + - Focused/static: `npm run test:run -- src/features/audio-contents/tests/audio-contract.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-form.test.tsx src/features/audio-contents/tests/audio-form-upload.test.tsx src/app/App.test.tsx src/app/App.protected-shell.test.tsx src/app/App.protected-errors.test.tsx src/app/App.logout.test.tsx src/shared/ui/__tests__/confirm-deactivate-dialog.test.tsx src/features/characters/tests/CharacterDetailPage.test.tsx src/shared/mocks/browser.test.ts src/shared/validation/file-media-policy.test.ts`는 12 files / 58 tests 통과했다. `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. + - Size/LSP/diff: 변경 source/test 12개는 모두 250 pure LOC 이하였고, 변경 TS/TSX 파일 LSP diagnostics는 clean이었다. `git diff --check`는 P4-T3 변경 파일과 이 문서에 대해 출력 없이 통과했다. + - Manual QA: `npm run dev:mock` 서버에서 Playwright Chromium으로 login → Audio create form → 예약 공개 → audio/cover 선택 → crop 적용 → upload progress in-flight 상태 → create detail → edit/save → deactivate confirm → list 이동 → 375px mobile list를 실제 조작했다. Fresh artifacts는 `.playwright-mcp/p4-t3-final-quiet/01-create-form.png`부터 `10-mobile-list.png`, `console.txt`, `network.txt`, `summary.json`이다. + - Visual evidence: PNG signature 10건은 모두 `89504e470d0a1a0a`였고, `04-upload-progress.png`는 `업로드 중`, `업로드 취소`, progressbar를 표시한다. `08-deactivate-confirm.png`는 `오디오 콘텐츠 비활성화 확인`과 `완전 삭제는 아니며 목록에서 제외됩니다.`를 표시하고 내부/영문 용어가 없다. `10-mobile-list.png`는 overlap 없이 표시됐다. `summary.json`은 `screenshotCount=10`, `containsPassword=false`, `containsMultipartBoundary=false`, `containsUploadFileName=false`, `visibleInternalTerms=false`였다. + - 정정: 최종 gap closure 후 `08-deactivate-confirm.png`의 비활성화 문구는 `목록 노출만 중지하며 콘텐츠는 보관됩니다.`로 갱신됐고, `02-cover-crop-dialog.png`는 640×480 PNG fixture와 `예상 결과 480 × 480px` crop preview를 표시하도록 재캡처했다. `summary.json`은 `screenshotCount=10`, `extraPngs=[]`, `pngSignaturesOk=true`, `containsPasswordText=false`, `containsMultipartBoundary=false`, `containsUploadFileName=false`, `containsInternalVisibleTerms=false`로 갱신됐다. +- 남은 항목: Audio 전용 backend 오류 message key, 실제 container/codec 검증, price 상한, active-only server 결과는 기존 P4-T1/P4-T2와 동일하게 backend 계약 제공 대기다. P4-T4 반응형·접근성 E2E와 Phase 4 Gate는 구현하거나 완료 표시하지 않았다. + +### P4-T4 Audio 반응형·접근성 구현, 2026-07-28 + +- 무엇을: Audio mock E2E에 mobile route capability, desktop/tablet mutation action, 320px overflow, keyboard-only player/form, 200% zoom, axe critical·serious 0건 검증을 추가하고 `P4-T4` 체크박스 4개를 완료로 표시했다. `P4-GATE`는 완료 표시하지 않았다. +- 왜: PRD `9`의 mobile 조회·재생 전용 범위와 desktop/tablet 운영 범위를 Audio route에서 분리하고, player/form 접근성 회귀를 Phase 4 Gate 전에 고정하기 위해서다. +- 어떻게: + - RED: `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`는 최초 실행에서 20 tests 중 4 failed / 13 passed / 3 skipped였고, 320px mobile list의 `오디오 생성` button이 visible이라 route capability 기대를 만족하지 못했다. + - GREEN: `AudioContentFormPage`에서 `(min-width: 768px)` route capability를 적용해 mobile 직접 `/new`·`/edit` route는 생성/수정/업로드/비활성화 form을 mount하지 않고 안내만 표시한다. 목록 `오디오 생성`과 상세 `수정` entrypoint는 `md` 이상에서만 노출한다. `AudioContentSummary`, mobile list item, `AdminAudioPlayer` error 영역은 긴 텍스트 wrapping과 `min-w-0`을 보강했다. + - Focused unit/component: `npm run test:run -- src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx src/features/audio-contents/tests/audio-form.test.tsx src/features/audio-contents/tests/audio-form-upload.test.tsx`는 4 files / 11 tests 통과했다. + - Mock E2E: `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`는 최종 실행에서 20 tests 중 17 passed / 3 skipped였다. Skipped 3건은 기존 browser 조건에 맞춘 keyboard path skip으로 mobile mutation 차단 및 WebKit tab focus 차이를 명시했다. + - Static/build: `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. `npm run build`는 Vite production build에서 204 modules transformed, `dist/assets/index-B5HKASem.js` 381.66 kB(gzip 106.65 kB)를 생성했다. + - LSP/size/diff: 변경 TS/TSX와 config 파일의 개별 LSP diagnostics는 clean이었고 `AudioContentFormPage.tsx` 개별 fresh diagnostics는 2회 timeout됐으나, `src/features/audio-contents` directory diagnostics는 14 TSX files scanned / errors 0 / diagnostics 0이었다. 변경 source/test/config 파일 pure LOC는 `playwright.config.ts 29`, `tests/e2e/audio-content.spec.ts 137`, `AudioContentFormPage.tsx 104`, `AudioContentListPage.tsx 98`, `AudioContentDetailPage.tsx 63`, `AudioContentSummary.tsx 22`, `AudioContentListItem.tsx 32`, `admin-audio-player.tsx 125`로 모두 250 이하이다. `git diff --check`와 untracked E2E spec 대상 `git diff --check --no-index /dev/null tests/e2e/audio-content.spec.ts`는 출력 없이 통과했다. +- 남은 항목: Phase 4 Gate의 server mode 통합 검증과 Phase 5+ 범위는 수행하지 않았다. 현재 작업트리에는 P4-T4 외 선행 작업의 수정·untracked 파일이 함께 남아 있어 이 기록은 P4-T4 변경과 검증 결과만 대상으로 한다. + +### P4-T4 독립 검증, 2026-07-28 + +- 무엇을: P4-T4 구현본을 main agent가 fresh 명령과 fresh browser evidence로 재검증하고, 독립 Oracle 2건으로 기능·시각/CJK 검증을 완료했다. +- 왜: worker의 완료 보고만으로 P4-T4를 수용하지 않고, 현재 작업트리 기준 route capability, 320px overflow, keyboard-only, 200% zoom, axe, P4-GATE 미완료 상태를 별도 증거로 확인하기 위해서다. +- 어떻게: + - E2E: `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`는 20 tests 중 17 passed / 3 skipped, exit 0이었다. skipped 3건은 mobile mutation route 차단과 WebKit tab focus 차이로 제한한 keyboard-only path다. + - Focused unit/component: `npm run test:run -- src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx src/features/audio-contents/tests/audio-form.test.tsx src/features/audio-contents/tests/audio-form-upload.test.tsx`는 4 files / 11 tests 통과했다. + - Static/build: `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. build는 204 modules transformed와 `dist/assets/index-B5HKASem.js` 381.66 kB(gzip 106.65 kB)를 기록했다. + - LSP/diff/size: `src/features/audio-contents`, `src/shared/ui/admin-audio-player.tsx`, `tests/e2e/audio-content.spec.ts` diagnostics는 clean이었다. P4-T4 대상 `git diff --check`는 출력 없이 통과했다. 순수 LOC는 `playwright.config.ts 29`, `tests/e2e/audio-content.spec.ts 137`, `AudioContentFormPage.tsx 104`, `AudioContentListPage.tsx 98`, `AudioContentDetailPage.tsx 63`, `AudioContentSummary.tsx 22`, `AudioContentListItem.tsx 32`, `admin-audio-player.tsx 125`로 모두 250 이하이다. + - Browser evidence: `.playwright-mcp/p4-t4-audio-responsive/01-mobile-list-320.png`부터 `06-desktop-detail-1280.png`까지 6개 fresh PNG를 캡처했고, PNG signature는 모두 `89504e470d0a1a0a`였다. 320px 목록·상세·mobile edit guidance, 768px form, 1280px 상세의 `scrollWidth === clientWidth`로 horizontal overflow 0건을 확인했다. console warning/error는 favicon 404 외 0건이었다. + - 독립 리뷰: 기능 Oracle은 PASS/HIGH로 mobile `/new`·`/edit`가 data fetch/form mount 없이 guidance만 반환하고, list/detail/player availability, desktop/tablet action, keyboard-only, axe/zoom coverage, P4-GATE 미완료 상태를 확인했다. Visual/CJK Oracle은 PASS/HIGH로 6개 screenshot에서 overflow, control clipping, awkward Korean break, mutation control leakage가 없다고 판정했다. +- 남은 항목: P4-GATE server mode 통합 검증은 아직 수행하지 않았고 완료 표시하지 않았다. Audio 전용 backend 오류 message key, 실제 container/codec 검증, price 상한, active-only server 결과는 기존 backend 계약 대기 항목으로 유지한다. + +### P4-GATE 부분 검증 및 미완료 기록, 2026-07-28 + +- 무엇을: Phase 4 Gate 명령을 fresh로 실행해 mock UI journey, server-mode 상태, static/build 결과를 분리 기록했다. `P4-GATE`는 완료로 판정하지 않았다. +- 왜: mock mode 통과는 최종 UI 확인 증거이지만 실제 server integration 완료 증거가 아니며, README의 server mode `testMatch` 교집합 규칙 때문에 filtered Audio spec 실행 결과를 별도 해석해야 하기 때문이다. +- 어떻게: + - Preflight: `git status --short`로 현재 작업트리에 P4 선행 변경과 untracked QA/source 파일이 함께 있음을 확인했고, unrelated 변경은 보존했다. `plan-task.md`의 P4-GATE 명령과 README `npm run e2e`/`npm run e2e:mock`의 `testMatch` 제한 문구를 재확인했다. + - Mock unit/component: `npm run test:run -- src/features/audio-contents src/shared/ui src/shared/validation`은 21 files / 55 tests 통과했다. + - Mock E2E: `npm run e2e:mock -- tests/e2e/audio-content.spec.ts`는 20 tests 중 17 passed / 3 skipped, exit 0이었다. skipped 3건은 mobile mutation route 차단과 WebKit tab focus 차이를 명시한 keyboard-only path다. + - Server filtered command: `npm run e2e -- tests/e2e/audio-content.spec.ts`는 `Error: No tests found.`로 종료했다. 현재 `playwright.config.ts`의 server mode `testMatch`가 `server-mode-boundary`, `smoke`, `auth`, `accessibility-shell`만 포함하므로 `audio-content.spec.ts`와 교집합이 없다. 따라서 이 결과는 Audio server integration pass가 아니라 미검증 상태다. + - Server allowlist sanity: bare `npm run e2e`는 현재 server allowlist 36 tests를 모두 통과했다. 이 명령은 server mode가 browser MSW로 fallback하지 않는 shell/boundary 검증이며, Audio `search_word`, `timezone`, `contentFile`, `releaseDate`, theme field, 수정 금지 control의 실제 server integration 완료 증거는 아니다. + - Static/build: `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. build는 204 modules transformed와 `dist/assets/index-B5HKASem.js` 381.66 kB(gzip 106.65 kB)를 기록했다. +- 남은 항목: `P4-GATE` 완료에는 실제 server mode에서 Audio flow가 실행되어 `search_word`, `timezone`, `contentFile`, `releaseDate`, theme field와 수정 금지 control이 network 요청·화면에 일치한다는 fresh evidence가 필요하다. Audio 전용 backend 오류 message key, 실제 container/codec 검증, price 상한, active-only server 결과는 backend 계약 제공 대기 상태로 유지한다. + +### P5-T1 Series 계약 확인 및 실행 범위 확정, 2026-07-28 + +- 무엇을: OpenAPI Series 9개 operation과 `Series*` schema를 PRD `SERIES-001~018`, `MOCK-001~009`, 외부 의존 `EXT-002`, `EXT-007`, `EXT-011`과 대조해 `P5-T1` 체크박스 6개를 완료로 표시했다. PRD는 이미 같은 요구사항과 외부 의존을 반영하고 있어 수정하지 않았다. +- 왜: `P5-T2`부터 계약에 없는 genre lookup, active-only server 결과, 도메인별 오류 key, missing ID와 concurrent conflict key를 추정하지 않고, 제공된 목록·상세·연결·순서 범위만 구현하게 하기 위해서다. +- 어떻게: + - JSON parse: `node -e "JSON.parse(require('fs').readFileSync('docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json','utf8')); console.log('JSON.parse ok')"`는 `JSON.parse ok`를 출력하고 exit 0으로 OpenAPI JSON 파싱을 확인했다. + - Series 계약 확인: focused `node` 검사는 exit 0으로 Series operation 9개, `SeriesCreateRequest`, `SeriesUpdateRequest`, `SeriesListResponse`, `SeriesListItem`, `SeriesDetailResponse`, `SeriesContentListResponse`, `SeriesContentSearchItem`, `SeriesContentAddRequest`, `SeriesOrderUpdateRequest`의 required/properties를 출력했다. `genre path count: 0`으로 genre lookup endpoint 부재도 확인했다. + - 요청·응답 고정: 연결 후보는 `GET .../contents/search?search_word=...`, 연결은 `{contentIdList}`, 해제는 body 없는 DELETE, 순서는 `{ids}`다. 목록과 연결 콘텐츠 목록은 `page`, `size`, `data.totalCount`, `data.items[]`를 사용한다. + - 외부 의존 고정: 장르 lookup, active-only 반환 보장, Series 도메인별 오류 key, 순서 저장 missing ID와 concurrent conflict key는 미제공이므로 UI와 mock에서 추정하지 않는다. Series 수정 초기화는 상세 응답의 수정용 원본값으로 처리한다. + - 상태·component map: 목록·상세·form·연결·순서 화면의 loading, empty, error, read-only, 저장 중, 충돌 또는 재조회 안내 상태와 action inventory를 기록했다. Page는 route/query/policy와 data loading만 조합하고, Feature component가 Series 규칙을 소유하며, 공통 상태·pagination·dialog·file/crop·guard는 Phase 1 shared contract를 사용한다. + - Mock scenario: 제공 계약 범위의 목록·상세·연결 콘텐츠 목록·후보 검색·연결·해제·전체 순서 happy path와 공통 오류만 browser fixture 대상으로 확정했다. genre lookup, active-only, 충돌 오류는 fixture에서 제외한다. + - 문서 추적: `rg -n 'SERIES-01[4-8]|EXT-002|EXT-007|EXT-011|contents/search|contentIdList|\{ids\}|genre' docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md`는 exit 0으로 PRD와 plan의 Series 계약, 외부 의존, 연결·순서 키워드 추적을 확인했다. + - Diff 검사: `git diff --check -- docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md`는 출력 없이 exit 0으로 문서 공백 오류 0건을 확인했다. +- 남은 항목: P4-GATE는 이전 기록처럼 미완료다. `P5-T1`에서는 애플리케이션 코드, tests, mocks, config, DESIGN.md, QA artifacts를 변경하지 않았고 `P5-T2` 이상과 `P5-GATE`는 완료 표시하지 않았다. + +### P5-T2 Series 목록·상세 조회 구현, 2026-07-28 + +- 무엇을: 선택한 Character workspace 아래 Series read-only 목록/상세 vertical slice를 구현하고 `P5-T2` 체크박스 4개만 완료로 표시했다. +- 왜: OpenAPI가 제공한 `SeriesListResponse`와 `SeriesDetailResponse`의 서로 다른 DTO를 그대로 표시하고, 계약 없는 genre lookup·active-only filter·mutation action을 추정하지 않기 위해서다. +- 어떻게: + - RED 1: `npm run test:run -- src/features/series/tests/series-contract.test.ts`는 테스트 파일 부재 상태에서 `No test files found, exiting with code 1`로 실패했다. + - RED 2: 같은 명령은 테스트 추가 후 `Failed to resolve import "@/features/series/api/series-api"`로 실패해 production Series slice 부재를 확인했다. 중간에 `.ts` 파일 JSX parse 오류 1회는 `createElement(App)`으로 test harness만 수정해 올바른 RED로 재실행했다. + - GREEN: `npm run test:run -- src/features/series/tests/series-contract.test.ts`는 1 file / 4 tests 통과했다. + - Related route/mock: `npm run test:run -- src/app/App.test.tsx src/app/App.protected-shell.test.tsx src/shared/mocks/__tests__/character-handlers.test.ts`는 3 files / 17 tests 통과했다. + - REFACTOR/regression: `npm run test:run -- src/features/series/tests/series-contract.test.ts src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx src/features/characters/tests/character-list.test.tsx`는 4 files / 8 tests 통과했다. 최종 회귀 `npm run test:run`은 52 files / 200 tests 통과했다. + - Static/build: `npm run typecheck`, `npm run lint`, `git diff --check -- src/features/series src/shared/mocks src/app src/layouts docs/20260725_AI캐릭터관리자웹/plan-task.md`는 출력 없이 exit 0이었다. `npm run build`는 production build를 완료했다. + - LSP/LOC: 변경 TS/TSX 파일 diagnostics는 clean이었다. `src/shared/mocks/series-handlers.ts` 단일 fresh diagnostics는 반복 timeout됐지만 `src/shared/mocks` directory diagnostics는 16 files / 0 diagnostics였고 `typecheck`로 보완했다. `handlers.ts`는 Series handler 분리 후 pure LOC 236으로 250 이하가 됐다. + - Manual QA: `npm run dev:mock` 서버에서 Playwright Chromium으로 login → `/ai-characters/101/series?page=0&size=20` → 목록 확인 → detail link → `/ai-characters/101/series/5001` → reload를 실제 조작했다. Artifacts는 `.playwright-mcp/p5-t2-series-readonly/list-1280.png`, `detail-1280.png`, `detail-reload.png`, 각 snapshot, `list-action-check.json`, `detail-dom-check.json`, `detail-reload-check.json`, network/console 파일이다. `list-action-check.json`은 `searchboxCount: 0`, `forbiddenActions: []`, `visibleSeriesText: true`였다. `detail-dom-check.json`은 `hasDetailStrings: true`, `hasReverseEnumLeak: false`, `forbiddenActions: []`였다. `detail-reload-check.json`은 `titleVisible: true`, `stringsVisible: true`였다. Series network는 목록 `?page=0&size=20`와 상세 `/series/5001`만 200으로 기록됐다. Console은 기능과 무관한 `/favicon.ico` 404 1건만 있었다. + - Visual evidence: `look_at`으로 3개 screenshot을 확인했고, 목록은 시리즈 2건과 상세 보기·pagination만 보이며 상세/reload는 표시 문자열만 보이고 생성/수정/비활성화/연결/순서 변경 control 및 명백한 clipping/layout 깨짐이 없었다. 이 harness의 subagent tool은 `oracle`을 지원하지 않아 `/visual-qa` dual-oracle pass는 실행하지 못했다. + - Final QA refresh: refactor 후 `npm run dev:mock` 서버에서 Playwright Chromium으로 login → `/ai-characters/101/series?page=0&size=20` → 상세 링크 → `/ai-characters/101/series/5001` → reload를 다시 조작했다. Fresh artifacts는 `.playwright-mcp/p5-t2-series-final/list-1280.png`, `detail-1280.png`, `detail-reload.png`, `list-action-check.json`, `detail-dom-check.json`, `detail-reload-check.json`, `list-network.txt`, `detail-network.txt`, `console-warnings.txt`다. 목록은 `visibleSeriesText: true`, `searchboxCount: 0`, `forbiddenControls: []`였고 상세는 `hasDetailStrings: true`, `hasReverseEnumLeak: false`, `forbiddenControls: []`였다. reload는 `titleVisible: true`, `stringsVisible: true`, URL `/ai-characters/101/series/5001`을 유지했다. Series network는 목록과 상세만 200이었고 console은 기능과 무관한 `/favicon.ico` 404만 있었다. +- 남은 항목: Series 생성·수정·비활성화, genre lookup, active-only server 보장, 연결·해제·순서 UI, P5-T3~P5-GATE는 구현하거나 완료 표시하지 않았다. P4-GATE도 이전 기록처럼 미완료다. + +### P5-T4 Series Audio 연결·해제·전체 순서 구현, 2026-07-28 + +- 무엇을: Series 상세에 연결된 Audio 목록/후보 검색/연결/해제 UI를 추가하고, 전체 Series 순서 관리 route와 keyboard 위/아래 저장 흐름을 구현해 `P5-T4` 체크박스만 완료로 표시했다. +- 왜: 제공된 `page/size + totalCount/items`, `search_word`, `{ contentIdList }`, body 없는 `DELETE`, `{ ids }` 계약을 추정 없이 고정하고 P5-T3의 genre lookup과 form 범위, P5-T5/GATE 범위는 건드리지 않기 위해서다. +- 어떻게: + - RED contract: `npm run test:run -- src/features/series/tests/series-contract.test.ts`는 1 file / 6 tests 중 2 failed, `TypeError: getSeriesContents is not a function`, `TypeError: getAllSeriesForOrder is not a function`으로 실패했다. + - RED linked UI: `npm run test:run -- src/features/series/tests/series-contents.test.tsx`는 `Failed to resolve import "@/features/series/components/SeriesContents"`로 실패했다. + - RED order UI: `npm run test:run -- src/features/series/tests/series-order.test.tsx`는 `Failed to resolve import "@/features/series/pages/SeriesOrderPage"`로 실패했다. + - RED E2E: `playwright.config.ts`가 새 spec을 제외해 최초 `No tests found`가 났고, mock `testMatch`에 `series.spec.ts`를 추가한 뒤 `npm run e2e:mock -- tests/e2e/series.spec.ts`는 4 failed, `연결된 오디오` heading not found로 실패했다. + - GREEN contract: `npm run test:run -- src/features/series/tests/series-contract.test.ts`는 1 file / 6 tests 통과했다. 새 contract는 현재 연결 목록 GET이 `?page=1&size=20`만 보내고, 후보 검색이 `contents/search?search_word=...`만 보내며, POST body가 `JSON.stringify({ contentIdList: [9002, 9003] })`, DELETE body가 `undefined`, PUT body가 `JSON.stringify({ ids: [5002, 5001] })`임을 고정한다. + - GREEN linked UI: `npm run test:run -- src/features/series/tests/series-contents.test.tsx`는 1 file / 3 tests 통과했다. linked list pagination, 중복 후보 제거, 단일 선택 POST, title/영향 confirmation, body-less DELETE, mutation 실패 시 현재 목록 보존과 재조회/재시도 안내를 확인했다. + - GREEN order UI: `npm run test:run -- src/features/series/tests/series-order.test.tsx`는 1 file / 2 tests 통과했다. `totalCount` 기반 page 0/1 전체 fetch, 위/아래 button reorder, `{ ids }` 저장, 실패 시 현재 UI 순서 보존과 재조회/재시도 안내를 확인했다. + - E2E: `npm run e2e:mock -- tests/e2e/series.spec.ts`는 Chromium/WebKit/Mobile Chrome/Mobile Safari 4 tests 통과했다. login → `/ai-characters/101/series/5001` → 후보 검색/연결 → 해제 confirmation/DELETE → order page → 위로 button → save → SPA refetch 순서 유지 흐름을 확인했다. + - Final focused regression: `npm run test:run -- src/features/series`는 3 files / 12 tests 통과했다. + - Static: `npm run typecheck`와 `npm run lint`는 출력 없이 exit 0이었다. + - Manual QA: `npm run dev:mock` 서버에서 Playwright MCP로 login → `/ai-characters/101/series/5001` → linked list pagination 확인 → 후보 `아침` 검색 → 중복 후보 제거와 선택/연결 → title/영향 confirmation 후 해제 → `/series/order` 위/아래 button 저장 → detail로 돌아갔다가 order 재진입해 저장 순서 유지까지 실제 조작했다. Artifacts는 `.playwright-mcp/p5-t4-series-link-order/01-detail-linked-list.png`, `02-network-linked-list.txt`, `03-after-link.png`, `04-network-link-post.txt`, `05-unlink-confirmation.png`, `06-after-unlink.png`, `07-network-unlink-delete.txt`, `08-order-page-before.png`, `09-order-after-save.png`, `10-network-order-save.txt`, `11-order-refetch-persisted.png`, `12-console-warning-error.txt`다. Network evidence는 linked GET이 `/contents?page=0&size=20`만, search가 `/contents/search?search_word=%EC%95%84%EC%B9%A8`만, DELETE가 `/contents/9001`, order page load가 `/series?page=0&size=50`임을 기록했다. Console은 기능과 무관한 `/favicon.ico` 404 1건만 있었다. 정정: 독립 검증에서 OpenAPI reorder path가 plural `PUT /api/v2/admin/ai-characters/{characterId}/series/orders`임을 확인해 follow-up에서 API/mock endpoint와 contract evidence를 `/series/orders`로 수정했다. Fresh browser 재확인에서는 `/ai-characters/101/series/order`에서 keyboard `위로` 이동 후 `순서 저장` 시 `PUT https://test-character-admin.sodalive.net/api/v2/admin/ai-characters/101/series/orders`와 body `{"ids":[5002,5001]}`가 발생했고 singular `/series/order` PUT은 0건이었다. Fresh screenshot은 `.playwright-mcp/p5-t4-series-link-order/13-order-plural-endpoint-fresh.png`다. + - Follow-up DTO/focus 정정: 독립 검증에서 Series content list/search DTO가 invented `detail`·`coverImageUrl`을 쓰고 unlink alertdialog가 기존 `useModalFocus` pattern을 쓰지 않는 blocker를 확인했다. OpenAPI 기준 list item은 `contentId`, `coverImage`, `title`, `isAdult`, search item은 `contentId`, `title`, `coverImage`만 parse/display하도록 수정하고, unlink dialog는 `ConfirmDeactivateDialog`와 같은 modal focus entry/trap pattern을 적용했다. 추가 품질 리뷰에서 Escape 닫힘과 Shift+Tab/focus return 회귀 검증 부족이 남아 `Escape` close와 trigger focus return test를 보강했고, focused/series/mock E2E/typecheck/lint/build 및 재리뷰 PASS를 확인했다. + - OQ-009: 실제 연결 UI는 checkbox 후보를 한 번에 확인·해제해야 하므로 `contentIdList` 권고안은 50개/요청, 실제 순서 UI는 keyboard 위/아래 조작과 전체 저장의 인지 부하를 고려해 `ids` 권고안은 200개/캐릭터로 기록한다. backend 호환 확인 전까지 client 상한은 구현하지 않았다. +- 남은 항목: P5-T3는 genre lookup과 상세 수정용 원본값 제공 전까지 pending/blocked이고, P5-T5와 P5-GATE는 완료 표시하지 않았다. P4-GATE도 이전 기록처럼 미완료다. 실제 backend의 domain-specific conflict/missing-id response key는 추정하지 않고 generic 재조회/재시도 안내만 구현했다. + +### P5-T5 Series 반응형·접근성 capability 완료, 2026-07-28 + +- 무엇을: 활성 Series 범위(P5-T2 read-only 목록/상세, P5-T4 연결 콘텐츠·전체 순서)만 대상으로 320px 모바일 조회 capability, desktop/tablet 관리 capability, keyboard-only 연결·해제·정렬, 200% zoom, axe critical/serious 0건을 `tests/e2e/series.spec.ts`로 고정하고 `P5-T5` 활성 체크박스를 완료로 표시했다. +- 왜: P5-T3의 genre lookup과 상세 수정용 원본값 제공이 아직 필요해 요일·genre/form 접근성은 구현·테스트하지 않고 대기 항목으로 분리해야 하며, 모바일에서는 제공되지 않은 CRUD와 P5-T4 mutation action이 route capability로 차단돼야 하기 때문이다. +- 어떻게: + - RED: `npm run e2e:mock -- tests/e2e/series.spec.ts`는 새 P5-T5 coverage 추가 후 24 tests 중 4 failed / 3 skipped / 17 passed로 실패했다. 실패 원인은 320px Series 상세에서 `검색어` searchbox가 visible이라 모바일 연결 mutation action이 차단되지 않은 것이었다. locator strictness 보정 전 1차 실패는 테스트 결함으로 수정 후 RED를 재확인했다. + - GREEN implementation: Series 전용 `useSeriesManagementCapability`를 추가해 `(min-width: 768px)` 이상에서만 연결 검색/연결/해제와 전체 순서 route를 활성화했다. 320px 상세는 연결 콘텐츠 목록만 조회하고, `/series/order` 직접 진입은 태블릿 이상 안내만 표시한다. P5-T3 create/edit/delete/genre UI/API는 추가하지 않았다. + - Focused E2E: 최종 `npm run e2e:mock -- tests/e2e/series.spec.ts`는 24 tests 중 21 passed / 3 skipped, exit 0이었다. skipped 3건은 mobile mutation route 차단과 WebKit tab focus 차이를 명시한 keyboard-only path다. E2E는 320px list/detail/order guidance overflow 0건, desktop/tablet 768·1280 linked search/link/unlink/order save, keyboard-only search/select/link, unlink dialog focus/Escape/focus return, order up/save, 200% zoom, axe critical/serious 0건을 확인한다. + - Focused component: `npm run test:run -- src/features/series/tests/series-contents.test.tsx`는 1 file / 3 tests 통과했다. 기존 component tests는 desktop management capability를 명시하도록 갱신했다. + - Static/build: 최초 `npm run typecheck`와 `npm run build`는 `SeriesContents` test fixture의 `canManageSeries` prop 누락으로 실패했고, fixture 수정 후 `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0이었다. build는 215 modules transformed와 `dist/assets/index-Bw6rMAMS.js` 405.92 kB(gzip 110.63 kB)를 기록했다. + - LSP/LOC: `src/features/series` directory diagnostics는 10 files / 0 diagnostics, `tests/e2e` directory diagnostics는 9 files / 0 diagnostics였다. 단일 `tests/e2e/series.spec.ts` fresh diagnostics는 반복 3초 timeout됐지만 directory diagnostics, typecheck, lint로 보완했다. 순수 LOC는 `use-series-management-capability.ts` 19, `SeriesDetailPage.tsx` 62, `SeriesContents.tsx` 161, `SeriesOrderPage.tsx` 102, `series-contents.test.tsx` 103, `series.spec.ts` 167로 모두 250 이하이다. + - Visual QA: 1차 pass A는 PASS였고 pass B는 320px 공통 header의 `모바일 메뉴 열기`, `AI 캐릭터`, `로그아웃` CJK 음절 줄바꿈을 blocker로 반환했다. `protected-admin-shell.tsx` header controls에 `shrink-0 whitespace-nowrap`을 적용하고 `App.protected-shell.test.tsx` 회귀 test를 추가했다. RED `npm run test:run -- src/app/App.protected-shell.test.tsx`는 `whitespace-nowrap` class 부재로 1 failed였고, 수정 후 7 tests passed였다. 최종 7개 fresh screenshot `.playwright-mcp/p5-t5-series-responsive/11-final-mobile-320-list.png`부터 `17-final-desktop-1280-order.png`까지 PNG signature `89504e470d0a1a0a`, 모든 route `hasHorizontalOverflow:false`, header computed `whiteSpace:"nowrap"`였고, visual QA pass B 재검토는 PASS였다. +- 남은 항목: P5-T3는 genre lookup과 상세 수정용 원본값 제공 전까지 pending/blocked이며 요일·genre/form 접근성 체크도 대기한다. P5-GATE는 완료 표시하지 않았다. P4-GATE도 이전 기록처럼 미완료다. 실제 server mode Series integration과 active-only/domain-specific 오류 key는 기존 외부 의존 상태를 유지한다. + +### P5-GATE Series 부분 검증, 2026-07-28 + +- 무엇을: Phase 5 Gate 명령을 현재 제공된 Series 계약 범위에서 fresh 실행하고, mock UI journey와 server mode 실행 가능 상태를 분리해 기록했다. +- 왜: P5-T3 genre lookup과 상세 수정용 원본값 제공이 아직 필요하고, server mode Series spec은 현재 Playwright server allowlist와 file filter 교집합에서 실행되지 않으므로 Gate 완료를 주장하면 안 되기 때문이다. +- 어떻게: + - `npm run test:run -- src/features/series`는 3 files / 12 tests 통과했다. + - `npm run e2e:mock -- tests/e2e/series.spec.ts`는 24 tests 중 21 passed / 3 skipped로 통과했다. skipped 3건은 mobile mutation 차단과 WebKit tab-focus 차이로 명시됐다. + - `npm run e2e -- tests/e2e/series.spec.ts`는 `No tests found`로 실행되지 않았다. 이는 server mode `testMatch`와 직접 file filter의 교집합 문제이며, Series 실제 server integration 성공 증거가 아니다. + - `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. build는 215 modules transformed와 `dist/assets/index-vqn3JSuw.js` 406.01 kB(gzip 110.65 kB)를 기록했다. +- 남은 항목: `P5-GATE` 완료에는 Series server mode spec이 실제로 실행되어 목록·상세·연결·해제·순서 network가 개발 API 계약과 일치한다는 fresh evidence가 필요하다. P5-T3 genre lookup, 상세 수정용 원본값, active-only 보장, Series 도메인별 오류 key도 외부 의존으로 유지한다. + +### P6-T1 Community 계약 확인, 2026-07-28 + +- 무엇을: OpenAPI Community 3개 operation과 `CommunityPost*` schema를 PRD `COMMUNITY-001~015`, 최신 외부 의존 `EXT-007~008`, Phase 6 계획과 대조해 `P6-T1` 체크박스 6개를 완료로 표시했다. +- 왜: P6-T2 이후 목록/Sheet/form 구현이 detail GET, pagination metadata, 수정 audio/price, 오류 key, price 상한을 추정하지 않게 하기 위해서다. +- 어떻게: + - OpenAPI parse: `node -e ...`로 Community path가 `GET/POST /api/v2/admin/ai-characters/{characterId}/community-posts`, `PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` 3개 operation임을 확인했다. + - 요청·응답 고정: 목록은 필수 `timezone`, `page`, `size` query와 배열 `data`를 사용한다. 생성 multipart는 optional `audioFile`, optional `postImage`, required `request`이고 request는 required `content`, `isCommentAvailable`, `isAdult`, optional `price`다. 수정 multipart는 optional `postImage`, required `request`만 허용하고 request field는 `content`, `isCommentAvailable`, `isAdult`, `isActive`, `isFixed`다. POST/PUT 성공은 `NullSuccess`의 `data=null`만 신뢰한다. + - 제외 고정: Community 목록 응답에는 `totalCount`, `page`, `hasNext`, `isActive`, `fixedAtUtc`가 없으므로 fixture·DTO·UI에 추가하지 않는다. 수정 request에는 `price`와 `audioFile`이 없으므로 수정 audio/price UI나 request를 만들지 않는다. Community detail GET과 detail/edit route도 만들지 않는다. + - 오류/외부 의존: 공통 오류 fixture는 `ApiErrorResponse`의 `success=false`, `message`, `data=null`, `errorProperty`와 OpenAPI에 있는 400/401/403/404/405/406/415/500 status만 사용한다. Community 전용 message key와 media upload 오류 key는 추정 분기하지 않고, price 최대값은 `99999`, 파일 용량·MIME backend 동일 검증은 확인됐다. active-only 반환과 pagination 종료 metadata는 `EXT-007~008` 제공 전까지 추정하지 않는다. + - 상태·component map: list Page는 `characterId`, `timezone=Asia/Seoul`, `page`, `size`, `items`, `loading`, `empty`, `error`, `retry`, selected sheet item을 소유한다. Sheet는 목록 item source로 열리며 별도 detail fetch 없이 update/pin/soft delete action과 cache invalidation을 소유한다. Form/media component는 Community content/isAdult/comment/price/postImage/audioFile 규칙을 소유하고, shared FileField/ImageCropDialog/AdminAudioPlayer/validation은 Phase 1·4 contract를 재사용한다. + - Mock scenario: deterministic seed는 고정 게시글, 이미지 게시글, audioUrl 게시글, 댓글 요약이 있는 게시글을 포함한다. mock handler는 list timezone/page/size, create multipart, update multipart, pin/unpin, soft delete `isActive=false`·`isFixed=false`, `data=null`, detail GET 0회를 검증한다. active-only server 결과, pagination 종료, Community 전용 오류 key, price 상한은 mock에서도 추정하지 않는다. + - 문서 추적: `rg -n 'COMMUNITY-01[2-5]|EXT-00[7-9]|EXT-010|EXT-011|CommunityPost|postImage|audioFile|timezone|totalCount|hasNext|fixedAtUtc' docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md docs/20260725_AI캐릭터관리자웹/api-contract.openapi.json`는 exit 0으로 PRD·plan·OpenAPI의 Community 계약과 외부 의존 범위를 확인했다. +- 남은 항목: `P6-T2`부터 실제 Community list/Sheet route, API, mock, tests를 TDD로 구현한다. Comments는 Phase 8 범위이며 P6에서 구현하지 않는다. + +### P6-T2 Community 목록·collection Sheet 구현, 2026-07-28 + +- 무엇을: Community posts 목록 route와 목록 item 기반 Sheet 수정·고정/해제·비활성화 흐름을 구현하고 `P6-T2` 체크박스 8개를 완료로 표시했다. +- 왜: 제공 계약에 없는 Community detail GET/route, 검색, `totalCount`, `hasNext`, `fixedAtUtc`, active filter를 만들지 않고 목록 응답만으로 collection Sheet mutation을 끝내기 위해서다. +- 어떻게: + - RED: focused test 최초 실행은 import/route 부재와 UI가 Character list로 fallback되는 실패를 확인했고, 이후 `@/features/community-posts/model/types` 부재와 Sheet mutation 미발생 실패를 거쳐 P6-T2 경로를 고정했다. + - GREEN focused: `npm run test:run -- src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-list.test.tsx src/features/community-posts/tests/community-sheet.test.tsx`는 3 files / 8 tests 통과했다. + - Static/build: `npm run typecheck`, `npm run lint`, `npm run build`는 모두 exit 0이었다. `src/features/community-posts`, `src/app`, `src/shared/mocks` directory LSP diagnostics는 0 diagnostics였고, Community 관련 source/test/mock 파일은 모두 pure LOC 250 이하이다. `git diff --check -- src/features/community-posts src/app/route-paths.ts src/app/browser-location.ts src/app/protected-admin-shell.tsx src/layouts/CharacterWorkspaceLayout.tsx src/shared/mocks/community-post-fixtures.ts src/shared/mocks/community-post-handlers.ts src/shared/mocks/community-post-mock-store.ts src/shared/mocks/handlers.ts src/shared/mocks/character-mock-store.ts docs/20260725_AI캐릭터관리자웹/plan-task.md`는 출력 없이 exit 0이었다. + - Review closure: 목록 `price`는 0 이상 정수로 파싱하고 update schema는 `isActive=false`만 허용하도록 고정했다. Sheet mutation 실패 시 `role="alert"` 오류를 표시하고 저장 상태를 해제하는 회귀 test를 추가했다. Sheet test의 multipart payload 검증 중복은 contract test로 이동해, Sheet test는 버튼 조작→PUT 호출→목록 갱신만 확인한다. + - Contract: 목록 request는 `timezone=Asia%2FSeoul&page=size` 순서의 `timezone/page/size`만 보내고 배열 `data`만 소비한다. update/soft delete는 multipart `request` part와 성공 `data=null`을 처리하며 soft delete request는 `{ isActive: false, isFixed: false }`이고 `isActive=true`를 보내지 않는다. + - Router/UI: `/ai-characters/:characterId/community-posts`를 workspace route로 추가했고 `/community-posts/:postId` 및 `/edit`은 앱 route가 아니며 detail GET handler 호출 0건을 test로 고정했다. + - Mock: `community-post-handlers`와 list store가 timezone/page/size, update multipart, pin/unpin, soft delete를 list store만으로 처리한다. Community detail handler는 만들지 않았다. + - Manual QA: `npm run dev:mock` 서버에서 Playwright MCP로 login → `/ai-characters/101/community-posts?page=0&size=20` → 목록 item Sheet 열기 → 내용 수정/댓글 허용 해제/성인 콘텐츠 체크 → `수정 저장` → `고정 해제` → `고정하기` → `비활성화`를 실제 조작했다. Network evidence는 list GET이 `?timezone=Asia%2FSeoul&page=0&size=20`만 사용하고 각 PUT 뒤 list GET이 재실행됨을 보였다. Soft delete 뒤 성공 알림 `커뮤니티 게시글을 비활성화했습니다.`와 남은 목록 2건을 확인했다. Artifacts는 `p6-t2-community/03-after-edit.png`, `04-after-unpin.png`, `05-after-pin.png`, `06-after-delete.png`, `network-after-unpin.txt`, `network-after-pin.txt`, `network-after-delete.txt`, `console-final.txt`다. Console은 기능과 무관한 `/favicon.ico` 404만 있었다. `look_at`으로 `06-after-delete.png`를 확인해 성공 알림, 목록 표시, 명백한 한글 clipping/overflow 없음도 확인했다. +- 남은 항목: Community 생성, postImage/audioFile form, GIF/media policy, 반응형·접근성 E2E, 실제 server integration, active-only 반환과 pagination 종료 metadata는 `P6-T3`~`P6-GATE`와 외부 의존 범위로 남겼다. + +### P6-T3 Community 게시글 form·첨부 media 구현, 2026-07-28 + +- 무엇을: Community 게시글 생성 route/form, create multipart serializer, update Sheet의 postImage-only 첨부, JPEG/PNG crop·GIF 원본 보존·audio policy·player/no-refetch 규칙을 구현하고 `P6-T3` 체크박스 12개를 완료로 표시했다. +- 왜: OpenAPI가 제공한 생성·수정 multipart 계약만 구현하고, 범위 밖인 댓글, detail GET, edit route, update audio/price, GIF 재인코딩, URL refresh-only request, mobile mutation capability를 추정하지 않기 위해서다. +- 어떻게: + - RED: `npm run test:run -- src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-form.test.tsx src/features/community-posts/tests/community-list.test.tsx src/features/community-posts/tests/community-sheet.test.tsx` 최초 실행은 `CommunityPostFormPage` import 부재, `createCommunityPost is not a function`, mock POST timeout으로 실패해 생성 API/page/mock 부재를 확인했다. + - GREEN focused: 최종 `npm run test:run -- src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-form.test.tsx src/features/community-posts/tests/community-list.test.tsx src/features/community-posts/tests/community-sheet.test.tsx`는 4 files / 14 tests 통과했다. 이후 fresh focused `npm run test:run -- src/features/community-posts src/shared/validation`도 실행해 Community와 shared file policy 회귀를 확인했다. + - Contract/form: create는 POST `/community-posts`에 optional `audioFile`, optional `postImage`, required `request`를 보내고 request는 `content`, `isCommentAvailable`, `isAdult`, optional `price`만 허용한다. create schema는 strict라 `isActive`와 `isFixed`를 거부한다. update는 PUT `/community-posts/:postId`에 optional `postImage`와 request `content`, `isCommentAvailable`, `isAdult`, `isActive=false`, `isFixed`만 허용하며 `audioFile`과 `price`를 보내지 않는다. + - Media: JPEG/PNG는 기존 `ImageCropDialog`와 crop helper를 사용해 `aspect: "free"`, `maxWidth: 800`, `noUpscale: true`로 전송한다. GIF는 Community 전용 정책에서만 허용하고 crop/canvas/re-encode 없이 원본 `File`을 전송하며 width 800px은 허용, 801px은 제출 전 오류로 차단한다. 첨부 audio는 기존 `AUDIO_FILE_POLICY`와 `FileField`를 사용해 MP3/AAC/M4A, `audio/x-m4a`, `1,024,000,000 bytes`, WAV 거부를 유지한다. + - Player/cache: `audioUrl`이 있으면 desktop row, mobile card, Sheet에 `AdminAudioPlayer`를 렌더링한다. audio error와 manual retry는 detail/list refetch, URL 재발급, autoplay를 일으키지 않는 test로 고정했다. 새 `audioUrl`은 create/update 후 정상 list refetch로만 화면에 반영된다. + - Mock: `community-post-handlers`가 POST/PUT multipart contract를 검증하고 list store에 local preview image/audio data URL을 반영한다. Community detail handler는 만들지 않았다. + - Static/build: 변경 TS/TSX 파일과 `src/features/community-posts`, `src/shared/mocks` directory LSP diagnostics는 0 diagnostics였다. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0이었다. + - Manual QA: `npm run dev:mock` 서버에서 Playwright MCP로 login → `/ai-characters/101/community-posts?page=0&size=20` → `커뮤니티 게시글 생성` link → 빈 submit validation `내용을 입력하세요.` → content/price 입력 → `생성` → `/ai-characters/101/community-posts` 복귀 → 성공 알림과 새 행 `브라우저 QA 생성 게시글` 표시를 실제 조작했다. Screenshot artifact는 `p6-t3-community-create-list.png`다. Console은 기능과 무관한 `/favicon.ico` 404만 있었다. + - Visual QA: read-only visual QA pass A/B는 모두 PASS였다. Pass A는 생성 route 이동, form validation, 성공 후 목록 복귀, 새 행/성공 배너, DESIGN.md token/component 재사용에 blocker가 없다고 판정했다. Pass B는 `p6-t3-community-create-list.png`에서 한국어 CJK clipping, 잘린 버튼/라벨, 깨진 레이아웃이 없고 기존 카드/테이블 패턴과 일치한다고 판정했다. + - OQ-009: 실제 Community Sheet/form의 `content` textarea와 목록 줄바꿈을 확인했다. 권고안은 `content` 2,000자이며, backend 호환 확인 전까지 client 상한은 구현하지 않았다. +- 남은 항목: Community 반응형·접근성 E2E와 mobile mutation capability는 `P6-T4`, 실제 server integration과 active-only 반환·pagination 종료 metadata는 `P6-GATE`/외부 의존으로 남긴다. Comments, Community detail GET, `/community-posts/:postId`, `/edit`, search, totalCount/hasNext, fixedAtUtc, update audio/price, price max enforcement, GIF re-encoding, URL refresh-only request는 구현하지 않았다. + +**P6-T3 review 보완 검증 기록 (2026-07-28):** + +- 무엇을: 비활성 캐릭터의 Community 생성/Sheet mutation 진입 차단, mock Community `characterId` 격리, `.m4a`의 `audio/mp4`/`audio/x-m4a` 허용, 유효한 mp4 data URL fixture, mock multipart part/cardinality 검증, update `postImage` preview 반영, async image 준비 중 submit disable, Sheet multipart 검증 test를 보완했다. +- 왜: P6-T3 review에서 활성 캐릭터 전제 누락, mock 격리 부족, audio MIME 경계와 multipart 계약 검증이 느슨한 부분이 확인됐기 때문이다. +- 어떻게: RED focused는 `npm run test:run -- src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-form.test.tsx src/features/community-posts/tests/community-sheet.test.tsx src/shared/validation/file-media-policy.test.ts`로 기대 실패를 확인했다. GREEN focused는 같은 명령이 4 files / 26 tests passed, fresh feature 회귀 `npm run test:run -- src/features/community-posts src/shared/validation`이 5 files / 28 tests passed였다. +- Static/build: `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`가 모두 exit 0이었다. +- Manual QA: 기존 `npm run dev:mock` 서버(`http://127.0.0.1:8889`)에서 Playwright MCP로 login → 비활성 캐릭터 `/ai-characters/202/community-posts` 생성 link 0건과 read-only 배너 확인 → `/ai-characters/202/community-posts/new` 직접 접근 시 `커뮤니티 게시글 생성 차단`과 `생성` button 0건 확인 → 활성 캐릭터 `/ai-characters/101/community-posts` 생성 form validation → 새 게시글 생성 → Sheet 수정 저장 → Sheet 비활성화를 실제 조작했다. 결과 steps는 `login-ok`, `inactive-list-readonly-ok`, `inactive-form-blocked-ok`, `active-create-ok`, `active-sheet-edit-ok`, `active-sheet-delete-ok`였고 missing 항목은 없었다. Network는 login POST, Community create POST, Sheet update/soft delete PUT과 character-scoped list GET만 200으로 기록됐으며 Community detail GET은 없었다. `p6-t3-community-create-list.png`를 갱신했고 `look_at` 확인 결과 성공 배너와 목록이 보이며 명백한 한국어 clipping/layout 깨짐은 없었다. Console은 기능과 무관한 React DevTools 안내와 `/favicon.ico` 404뿐이었다. +- 범위 유지: Comments, Community detail GET/route, search, total/hasNext/fixedAtUtc, active filter, server-specific error key, P6-T4 mobile mutation capability는 구현하지 않았다. + +**P6-T3 2차 review 보완 정정 기록 (2026-07-28):** + +- 정정: 위 P6-T3 최초 RED 기록의 `CommunityPostFormPage` import 부재와 `createCommunityPost is not a function`, mock POST timeout은 §2.1 line 72의 "import 오류, test 환경 오류, 임시 mock 누락 같은 우발적 실패는 Red 증거로 인정하지 않는다" 규칙상 유효한 RED 증거가 아니다. 유효 RED는 2차 보완에서 추가한 `mock handlers reject non-multipart bodies and text upload fields`, `mock handlers update postImage preview and use a valid bundled audio preview`, `Community Sheet keeps save impossible while image preparation is pending`의 기대 실패다. +- 무엇을: Community mock parser를 `request.formData()` 우선 strict parser로 바꾸고 raw multipart fallback에도 같은 part/cardinality/file-field 검증을 적용했다. `text/plain` raw body, string `postImage`, string `audioFile`, update의 `audioFile`과 unknown/duplicate part를 400으로 거부한다. Community mock audio fixture는 기존 잘린 `audio/mp4`가 아니라 Audio mock의 decodable WAV data URL을 재사용한다. Sheet는 이미지 준비 중이거나 crop dialog가 열린 동안 `수정 저장`/고정/비활성화 mutation을 disabled 처리한다. `community-sheet.test.tsx`는 support를 분리해 250 pure LOC 아래로 낮췄다. +- 왜: 두 번째 review에서 playable Community mock audio, strict multipart boundary parsing, Sheet pending image race, update image URL proof, test file size, 문서 overclaim이 남았기 때문이다. +- RED: `npm run test:run -- src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-sheet.test.tsx`는 추가 직후 2 files 중 3 tests failed였다. 실패는 `text/plain` raw multipart가 200으로 통과, `previewCommunityPostAudioUrl`이 `data:audio/mp4;base64,AAAAIGZ0eXBtcDQy`라 WAV 기대 불일치, Sheet 이미지 준비 중 `수정 저장` button이 disabled가 아닌 문제였다. +- GREEN focused: `npm run test:run -- src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-sheet.test.tsx`는 2 files / 16 tests passed였다. `npm run e2e:mock -- tests/e2e/community.spec.ts --project=chromium`은 Chromium 1 test passed로 mock Community audio `data:audio/wav` metadata load, `errorCode=null`, duration > 0을 확인했다. +- 파일 지도 정정: 실제 P6-T3 추가/수정 파일은 `src/features/community-posts/validation/community-post-media-policy.ts`, `src/features/community-posts/components/CommunityPostForm.tsx`, `src/features/community-posts/tests/community-form.test.tsx`, `src/features/community-posts/tests/community-test-support.ts`, `tests/e2e/community.spec.ts`, `src/shared/mocks/{audio-content-fixtures.ts,community-post-fixtures.ts,community-post-mock-store.ts}`, `playwright.config.ts`를 포함한다. `community-post-schema.ts`, `community-media-policy.ts`와 과거 잘못 기재된 Community E2E 파일명은 실제 파일명이 아니므로 위 파일 지도에서 정정했다. +- 범위 유지: Comments, Community detail GET/route, search, total/hasNext/fixedAtUtc, active filter, server-specific error key, P6-T4 mobile mutation capability는 구현하지 않았다. + +**P6-T3 2차 review 최종 검증 기록 (2026-07-28):** + +- 무엇을: 2차 보완 후 Community mock parser, playable audio fixture, Sheet pending state, test size, 문서 기록을 현재 작업트리 기준으로 재검증했다. +- 왜: mock mode 통과와 문서 기록만으로 server integration 또는 P6-T4 완료를 과장하지 않고, P6-T3 blocker만 닫혔는지 확인하기 위해서다. +- 자동 검증: `npm run test:run -- src/features/community-posts src/shared/validation`은 5 files / 31 tests passed였다. `npm run e2e:mock -- tests/e2e/community.spec.ts --project=chromium`은 1 passed로 Community mock audio metadata load를 확인했다. `npm run typecheck`, `npm run lint`, `npm run build`, `git diff --check`는 모두 exit 0이었다. build는 226 modules transformed와 `dist/assets/index-Dw4zhRsK.js` 429.63 kB(gzip 115.07 kB)를 기록했다. +- LSP/size: `src/features/community-posts` diagnostics는 9 TSX files / 0 diagnostics, `src/shared/mocks` diagnostics는 19 TS files / 0 diagnostics, `tests/e2e` diagnostics는 10 TS files / 0 diagnostics였다. 단일 `playwright.config.ts`, `CommunityPostForm.tsx`, `community-post-mock-store.ts`, `community-form.test.tsx` fresh diagnostics는 일부 3초 timeout됐지만 directory diagnostics, typecheck, lint로 보완했다. 순수 LOC는 `community-contract.test.ts 165`, `community-form.test.tsx 162`, `community-list.test.tsx 92`, `community-sheet.test.tsx 155`, `community-test-support.ts 180`, `tests/e2e/community.spec.ts 34`, `community-post-mock-store.ts 221`, `community-post-fixtures.ts 75`, `audio-content-fixtures.ts 118`, `CommunityPostSheet.tsx 148`, `CommunityPostForm.tsx 131`, `playwright.config.ts 29`로 모두 250 이하이다. +- Browser QA: 기존 mock 서버 `http://127.0.0.1:8889`에서 Playwright Chromium으로 login → `/ai-characters/101/community-posts` 이동 → `커뮤니티 게시글 7003 오디오 플레이어` 확인 → `audio[src^='data:audio/wav']` metadata load를 실제 실행했다. 결과는 `kind="loaded"`, `errorCode=null`, `duration=1`, `readyState=4`, `srcPrefix="data:audio/wav;base64,"`였다. Network는 Community list GET 2건만 200으로 기록됐고 Community detail GET은 없었다. Console warning/error는 기능과 무관한 `/favicon.ico` 404뿐이었다. +- Visual evidence: fresh screenshot `p6-t3-community-audio-list.png`를 저장했고 PNG signature는 `89504e470d0a1a0a`, size는 207392 bytes였다. `look_at` 확인 결과 Community 목록과 오디오 플레이어가 보이고, 재생 시간 `0:00 / 0:01`, 재생/위치/볼륨/속도 controls가 표시되며, 명백한 한국어 clipping이나 깨진 레이아웃은 없었다. +- 남은 항목: P6-T4 mobile/viewport/focus/axe capability와 P6-GATE server mode integration은 아직 완료 표시하지 않는다. active-only 반환과 pagination 종료 metadata는 기존 외부 의존으로 유지한다. + +**P6-T3 final review close 기록 (2026-07-28):** + +- RED: `npm run test:run -- src/features/community-posts/tests/community-form.test.tsx src/features/community-posts/tests/community-contract.test.ts src/features/community-posts/tests/community-sheet.test.tsx`는 3 files 중 3 tests failed였다. 실패는 crop dialog open 상태에서 `생성`이 disabled가 아님, raw multipart declared/body boundary mismatch가 200으로 통과, Sheet player 접근성 쿼리가 전역 중복 name에 취약한 문제였다. +- GREEN focused: 같은 명령은 3 files / 22 tests passed였다. `CommunityPostForm`은 `cropSource !== null` 동안 disabled와 submit guard를 유지하고 apply 시 image error를 정리한다. raw multipart fallback은 `Content-Type`의 declared boundary와 body delimiter line이 다르면 null 처리한다. Sheet test는 전역 `