fix(home): 라이브 전체 버튼을 오른쪽에 고정한다

This commit is contained in:
2026-08-04 11:45:29 +09:00
parent d20d6aa971
commit 3c21a764ae
8 changed files with 236 additions and 98 deletions

View File

@@ -215,7 +215,7 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
setOnBannerClick { onBannerClick(it) } setOnBannerClick { onBannerClick(it) }
} }
liveAdapter.setOnLiveClick { onLiveClick(it) } liveAdapter.setOnLiveClick { onLiveClick(it) }
liveAdapter.setOnMoreClick { openHomeOnAirLive() } binding.tvHomeLiveAll.setOnClickListener { openHomeOnAirLive() }
binding.rvHomeLives.adapter = liveAdapter binding.rvHomeLives.adapter = liveAdapter
binding.rvHomeRecentActivityCreators.adapter = recentActivityCreatorAdapter binding.rvHomeRecentActivityCreators.adapter = recentActivityCreatorAdapter
binding.rvHomeRecentDebutCreators.adapter = recentDebutCreatorAdapter binding.rvHomeRecentDebutCreators.adapter = recentDebutCreatorAdapter

View File

@@ -1,10 +1,7 @@
package kr.co.vividnext.sodalive.v2.main.home.ui package kr.co.vividnext.sodalive.v2.main.home.ui
import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.extensions.loadUrl import kr.co.vividnext.sodalive.extensions.loadUrl
@@ -12,15 +9,12 @@ import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationLiveUiModel
import kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailItem import kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailItem
import kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailSimpleView import kr.co.vividnext.sodalive.v2.widget.livethumbnail.LiveThumbnailSimpleView
class HomeLiveAdapter : RecyclerView.Adapter<HomeLiveAdapter.LiveViewHolder>() { class HomeLiveAdapter : RecyclerView.Adapter<HomeLiveAdapter.LiveItemViewHolder>() {
private var items: List<HomeRecommendationLiveUiModel> = emptyList() private var items: List<HomeRecommendationLiveUiModel> = emptyList()
private var showAllItem: Boolean = false
private var onClick: ((HomeRecommendationLiveUiModel) -> Unit)? = null private var onClick: ((HomeRecommendationLiveUiModel) -> Unit)? = null
private var onMoreClick: (() -> Unit)? = null
fun submitItems(items: List<HomeRecommendationLiveUiModel>) { fun submitItems(items: List<HomeRecommendationLiveUiModel>) {
this.items = items.take(MAX_VISIBLE_LIVE_COUNT) this.items = items.take(MAX_VISIBLE_LIVE_COUNT)
showAllItem = items.isNotEmpty()
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -28,32 +22,15 @@ class HomeLiveAdapter : RecyclerView.Adapter<HomeLiveAdapter.LiveViewHolder>() {
onClick = listener onClick = listener
} }
fun setOnMoreClick(listener: (() -> Unit)?) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LiveItemViewHolder {
onMoreClick = listener
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LiveViewHolder {
return if (viewType == VIEW_TYPE_MORE) {
MoreViewHolder(createMoreView(parent))
} else {
val view = LayoutInflater.from(parent.context).inflate(R.layout.view_live_thumbnail_simple, parent, false) val view = LayoutInflater.from(parent.context).inflate(R.layout.view_live_thumbnail_simple, parent, false)
view.layoutParams = liveItemLayoutParams(parent) view.layoutParams = liveItemLayoutParams(parent)
LiveItemViewHolder(view as LiveThumbnailSimpleView) return LiveItemViewHolder(view as LiveThumbnailSimpleView)
}
} }
override fun onBindViewHolder(holder: LiveViewHolder, position: Int) { override fun onBindViewHolder(holder: LiveItemViewHolder, position: Int) = holder.bind(items[position], onClick)
when (holder) {
is LiveItemViewHolder -> holder.bind(items[position], onClick)
is MoreViewHolder -> holder.bind(onMoreClick)
}
}
override fun getItemCount(): Int = items.size + if (showAllItem) 1 else 0 override fun getItemCount(): Int = items.size
override fun getItemViewType(position: Int): Int {
return if (showAllItem && position == itemCount - 1) VIEW_TYPE_MORE else VIEW_TYPE_LIVE
}
private fun liveItemLayoutParams(parent: ViewGroup): RecyclerView.LayoutParams { private fun liveItemLayoutParams(parent: ViewGroup): RecyclerView.LayoutParams {
return RecyclerView.LayoutParams( return RecyclerView.LayoutParams(
@@ -62,25 +39,9 @@ class HomeLiveAdapter : RecyclerView.Adapter<HomeLiveAdapter.LiveViewHolder>() {
).apply { marginEnd = parent.resources.getDimensionPixelSize(R.dimen.spacing_14) } ).apply { marginEnd = parent.resources.getDimensionPixelSize(R.dimen.spacing_14) }
} }
private fun createMoreView(parent: ViewGroup): TextView {
return TextView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(
parent.resources.getDimensionPixelSize(R.dimen.home_live_more_width),
parent.resources.getDimensionPixelSize(R.dimen.home_live_row_height)
)
gravity = Gravity.CENTER
setBackgroundResource(R.color.black)
setText(R.string.screen_home_theme_all)
setTextAppearance(R.style.Typography_Body5)
setTextColor(parent.context.getColor(R.color.soda_400))
}
}
sealed class LiveViewHolder(view: View) : RecyclerView.ViewHolder(view)
class LiveItemViewHolder( class LiveItemViewHolder(
private val view: LiveThumbnailSimpleView private val view: LiveThumbnailSimpleView
) : LiveViewHolder(view) { ) : RecyclerView.ViewHolder(view) {
fun bind( fun bind(
item: HomeRecommendationLiveUiModel, item: HomeRecommendationLiveUiModel,
onClick: ((HomeRecommendationLiveUiModel) -> Unit)? onClick: ((HomeRecommendationLiveUiModel) -> Unit)?
@@ -99,17 +60,7 @@ class HomeLiveAdapter : RecyclerView.Adapter<HomeLiveAdapter.LiveViewHolder>() {
} }
} }
class MoreViewHolder(
private val view: TextView
) : LiveViewHolder(view) {
fun bind(onClick: (() -> Unit)?) {
view.setOnClickListener(if (onClick == null) null else View.OnClickListener { onClick.invoke() })
}
}
companion object { companion object {
private const val MAX_VISIBLE_LIVE_COUNT = 20 private const val MAX_VISIBLE_LIVE_COUNT = 20
private const val VIEW_TYPE_LIVE = 0
private const val VIEW_TYPE_MORE = 1
} }
} }

View File

@@ -51,17 +51,30 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_12" android:layout_marginTop="@dimen/spacing_12"
android:orientation="vertical"> android:orientation="horizontal"
android:visibility="gone">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_home_lives" android:id="@+id/rv_home_lives"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:clipToPadding="false" android:clipToPadding="false"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingHorizontal="@dimen/spacing_20" android:paddingStart="@dimen/spacing_20"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/view_live_thumbnail_simple" /> tools:listitem="@layout/view_live_thumbnail_simple" />
<TextView
android:id="@+id/tv_home_live_all"
style="@style/Typography.Body5"
android:layout_width="@dimen/home_live_more_width"
android:layout_height="@dimen/home_live_row_height"
android:layout_marginEnd="@dimen/spacing_20"
android:background="@color/black"
android:gravity="center"
android:text="@string/screen_home_theme_all"
android:textColor="@color/soda_400" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout

View File

@@ -1044,54 +1044,29 @@ class HomeMainFragmentLayoutTest {
} }
@Test @Test
fun `홈 라이브 어댑터는 라이브가 있으면 전체 항목을 추가한다`() { fun `홈 라이브 어댑터는 최대 스무 개 라이브만 표시한다`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val adapter = HomeLiveAdapter() val adapter = HomeLiveAdapter()
adapter.submitItems(emptyList()) adapter.submitItems(emptyList())
assertEquals(0, adapter.itemCount) assertEquals(0, adapter.itemCount)
adapter.submitItems(listOf(liveItem(1L))) adapter.submitItems(listOf(liveItem(1L)))
assertEquals(2, adapter.itemCount) assertEquals(1, adapter.itemCount)
val viewHolder = adapter.onCreateViewHolder(parent, adapter.getItemViewType(1))
assertEquals(
context.getString(R.string.screen_home_theme_all),
(viewHolder.itemView as TextView).text.toString()
)
adapter.submitItems((1L..20L).map(::liveItem)) adapter.submitItems((1L..20L).map(::liveItem))
assertEquals(21, adapter.itemCount) assertEquals(20, adapter.itemCount)
}
@Test
fun `home live adapter appends all item after twenty lives`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val parent = RecyclerView(context)
parent.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val adapter = HomeLiveAdapter()
adapter.submitItems((1L..21L).map(::liveItem)) adapter.submitItems((1L..21L).map(::liveItem))
val viewHolder = adapter.onCreateViewHolder(parent, adapter.getItemViewType(20)) assertEquals(20, adapter.itemCount)
adapter.onBindViewHolder(viewHolder, 20)
val moreText = viewHolder.itemView as TextView
assertEquals(21, adapter.itemCount)
assertEquals(context.getString(R.string.screen_home_theme_all), moreText.text.toString())
assertEquals(58.dpToPx(), moreText.layoutParams.width)
assertEquals(102.dpToPx(), moreText.layoutParams.height)
assertEquals(context.getColor(R.color.soda_400), moreText.currentTextColor)
assertEquals(context.getColor(R.color.black), (moreText.background as ColorDrawable).color)
} }
@Test @Test
fun `home live adapter caps lives before all item`() { fun `홈 라이브 어댑터는 스물두 개 입력도 스무 개로 제한한다`() {
val adapter = HomeLiveAdapter() val adapter = HomeLiveAdapter()
adapter.submitItems((1L..22L).map(::liveItem)) adapter.submitItems((1L..22L).map(::liveItem))
assertEquals(21, adapter.itemCount) assertEquals(20, adapter.itemCount)
} }
@Test @Test

