From 7a21b37d8fb9ba332d4854df4bc5f4ee676b52b2 Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 14 Aug 2026 16:20:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B0=A8=EB=8B=A8=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=ED=9B=84=20=EC=B1=84=EB=84=90=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=ED=83=88=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creator/channel/CreatorChannelActivity.kt | 8 + .../channel/CreatorChannelHomeFragment.kt | 4 + .../channel/CreatorChannelHomeViewModel.kt | 5 + .../CreatorChannelActivitySourceTest.kt | 37 +++ .../CreatorChannelHomeViewModelTest.kt | 31 ++- .../plan-task.md | 261 ++++++++++++++++++ .../prd.md | 150 ++++++++++ .../phase1-creator-block-exit-review.md | 87 ++++++ 8 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 docs/20260814_크리에이터_채널_차단_후_이탈/plan-task.md create mode 100644 docs/20260814_크리에이터_채널_차단_후_이탈/prd.md create mode 100644 docs/20260814_크리에이터_채널_차단_후_이탈/reviews/phase1-creator-block-exit-review.md diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt index 5e178bbe..fd12f11f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt @@ -82,6 +82,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.series.CreatorChannelSeriesFr import kr.co.vividnext.sodalive.v2.live.action.LiveActionCoordinator import kr.co.vividnext.sodalive.v2.live.action.LiveCreationResult import kr.co.vividnext.sodalive.v2.live.action.resolveLiveCreationResult +import kr.co.vividnext.sodalive.v2.main.MainV2Activity import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import org.koin.android.ext.android.inject @@ -557,6 +558,13 @@ class CreatorChannelActivity : handleChatAction(ChatActionCommand.AiRoom(chatRoomId)) } + override fun onCreatorChannelBlockSucceeded() { + if (isTaskRoot) { + startActivity(Intent(this, MainV2Activity::class.java)) + } + finish() + } + override fun onCreatorChannelScheduleClicked(schedule: CreatorChannelScheduleResponse) { onScheduleClicked(schedule) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt index ae49347c..bf324337 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt @@ -101,6 +101,9 @@ class CreatorChannelHomeFragment : BaseFragment event.consume()?.let(host::onCreatorChannelChatRoomCreated) } + viewModel.blockSuccessLiveData.observe(viewLifecycleOwner) { event -> + event.consume()?.let { host.onCreatorChannelBlockSucceeded() } + } viewModel.toastLiveData.observe(viewLifecycleOwner) { event -> event.consume()?.let { val message = it.message ?: it.resId?.let(::getString) @@ -148,6 +151,7 @@ class CreatorChannelHomeFragment : BaseFragment> get() = _chatRoomIdLiveData + private val _blockSuccessLiveData = MutableLiveData>() + val blockSuccessLiveData: LiveData> + get() = _blockSuccessLiveData + private val _isFollowInProgressLiveData = MutableLiveData(false) val isFollowInProgressLiveData: LiveData get() = _isFollowInProgressLiveData @@ -179,6 +183,7 @@ class CreatorChannelHomeViewModel( _toastLiveData.value = CreatorChannelEvent( ToastMessage(resId = R.string.creator_channel_block_success) ) + _blockSuccessLiveData.value = CreatorChannelEvent(Unit) } else { showUnknownErrorToast() } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt index ae88e25d..d02ad297 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt @@ -2457,6 +2457,43 @@ class CreatorChannelActivitySourceTest { assertTrue(source.contains("homeActionDelegate?.refreshHome()")) } + @Test + fun `차단 성공은 이전 화면으로 돌아가고 task root이면 MainV2Activity로 이동한다`() { + val fragmentSource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt" + ).readText() + val activitySource = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt" + ).readText() + val observerSource = sourceSection( + source = fragmentSource, + startMarker = "private fun observeViewModel()", + endMarker = "private fun onScheduleClicked" + ) + + assertTrue(observerSource.contains("viewModel.blockSuccessLiveData.observe(viewLifecycleOwner)")) + assertTrue(observerSource.contains("event.consume()?.let { host.onCreatorChannelBlockSucceeded() }")) + assertTrue(fragmentSource.contains("fun onCreatorChannelBlockSucceeded()")) + + val blockDialogSource = sourceSection( + source = activitySource, + startMarker = "private fun showUserBlockDialog()", + endMarker = "private fun showUserReportDialog()" + ) + assertTrue(blockDialogSource.contains("homeActionDelegate?.blockUser()")) + assertFalse(blockDialogSource.contains("finish()")) + assertFalse(blockDialogSource.contains("MainV2Activity")) + + val callbackSource = sourceSection( + source = activitySource, + startMarker = "override fun onCreatorChannelBlockSucceeded()", + endMarker = "override fun onCreatorChannelScheduleClicked" + ) + assertTrue(callbackSource.contains("if (isTaskRoot)")) + assertTrue(callbackSource.contains("startActivity(Intent(this, MainV2Activity::class.java))")) + assertTrue(callbackSource.contains("finish()")) + } + private fun sourceSection(source: String, startMarker: String, endMarker: String): String { val start = source.indexOf(startMarker) val end = source.indexOf(endMarker, start) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt index d716d876..2509f037 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt @@ -337,7 +337,7 @@ class CreatorChannelHomeViewModelTest { } @Test - fun `차단 성공은 block API를 호출하고 차단 완료 토스트를 emit한다`() { + fun `차단 성공은 block API를 호출하고 차단 완료 토스트와 성공 이벤트를 emit한다`() { whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null))) whenever(repository.blockUser(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, Any(), null))) viewModel.loadHome(100L) @@ -347,6 +347,35 @@ class CreatorChannelHomeViewModelTest { verify(repository).blockUser(100L, "Bearer test-token") val toastEvent = viewModel.toastLiveData.requireValue() assertEquals(R.string.creator_channel_block_success, toastEvent?.consume()?.resId) + val blockSuccessEvent = viewModel.blockSuccessLiveData.requireValue() + assertEquals(Unit, blockSuccessEvent?.consume()) + assertEquals(null, blockSuccessEvent?.consume()) + } + + @Test + fun `차단 실패는 unknown toast를 emit하고 성공 이벤트를 emit하지 않는다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null))) + whenever(repository.blockUser(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(false, null, "failed"))) + viewModel.loadHome(100L) + + viewModel.blockUser() + + val toastEvent = viewModel.toastLiveData.requireValue() + assertEquals(R.string.common_error_unknown, toastEvent?.consume()?.resId) + assertEquals(null, viewModel.blockSuccessLiveData.value) + } + + @Test + fun `차단 예외는 unknown toast를 emit하고 성공 이벤트를 emit하지 않는다`() { + whenever(repository.getHome(100L, "Bearer test-token")).thenReturn(Single.just(ApiResponse(true, response(), null))) + whenever(repository.blockUser(100L, "Bearer test-token")).thenReturn(Single.error(IllegalStateException("network"))) + viewModel.loadHome(100L) + + viewModel.blockUser() + + val toastEvent = viewModel.toastLiveData.requireValue() + assertEquals(R.string.common_error_unknown, toastEvent?.consume()?.resId) + assertEquals(null, viewModel.blockSuccessLiveData.value) } @Test diff --git a/docs/20260814_크리에이터_채널_차단_후_이탈/plan-task.md b/docs/20260814_크리에이터_채널_차단_후_이탈/plan-task.md new file mode 100644 index 00000000..98a38237 --- /dev/null +++ b/docs/20260814_크리에이터_채널_차단_후_이탈/plan-task.md @@ -0,0 +1,261 @@ +# 크리에이터 채널 차단 후 이탈 구현 계획/TASK + +> **For agentic workers:** 각 단계는 체크박스(`- [ ]`)로 추적하고, 완료 즉시 `- [x]`로 갱신한다. 구현 범위 변경이 생기면 이 문서를 먼저 수정한 뒤 코드에 반영한다. + +**Goal:** 차단 성공 후 사용자가 현재 크리에이터 채널에 머물지 않고 이전 화면 또는 `MainV2Activity` 홈 fallback으로 이동한다. + +**Architecture:** `CreatorChannelHomeViewModel`은 기존 차단 API 성공 흐름에서 one-shot 성공 이벤트를 추가로 emit한다. `CreatorChannelHomeFragment`는 이벤트를 consume해 `Host` callback으로 넘기고, `CreatorChannelActivity`는 `isTaskRoot` 여부에 따라 `finish()` 또는 `MainV2Activity` fallback 후 `finish()`를 수행한다. + +**Tech Stack:** Kotlin, AndroidX LiveData, RxJava3, JUnit4 local unit test, source contract test. + +--- + +## 전제와 성공 기준 + +- PRD: `docs/20260814_크리에이터_채널_차단_후_이탈/prd.md` +- 구현은 차단 API 성공 후에만 화면 이탈을 수행한다. +- 기존 차단 확인 모달과 성공/실패 toast는 유지한다. +- 홈 fallback Activity는 `kr.co.vividnext.sodalive.v2.main.MainV2Activity`다. +- 테스트 범위는 `app/src/test` local unit/source contract test로 한정한다. +- 사용자가 명시적으로 요청하지 않았으므로 `app/src/androidTest`, `connectedDebugAndroidTest`, 기기·에뮬레이터 조작, 스크린샷 검증은 포함하지 않는다. + +--- + +## 파일 구조 + +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModel.kt` + - 차단 성공 one-shot event를 제공한다. +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt` + - 차단 성공 event를 observe/consume하고 host callback으로 전달한다. +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt` + - host callback을 구현하고 `isTaskRoot` fallback을 처리한다. +- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt` + - 차단 성공/실패 event contract를 검증한다. +- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt` + - Fragment/Activity wiring과 `MainV2Activity` fallback source contract를 검증한다. + +--- + +## Phase 1: 차단 성공 후 채널 이탈 + +**Phase 결과:** 사용자가 크리에이터 채널에서 현재 크리에이터 차단에 성공하면 현재 채널에서 빠져나가고, 이전 화면이 없으면 `MainV2Activity`로 이동한다. + +**선행조건:** `docs/20260814_크리에이터_채널_차단_후_이탈/prd.md` 확정. + +**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-GATE` 완료와 검증 기록 누적. + +### 구현 항목 + +#### Task 1.1 차단 성공 이벤트 추가 + +**Goal 실행 `P1-T1`:** `CreatorChannelHomeViewModel.blockUser()`가 API 성공 시 기존 성공 toast와 함께 차단 성공 event를 emit한다. + +- **시작 조건:** `CCB-001`, `CCB-005` 확정. +- **완료 증거:** RED/GREEN 체크박스 전체 완료, focused `CreatorChannelHomeViewModelTest` 통과. +- **범위 밖:** Fragment/Activity navigation wiring은 `P1-T2`에서 처리한다. + +**Files:** + +- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModelTest.kt` +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeViewModel.kt` + +**Interfaces:** + +- Consumes: 기존 `CreatorChannelHomeUiState.Content`, `CreatorChannelRepository.blockUser(userId, token)`, `CreatorChannelEvent`. +- Produces: `blockSuccessLiveData: LiveData>`. + +- [x] **RED:** 기존 `차단 성공은 block API를 호출하고 차단 완료 토스트를 emit한다` test에 `blockSuccessLiveData` event 검증을 추가한다. 실패 케이스 test에는 `ApiResponse.success == false`일 때 `blockSuccessLiveData.value == null`을 검증한다. +- [x] **RED 확인:** 아래 focused test를 실행해 `blockSuccessLiveData` 미정의 또는 event 미발행 assertion 실패를 확인한다. + +```bash +./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeViewModelTest" --no-parallel --no-daemon -Pkotlin.incremental=false +``` + +- [x] **GREEN:** `CreatorChannelHomeViewModel`에 `MutableLiveData>` 기반 `blockSuccessLiveData`를 추가하고, `blockUser()` 성공 분기에서 기존 `creator_channel_block_success` toast emit 후 `CreatorChannelEvent(Unit)`을 emit한다. 실패·예외 분기에서는 emit하지 않는다. +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다. +- [x] **REFACTOR:** event naming과 observe 순서를 기존 `toastLiveData`, `chatRoomIdLiveData` 스타일과 맞추고 focused test를 재실행해 결과를 이 Task 아래 검증 기록에 남긴다. + +**검증 기록:** + +- 2026-08-14 RED: `CreatorChannelHomeViewModelTest` 실행 시 `blockSuccessLiveData` 미정의로 `compileDebugUnitTestKotlin`이 실패했다. +- 2026-08-14 GREEN: `blockSuccessLiveData`와 성공 분기 emit을 최소 추가한 뒤 같은 focused test가 `BUILD SUCCESSFUL`로 통과했다. 성공 event는 한 번만 consume되고, API 실패·예외에서는 emit되지 않음을 확인했다. + +#### Task 1.2 Fragment/Activity 이탈 연결 + +**Goal 실행 `P1-T2`:** 차단 성공 event를 `CreatorChannelActivity`까지 전달하고, 이전 화면 또는 `MainV2Activity` fallback으로 이동한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** RED/GREEN 체크박스 전체 완료, focused `CreatorChannelActivitySourceTest` 통과. +- **범위 밖:** 차단 API 호출 방식, 기존 modal UI, `MainV2Activity` 내부 동작 변경. + +**Files:** + +- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivitySourceTest.kt` +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelHomeFragment.kt` +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelActivity.kt` + +**Interfaces:** + +- Consumes: `CreatorChannelHomeViewModel.blockSuccessLiveData`. +- Produces: `CreatorChannelHomeFragment.Host.onCreatorChannelBlockSucceeded()`. + +- [x] **RED:** `CreatorChannelActivitySourceTest`에 source contract test를 추가한다. 검증 조건은 `CreatorChannelHomeFragment`가 `blockSuccessLiveData.observe`와 `event.consume()?.let { host.onCreatorChannelBlockSucceeded() }` 흐름을 갖는지, `CreatorChannelActivity`가 `onCreatorChannelBlockSucceeded()`를 구현하는지, 구현 내부에서 `isTaskRoot`, `MainV2Activity::class.java`, `finish()`를 사용하는지 확인한다. 또한 `showUserBlockDialog()` 구간에는 `homeActionDelegate?.blockUser()`만 있고 직접 `finish()`나 `MainV2Activity` 이동이 없음을 확인한다. +- [x] **RED 확인:** 아래 focused test를 실행해 observer, host callback, fallback 구현 부재로 인한 assertion 실패를 확인한다. + +```bash +./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" --no-parallel --no-daemon -Pkotlin.incremental=false +``` + +- [x] **GREEN:** `CreatorChannelHomeFragment.observeViewModel()`에서 `blockSuccessLiveData`를 observe하고 consume 후 host callback을 호출한다. `CreatorChannelHomeFragment.Host`에 `onCreatorChannelBlockSucceeded()`를 추가한다. `CreatorChannelActivity`는 `MainV2Activity` import를 추가하고, `onCreatorChannelBlockSucceeded()`에서 `isTaskRoot`이면 `Intent(this, MainV2Activity::class.java)`를 `startActivity`한 뒤 `finish()`를 호출한다. `isTaskRoot`가 아니면 `finish()`만 호출한다. +- [x] **GREEN 확인:** 같은 focused source test를 다시 실행해 성공을 확인한다. +- [x] **REFACTOR:** 추가한 source contract가 과도하게 넓지 않은지 확인하고, 기존 `CreatorChannelActivitySourceTest.sourceSection()` helper를 재사용한다. focused test를 재실행해 결과를 이 Task 아래 검증 기록에 남긴다. + +**검증 기록:** + +- 2026-08-14 RED: `CreatorChannelActivitySourceTest` 100개 중 신규 차단 이탈 contract만 observer 부재 assertion으로 실패했다. +- 2026-08-14 GREEN: Fragment observer/Host callback과 Activity `isTaskRoot` 분기를 최소 추가한 뒤 같은 source test 100개가 모두 통과했다. + +### 완료 조건 + +- [x] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 모두 충족됐다. +- [x] `CCB-001~005`가 구현 또는 명시 검증으로 추적된다. +- [x] API 성공 전 navigation이 발생하지 않는다는 source contract가 남아 있다. +- [x] task root fallback 대상이 `MainV2Activity`임을 source contract가 검증한다. + +### 검증 방법 + +#### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 차단 성공 후 채널 이탈과 홈 fallback 동작을 최종 판정한다. + +- **시작 조건:** `P1-T1`, `P1-T2` 완료. +- **완료 증거:** 아래 자동 검증 통과와 Progress 기록. +- **범위 밖:** Gate 통과를 위한 test 삭제·완화, unrelated refactor, instrumentation test 추가. + +```bash +./gradlew :app:testDebugUnitTest \ + --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelHomeViewModelTest" \ + --tests "kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivitySourceTest" \ + --no-parallel --no-daemon -Pkotlin.incremental=false + +./gradlew :app:compileDebugKotlin --no-parallel --no-daemon -Pkotlin.incremental=false + +./gradlew :app:ktlintCheck --no-parallel --no-daemon + +git diff --check +``` + +**Expected:** focused tests와 compile은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없음. `ktlintCheck`가 기존 전역 위반으로 실패하면 이번 변경 파일 포함 여부를 확인해 Progress에 구분 기록한다. + +- [x] ViewModel과 Activity source focused test 통합 실행이 통과했다. +- [x] `compileDebugKotlin`과 `ktlintCheck`가 통과했다. +- [x] `git diff --check`와 `git diff --cached --check` 결과 출력이 없었다. +- [x] 전체 local unit suite의 범위 밖 실패를 분리하고 아래 Progress에 기록했다. + +--- + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | PRD 확정 | 아니요 | ViewModel event contract 재검토 | +| 2 | `P1-T2` | `P1-T1` | 아니요 | Host callback 또는 Activity fallback contract 재검토 | +| 3 | `P1-GATE` | `P1-T1`, `P1-T2` | 아니요 | 실패 소유 Task의 회귀 수정 Task 추가 | + +```text +P1-T1 -> P1-T2 -> P1-GATE +``` + +## 변경 금지 항목 + +- 차단 API request/response 계약을 변경하지 않는다. +- `MainV2Activity` 내부 구현을 변경하지 않는다. +- API 성공 전 `CreatorChannelActivity`를 종료하거나 홈으로 이동하지 않는다. +- 기존 modal UI와 string을 변경하지 않는다. +- `app/src/androidTest`, 기기·에뮬레이터 UI 조작, 스크린샷 QA를 추가하지 않는다. +- 관련 없는 creator channel tab, community, fan talk, live routing 코드를 리팩터링하지 않는다. + +## 의사결정 및 중단 규칙 + +- 실패 event 또는 예외 event에서도 화면 이탈이 필요하다는 요구가 생기면 PRD `Decision Log`와 이 문서 범위를 먼저 갱신한다. +- `MainV2Activity` fallback에 flag 정책이 필요하다는 증거가 나오면 source/test로 먼저 실패를 고정하고, 기존 홈 진입점의 intent flag 관례를 확인한 뒤 최소 변경한다. +- 검증 중 기존 전역 test/ktlint 실패가 나오면 이번 변경 파일 포함 여부를 분리해 기록하고, 범위 밖 실패는 임의 수정하지 않는다. + +## Progress + +기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다. + +### 문서 작성 — 2026-08-14 + +- 상태: 완료 +- 무엇을: PRD와 구현 계획/TASK 문서를 생성했다. production code와 test code는 변경하지 않았다. +- 왜: 사용자가 “문서만 생성 또는 수정하고 아직 구현은 하지 않는다”고 지시했다. +- 어떻게: + - `docs/sample/sample-prd.md` — 샘플 구조 확인. + - `docs/sample/sample-plan-task.md` — TDD Task 구조 확인. + - `docs/agent-guides/work-plan-docs.md` — 신규 문서 배치와 검증 기록 규칙 확인. + - `rg -n "차단|block|CreatorChannel|크리에이터 채널|MainV2Activity|뒤로가기" "docs"` — 기존 관련 문서 범위 확인. +- 남은 항목: `P1-T1`, `P1-T2`, `P1-GATE` 구현과 검증. +- 다음 행동: 사용자가 구현 진행을 승인하면 `P1-T1` RED 단계부터 시작한다. + +### P1-GATE 1차 실행 — 2026-08-14 + +- 상태: 완료 +- 무엇을: `P1-T1`, `P1-T2`를 RED/GREEN으로 구현하고 focused test, compile, ktlint, diff 검증을 완료했다. +- 왜: 차단 성공 후 현재 크리에이터 채널에서 이탈하고 task root에서는 `MainV2Activity`로 fallback해야 한다. +- 어떻게: + - ViewModel RED — `blockSuccessLiveData` 미정의 compile failure 확인. + - ViewModel GREEN — focused `CreatorChannelHomeViewModelTest` `BUILD SUCCESSFUL`. + - Activity RED — source test 100개 중 신규 차단 이탈 contract 1건 실패 확인. + - Activity GREEN — source test 100개 전체 통과. + - focused 통합 test — `BUILD SUCCESSFUL`. + - `./gradlew :app:compileDebugKotlin --no-parallel --no-daemon -Pkotlin.incremental=false` — `BUILD SUCCESSFUL`. + - `./gradlew :app:ktlintCheck --no-parallel --no-daemon` — 기존 `disabled_rules` deprecation warning만 출력하고 `BUILD SUCCESSFUL`. + - `git diff --check`, `git diff --cached --check` — 출력 없음. + - 전체 `testDebugUnitTest` — 1,221개 중 범위 밖 `CreatorChannelFanTalkFragmentLayoutTest` 1건 실패. 일본어 리소스는 팬레터 문구이나 테스트는 기존 응원 문구를 기대하며, 관련 파일은 현재 diff에 없다. +- 남은 항목: Phase 1 리뷰 blocker 해소 확인. +- 다음 행동: 문서 완료 상태를 반영한 diff를 재리뷰한다. + +### P1-GATE 재리뷰 — 2026-08-14 + +- 상태: 완료 +- 무엇을: PRD와 계획 문서의 체크박스·검증 기록을 현재 구현 증거와 재대조했다. +- 왜: 초기 리뷰의 유일한 blocker였던 문서 완료 상태 누락을 해소하기 위해서다. +- 어떻게: + - Oracle 재리뷰 — 이전 blocker 해소 및 코드 신규 blocker 없음, 최종 `APPROVE`. + - `rg -n -- "- \\[ \\]" prd.md plan-task.md` — 실제 미완료 체크박스 없음. 템플릿 안내문에 포함된 리터럴 표기만 검색됨. + - `git diff --check`, `git diff --cached --check` — 출력 없음. +- 남은 항목: 없음. +- 다음 행동: 완료 결과를 보고한다. + +### 범위 밖 테스트 정정 — 2026-08-14 + +- 상태: 완료 +- 무엇을: `CreatorChannelFanTalkFragmentLayoutTest`의 일본어 empty message 기대값을 현재 `values-ja/strings.xml`과 맞췄다. +- 왜: 전체 unit suite에서 남아 있던 유일한 실패가 테스트 기대값의 오래된 “응원” 문구 때문이었고, 사용자가 테스트 수정 지시를 했다. +- 어떻게: + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.creator.channel.fantalk.CreatorChannelFanTalkFragmentLayoutTest" --no-parallel --no-daemon -Pkotlin.incremental=false` — `BUILD SUCCESSFUL`. + - `./gradlew :app:testDebugUnitTest --no-parallel --no-daemon -Pkotlin.incremental=false` — `BUILD SUCCESSFUL`. +- 남은 항목: 없음. +- 다음 행동: 완료 결과를 보고한다. + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| `2026-08-14` | `DEC-001` | 확정 | 차단 성공 후 현재 크리에이터 채널에서 이탈한다. | 사용자 요청과 후속 확인 | `P1-T1`, `P1-T2`, PRD `CCB-001~005` | +| `2026-08-14` | `DEC-002` | 확정 | 이전 화면이 없으면 홈인 `MainV2Activity`로 fallback 이동한다. | 사용자 명시 | `P1-T2`, PRD `CCB-004` | +| `2026-08-14` | `DEC-003` | 확정 | 이번 턴에서는 문서만 생성하고 구현하지 않는다. | 사용자 명시 | 이 문서 `Progress` | + +## 발견된 문제 + +| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | +|---|---|---|---|---|---| +| `ISSUE-001` | Medium | 확정 | 현재 차단 성공 흐름은 성공 토스트만 emit하고 채널 화면에 머문다. | `P1-T1`, `P1-T2` | 구현 승인 후 RED test부터 수정한다. | + +## Verification Log + +- 2026-08-14: 문서 작성만 수행했다. 구현 파일과 테스트 파일은 수정하지 않았으므로 Gradle 테스트·compile은 실행하지 않았다. 문서 샘플과 작업 문서 규칙, 기존 관련 문서 검색 결과를 기준으로 새 작업 디렉터리를 생성했다. +- 2026-08-14: `P1-T1`, `P1-T2`를 RED/GREEN으로 완료했다. focused test 통합 실행, `compileDebugKotlin`, `ktlintCheck`, diff check는 통과했다. 전체 unit suite는 현재 diff 밖 일본어 팬레터 문구와 기존 테스트 기대값 불일치 1건으로 실패했으며 해당 실패를 범위 밖 blocker로 분리 기록했다. +- 2026-08-14: 문서 완료 상태를 갱신한 뒤 Oracle 재리뷰에서 최종 `APPROVE`를 받았다. PRD와 계획 문서에 실제 미완료 체크박스가 없음을 확인했다. +- 2026-08-14: 후속 테스트 정정으로 Fan Talk 일본어 empty message 기대값을 현재 리소스와 맞췄고, focused Fan Talk test와 전체 local unit suite가 통과했다. diff --git a/docs/20260814_크리에이터_채널_차단_후_이탈/prd.md b/docs/20260814_크리에이터_채널_차단_후_이탈/prd.md new file mode 100644 index 00000000..d0c7c2ee --- /dev/null +++ b/docs/20260814_크리에이터_채널_차단_후_이탈/prd.md @@ -0,0 +1,150 @@ +# PRD: 크리에이터 채널 차단 후 이탈 + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 완료 | +| 작성일 | `2026-08-14` | +| 최종 수정일 | `2026-08-14` | +| 대상 제품 | 크리에이터 채널 사용자 차단 흐름 | +| 작성자·결정권자 | 사용자, Sisyphus | +| 관련 API Contract | 기존 `CreatorChannelRepository.blockUser(userId, token)` 계약 사용 | +| 관련 구현 계획 | `docs/20260814_크리에이터_채널_차단_후_이탈/plan-task.md` | +| 관련 review | 없음 | + +## 1. Overview + +사용자가 크리에이터 채널에서 현재 보고 있는 크리에이터를 차단하면, 차단 성공 후 더 이상 해당 크리에이터 채널 화면에 머물지 않는다. 일반 진입에서는 현재 채널 화면을 종료해 이전 화면으로 돌아가고, 푸시 또는 딥링크처럼 이전 화면이 없는 진입에서는 홈 화면인 `MainV2Activity`로 이동한다. + +## 2. Problem Statement + +- 현재 `CreatorChannelHomeViewModel.blockUser()`는 차단 성공 시 성공 토스트만 emit한다. +- 그 결과 사용자가 차단한 크리에이터 채널 화면에 그대로 남아, 차단 이후에도 차단 대상의 채널 콘텐츠를 보는 부자연스러운 상태가 된다. +- 딥링크·푸시 진입처럼 이전 화면이 없는 경우 단순 종료만 하면 앱 내 안전한 목적지가 부족할 수 있다. + +문제를 해결했다는 판단은 차단 API 성공 후 현재 `CreatorChannelActivity`가 종료되고, task root 상태에서는 `MainV2Activity`가 fallback으로 시작되는 것으로 한다. + +## 3. Goals + +### 3.1 제품 목표 + +- 사용자는 차단 성공 후 현재 크리에이터 채널에서 즉시 빠져나간다. +- 일반 진입에서는 Android 뒤로가기와 같은 의미로 이전 화면으로 돌아간다. +- 이전 화면이 없는 푸시·딥링크 진입에서는 홈인 `MainV2Activity`로 이동한다. + +### 3.2 UX 목표 + +- 기존 차단 확인 모달과 성공 토스트는 유지한다. +- API 성공 전에는 화면을 이탈하지 않는다. +- API 실패 시에는 현재처럼 오류 토스트만 표시하고 화면에 머문다. + +## 4. Non-Goals + +- 차단 API 계약, 차단 대상 식별자, 서버 응답 형식을 변경하지 않는다. +- `MainV2Activity` 자체의 홈 탭 구조나 초기 탭 정책을 변경하지 않는다. +- 차단 목록, 차단 해제, 차단된 크리에이터 콘텐츠 필터링 정책은 이번 범위에 포함하지 않는다. +- `app/src/androidTest`, 기기·에뮬레이터 조작, 스크린샷 기반 검증은 이번 계획에 포함하지 않는다. + +## 5. Target Users and Permissions + +| 사용자 | 목표 | 주요 작업 | 사용 환경 | +|---|---|---|---| +| 로그인 사용자 | 원하지 않는 크리에이터를 차단하고 해당 채널에서 벗어난다 | 크리에이터 채널 더보기 > 차단 확인 | Android 앱 | + +- 인증 주체: 기존 `SharedPreferenceManager.token` 기반 로그인 사용자. +- 허용 조건: 기존 크리에이터 채널 차단 액션을 수행할 수 있는 상태. +- 거부 조건: API 실패, 네트워크 오류, `CreatorChannelHomeUiState.Content` 미준비 상태. + +## 6. 핵심 사용자 흐름 + +1. 사용자가 `CreatorChannelActivity`에서 더보기 메뉴를 연다. +2. 사용자가 차단을 선택하고 기존 확인 모달에서 확인한다. +3. 앱은 기존 `homeActionDelegate?.blockUser()` 경로로 차단 API를 호출한다. +4. API 성공 시 기존 차단 성공 토스트를 표시하고 현재 크리에이터 채널에서 이탈한다. +5. `CreatorChannelActivity.isTaskRoot == false`이면 `finish()`로 이전 화면에 돌아간다. +6. `CreatorChannelActivity.isTaskRoot == true`이면 `MainV2Activity`를 시작한 뒤 현재 `CreatorChannelActivity`를 종료한다. +7. API 실패 또는 예외 발생 시 기존 unknown error toast를 표시하고 현재 화면에 머문다. + +## 7. 정보 구조와 라우팅 + +```text +CreatorChannelActivity + block success, isTaskRoot=false -> finish() -> previous Activity + block success, isTaskRoot=true -> MainV2Activity -> finish() +``` + +- 홈 fallback 대상은 `kr.co.vividnext.sodalive.v2.main.MainV2Activity`다. +- fallback은 차단 성공 후 현재 Activity가 task root일 때만 수행한다. + +## 8. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 | +|---|---|---|---|---| +| `CCB-001` | 확정 | 차단 성공은 화면 이탈 이벤트를 발생시킨다. | `CreatorChannelHomeViewModel.blockUser()`가 `ApiResponse.success == true`일 때만 one-shot 차단 성공 이벤트를 emit한다. | `P1-T1` | +| `CCB-002` | 확정 | API 성공 전에는 화면 이탈을 시작하지 않는다. | `showUserBlockDialog()`의 확인 콜백은 기존처럼 `homeActionDelegate?.blockUser()`만 호출하며 직접 `finish()` 또는 홈 이동을 하지 않는다. | `P1-T2` | +| `CCB-003` | 확정 | 일반 진입에서는 이전 화면으로 돌아간다. | `CreatorChannelActivity.isTaskRoot == false`이면 `finish()`만 호출한다. | `P1-T2` | +| `CCB-004` | 확정 | 이전 화면이 없는 진입에서는 홈으로 이동한다. | `CreatorChannelActivity.isTaskRoot == true`이면 `MainV2Activity` intent를 시작하고 현재 Activity를 종료한다. | `P1-T2` | +| `CCB-005` | 확정 | 기존 성공/실패 feedback은 유지한다. | 성공 시 `creator_channel_block_success`, 실패·예외 시 `common_error_unknown` toast 흐름을 유지한다. | `P1-T1` | + +## 9. UI/UX Expectations + +- 신규 UI는 없다. +- 기존 차단 확인 모달 문구와 버튼은 변경하지 않는다. +- 성공 후 이동은 추가 확인 없이 즉시 수행한다. + +## 10. API 계약 + +- 신규 API는 없다. +- 기존 호출 경로: `CreatorChannelRepository.blockUser(userId, token)`. +- 성공 판단은 기존 `ApiResponse.success`를 따른다. + +## 11. 보안과 데이터 취급 + +- token, URL, 사용자 식별자 등 민감 정보는 로그·Toast·문서 fixture에 추가 노출하지 않는다. +- 실패 시 서버 오류 상세를 사용자에게 직접 노출하지 않고 기존 unknown error toast를 유지한다. + +## 12. 성공 기준 + +### 12.1 기능 수용 기준 + +- [x] 차단 성공 시 `CreatorChannelHomeViewModel`이 성공 토스트와 차단 성공 이벤트를 emit한다. (`CCB-001`, `CCB-005`) +- [x] 차단 실패 또는 예외 시 차단 성공 이벤트가 emit되지 않는다. (`CCB-001`, `CCB-005`) +- [x] `CreatorChannelHomeFragment`가 차단 성공 이벤트를 consume한 뒤 `Host`에 전달한다. (`CCB-001`) +- [x] `CreatorChannelActivity`가 `isTaskRoot`에 따라 이전 화면 복귀 또는 `MainV2Activity` fallback을 수행한다. (`CCB-003`, `CCB-004`) +- [x] 확인 모달의 confirm callback은 API 성공 전 직접 이동하지 않는다. (`CCB-002`) + +### 12.2 검증 기준 + +- [x] `CreatorChannelHomeViewModelTest` focused unit test가 통과한다. +- [x] `CreatorChannelActivitySourceTest` focused source contract test가 통과한다. +- [x] 변경 파일 compile, ktlint 범위 검증이 통과하거나 기존 전역 blocker를 문서에 기록한다. + +## 13. Open Questions + +| ID | 상태 | 결정 필요 사항 | 현재 결정 | 결정 주체 | 영향 Goal | +|---|---|---|---|---|---| +| `OQ-001` | 확정 | 뒤로가기 의미 | 현재 `CreatorChannelActivity`에서 빠져나가 이전 화면으로 돌아가는 의미 | 사용자 | `P1-T2` | +| `OQ-002` | 확정 | 이전 화면이 없는 경우 fallback | `MainV2Activity`로 이동 | 사용자 | `P1-T2` | + +## 14. 요구사항 추적표 + +| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 | +|---|---|---:|---|---|---| +| `CCB-001`, `CCB-005` | 기존 `blockUser` | 1 | `P1-T1` | `CreatorChannelHomeViewModelTest` | 없음 | +| `CCB-002~004` | 없음 | 1 | `P1-T2` | `CreatorChannelActivitySourceTest` | 없음 | + +## 15. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal | +|---|---|---|---|---|---| +| `2026-08-14` | `DEC-001` | 확정 | 차단 성공 후 현재 크리에이터 채널에서 이탈한다. | 사용자 요청 | `CCB-001~005`, `P1-T1`, `P1-T2` | +| `2026-08-14` | `DEC-002` | 확정 | 일반 진입은 `finish()`로 이전 화면에 돌아가고, task root는 `MainV2Activity`로 fallback한다. | 사용자 확인 | `CCB-003`, `CCB-004`, `P1-T2` | + +## Verification Log + +- 2026-08-14: 현재 구현 맥락을 확인했다. `CreatorChannelHomeViewModel.blockUser()`는 성공 토스트만 emit하고, `CreatorChannelActivity.showUserBlockDialog()`는 확인 시 `homeActionDelegate?.blockUser()`를 호출한다. 이번 문서 작성 요청에 따라 production code와 test code는 변경하지 않았다. +- 2026-08-14: 승인 후 `CreatorChannelHomeViewModel`의 차단 성공 one-shot event, `CreatorChannelHomeFragment.Host` 전달, `CreatorChannelActivity`의 `isTaskRoot` 기반 `MainV2Activity` fallback을 구현했다. ViewModel/source contract RED를 각각 확인한 뒤 최소 구현으로 GREEN 전환했다. +- 2026-08-14: focused test 통합 실행, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`가 성공했다. 전체 `testDebugUnitTest`는 1,221개 중 이번 변경과 무관한 `CreatorChannelFanTalkFragmentLayoutTest` 일본어 문구 기대값 불일치 1건으로 실패했으며, 실패 test와 locale string 파일은 현재 diff에 포함되지 않는다. +- 2026-08-14: Phase 1 재리뷰에서 이전 문서 정합성 blocker 해소를 확인하고 최종 `APPROVE` 판정을 받았다. +- 2026-08-14: 후속으로 `CreatorChannelFanTalkFragmentLayoutTest`의 일본어 empty message 기대값을 현재 `values-ja/strings.xml`의 팬레터 문구와 맞췄다. focused Fan Talk layout test와 전체 `testDebugUnitTest`가 `BUILD SUCCESSFUL`로 통과했다. diff --git a/docs/20260814_크리에이터_채널_차단_후_이탈/reviews/phase1-creator-block-exit-review.md b/docs/20260814_크리에이터_채널_차단_후_이탈/reviews/phase1-creator-block-exit-review.md new file mode 100644 index 00000000..edfc3629 --- /dev/null +++ b/docs/20260814_크리에이터_채널_차단_후_이탈/reviews/phase1-creator-block-exit-review.md @@ -0,0 +1,87 @@ +# Phase 1 크리에이터 차단 후 이탈 코드 리뷰 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-T2`, `P1-GATE` | +| 기준 commit 또는 working tree | 2026-08-14 working tree | +| 리뷰 일자 | `2026-08-14` | +| 리뷰어 | Oracle, Sisyphus | +| 기준 문서 | `docs/20260814_크리에이터_채널_차단_후_이탈/prd.md`, `plan-task.md` | +| 리뷰 상태 | 수정 검증 완료 | + +## 2. 리뷰 목적과 범위 + +- 차단 성공 후에만 현재 채널에서 이탈하는지 확인한다. +- 일반 진입은 이전 화면으로 돌아가고 task root는 `MainV2Activity`로 fallback하는지 확인한다. +- ViewModel event, Fragment Host 전달, Activity navigation과 local test·문서 완료 증거를 검토한다. +- `app/src/androidTest`, 기기·에뮬레이터 조작, 범위 밖 i18n 실패 수정은 제외한다. + +## 3. 검토한 근거 + +- 요구사항: `CCB-001~005`. +- 계획: `P1-T1`, `P1-T2`, `P1-GATE`. +- 코드: `CreatorChannelHomeViewModel.kt`, `CreatorChannelHomeFragment.kt`, `CreatorChannelActivity.kt`. +- 테스트: `CreatorChannelHomeViewModelTest`, `CreatorChannelActivitySourceTest`. + +## 4. 실행한 검증 + +| 명령 | 결과 | 핵심 증거 | +|---|---|---| +| ViewModel focused RED/GREEN | 성공 | 미정의 compile failure 후 `BUILD SUCCESSFUL` | +| Activity source RED/GREEN | 성공 | 신규 contract 1건 실패 후 source test 100개 통과 | +| focused test 통합 실행 | 성공 | `BUILD SUCCESSFUL` | +| `compileDebugKotlin` | 성공 | `BUILD SUCCESSFUL` | +| `ktlintCheck` | 성공 | 기존 deprecation warning 외 오류 없음 | +| `git diff --check`, `git diff --cached --check` | 성공 | 출력 없음 | +| 전체 `testDebugUnitTest` | 범위 밖 실패 | 1,221개 중 기존 Fan Talk 일본어 문구 불일치 1건 | + +## 5. 발견 사항 + +### REV-P1-001 — 계획 문서 완료 상태와 검증 기록 누락 + +- **심각도:** Blocker +- **상태:** 수정 완료 +- **관련 요구사항:** Phase 완료 조건 +- **소유 Task:** `P1-GATE` + +초기 리뷰에서 구현과 테스트는 `CCB-001~005`를 충족했지만, `plan-task.md` 체크박스와 검증 기록이 미구현 상태로 남아 완료 판정을 막았다. 실제 RED/GREEN/focused/compile/ktlint/diff/전체 suite 결과를 기존 기록을 삭제하지 않고 누적하고, 증거가 충족된 체크박스를 완료 처리했다. + +## 6. plan·goal 전환 + +별도 코드 회귀 수정 Task는 없다. `REV-P1-001`은 코드 결함이 아닌 현재 Phase 문서 정합성 문제로, 기존 `P1-GATE` 기록 갱신으로 처리했다. + +## 7. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | `CCB-001~005` 코드·test 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P1-001` 수정 완료 | +| 확정 항목 plan 반영 | 충족 | `P1-GATE` 체크박스와 Progress 갱신 | +| 검증 명령과 결과 기록 | 충족 | Task별 검증 기록과 Verification Log | + +**최종 결론:** 수정 검증 완료 + +**남은 항목:** 없음. + +## 8. 수정 후 검증 기록 + +### 1차 수정 검증 — 2026-08-14 + +- 무엇을: `REV-P1-001`의 문서 체크박스와 RED/GREEN/Gate 검증 기록을 현재 증거에 맞게 갱신했다. +- 왜: 구현과 테스트가 완료됐지만 계획 문서가 미구현 상태로 남아 Phase 완료 판정을 막았기 때문이다. +- 어떻게: + - Oracle 재리뷰 — 이전 blocker 해소, 코드 신규 blocker 없음, 최종 `APPROVE`. + - PRD/plan 미완료 체크박스 검색 — 실제 미완료 항목 없음. + - `git diff --check`, `git diff --cached --check` — 출력 없음. +- 남은 항목: 없음. + +### 2차 수정 검증 — 2026-08-14 + +- 무엇을: 이전 전체 suite 실패 원인이던 Fan Talk 일본어 empty message 테스트 기대값을 현재 리소스와 맞췄다. +- 왜: 테스트가 오래된 “응원” 문구를 기대했고, 현재 일본어 리소스는 “팬레터” 문구가 맞기 때문이다. +- 어떻게: + - focused `CreatorChannelFanTalkFragmentLayoutTest` — `BUILD SUCCESSFUL`. + - 전체 `:app:testDebugUnitTest` — `BUILD SUCCESSFUL`. +- 남은 항목: 없음.