feat(content): 전체 탭 화면 연결을 추가한다

This commit is contained in:
2026-06-25 11:54:43 +09:00
parent 4df724ee56
commit f4a32086b0
2 changed files with 396 additions and 2 deletions

View File

@@ -2,9 +2,15 @@ package kr.co.vividnext.sodalive.v2.main.content
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity
import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity
@@ -14,7 +20,13 @@ import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding
import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding
import kr.co.vividnext.sodalive.extensions.moneyFormat
import kr.co.vividnext.sodalive.home.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.v2.common.data.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.model.toLabelResId
import kr.co.vividnext.sodalive.v2.creator.channel.ui.CreatorChannelSortPopup
import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType import kr.co.vividnext.sodalive.v2.main.content.data.AudioRankingType
import kr.co.vividnext.sodalive.v2.main.content.data.MainContentAllType
import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState import kr.co.vividnext.sodalive.v2.main.content.model.AudioRankingsUiState
import kr.co.vividnext.sodalive.v2.main.content.model.AudioRecommendationsUiState import kr.co.vividnext.sodalive.v2.main.content.model.AudioRecommendationsUiState
import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardSection import kr.co.vividnext.sodalive.v2.main.content.model.ContentAudioCardSection
@@ -25,9 +37,17 @@ import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioSecti
import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioUiModel import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioUiModel
import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesSection import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesSection
import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesUiModel import kr.co.vividnext.sodalive.v2.main.content.model.ContentOriginalSeriesUiModel
import kr.co.vividnext.sodalive.v2.main.content.model.MainContentAllTabUiState
import kr.co.vividnext.sodalive.v2.main.content.model.contentAllDayOfWeekOptions
import kr.co.vividnext.sodalive.v2.main.content.model.toContentAllDayLabelResId
import kr.co.vividnext.sodalive.v2.main.content.model.toContentAllTypeLabelResId
import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerIntent import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerIntent
import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerRoute import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerRoute
import kr.co.vividnext.sodalive.v2.main.content.model.usesDayOfWeekQuery
import kr.co.vividnext.sodalive.v2.main.content.model.usesSeriesItems
import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_RECOMMENDED_GRID_SPAN_COUNT import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_RECOMMENDED_GRID_SPAN_COUNT
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAllAudioCardAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAllSeriesCardAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAudioCardAdapter import kr.co.vividnext.sodalive.v2.main.content.ui.ContentAudioCardAdapter
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentBannerBinder import kr.co.vividnext.sodalive.v2.main.content.ui.ContentBannerBinder
import kr.co.vividnext.sodalive.v2.main.content.ui.ContentCommentedAudioAdapter import kr.co.vividnext.sodalive.v2.main.content.ui.ContentCommentedAudioAdapter
@@ -45,7 +65,15 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
) { ) {
private val contentMainViewModel: ContentMainViewModel by viewModel() private val contentMainViewModel: ContentMainViewModel by viewModel()
private val contentRankingViewModel: ContentRankingViewModel by viewModel() private val contentRankingViewModel: ContentRankingViewModel by viewModel()
private val contentAllTabViewModel: ContentAllTabViewModel by viewModel()
private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) } private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) }
private val contentAllTypes = listOf(
MainContentAllType.AUDIO,
MainContentAllType.SERIES,
MainContentAllType.ORIGINAL,
MainContentAllType.FREE,
MainContentAllType.POINT
)
private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) } private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) }
private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) }
private val newAndHotAdapter = ContentNewAndHotAdapter { openAudioContentDetail(it) } private val newAndHotAdapter = ContentNewAndHotAdapter { openAudioContentDetail(it) }
@@ -54,15 +82,27 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) } private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) }
private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(it) } private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(it) }
private val contentRankingAdapter = ContentRankingAdapter { openRankingAudioContentDetail(it) } private val contentRankingAdapter = ContentRankingAdapter { openRankingAudioContentDetail(it) }
private val contentAllAudioCardAdapter = ContentAllAudioCardAdapter { audioContentId ->
openAudioContentDetail(audioContentId)
}
private val contentAllSeriesCardAdapter = ContentAllSeriesCardAdapter { seriesId -> openSeriesDetail(seriesId) }
private var bannerBinder: ContentBannerBinder? = null private var bannerBinder: ContentBannerBinder? = null
private var sortPopup: CreatorChannelSortPopup? = null
private var isRecommendationLoading = false private var isRecommendationLoading = false
private var isRankingLoading = false private var isRankingLoading = false
private var isAllTabLoading = false
private var hasSelectedRankingTab = false private var hasSelectedRankingTab = false
private var hasSelectedAllTab = false
private var currentContentTab = CONTENT_TAB_RECOMMENDATION
private var currentAllTabState: MainContentAllTabUiState? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
setUpTextTabs() setUpTextTabs()
setUpRankingTypeTabs() setUpRankingTypeTabs()
setUpAllTypeTabs()
setUpAllDayFilter()
setUpAllSortButton()
showContentTab(CONTENT_TAB_RECOMMENDATION) showContentTab(CONTENT_TAB_RECOMMENDATION)
setUpSectionTitles() setUpSectionTitles()
setUpAdapters() setUpAdapters()
@@ -70,6 +110,14 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
contentMainViewModel.loadRecommendations() contentMainViewModel.loadRecommendations()
} }
override fun onDestroyView() {
sortPopup?.dismiss()
sortPopup = null
bannerBinder = null
binding.rvContentAllItems.adapter = null
super.onDestroyView()
}
private fun setUpTextTabs() { private fun setUpTextTabs() {
binding.textTabBarContent.root.setMenus( binding.textTabBarContent.root.setMenus(
listOf( listOf(
@@ -94,11 +142,56 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
} }
private fun setUpAllTypeTabs() {
binding.viewContentAllTypeTabs.root.setMenus(
contentAllTypes.map { type -> getString(type.toContentAllTypeLabelResId()) },
selectedIndex = MainContentAllType.AUDIO.ordinal
)
binding.viewContentAllTypeTabs.root.setOnTabSelectedListener { index ->
contentAllTabViewModel.changeType(contentAllTypes[index])
}
}
private fun setUpAllDayFilter() {
binding.layoutContentAllDayFilter.removeAllViews()
contentAllDayOfWeekOptions.forEach { dayOfWeek ->
binding.layoutContentAllDayFilter.addView(createAllDayFilterView(dayOfWeek))
}
}
private fun createAllDayFilterView(dayOfWeek: SeriesPublishedDaysOfWeek): TextView {
return TextView(requireContext()).apply {
tag = dayOfWeek
gravity = Gravity.CENTER
setText(dayOfWeek.toContentAllDayLabelResId())
setTextAppearance(R.style.Typography_Body5)
setPadding(
resources.getDimensionPixelSize(R.dimen.spacing_8),
0,
resources.getDimensionPixelSize(R.dimen.spacing_8),
0
)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setOnClickListener { contentAllTabViewModel.changeDayOfWeek(dayOfWeek) }
}
}
private fun setUpAllSortButton() {
binding.layoutContentAllSortButton.setOnClickListener {
val state = currentAllTabState ?: return@setOnClickListener
showAllSortPopup(state.selectedSort)
}
}
private fun showContentTab(index: Int) { private fun showContentTab(index: Int) {
currentContentTab = index
when (index) { when (index) {
CONTENT_TAB_RECOMMENDATION -> showRecommendationContent() CONTENT_TAB_RECOMMENDATION -> showRecommendationContent()
CONTENT_TAB_RANKING -> showRankingContent() CONTENT_TAB_RANKING -> showRankingContent()
CONTENT_TAB_ALL -> hideContentSurfaces() CONTENT_TAB_ALL -> showAllContent()
} }
} }
@@ -106,22 +199,37 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
binding.nsvContentRecommendationContent.visibility = View.VISIBLE binding.nsvContentRecommendationContent.visibility = View.VISIBLE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE binding.rvContentRankings.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.GONE
} }
private fun showRankingContent() { private fun showRankingContent() {
binding.nsvContentRecommendationContent.visibility = View.GONE binding.nsvContentRecommendationContent.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE
binding.rvContentRankings.visibility = View.VISIBLE binding.rvContentRankings.visibility = View.VISIBLE
binding.layoutContentAllSurface.visibility = View.GONE
if (!hasSelectedRankingTab) { if (!hasSelectedRankingTab) {
hasSelectedRankingTab = true hasSelectedRankingTab = true
contentRankingViewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR) contentRankingViewModel.loadRankings(AudioRankingType.WEEKLY_POPULAR)
} }
} }
private fun showAllContent() {
binding.nsvContentRecommendationContent.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.VISIBLE
currentAllTabState?.let(::renderAllTabState)
if (currentAllTabState == null && !hasSelectedAllTab) {
hasSelectedAllTab = true
contentAllTabViewModel.loadInitial()
}
}
private fun hideContentSurfaces() { private fun hideContentSurfaces() {
binding.nsvContentRecommendationContent.visibility = View.GONE binding.nsvContentRecommendationContent.visibility = View.GONE
binding.viewContentRankingTypeTabs.root.visibility = View.GONE binding.viewContentRankingTypeTabs.root.visibility = View.GONE
binding.rvContentRankings.visibility = View.GONE binding.rvContentRankings.visibility = View.GONE
binding.layoutContentAllSurface.visibility = View.GONE
} }
private fun setUpAdapters() { private fun setUpAdapters() {
@@ -167,6 +275,24 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
layoutManager = ContentRankingAdapter.createGridLayoutManager(requireContext()) layoutManager = ContentRankingAdapter.createGridLayoutManager(requireContext())
adapter = contentRankingAdapter adapter = contentRankingAdapter
} }
val contentAllGridLayoutManager = GridLayoutManager(requireContext(), CONTENT_ALL_GRID_SPAN_COUNT)
binding.rvContentAllItems.apply {
layoutManager = contentAllGridLayoutManager
adapter = contentAllAudioCardAdapter
addContentGridItemSpacing(CONTENT_ALL_GRID_SPAN_COUNT)
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy <= 0) return
val itemCount = recyclerView.adapter?.itemCount ?: return
val lastVisiblePosition = contentAllGridLayoutManager.findLastVisibleItemPosition()
if (lastVisiblePosition >= itemCount - CONTENT_ALL_GRID_SPAN_COUNT) {
contentAllTabViewModel.loadMore()
}
}
})
}
} }
private fun bindObservers() { private fun bindObservers() {
@@ -202,6 +328,116 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
contentRankingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> contentRankingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast) toastMessage?.let(::showToast)
} }
contentAllTabViewModel.allTabStateLiveData.observe(viewLifecycleOwner) { state ->
currentAllTabState = state
renderAllTabState(state)
}
contentAllTabViewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
isAllTabLoading = isLoading
updateLoadingDialog()
}
contentAllTabViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage ->
toastMessage?.let(::showToast)
}
}
private fun renderAllTabState(state: MainContentAllTabUiState) {
if (currentContentTab == CONTENT_TAB_ALL) {
when (state) {
is MainContentAllTabUiState.Content -> bindAllTabContent(state)
is MainContentAllTabUiState.Empty -> bindAllTabEmpty(state)
is MainContentAllTabUiState.Error -> bindAllTabError(state)
is MainContentAllTabUiState.Loading -> bindAllTabLoading(state)
}
}
}
private fun bindAllTabContent(state: MainContentAllTabUiState.Content) {
bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE
hideAllTabEmptyError()
if (state.selectedType.usesSeriesItems()) {
binding.rvContentAllItems.adapter = contentAllSeriesCardAdapter
contentAllAudioCardAdapter.submitItems(emptyList())
contentAllSeriesCardAdapter.submitItems(state.seriesItems)
} else {
binding.rvContentAllItems.adapter = contentAllAudioCardAdapter
contentAllSeriesCardAdapter.submitItems(emptyList())
contentAllAudioCardAdapter.submitItems(state.audioItems)
}
state.paginationErrorMessage?.let { message ->
showToast(message)
contentAllTabViewModel.consumePaginationErrorMessage()
}
}
private fun bindAllTabEmpty(state: MainContentAllTabUiState.Empty) {
bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE
binding.layoutContentAllEmptyError.visibility = View.VISIBLE
binding.tvContentAllEmptyError.setText(R.string.screen_content_all_empty)
clearAllTabItems()
}
private fun bindAllTabError(state: MainContentAllTabUiState.Error) {
bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE
binding.layoutContentAllEmptyError.visibility = View.VISIBLE
binding.tvContentAllEmptyError.text = state.message ?: getString(R.string.common_error_unknown)
clearAllTabItems()
}
private fun bindAllTabLoading(state: MainContentAllTabUiState.Loading) {
bindAllTabControls(state)
binding.layoutContentAllSurface.visibility = View.VISIBLE
hideAllTabEmptyError()
clearAllTabItems()
}
private fun bindAllTabControls(state: MainContentAllTabUiState) {
binding.tvContentAllTotalCount.text = state.totalCount.moneyFormat()
binding.tvContentAllSortLabel.setText(state.selectedSort.toLabelResId())
binding.viewContentAllTypeTabs.root.setMenus(
contentAllTypes.map { type -> getString(type.toContentAllTypeLabelResId()) },
selectedIndex = contentAllTypes.indexOf(state.selectedType).coerceAtLeast(0)
)
binding.layoutContentAllDayFilter.visibility = if (state.selectedType.usesDayOfWeekQuery()) View.VISIBLE else View.GONE
bindAllDayFilterSelection(state.selectedDayOfWeek)
}
private fun bindAllDayFilterSelection(selectedDayOfWeek: SeriesPublishedDaysOfWeek?) {
for (index in 0 until binding.layoutContentAllDayFilter.childCount) {
val child = binding.layoutContentAllDayFilter.getChildAt(index) as? TextView ?: continue
val isSelected = child.tag == selectedDayOfWeek
child.isSelected = isSelected
child.setBackgroundResource(if (isSelected) R.drawable.bg_content_all_day_selected else 0)
child.setTextColor(
ContextCompat.getColor(requireContext(), if (isSelected) R.color.black else R.color.white)
)
}
}
private fun clearAllTabItems() {
binding.rvContentAllItems.adapter = null
contentAllAudioCardAdapter.submitItems(emptyList())
contentAllSeriesCardAdapter.submitItems(emptyList())
}
private fun hideAllTabEmptyError() {
binding.layoutContentAllEmptyError.visibility = View.GONE
}
private fun showAllSortPopup(selectedSort: ContentSort) {
sortPopup?.dismiss()
sortPopup = CreatorChannelSortPopup(
anchor = binding.layoutContentAllSortButton,
selectedSort = selectedSort,
onSortSelected = { sort ->
if (sort in ContentSort.entries) {
contentAllTabViewModel.changeSort(sort)
}
}
).also { it.show() }
} }
private fun bindContent(content: AudioRecommendationsUiState.Content) { private fun bindContent(content: AudioRecommendationsUiState.Content) {
@@ -299,6 +535,11 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) { private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) {
val seriesId = item.seriesId.takeIf { it > 0L } ?: return val seriesId = item.seriesId.takeIf { it > 0L } ?: return
openSeriesDetail(seriesId)
}
private fun openSeriesDetail(seriesId: Long) {
if (seriesId <= 0L) return
startActivity( startActivity(
Intent(requireContext(), SeriesDetailActivity::class.java).apply { Intent(requireContext(), SeriesDetailActivity::class.java).apply {
putExtra(Constants.EXTRA_SERIES_ID, seriesId) putExtra(Constants.EXTRA_SERIES_ID, seriesId)
@@ -312,7 +553,7 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
} }
private fun updateLoadingDialog() { private fun updateLoadingDialog() {
if (isRecommendationLoading || isRankingLoading) { if (isRecommendationLoading || isRankingLoading || isAllTabLoading) {
loadingDialog.show(screenWidth) loadingDialog.show(screenWidth)
} else { } else {
loadingDialog.dismiss() loadingDialog.dismiss()
@@ -347,5 +588,6 @@ class ContentMainFragment : BaseFragment<FragmentV2MainContentBinding>(
private const val CONTENT_TAB_RECOMMENDATION = 0 private const val CONTENT_TAB_RECOMMENDATION = 0
private const val CONTENT_TAB_RANKING = 1 private const val CONTENT_TAB_RANKING = 1
private const val CONTENT_TAB_ALL = 2 private const val CONTENT_TAB_ALL = 2
private const val CONTENT_ALL_GRID_SPAN_COUNT = 3
} }
} }

View File

@@ -71,6 +71,154 @@ class ContentMainFragmentSourceTest {
assertTrue(source.contains("screen_content_ranking_type_like_count")) assertTrue(source.contains("screen_content_ranking_type_like_count"))
} }
@Test
fun `content 전체 layout과 source는 Phase 5 요구를 포함한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
val fragmentLayout = projectFile("app/src/main/res/layout/fragment_v2_main_content.xml").readText()
val dayFilterBackground = projectFile("app/src/main/res/drawable/bg_content_all_day_filter.xml").readText()
val selectedDayBackground = projectFile("app/src/main/res/drawable/bg_content_all_day_selected.xml").readText()
assertTrue(fragmentLayout.contains("@+id/layout_content_all_surface"))
assertTrue(fragmentLayout.contains("@+id/view_content_all_type_tabs"))
assertTrue(fragmentLayout.contains("@+id/layout_content_all_day_filter"))
assertTrue(fragmentLayout.contains("android:layout_width=\"wrap_content\""))
assertTrue(fragmentLayout.contains("android:layout_height=\"36dp\""))
assertTrue(fragmentLayout.contains("android:layout_marginTop=\"@dimen/spacing_8\""))
assertTrue(fragmentLayout.contains("android:background=\"@drawable/bg_content_all_day_filter\""))
assertTrue(fragmentLayout.contains("android:padding=\"@dimen/spacing_4\""))
assertTrue(fragmentLayout.contains("app:layout_constraintEnd_toEndOf=\"parent\""))
assertTrue(fragmentLayout.contains("app:layout_constraintHorizontal_bias=\"0.5\""))
assertTrue(dayFilterBackground.contains("@color/gray_900"))
assertTrue(dayFilterBackground.contains("@dimen/radius_8"))
assertTrue(selectedDayBackground.contains("@color/white"))
assertTrue(selectedDayBackground.contains("@dimen/radius_8"))
assertTrue(fragmentLayout.contains("@+id/layout_content_all_sort_bar"))
assertTrue(fragmentLayout.contains("android:layout_height=\"52dp\""))
assertTrue(fragmentLayout.contains("@+id/tv_content_all_total_label"))
assertTrue(fragmentLayout.contains("android:text=\"@string/screen_content_tab_all\""))
assertTrue(fragmentLayout.contains("android:textColor=\"@color/white\""))
assertTrue(fragmentLayout.contains("@+id/tv_content_all_total_count"))
assertTrue(fragmentLayout.contains("style=\"@style/Typography.Body2\""))
assertTrue(fragmentLayout.contains("android:textColor=\"@color/gray_500\""))
assertTrue(fragmentLayout.contains("@+id/layout_content_all_sort_button"))
assertTrue(fragmentLayout.contains("android:layout_height=\"match_parent\""))
assertTrue(fragmentLayout.contains("@+id/tv_content_all_sort_label"))
assertTrue(fragmentLayout.contains("style=\"@style/Typography.Body3\""))
assertTrue(fragmentLayout.contains("@+id/iv_content_all_sort"))
assertTrue(fragmentLayout.contains("android:src=\"@drawable/ic_new_sort\""))
assertTrue(fragmentLayout.contains("@+id/rv_content_all_items"))
assertTrue(fragmentLayout.contains("@+id/layout_content_all_empty_error"))
assertTrue(fragmentLayout.contains("@+id/tv_content_all_empty_error"))
assertTrue(source.contains("private val contentAllTabViewModel: ContentAllTabViewModel by viewModel()"))
assertTrue(source.contains("ContentAllAudioCardAdapter"))
assertTrue(source.contains("ContentAllSeriesCardAdapter"))
assertTrue(source.contains("CONTENT_ALL_GRID_SPAN_COUNT"))
assertTrue(source.contains("GridLayoutManager(requireContext(), CONTENT_ALL_GRID_SPAN_COUNT)"))
assertTrue(source.contains("addContentGridItemSpacing(CONTENT_ALL_GRID_SPAN_COUNT)"))
assertTrue(source.contains("MainContentAllType.AUDIO"))
assertTrue(source.contains("MainContentAllType.SERIES"))
assertTrue(source.contains("MainContentAllType.ORIGINAL"))
assertTrue(source.contains("MainContentAllType.FREE"))
assertTrue(source.contains("MainContentAllType.POINT"))
assertFalse(source.contains("screen_content_all_type_all"))
assertFalse(source.contains("screen_content_all_type_serialized"))
assertTrue(source.contains("CreatorChannelSortPopup"))
assertTrue(source.contains("ContentSort.entries"))
assertTrue(source.contains("toLabelResId()"))
assertTrue(source.contains("contentAllDayOfWeekOptions"))
assertTrue(source.contains("toContentAllDayLabelResId()"))
assertTrue(source.contains("R.drawable.bg_content_all_day_selected"))
assertTrue(source.contains("if (isSelected) R.color.black else R.color.white"))
assertTrue(source.contains("loadMore()"))
assertTrue(source.contains("consumePaginationErrorMessage()"))
assertTrue(source.contains("openAudioContentDetail(audioContentId)"))
assertTrue(source.contains("openSeriesDetail(seriesId)"))
assertTrue(source.contains("override fun onDestroyView()"))
assertTrue(source.contains("sortPopup?.dismiss()"))
assertTrue(source.contains("sortPopup = null"))
assertTrue(source.contains("bannerBinder = null"))
assertTrue(source.contains("layoutContentAllEmptyError"))
assertTrue(source.contains("tvContentAllEmptyError.setText"))
}
@Test
fun `content 전체 탭 source는 최신 전체 상태를 보관한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertSourceContains(
source,
"private var currentAllTabState: MainContentAllTabUiState? = null",
"전체 탭은 Content만 보관하지 않고 Empty Error Loading까지 포함하는 최신 상태를 캐시해야 한다."
)
assertFalse(
"전체 탭 캐시는 MainContentAllTabUiState.Content 전용 currentAllTabContent에 머물면 안 된다.",
source.contains("currentAllTabContent: MainContentAllTabUiState.Content?")
)
}
@Test
fun `content 전체 탭 observer는 guard 전에 최신 상태를 저장한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertSourceContains(source, "contentAllTabViewModel.allTabStateLiveData.observe(viewLifecycleOwner) { state ->")
assertSourceContains(source, "currentAllTabState = state")
assertSourceContains(source, "renderAllTabState(state)")
assertTrue(
"allTabStateLiveData observer는 render/guard 전에 currentAllTabState = state를 먼저 실행해야 한다.",
source.indexOf("currentAllTabState = state") < source.indexOf("renderAllTabState(state)")
)
assertFalse(
"inactive ALL 탭 emission을 버리는 observer/render guard는 제거되어야 한다.",
source.contains("if (currentContentTab != CONTENT_TAB_ALL) return")
)
}
@Test
fun `content 전체 탭 진입은 캐시 상태를 우선 렌더링하고 필요할 때만 초기 로드한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertSourceContains(source, "private fun showAllContent()")
assertSourceContains(source, "currentAllTabState?.let(::renderAllTabState)")
assertSourceContains(source, "if (currentAllTabState == null && !hasSelectedAllTab)")
assertSourceContains(source, "contentAllTabViewModel.loadInitial()")
}
@Test
fun `content 전체 탭 source는 상태별 dispatcher를 둔다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertSourceContains(source, "private fun renderAllTabState(state: MainContentAllTabUiState)")
assertSourceContains(source, "is MainContentAllTabUiState.Content ->")
assertSourceContains(source, "is MainContentAllTabUiState.Empty ->")
assertSourceContains(source, "is MainContentAllTabUiState.Error ->")
assertSourceContains(source, "is MainContentAllTabUiState.Loading ->")
}
@Test
fun `content 전체 탭 source는 비 content 상태에도 공통 control을 바인딩한다`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt"
).readText()
assertSourceContains(source, "private fun bindAllTabControls(state: MainContentAllTabUiState)")
assertSourceContains(source, "bindAllTabControls(state)")
assertSourceContains(source, "binding.viewContentAllTypeTabs.root.setMenus")
assertSourceContains(source, "binding.layoutContentAllDayFilter.visibility")
assertSourceContains(source, "binding.tvContentAllSortLabel.setText")
assertSourceContains(source, "binding.tvContentAllTotalCount.text")
}
@Test @Test
fun `content 추천 source는 오디오와 시리즈 routing extra를 사용한다`() { fun `content 추천 source는 오디오와 시리즈 routing extra를 사용한다`() {
val source = projectFile( val source = projectFile(
@@ -207,6 +355,10 @@ class ContentMainFragmentSourceTest {
link = link link = link
) )
private fun assertSourceContains(source: String, expected: String, message: String? = null) {
assertTrue(message ?: "Expected source to contain: $expected", source.contains(expected))
}
private fun projectFile(relativePath: String): File { private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath")) val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() } return candidates.firstOrNull { it.exists() }