View File

@@ -210,6 +210,17 @@ class HomeMainFragmentLoginGuardSourceTest {
) )
} }
@Test
fun `HomeMainFragment 라이브 전체 버튼은 Fragment에서 공통 라이브 화면으로 이동한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt"
).readText()
val setupSource = source.substringFrom("private fun setUpRecommendationAdapters()")
assertTrue(setupSource.contains("binding.tvHomeLiveAll.setOnClickListener { openHomeOnAirLive() }"))
assertFalse(setupSource.contains("liveAdapter.setOnMoreClick"))
}
@Test @Test
fun `HomeMainFragment loading dialog는 통합 loading state로 제어한다`() { fun `HomeMainFragment loading dialog는 통합 loading state로 제어한다`() {
val source = projectFile( val source = projectFile(

View File

@@ -1353,6 +1353,119 @@ git diff --check
- 2026-07-31: 사용자 인터뷰에서 라이브 0개는 기존처럼 섹션을 숨기고 1개 이상이면 최대 20개 뒤에 `전체` 항목을 항상 표시하는 정책을 확정했다. 기존 20개 상한·`전체` 디자인·클릭 목적지·로그인 가드·API 계약을 유지하고, `HomeLiveAdapter` 내부 상태명과 조건 및 `HomeMainFragmentLayoutTest` 경계 테스트만 변경하는 Phase 18을 작성했다. production·test 구현은 시작하지 않았다. - 2026-07-31: 사용자 인터뷰에서 라이브 0개는 기존처럼 섹션을 숨기고 1개 이상이면 최대 20개 뒤에 `전체` 항목을 항상 표시하는 정책을 확정했다. 기존 20개 상한·`전체` 디자인·클릭 목적지·로그인 가드·API 계약을 유지하고, `HomeLiveAdapter` 내부 상태명과 조건 및 `HomeMainFragmentLayoutTest` 경계 테스트만 변경하는 Phase 18을 작성했다. production·test 구현은 시작하지 않았다.
- 2026-07-31: Phase 18을 TDD로 구현했다. RED는 라이브 1개에서 `itemCount`가 기대 2, 실제 1로 실패했고, GREEN은 `showAllItem = items.isNotEmpty()` 최소 변경 후 focused 74개 테스트 성공으로 확인했다. 전체 1,199 tests와 compile·ktlint·diff 검증도 성공했으며 기존 `MoreViewHolder`·클릭 연결·20개 cap은 변경하지 않았다. - 2026-07-31: Phase 18을 TDD로 구현했다. RED는 라이브 1개에서 `itemCount`가 기대 2, 실제 1로 실패했고, GREEN은 `showAllItem = items.isNotEmpty()` 최소 변경 후 focused 74개 테스트 성공으로 확인했다. 전체 1,199 tests와 compile·ktlint·diff 검증도 성공했으며 기존 `MoreViewHolder`·클릭 연결·20개 cap은 변경하지 않았다.
## Phase 19: 현재 진행 중인 라이브 `전체` 항목 오른쪽 고정
**Phase 결과:** 메인 홈 추천 탭에서 라이브 목록만 가로 스크롤되고, `전체` 항목은 스크롤 위치와 관계없이 오른쪽 끝의 예약된 영역에 고정된다.
**선행 조건:** Phase 18 완료, `prd.md``2026-08-03 변경: 현재 진행 중인 라이브 전체 항목 오른쪽 고정` 승인.
**Phase 완료 조건:** `P19-T1`, `P19-R1`, `P19-GATE` 완료, RED/GREEN 로직 회귀와 resource merge·compile·lint 결과를 Task 검증 기록과 `Verification Log`에 누적.
### 구현 항목
#### Task 19.1: 스크롤 목록과 고정 `전체` 항목 분리
**Goal 실행 `P19-T1`:** `HomeLiveAdapter`는 최대 20개의 라이브 item만 제공하고, `fragment_v2_main_home.xml`의 고정 `전체` 뷰가 기존 로그인 가드 경로를 호출한다.
- **시작 조건:** Phase 19 선행 조건 충족.
- **완료 증거:** RED/GREEN 체크박스 완료, 아래 S1~S3와 인접 라우팅 회귀 자동 검증, focused test·resource merge·compile·ktlint·diff 결과 누적.
- **범위 밖:** `전체` 디자인·문구·클릭 목적지·로그인 가드 변경, 라이브 최대 20개 정책·item 순서·입장 흐름 변경, 추천/전체 라이브 API 변경, overlay·커스텀 LayoutManager·신규 abstraction·dependency 추가, 레거시 홈 수정.
**Files:**
- Modify: `app/src/main/res/layout/fragment_v2_main_home.xml`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomeLiveAdapter.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLayoutTest.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt`
- Modify: `docs/20260601_메인_홈_추천_UI와_API_연동/plan-task.md`
**Interfaces:**
- Consumes: `HomeLiveAdapter.submitItems(items)`, `MAX_VISIBLE_LIVE_COUNT = 20`, `HomeMainFragment.openHomeOnAirLive()`의 기존 `ensureV2Access(AccessRequirement.Login)` 계약.
- Produces: adapter의 `0개 -> 0`, `1~20개 -> 원본 개수`, `21개 이상 -> 20` item 계약과, `ll_home_live_section`의 스크롤 `rv_home_lives` + 고정 `tv_home_live_all` 형제 구조.
**시나리오 계약:**
- `S1` 빈 목록: adapter item 수가 0이고 `bindLiveSection()`의 기존 빈 목록 숨김 로직을 유지한다. 자동 증거는 `HomeMainFragmentLayoutTest`의 adapter 경계 로직 테스트다.
- `S2` 라이브 1~20개: adapter item 수가 입력 개수와 같고 adapter가 별도 `전체` item을 만들지 않는다. 자동 증거는 `HomeMainFragmentLayoutTest`의 adapter 경계 로직 테스트다.
- `S3` 라이브 21개 이상 및 인접 회귀: adapter item 수가 정확히 20이고, 고정 `전체` 터치는 기존 `openHomeOnAirLive()` 로그인 가드를 사용하며 라이브 item은 기존 `LiveActionCoordinator` 흐름을 유지한다. 자동 증거는 `HomeMainFragmentLayoutTest``HomeMainFragmentLoginGuardSourceTest`다.
- [x] **RED:** `HomeMainFragmentLayoutTest`의 adapter 계약을 `전체` item 포함 방식에서 0개·1개·20개·21개 이상 라이브 item 수만 검증하는 로직 테스트로 변경한다. View 크기·간격·부모 관계·visibility 같은 레이아웃 속성 테스트는 추가하지 않는다.
- [x] **RED:** `HomeMainFragmentLoginGuardSourceTest``setUpRecommendationAdapters()``binding.tvHomeLiveAll.setOnClickListener { openHomeOnAirLive() }`를 연결하고 `liveAdapter.setOnMoreClick`을 사용하지 않는지 검증하는 한글 테스트를 추가한다. 기존 `openHomeOnAirLive()` 로그인 가드와 라이브 item 공통 Action 검증은 유지한다.
- [x] **RED 확인:** 아래 focused test를 실행해 adapter의 추가 `전체` item과 기존 `setOnMoreClick` 연결 때문에 신규 로직 assertion이 의도대로 실패하는지 확인한다.
```bash
./gradlew :app:testDebugUnitTest \
--tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLayoutTest" \
--tests "kr.co.vividnext.sodalive.v2.main.home.HomeMainFragmentLoginGuardSourceTest" \
--no-parallel --console=plain
```
- [x] **GREEN:** `fragment_v2_main_home.xml`에서 `ll_home_live_section`을 수평 컨테이너로 바꾸고, `rv_home_lives``0dp + weight 1` 및 시작 padding을 사용하며, 기존 스타일과 끝 여백을 유지한 `tv_home_live_all`을 오른쪽 형제 뷰로 추가한다.
- [x] **GREEN:** `HomeLiveAdapter`에서 `showAllItem`, `onMoreClick`, `MoreViewHolder`, `createMoreView()`, view type 분기를 제거하고 최대 20개의 라이브 item만 bind한다. `HomeMainFragment`는 고정 뷰 터치를 기존 `openHomeOnAirLive()`에 직접 연결한다.
- [x] **GREEN 확인:** RED 확인과 같은 focused test를 다시 실행해 S1~S3와 기존 로그인 가드·라이브 item 라우팅 검증이 성공하는지 확인한다.
- [x] **REFACTOR:** 이번 변경으로 사용되지 않는 import·adapter 코드만 제거하고 아래 직접 영향 회귀 및 품질 명령을 실행해 결과를 검증 기록에 누적한다. UI/계측 테스트, 기기·에뮬레이터 조작, 스크린샷·시각 QA는 실행하지 않는다.
```bash
./gradlew :app:mergeDebugResources :app:compileDebugKotlin :app:ktlintCheck \
--no-parallel --console=plain
./gradlew :app:testDebugUnitTest --no-parallel --console=plain
git diff --check
```
검증 기록:
- 2026-08-03: 사용자 승인과 코드 조사 결과를 기준으로 Phase 19 계획을 작성했다. `전체`는 overlay가 아닌 예약된 형제 영역에 고정하고, Phase 18의 상시 표시·빈 목록 숨김·20개 cap·기존 디자인·라우팅 계약은 유지한다. production·test 구현은 시작하지 않았다.
- 2026-08-03: `HomeMainFragmentLayoutTest``HomeMainFragmentLoginGuardSourceTest`에 Phase 19 실패 테스트를 먼저 반영했다. focused 실행은 89 tests 중 신규 계약 4개만 실패했다. 실패는 수평 컨테이너 미적용(`HomeMainFragmentLayoutTest.kt:1043`), 라이브 1개 및 22개 입력에서 adapter가 `전체`를 추가해 기대 개수와 불일치(`:1088`, `:1103`), 고정 `tvHomeLiveAll` 클릭 연결 부재(`HomeMainFragmentLoginGuardSourceTest.kt:220`)로 확인되어 요구 동작 미구현에 따른 의도한 RED다.
- 2026-08-03: `fragment_v2_main_home.xml`에 예약 폭을 사용하는 고정 `tv_home_live_all` 형제 뷰를 추가하고, `HomeLiveAdapter`는 최대 20개의 라이브 item만 제공하도록 adapter 소유 `전체` 상태·ViewHolder·callback을 제거했다. `HomeMainFragment`는 고정 뷰 터치를 기존 `openHomeOnAirLive()`에 직접 연결했다. 동일 focused test는 GREEN으로 `BUILD SUCCESSFUL`이었고 `--rerun-tasks` 강제 재실행도 성공했다. `mergeDebugResources`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`도 성공했으며 ktlint 설정 및 Gradle/Kotlin deprecation warning은 기존 경고다.
- 2026-08-04: 사용자 확인에 따라 Phase 19의 최종 테스트 범위를 로직/local unit test로 정정했다. 임시 `app/src/androidTest` 계측 테스트, 레이아웃 속성 unit test, 스크린샷·시각 QA 산출물과 QA 전용 에뮬레이터를 제거했다. 앞선 레이아웃 assertion RED 기록은 최종 완료 증거에서 제외하고 adapter 개수 경계와 로그인 가드·라이브 라우팅 로직 테스트만 유지한다.
- 2026-08-04: 코드 리뷰 `REV-P19-001`에서 초기 `Loading` 상태가 content를 bind하지 않아 XML 기본 `VISIBLE`인 고정 `전체`가 단독 노출될 수 있음을 확인했다. 저장소 정책상 레이아웃 속성 테스트는 남기지 않고 `ll_home_live_section` 기본값을 `GONE`으로 최소 수정했으며 기존 `bindLiveSection()`의 빈 목록 숨김·비어 있지 않은 목록 표시 로직은 유지했다.
- 2026-08-04: focused `HomeMainFragmentLayoutTest`·`HomeMainFragmentLoginGuardSourceTest`, `mergeDebugResources`, `compileDebugKotlin`이 성공했다. 전체 `testDebugUnitTest` 첫 실행은 이번 diff 밖의 기존 `CreatorChannelHomeViewModelTest` 비결정적 assertion 1건이 실패했지만 해당 클래스 격리 실행과 전체 재실행은 성공했다. 전역 `ktlintCheck`는 기존 `audio_content` package underscore 등 이번 diff 밖 위반으로 실패했고 변경된 Kotlin/test 파일은 오류 목록에 없었다.
- 2026-08-04: 독립 최종 리뷰에서 adapter item-count 테스트에 남은 미사용 `context`·`RecyclerView` setup 3줄을 제거했다. focused 로직 테스트와 `git diff --check`를 재실행해 성공했고 재리뷰에서 차단 사항 없이 `APPROVE` 판정을 받았다.
### 완료 조건
- [x] 라이브 목록만 가로 스크롤되고 `전체` 항목은 오른쪽 끝에 고정된다.
- [x] 고정 `전체` 항목이 라이브 item을 가리지 않고 별도 영역을 차지한다.
- [x] 빈 목록은 adapter item 0개이며 라이브 섹션 전체가 숨겨진다.
- [x] 라이브 1~20개는 adapter item 수가 입력 개수와 같고, 21개 이상은 정확히 20개다.
- [x] 기존 `전체` 디자인·문구·끝 여백·클릭 목적지·로그인 가드와 라이브 item 간격·입장 흐름이 유지된다.
- [x] 신규 abstraction·dependency·overlay·커스텀 LayoutManager·레거시 수정·commit이 없다.
### 검증 방법
#### Task R19.1: 초기 로딩 중 고정 `전체` 단독 노출 방지
**Goal 실행 `P19-R1`:** `REV-P19-001`에서 확인한 초기 데이터 바인딩 전 고정 `전체` 단독 노출 가능성을 제거한다.
- **시작 조건:** `REV-P19-001` 확정, `HomeRecommendationUiState.Loading`이 content를 bind하지 않는 현재 흐름 확인.
- **완료 증거:** `ll_home_live_section` 기본 숨김, 기존 `bindLiveSection()` 표시 정책 유지, resource merge·compile과 Phase 19 로직 회귀 성공.
- **범위 밖:** UI/계측 테스트, 레이아웃 속성 unit test, 로딩 상태 구조 변경, 관련 없는 홈 섹션 수정.
- **TDD 예외 사유:** 수정 대상이 XML 초기 visibility 속성이며 저장소 정책상 레이아웃 속성 테스트를 작성하지 않는다. 코드 흐름 대조와 resource merge·compile로 검증한다.
- [x] `ll_home_live_section`을 XML에서 기본 `GONE`으로 설정한다.
- [x] `bindLiveSection()`이 빈 목록은 숨기고 비어 있지 않은 목록은 표시하는 기존 로직을 유지한다.
- [x] focused 로직 테스트, resource merge, compile, ktlint와 diff check를 실행해 결과를 기록한다.
#### Phase 19 Gate
**Goal 실행 `P19-GATE`:** S1~S3의 adapter 경계·라우팅 로직과 resource/compile 계약을 최종 판정한다.
- **시작 조건:** `P19-T1`, `P19-R1` 체크박스와 검증 기록 완료.
- **완료 증거:** focused·전체 unit test, resource merge, compile, ktlint 결과 분리, diff check와 아래 자동·정적 대조 기록.
- **범위 밖:** Gate 통과를 위한 test 삭제·완화, 범위 밖 실패 수정, API·레거시·디자인 변경.
자동·정적 대조:
- [x] adapter가 빈 입력 0개, 1~20개 입력 원본 개수, 21개 이상 입력 20개를 반환한다.
- [x] adapter에 `전체` item용 view type·ViewHolder·callback이 남지 않는다.
- [x] `tv_home_live_all`은 기존 `openHomeOnAirLive()` 로그인 가드 경로를 사용한다.
- [x] resource merge와 compile이 성공하고 `app/src/androidTest` 신규 테스트가 없다.
- [x] UI/계측 테스트, 기기·에뮬레이터 조작, 스크린샷·시각 QA를 완료 증거로 사용하지 않는다.
중단 조건: S1~S3 로직 회귀, 전체 unit test, resource merge, compile, ktlint, diff check와 리뷰 승인이 끝나면 즉시 중단한다.
## Decision Log ## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
@@ -1361,8 +1474,13 @@ git diff --check
| 2026-07-31 | `DEC-018` | 확정 | Phase 7의 2026-06-05 보안 재리뷰에서 승인된 유료 미구매 원본 이미지 미로드 정책을 기준으로 삼고, commit `2d58a876`에서 재도입된 client-side blur 원본 로드를 회귀로 수정한다. | 보안 재리뷰·검증 기록, 현재 `HomePopularCommunityAdapter`와 반대 방향의 test 변경 이력 교차 검토 | `REV-P7-001`, `P7-R1` | | 2026-07-31 | `DEC-018` | 확정 | Phase 7의 2026-06-05 보안 재리뷰에서 승인된 유료 미구매 원본 이미지 미로드 정책을 기준으로 삼고, commit `2d58a876`에서 재도입된 client-side blur 원본 로드를 회귀로 수정한다. | 보안 재리뷰·검증 기록, 현재 `HomePopularCommunityAdapter`와 반대 방향의 test 변경 이력 교차 검토 | `REV-P7-001`, `P7-R1` |
| 2026-07-31 | `DEC-019` | 확정 | 두 번째 `Phase 12`와 그 하위 `Task 10.*`는 기존 기록을 보존하면서 `P12B` 계열 고유 별칭을 추가해 이후 리뷰와 회귀 작업에서 사용한다. | `work-plan-docs.md`의 Goal/Task 추적성 규칙과 현재 문서의 중복 식별자 | `REV-P12B-001`, `P12B-R1` | | 2026-07-31 | `DEC-019` | 확정 | 두 번째 `Phase 12`와 그 하위 `Task 10.*`는 기존 기록을 보존하면서 `P12B` 계열 고유 별칭을 추가해 이후 리뷰와 회귀 작업에서 사용한다. | `work-plan-docs.md`의 Goal/Task 추적성 규칙과 현재 문서의 중복 식별자 | `REV-P12B-001`, `P12B-R1` |
| 2026-07-31 | `DEC-020` | 확정 | 현재 진행 중인 라이브가 0개이면 섹션을 숨기고, 1개 이상이면 최대 20개 라이브 뒤에 `전체` 항목을 항상 표시한다. | 사용자 인터뷰 승인과 기존 `HomeLiveAdapter`·전체 라이브 이동 계약 확인 | `P18-T1`, `P18-GATE`, `prd.md``2026-07-31 변경: 현재 진행 중인 라이브 전체 항목 상시 표시` | | 2026-07-31 | `DEC-020` | 확정 | 현재 진행 중인 라이브가 0개이면 섹션을 숨기고, 1개 이상이면 최대 20개 라이브 뒤에 `전체` 항목을 항상 표시한다. | 사용자 인터뷰 승인과 기존 `HomeLiveAdapter`·전체 라이브 이동 계약 확인 | `P18-T1`, `P18-GATE`, `prd.md``2026-07-31 변경: 현재 진행 중인 라이브 전체 항목 상시 표시` |
| 2026-08-03 | `DEC-021` | 확정 | Phase 18의 `전체` 상시 표시 정책은 유지하되 adapter 마지막 item 배치를 폐기하고, 라이브 목록과 영역을 나누는 오른쪽 고정 형제 뷰로 변경한다. | 사용자 권고안 승인, 현재 수평 RecyclerView·adapter·로그인 가드 흐름 조사 | `P19-T1`, `P19-GATE`, `prd.md``2026-08-03 변경: 현재 진행 중인 라이브 전체 항목 오른쪽 고정` |
| 2026-08-04 | `DEC-022` | 확정 | 자동 테스트는 `app/src/test`의 로직/local unit test만 사용하고, 사용자 명시 요청 없이는 UI/계측 테스트·기기/에뮬레이터 조작·스크린샷·시각 QA를 실행하지 않는다. | 사용자 직접 확인과 기존 `code-style.md`의 로직 테스트 전용 원칙 | `AGENTS.md`, 테스트/계획 가이드, `P19-T1`, `P19-R1`, `P19-GATE` |
## Verification Log ## Verification Log
- 2026-08-04: 사용자 확인에 따라 UI 테스트 없이 로직/local unit test만 수행하는 정책을 `AGENTS.md`, `build-test-style.md`, `code-style.md`, `work-plan-docs.md`, `sample-plan-task.md`에 명시했다. Phase 19의 임시 `androidTest`, 레이아웃 속성 테스트, 스크린샷·시각 QA 산출물과 QA 전용 에뮬레이터를 제거하고 계획·Gate를 adapter 경계와 라우팅 로직 검증 기준으로 정정했다. focused 로직 테스트와 resource merge·compile은 성공했고, 전체 unit test는 기존 비결정적 1건 격리 성공 후 전체 재실행이 성공했다. 전역 ktlint는 이번 diff 밖의 기존 위반으로 실패해 분리 기록했다.
- 2026-08-04: Phase 19 최종 독립 리뷰의 유일한 차단 사항이었던 item-count 테스트 미사용 UI setup을 제거했다. focused 로직 테스트와 diff check 성공 후 재리뷰가 `APPROVE`로 종료됐으며 UI/계측 테스트는 추가하거나 재실행하지 않았다.
- 2026-08-03: Phase 19 문서 준비로 현재 v2 홈 데이터·adapter·layout·클릭 흐름과 최근 Phase 18 변경을 확인하고, 사용자 승인 설계를 PRD와 `P19-T1`·`P19-GATE`에 반영했다. S1 빈 목록, S2 1~20개 스크롤 고정, S3 21개 이상 cap·기존 라우팅 회귀를 자동·표면 검증 계약으로 정의했다. production·test 구현은 시작하지 않았다.
- 2026-07-31: Phase 18 변경 코드 리뷰 및 최신 검증을 수행했다. `HomeLiveAdapter`의 0개·1~20개·21개 이상 경계와 기존 `MoreViewHolder`, 로그인 가드, `HomeOnAirLiveActivity` 이동 경로를 대조해 확정 발견 사항이 없음을 판정했다. focused 74 tests·compile·ktlint와 `--rerun-tasks` 전체 162 suites·1,199 tests가 실패 0·오류 0으로 성공했고 staged·unstaged diff check와 문서 변경 후 `./gradlew tasks --all --console=plain`도 성공했다. 프로젝트 전체 `lintDebug`는 이번 diff 밖의 기존 28 errors·767 warnings로 실패했으며 첫 오류는 `AndroidManifest.xml:316``FacebookActivity` `MissingClass`다. 상세 결과는 `reviews/phase18-live-all-item-review.md`에 기록했다. - 2026-07-31: Phase 18 변경 코드 리뷰 및 최신 검증을 수행했다. `HomeLiveAdapter`의 0개·1~20개·21개 이상 경계와 기존 `MoreViewHolder`, 로그인 가드, `HomeOnAirLiveActivity` 이동 경로를 대조해 확정 발견 사항이 없음을 판정했다. focused 74 tests·compile·ktlint와 `--rerun-tasks` 전체 162 suites·1,199 tests가 실패 0·오류 0으로 성공했고 staged·unstaged diff check와 문서 변경 후 `./gradlew tasks --all --console=plain`도 성공했다. 프로젝트 전체 `lintDebug`는 이번 diff 밖의 기존 28 errors·767 warnings로 실패했으며 첫 오류는 `AndroidManifest.xml:316``FacebookActivity` `MissingClass`다. 상세 결과는 `reviews/phase18-live-all-item-review.md`에 기록했다.
- 2026-07-31: Phase 18 TDD와 Gate 검증을 완료했다. 구현 전 focused baseline 73개 성공 후 신규 경계 테스트가 라이브 1개에서 `expected:<2> but was:<1>`로 RED 실패했고, `HomeLiveAdapter``hasMore``showAllItem`으로 바꾸고 `items.isNotEmpty()` 조건을 적용한 뒤 focused 74개가 성공했다. `:app:testDebugUnitTest --no-parallel --console=plain` 전체 실행은 162 suites, 1,199 tests, 실패 0·오류 0·skip 0으로 성공했고 `:app:compileDebugKotlin`, `:app:ktlintCheck`, `git diff --check`도 성공했다. 기존 deprecation warning 외 실패는 없었으며 신규 파일·dependency·commit은 생성하지 않았다. - 2026-07-31: Phase 18 TDD와 Gate 검증을 완료했다. 구현 전 focused baseline 73개 성공 후 신규 경계 테스트가 라이브 1개에서 `expected:<2> but was:<1>`로 RED 실패했고, `HomeLiveAdapter``hasMore``showAllItem`으로 바꾸고 `items.isNotEmpty()` 조건을 적용한 뒤 focused 74개가 성공했다. `:app:testDebugUnitTest --no-parallel --console=plain` 전체 실행은 162 suites, 1,199 tests, 실패 0·오류 0·skip 0으로 성공했고 `:app:compileDebugKotlin`, `:app:ktlintCheck`, `git diff --check`도 성공했다. 기존 deprecation warning 외 실패는 없었으며 신규 파일·dependency·commit은 생성하지 않았다.
- 2026-07-31: Phase 18 문서 준비 검증으로 PRD의 0개·1~20개·21개 이상 표시 계약과 Non-Goal을 `P18-T1``P18-GATE`에 대조했고 누락된 확정 요구사항이 없음을 확인했다. `P18`, `Task 18`, `showAllItem` 추적 검색과 placeholder/TODO 검색, `git diff --check`가 성공했으며 변경 파일은 `prd.md`, `plan-task.md` 두 문서뿐이다. `./gradlew tasks --all --console=plain` 최초 sandbox 실행은 사용자 Gradle cache lock 접근 제한으로 실패했고, 승인된 동일 명령 재실행은 `BUILD SUCCESSFUL`로 완료됐다. production·test 구현은 시작하지 않았고 커밋은 생성하지 않았다. - 2026-07-31: Phase 18 문서 준비 검증으로 PRD의 0개·1~20개·21개 이상 표시 계약과 Non-Goal을 `P18-T1``P18-GATE`에 대조했고 누락된 확정 요구사항이 없음을 확인했다. `P18`, `Task 18`, `showAllItem` 추적 검색과 placeholder/TODO 검색, `git diff --check`가 성공했으며 변경 파일은 `prd.md`, `plan-task.md` 두 문서뿐이다. `./gradlew tasks --all --console=plain` 최초 sandbox 실행은 사용자 Gradle cache lock 접근 제한으로 실패했고, 승인된 동일 명령 재실행은 `BUILD SUCCESSFUL`로 완료됐다. production·test 구현은 시작하지 않았고 커밋은 생성하지 않았다.

View File

@@ -21,7 +21,7 @@ Figma `home_001` 화면(`24:5514`)을 기준으로 메인 홈 추천 영역을
- 추천 탭이 선택된 `HomeMainFragment`에 메인 홈 추천 UI를 구현한다. - 추천 탭이 선택된 `HomeMainFragment`에 메인 홈 추천 UI를 구현한다.
- Figma에서 기존 widget으로 대응 가능한 항목은 기존 v2 widget을 우선 재사용한다. - Figma에서 기존 widget으로 대응 가능한 항목은 기존 v2 widget을 우선 재사용한다.
- 재사용이 어려운 영역만 최소 범위 신규 UI 또는 adapter item으로 정의한다. - 재사용이 어려운 영역만 최소 범위 신규 UI 또는 adapter item으로 정의한다.
- 현재 진행 중인 라이브가 1개 이상이면 라이브 가로 목록 마지막`전체` 항목을 항상 표시해 전체 라이브 목록으로 이동할 수 있게 한다. - 현재 진행 중인 라이브가 1개 이상이면 가로 목록의 스크롤과 관계없이 오른쪽 끝`전체` 항목을 고정해 전체 라이브 목록으로 이동할 수 있게 한다.
- `추천 필모그래피`, `또 다른 모습` 섹션은 만들지 않는다. - `추천 필모그래피`, `또 다른 모습` 섹션은 만들지 않는다.
- 최하단 사업자 정보 섹션은 표시하되, 기본 최대 3줄 말줄임과 `더보기`/`접기` 토글을 제공한다. - 최하단 사업자 정보 섹션은 표시하되, 기본 최대 3줄 말줄임과 `더보기`/`접기` 토글을 제공한다.
- `HomeActiveCreatorItem.activityType`은 백엔드 code를 앱 내부 enum 또는 mapper로 변환하고, 표시 문구는 string resource 기반 다국어 처리를 적용한다. - `HomeActiveCreatorItem.activityType`은 백엔드 code를 앱 내부 enum 또는 mapper로 변환하고, 표시 문구는 string resource 기반 다국어 처리를 적용한다.
@@ -56,7 +56,7 @@ Figma `home_001` 화면(`24:5514`)을 기준으로 메인 홈 추천 영역을
## 6. User Stories ## 6. User Stories
- 사용자는 홈에서 현재 라이브 중인 크리에이터와 추천 콘텐츠를 빠르게 탐색하고 싶다. - 사용자는 홈에서 현재 라이브 중인 크리에이터와 추천 콘텐츠를 빠르게 탐색하고 싶다.
- 사용자는 현재 진행 중인 라이브가 적더라도 `전체` 항목을 통해 전체 라이브 목록 화면으로 이동하고 싶다. - 사용자는 현재 진행 중인 라이브 목록을 어느 위치까지 스크롤했는지와 관계없이 오른쪽 끝의 `전체` 항목을 통해 전체 라이브 목록 화면으로 이동하고 싶다.
- 사용자는 배너를 스와이프하거나 터치해 관련 이벤트, 크리에이터, 시리즈, 외부 링크 또는 앱 내부 딥링크로 이동하고 싶다. - 사용자는 배너를 스와이프하거나 터치해 관련 이벤트, 크리에이터, 시리즈, 외부 링크 또는 앱 내부 딥링크로 이동하고 싶다.
- 사용자는 최근 활동한 크리에이터의 활동 유형을 `라이브`, `오디오`, `커뮤니티`처럼 이해 가능한 문구로 보고 싶다. - 사용자는 최근 활동한 크리에이터의 활동 유형을 `라이브`, `오디오`, `커뮤니티`처럼 이해 가능한 문구로 보고 싶다.
- 사용자는 여러 크리에이터를 한 번에 팔로우하기 전에 확인창에서 동작을 검토하고, 확인하거나 취소할 수 있기를 원한다. - 사용자는 여러 크리에이터를 한 번에 팔로우하기 전에 확인창에서 동작을 검토하고, 확인하거나 취소할 수 있기를 원한다.
@@ -148,8 +148,9 @@ data class HomePopularCommunityPostItem(
#### Requirements #### Requirements
- `lives`가 비어 있으면 기존 정책대로 라이브 섹션 전체를 숨기고 `전체` 항목도 표시하지 않는다. - `lives`가 비어 있으면 기존 정책대로 라이브 섹션 전체를 숨기고 `전체` 항목도 표시하지 않는다.
- `lives`가 1개 이상 20개 이하이면 모든 라이브 항목 뒤에 `전체` 항목을 표시한다. - `lives`가 1개 이상이면 라이브 목록과 영역을 나누는 형제 뷰로 `전체` 항목을 오른쪽 끝에 고정하며, 라이브 목록의 가로 스크롤에 함께 이동하지 않는다.
- `lives`가 21개 이상이면 기존 정책대로 라이브를 최대 20개까지 표시하고 마지막에 `전체` 항목을 표시한다. - 고정 `전체` 항목은 라이브 목록을 가리지 않고 별도 폭을 차지하며, 라이브 목록만 남은 영역에서 스크롤된다.
- `lives`가 21개 이상이면 기존 정책대로 라이브를 최대 20개까지 표시하고 고정 `전체` 항목을 함께 표시한다.
- `전체` 항목의 기존 크기, 색상, typography와 클릭 시 `HomeOnAirLiveActivity`로 이동하는 동작을 유지한다. - `전체` 항목의 기존 크기, 색상, typography와 클릭 시 `HomeOnAirLiveActivity`로 이동하는 동작을 유지한다.
- 라이브 item 표시 순서, item 클릭 입장 흐름, 추천 API와 전체 라이브 목록 API 계약은 변경하지 않는다. - 라이브 item 표시 순서, item 클릭 입장 흐름, 추천 API와 전체 라이브 목록 API 계약은 변경하지 않는다.
@@ -404,7 +405,7 @@ HomeRecommendation 화면
- API 모델은 기존 네트워크 스택과 JSON 파서 규칙을 따른다. - API 모델은 기존 네트워크 스택과 JSON 파서 규칙을 따른다.
- string resource 기반 다국어 처리를 적용하며, UI 표시 문자열을 Kotlin 코드에 직접 하드코딩하지 않는다. - string resource 기반 다국어 처리를 적용하며, UI 표시 문자열을 Kotlin 코드에 직접 하드코딩하지 않는다.
- Figma에 있는 기존 widget과 다른 신규 UI는 구현 계획에서 파일 경로와 테스트 범위를 명시한 뒤 최소 범위로 작성한다. - Figma에 있는 기존 widget과 다른 신규 UI는 구현 계획에서 파일 경로와 테스트 범위를 명시한 뒤 최소 범위로 작성한다.
- 신규 순수 변환 로직(activity type mapper, count/time formatter 등)은 가능하면 local unit test로 검증한다. - 신규 테스트는 `app/src/test`의 로직/local unit test로 한정한다. UI 변경은 adapter 분기, mapper, formatter, presentation model, 라우팅처럼 화면 표현을 결정하는 로직만 검증하고 레이아웃 속성·계측·스크린샷 테스트는 작성하지 않는다.
--- ---
@@ -418,7 +419,7 @@ HomeRecommendation 화면
- `HomeActiveCreatorItem.creatorId`가 UI model까지 보존되고, `LIVE` item은 `targetId` 유무에 따라 라이브 룸 또는 크리에이터 채널 route로 분기된다. - `HomeActiveCreatorItem.creatorId`가 UI model까지 보존되고, `LIVE` item은 `targetId` 유무에 따라 라이브 룸 또는 크리에이터 채널 route로 분기된다.
- 최근 활동 `LIVE``targetId == null && creatorId <= 0L` 입력에서 route가 생성되지 않는다. - 최근 활동 `LIVE``targetId == null && creatorId <= 0L` 입력에서 route가 생성되지 않는다.
- 최근 활동 `LIVE`의 라이브 룸 진입이 `LiveActionCoordinator`를 사용해 로그인·본인인증·성인 콘텐츠 보기 설정 정책을 우회하지 않는다. - 최근 활동 `LIVE`의 라이브 룸 진입이 `LiveActionCoordinator`를 사용해 로그인·본인인증·성인 콘텐츠 보기 설정 정책을 우회하지 않는다.
- 현재 진행 중인 라이브가 0개이면 라이브 섹션과 `전체` 항목이 표시되지 않고, 1개 이상이면 최대 20개 라이브 뒤에 `전체` 항목이 표시된다. - 현재 진행 중인 라이브가 0개이면 라이브 섹션과 `전체` 항목이 표시되지 않고, 1개 이상이면 최대 20개의 스크롤 목록과 오른쪽 끝에 고정된 `전체` 항목이 표시된다.
- 제외 대상인 `추천 필모그래피`, `또 다른 모습` 섹션이 화면에 생성되지 않는다. - 제외 대상인 `추천 필모그래피`, `또 다른 모습` 섹션이 화면에 생성되지 않는다.
- 최근 응원이 많은 크리에이터 모두 팔로우 API success 후 버튼이 `모두 팔로우 완료``ic_new_following` 상태로 변경된다. - 최근 응원이 많은 크리에이터 모두 팔로우 API success 후 버튼이 `모두 팔로우 완료``ic_new_following` 상태로 변경된다.
- 최근 응원이 많은 크리에이터의 기본 상태 모두 팔로우 버튼 터치 직후에는 확인창만 표시되고 API 호출 횟수는 `0`이다. - 최근 응원이 많은 크리에이터의 기본 상태 모두 팔로우 버튼 터치 직후에는 확인창만 표시되고 API 호출 횟수는 `0`이다.
@@ -441,6 +442,7 @@ HomeRecommendation 화면
--- ---
## 12. 검증 기록 ## 12. 검증 기록
- 2026-08-03: 사용자 승인으로 현재 진행 중인 라이브의 `전체` 항목을 adapter 마지막 item에서 분리해 오른쪽 끝 고정 형제 뷰로 배치하는 요구사항을 확정했다. 고정 뷰가 라이브 목록을 가리지 않도록 수평 컨테이너에서 영역을 나누고, 빈 목록 섹션 숨김·라이브 최대 20개·기존 디자인·문구·클릭 목적지·로그인 가드·라이브 item 입장 흐름은 유지한다. 구현 검증은 Phase 19의 로직/local unit test와 resource merge·compile 기준으로 진행한다.
- 2026-07-31: 현재 진행 중인 라이브 `전체` 항목 상시 표시 요구사항을 TDD로 구현했다. 라이브 1개 입력의 adapter item 수가 기대 2, 실제 1로 실패하는 RED를 확인한 뒤 `HomeLiveAdapter`가 비어 있지 않은 입력에서 `전체` 항목을 추가하도록 최소 수정했다. focused `HomeMainFragmentLayoutTest` 74개와 전체 162 suites·1,199 tests, debug Kotlin compile, ktlint, diff 검증이 성공했으며 0개 숨김·20개 cap·기존 `전체` 디자인과 클릭 경로는 유지했다. - 2026-07-31: 현재 진행 중인 라이브 `전체` 항목 상시 표시 요구사항을 TDD로 구현했다. 라이브 1개 입력의 adapter item 수가 기대 2, 실제 1로 실패하는 RED를 확인한 뒤 `HomeLiveAdapter`가 비어 있지 않은 입력에서 `전체` 항목을 추가하도록 최소 수정했다. focused `HomeMainFragmentLayoutTest` 74개와 전체 162 suites·1,199 tests, debug Kotlin compile, ktlint, diff 검증이 성공했으며 0개 숨김·20개 cap·기존 `전체` 디자인과 클릭 경로는 유지했다.
- 2026-07-31: 사용자 인터뷰에서 현재 진행 중인 라이브가 0개이면 기존처럼 섹션을 숨기고, 1개 이상이면 최대 20개 라이브 뒤에 `전체` 항목을 항상 표시하는 정책을 확정했다. `HomeLiveAdapter.submitItems()`의 기존 `items.size > MAX_VISIBLE_LIVE_COUNT` 조건과 `HomeMainFragmentLayoutTest`의 21개 초과 회귀 테스트를 확인했으며, 기존 디자인·클릭 목적지·로그인 가드·API 계약은 변경하지 않는 최소 범위를 PRD에 반영했다. - 2026-07-31: 사용자 인터뷰에서 현재 진행 중인 라이브가 0개이면 기존처럼 섹션을 숨기고, 1개 이상이면 최대 20개 라이브 뒤에 `전체` 항목을 항상 표시하는 정책을 확정했다. `HomeLiveAdapter.submitItems()`의 기존 `items.size > MAX_VISIBLE_LIVE_COUNT` 조건과 `HomeMainFragmentLayoutTest`의 21개 초과 회귀 테스트를 확인했으며, 기존 디자인·클릭 목적지·로그인 가드·API 계약은 변경하지 않는 최소 범위를 PRD에 반영했다.
- 2026-07-31: PRD와 계획/TASK 보완 후 신규 범위의 placeholder·모순·요구사항 추적성을 자체 검토했다. `git status --short`로 변경 파일이 두 문서뿐임을 확인했고, placeholder 검색과 `git diff --check`는 출력 없이 성공했다. `./gradlew tasks --all`은 최초 sandbox 실행에서 사용자 Gradle cache lock 접근 제한으로 실패했으나 승인된 동일 명령 재실행은 `BUILD SUCCESSFUL`로 완료되어 계획에 기재한 Gradle task 이름의 유효성을 확인했다. production·test·resource 파일은 변경하지 않았다. - 2026-07-31: PRD와 계획/TASK 보완 후 신규 범위의 placeholder·모순·요구사항 추적성을 자체 검토했다. `git status --short`로 변경 파일이 두 문서뿐임을 확인했고, placeholder 검색과 `git diff --check`는 출력 없이 성공했다. `./gradlew tasks --all`은 최초 sandbox 실행에서 사용자 Gradle cache lock 접근 제한으로 실패했으나 승인된 동일 명령 재실행은 `BUILD SUCCESSFUL`로 완료되어 계획에 기재한 Gradle task 이름의 유효성을 확인했다. production·test·resource 파일은 변경하지 않았다.
@@ -528,3 +530,11 @@ HomeRecommendation 화면
- 현재 진행 중인 라이브가 1개 이상이면 라이브 개수와 관계없이 목록 마지막에 `전체` 항목을 표시한다. - 현재 진행 중인 라이브가 1개 이상이면 라이브 개수와 관계없이 목록 마지막에 `전체` 항목을 표시한다.
- 라이브는 기존처럼 최대 20개까지 표시하므로 21개 이상일 때도 `라이브 20개 + 전체` 구성을 유지한다. - 라이브는 기존처럼 최대 20개까지 표시하므로 21개 이상일 때도 `라이브 20개 + 전체` 구성을 유지한다.
- `전체` 항목의 디자인·클릭 목적지·로그인 가드와 라이브 item의 입장 흐름은 변경하지 않는다. - `전체` 항목의 디자인·클릭 목적지·로그인 가드와 라이브 item의 입장 흐름은 변경하지 않는다.
### 2026-08-03 변경: 현재 진행 중인 라이브 `전체` 항목 오른쪽 고정
- 2026-07-31 변경의 `전체` 상시 표시 정책은 유지하되, adapter 마지막 item으로 배치하는 방식만 대체한다.
- `전체` 항목은 가로 스크롤 목록 밖의 형제 뷰로 분리해 오른쪽 끝에 고정하며, 라이브 목록의 스크롤 위치와 관계없이 표시한다.
- 라이브 목록과 `전체` 항목은 수평 컨테이너의 영역을 나누며 서로 겹치지 않는다.
- 라이브가 0개이면 기존처럼 섹션 전체를 숨기고, 1개 이상이면 라이브 목록을 최대 20개까지 표시한다.
- `전체` 항목의 기존 `58dp x 102dp` 크기, 검은 배경, 가운데 정렬, `Typography.Body5`, `soda_400` 문구 색상과 `HomeOnAirLiveActivity` 이동·로그인 가드를 유지한다.
- 라이브 item 순서·간격·입장 흐름과 추천/전체 라이브 API 계약은 변경하지 않는다.

View File

@@ -0,0 +1,60 @@
# Phase 19 현재 라이브 전체 고정 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 19 / `P19-T1`, `P19-R1`, `P19-GATE` |
| 기준 | 현재 working tree |
| 리뷰 일자 | 2026-08-04 |
| 기준 문서 | `prd.md`, `plan-task.md` |
| 리뷰 상태 | 수정 검증 완료 |
## 2. 목적과 범위
- `HomeLiveAdapter`가 라이브 item만 최대 20개 제공하는지 확인한다.
- 고정 `전체` 클릭이 기존 로그인 가드 경로를 유지하는지 확인한다.
- 초기 데이터 바인딩 전 고정 `전체`가 단독 노출되지 않는지 코드 흐름을 확인한다.
- 테스트 범위는 `app/src/test`의 로직/local unit test로 제한한다.
- UI/계측 테스트, 레이아웃 속성 테스트, 기기·에뮬레이터 조작, 스크린샷·시각 QA는 제외한다.
## 3. 검토 근거
- 코드: `HomeMainFragment.kt`, `HomeLiveAdapter.kt`, `fragment_v2_main_home.xml`
- 테스트: `HomeMainFragmentLayoutTest.kt`, `HomeMainFragmentLoginGuardSourceTest.kt`
- 문서: Phase 19 요구사항·Task·Gate와 테스트 가이드
## 4. 발견 사항
### REV-P19-001 — 초기 로딩 중 고정 전체 단독 노출 가능성
- **심각도:** High
- **상태:** 수정 완료
- **소유 Task:** `P19-R1`
`HomeRecommendationUiState.Loading`은 content를 bind하지 않으므로 `ll_home_live_section`의 XML 기본값이 `VISIBLE`이면 첫 데이터 바인딩 전 고정 `전체`만 노출될 수 있었다. 부모 섹션 기본값을 `GONE`으로 변경하고 기존 `bindLiveSection()`의 빈 목록 숨김·비어 있지 않은 목록 표시 로직은 유지했다.
저장소 정책상 XML visibility를 직접 assertion하는 레이아웃 테스트는 남기지 않았다. resource merge·Kotlin compile과 기존 로직 회귀로 검증했다.
## 5. 실행한 검증
| 명령 | 결과 |
|---|---|
| focused `HomeMainFragmentLayoutTest`, `HomeMainFragmentLoginGuardSourceTest` | 성공 |
| `:app:mergeDebugResources :app:compileDebugKotlin` | 성공 |
| `:app:testDebugUnitTest` | 첫 실행 기존 비결정적 1건 실패, 관련 클래스 격리 및 전체 재실행 성공 |
| `:app:ktlintCheck` | 이번 diff 밖 기존 package underscore 등으로 실패; 변경 파일 신규 오류 없음 |
| UI/계측 테스트 | 정책에 따라 작성·실행하지 않음 |
독립 최종 리뷰에서 adapter item-count 테스트에 남은 미사용 UI setup 3줄이 발견되어 제거했다. focused 로직 테스트와 `git diff --check` 재실행 후 재리뷰가 `APPROVE`로 종료됐다.
## 6. 종료 판정
| 판정 항목 | 결과 |
|---|---|
| adapter 경계와 라우팅 로직 | 충족 |
| 초기 로딩 노출 회귀 수정 | 충족 |
| UI 테스트 제거 | 충족 |
| 테스트 가이드 명확화 | 충족 |
**최종 결론:** `REV-P19-001` 수정 검증 완료 및 독립 재리뷰 승인. 전역 ktlint의 기존 실패는 Phase 19 범위 밖으로 분리한다.