diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt index 6f3303a5..0699427a 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt @@ -2,9 +2,15 @@ package kr.co.vividnext.sodalive.v2.main.content import android.content.Intent import android.os.Bundle +import android.view.Gravity 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.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity 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.databinding.FragmentV2MainContentBinding 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.MainContentAllType 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.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.ContentOriginalSeriesSection 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.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.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.ContentBannerBinder import kr.co.vividnext.sodalive.v2.main.content.ui.ContentCommentedAudioAdapter @@ -45,7 +65,15 @@ class ContentMainFragment : BaseFragment( ) { private val contentMainViewModel: ContentMainViewModel 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 contentAllTypes = listOf( + MainContentAllType.AUDIO, + MainContentAllType.SERIES, + MainContentAllType.ORIGINAL, + MainContentAllType.FREE, + MainContentAllType.POINT + ) private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) } private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } private val newAndHotAdapter = ContentNewAndHotAdapter { openAudioContentDetail(it) } @@ -54,15 +82,27 @@ class ContentMainFragment : BaseFragment( private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) } private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(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 sortPopup: CreatorChannelSortPopup? = null private var isRecommendationLoading = false private var isRankingLoading = false + private var isAllTabLoading = 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?) { super.onViewCreated(view, savedInstanceState) setUpTextTabs() setUpRankingTypeTabs() + setUpAllTypeTabs() + setUpAllDayFilter() + setUpAllSortButton() showContentTab(CONTENT_TAB_RECOMMENDATION) setUpSectionTitles() setUpAdapters() @@ -70,6 +110,14 @@ class ContentMainFragment : BaseFragment( contentMainViewModel.loadRecommendations() } + override fun onDestroyView() { + sortPopup?.dismiss() + sortPopup = null + bannerBinder = null + binding.rvContentAllItems.adapter = null + super.onDestroyView() + } + private fun setUpTextTabs() { binding.textTabBarContent.root.setMenus( listOf( @@ -94,11 +142,56 @@ class ContentMainFragment : BaseFragment( } } + 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) { + currentContentTab = index when (index) { CONTENT_TAB_RECOMMENDATION -> showRecommendationContent() CONTENT_TAB_RANKING -> showRankingContent() - CONTENT_TAB_ALL -> hideContentSurfaces() + CONTENT_TAB_ALL -> showAllContent() } } @@ -106,22 +199,37 @@ class ContentMainFragment : BaseFragment( binding.nsvContentRecommendationContent.visibility = View.VISIBLE binding.viewContentRankingTypeTabs.root.visibility = View.GONE binding.rvContentRankings.visibility = View.GONE + binding.layoutContentAllSurface.visibility = View.GONE } private fun showRankingContent() { binding.nsvContentRecommendationContent.visibility = View.GONE binding.viewContentRankingTypeTabs.root.visibility = View.VISIBLE binding.rvContentRankings.visibility = View.VISIBLE + binding.layoutContentAllSurface.visibility = View.GONE if (!hasSelectedRankingTab) { hasSelectedRankingTab = true 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() { binding.nsvContentRecommendationContent.visibility = View.GONE binding.viewContentRankingTypeTabs.root.visibility = View.GONE binding.rvContentRankings.visibility = View.GONE + binding.layoutContentAllSurface.visibility = View.GONE } private fun setUpAdapters() { @@ -167,6 +275,24 @@ class ContentMainFragment : BaseFragment( layoutManager = ContentRankingAdapter.createGridLayoutManager(requireContext()) 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() { @@ -202,6 +328,116 @@ class ContentMainFragment : BaseFragment( contentRankingViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> 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) { @@ -299,6 +535,11 @@ class ContentMainFragment : BaseFragment( private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) { val seriesId = item.seriesId.takeIf { it > 0L } ?: return + openSeriesDetail(seriesId) + } + + private fun openSeriesDetail(seriesId: Long) { + if (seriesId <= 0L) return startActivity( Intent(requireContext(), SeriesDetailActivity::class.java).apply { putExtra(Constants.EXTRA_SERIES_ID, seriesId) @@ -312,7 +553,7 @@ class ContentMainFragment : BaseFragment( } private fun updateLoadingDialog() { - if (isRecommendationLoading || isRankingLoading) { + if (isRecommendationLoading || isRankingLoading || isAllTabLoading) { loadingDialog.show(screenWidth) } else { loadingDialog.dismiss() @@ -347,5 +588,6 @@ class ContentMainFragment : BaseFragment( private const val CONTENT_TAB_RECOMMENDATION = 0 private const val CONTENT_TAB_RANKING = 1 private const val CONTENT_TAB_ALL = 2 + private const val CONTENT_ALL_GRID_SPAN_COUNT = 3 } } diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt index b9f32dbb..85630d86 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt @@ -71,6 +71,154 @@ class ContentMainFragmentSourceTest { 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 fun `content 추천 source는 오디오와 시리즈 routing extra를 사용한다`() { val source = projectFile( @@ -207,6 +355,10 @@ class ContentMainFragmentSourceTest { 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 { val candidates = listOf(File(relativePath), File("../$relativePath")) return candidates.firstOrNull { it.exists() }