From a0cfa705517278ba46dbd86a70680718eb14ee31 Mon Sep 17 00:00:00 2001 From: klaus Date: Sat, 27 Jun 2026 03:54:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=ED=8C=94=EB=A1=9C=EC=9E=89=20?= =?UTF-8?q?=ED=83=AD=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B0=80=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/main/home/HomeMainFragment.kt | 79 ++++++++---- .../HomeMainFragmentLoginGuardSourceTest.kt | 117 ++++++++++++++++++ 2 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt index aec8e926..f188bfd7 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivity import kr.co.vividnext.sodalive.base.BaseFragment import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.LoadingDialog +import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.ToastMessage import kr.co.vividnext.sodalive.common.formatUtcRelativeTimeText import kr.co.vividnext.sodalive.databinding.FragmentV2MainHomeBinding @@ -20,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.live.onair.HomeOnAirLiveActivity import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivity import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomListUiItem import kr.co.vividnext.sodalive.v2.main.chat.model.ChatRoomType +import kr.co.vividnext.sodalive.v2.main.ensureMainV2NavigationAllowed import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingChatSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingCreatorSection import kr.co.vividnext.sodalive.v2.main.home.model.HomeFollowingLiveSection @@ -106,6 +108,7 @@ class HomeMainFragment : BaseFragment( private var onCheerFollowAllClick: (List) -> Unit = {} private var hasLoadedCreatorRankings = false private var hasLoadedFollowing = false + private var currentHomeTabIndex = HOME_TAB_RECOMMENDATION override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -118,7 +121,11 @@ class HomeMainFragment : BaseFragment( selectedIndex = 0 ) binding.textTabBarHome.root.setOnTabSelectedListener { index -> - showHomeTab(index) + when (index) { + HOME_TAB_FOLLOWING -> openFollowingTab() + + else -> showHomeTab(index) + } } setUpSectionTitles() setUpRecommendationAdapters() @@ -233,7 +240,17 @@ class HomeMainFragment : BaseFragment( } } + private fun openFollowingTab() { + if (SharedPreferenceManager.token.isBlank()) { + binding.textTabBarHome.root.selectTab(currentHomeTabIndex) + } + ensureMainV2NavigationAllowed { + showHomeTab(HOME_TAB_FOLLOWING) + } + } + private fun showHomeTab(index: Int) { + currentHomeTabIndex = index when (index) { HOME_TAB_RECOMMENDATION -> { binding.nsvHomeRecommendationContent.visibility = View.VISIBLE @@ -487,7 +504,9 @@ class HomeMainFragment : BaseFragment( private fun onLiveClick(item: HomeRecommendationLiveUiModel) = Unit private fun openHomeOnAirLive() { - startActivity(HomeOnAirLiveActivity.newIntent(requireContext())) + ensureMainV2NavigationAllowed { + startActivity(HomeOnAirLiveActivity.newIntent(requireContext())) + } } private fun onFollowingSectionMoreClick(section: HomeFollowingSection) = Unit @@ -499,25 +518,33 @@ class HomeMainFragment : BaseFragment( private fun onFollowingNewsClick(item: HomeFollowingNewsUiItem) = Unit private fun openFollowingChat(item: ChatRoomListUiItem) { - when (item.chatType) { - ChatRoomType.AI -> startActivity(ChatRoomActivity.newIntent(requireContext(), item.roomId)) - ChatRoomType.DM -> startActivity(DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)) + ensureMainV2NavigationAllowed { + when (item.chatType) { + ChatRoomType.AI -> startActivity(ChatRoomActivity.newIntent(requireContext(), item.roomId)) + ChatRoomType.DM -> startActivity(DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)) + } } } private fun onBannerClick(item: HomeRecommendationBannerUiModel) { val route = item.toHomeRecommendationBannerRoute() ?: return - startActivity(route.toHomeRecommendationBannerIntent(requireContext())) + ensureMainV2NavigationAllowed { + startActivity(route.toHomeRecommendationBannerIntent(requireContext())) + } } private fun onRecentActivityClick(item: HomeRecommendationRecentlyActiveCreatorUiModel) { val route = item.toHomeRecommendationRecentlyActiveCreatorRoute() ?: return - startActivity(route.toHomeRecommendationRecentlyActiveCreatorIntent(requireContext())) + ensureMainV2NavigationAllowed { + startActivity(route.toHomeRecommendationRecentlyActiveCreatorIntent(requireContext())) + } } private fun onAiCharacterClick(item: HomeRecommendationAiCharacterUiModel) { val route = item.toHomeRecommendationAiCharacterRoute() ?: return - startActivity(route.toHomeRecommendationAiCharacterIntent(requireContext())) + ensureMainV2NavigationAllowed { + startActivity(route.toHomeRecommendationAiCharacterIntent(requireContext())) + } } private fun openCreatorRankingProfile(item: CreatorRankingItem) { @@ -526,29 +553,35 @@ class HomeMainFragment : BaseFragment( } private fun openCreatorProfile(creatorId: Long) { - startActivity( - CreatorChannelActivity.newIntent(requireContext(), creatorId) - ) + ensureMainV2NavigationAllowed { + startActivity( + CreatorChannelActivity.newIntent(requireContext(), creatorId) + ) + } } private fun openAudioContentDetail(item: HomeRecommendationFirstAudioContentUiModel) { - startActivity( - Intent(requireContext(), AudioContentDetailActivity::class.java).apply { - putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, item.contentId) - } - ) + ensureMainV2NavigationAllowed { + startActivity( + Intent(requireContext(), AudioContentDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, item.contentId) + } + ) + } } private fun openPopularCommunityPost(item: FeedItem.Community) { val creatorId = item.creatorId.toLongOrNull() ?: return val postId = item.postId.toLongOrNull() ?: return - startActivity( - Intent(requireContext(), CreatorCommunityAllActivity::class.java).apply { - putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, creatorId) - putExtra(Constants.EXTRA_COMMUNITY_POST_ID, postId) - putExtra(Constants.EXTRA_COMMUNITY_EXIST_ORDERED, item.existOrdered) - } - ) + ensureMainV2NavigationAllowed { + startActivity( + Intent(requireContext(), CreatorCommunityAllActivity::class.java).apply { + putExtra(Constants.EXTRA_COMMUNITY_CREATOR_ID, creatorId) + putExtra(Constants.EXTRA_COMMUNITY_POST_ID, postId) + putExtra(Constants.EXTRA_COMMUNITY_EXIST_ORDERED, item.existOrdered) + } + ) + } } private fun showToast(toastMessage: ToastMessage) { diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt new file mode 100644 index 00000000..887f2745 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragmentLoginGuardSourceTest.kt @@ -0,0 +1,117 @@ +package kr.co.vividnext.sodalive.v2.main.home + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class HomeMainFragmentLoginGuardSourceTest { + + @Test + fun `HomeMainFragment 실제 이동은 MainV2 로그인 가드를 통과한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt" + ).readText() + + assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.main.ensureMainV2NavigationAllowed")) + assertGuardedStartActivity(source, "private fun openHomeOnAirLive()") + assertGuardedStartActivity(source, "private fun openFollowingChat(item: ChatRoomListUiItem)") + assertGuardedStartActivity(source, "private fun onBannerClick(item: HomeRecommendationBannerUiModel)") + assertGuardedStartActivity( + source, + "private fun onRecentActivityClick(item: HomeRecommendationRecentlyActiveCreatorUiModel)" + ) + assertGuardedStartActivity(source, "private fun onAiCharacterClick(item: HomeRecommendationAiCharacterUiModel)") + assertGuardedStartActivity(source, "private fun openCreatorProfile(creatorId: Long)") + assertGuardedStartActivity(source, "private fun openAudioContentDetail(item: HomeRecommendationFirstAudioContentUiModel)") + assertGuardedStartActivity(source, "private fun openPopularCommunityPost(item: FeedItem.Community)") + } + + @Test + fun `HomeMainFragment invalid route와 id return은 로그인 가드보다 먼저 유지된다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt" + ).readText() + + assertBeforeGuard(source, "val route = item.toHomeRecommendationBannerRoute() ?: return") + assertBeforeGuard(source, "val route = item.toHomeRecommendationRecentlyActiveCreatorRoute() ?: return") + assertBeforeGuard(source, "val route = item.toHomeRecommendationAiCharacterRoute() ?: return") + assertBeforeGuard(source, "val creatorId = item.creatorId.toLongOrNull() ?: return") + assertBeforeGuard(source, "val postId = item.postId.toLongOrNull() ?: return") + assertFalse(source.contains("requiresAdultContentAccess = true")) + } + + @Test + fun `HomeMainFragment 팔로잉 탭 선택은 로그인 가드 통과 후 탭을 전환한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt" + ).readText() + + val listenerSource = source.substringFrom("binding.textTabBarHome.root.setOnTabSelectedListener { index ->") + assertTrue(listenerSource.contains("HOME_TAB_FOLLOWING -> openFollowingTab()")) + assertTrue(listenerSource.contains("else -> showHomeTab(index)")) + assertFalse(listenerSource.contains("showHomeTab(index)\n }")) + + val followingTabSource = source.substringFrom("HOME_TAB_FOLLOWING -> {") + assertTrue(followingTabSource.contains("homeFollowingViewModel.loadFollowing()")) + } + + @Test + fun `HomeMainFragment 팔로잉 탭 미로그인 차단은 이전 탭 선택 상태로 복구한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/HomeMainFragment.kt" + ).readText() + + assertTrue(source.contains("import kr.co.vividnext.sodalive.common.SharedPreferenceManager")) + assertTrue(source.contains("private var currentHomeTabIndex = HOME_TAB_RECOMMENDATION")) + + val followingTabSource = source.substringFrom("private fun openFollowingTab()") + assertTrue(followingTabSource.contains("if (SharedPreferenceManager.token.isBlank())")) + assertBefore( + followingTabSource, + "binding.textTabBarHome.root.selectTab(currentHomeTabIndex)", + "ensureMainV2NavigationAllowed" + ) + assertTrue(followingTabSource.contains("showHomeTab(HOME_TAB_FOLLOWING)")) + + val showHomeTabSource = source.substringFrom("private fun showHomeTab(index: Int)") + assertTrue(showHomeTabSource.contains("currentHomeTabIndex = index")) + } + + private fun assertGuardedStartActivity(source: String, functionSignature: String) { + val functionSource = source.substringFrom(functionSignature) + assertTrue( + "$functionSignature must call ensureMainV2NavigationAllowed before startActivity.", + functionSource.indexOf("ensureMainV2NavigationAllowed") in 0 until functionSource.indexOf("startActivity") + ) + } + + private fun assertBeforeGuard(source: String, expectedReturn: String) { + val returnIndex = source.indexOf(expectedReturn) + val guardIndex = source.indexOf("ensureMainV2NavigationAllowed", returnIndex) + + assertTrue("Missing source: $expectedReturn", returnIndex >= 0) + assertTrue("$expectedReturn must stay before guard.", guardIndex > returnIndex) + } + + private fun assertBefore(source: String, expectedBefore: String, expectedAfter: String) { + val beforeIndex = source.indexOf(expectedBefore) + val afterIndex = source.indexOf(expectedAfter, beforeIndex) + + assertTrue("Missing source: $expectedBefore", beforeIndex >= 0) + assertTrue("Missing source after $expectedBefore: $expectedAfter", afterIndex > beforeIndex) + } + + private fun String.substringFrom(marker: String): String { + val startIndex = indexOf(marker) + assertTrue("Missing function: $marker", startIndex >= 0) + val nextFunctionIndex = indexOf("\n private fun ", startIndex + marker.length).takeIf { it >= 0 } ?: length + return substring(startIndex, nextFunctionIndex) + } + + private fun projectFile(relativePath: String): File { + val candidates = listOf(File(relativePath), File("../$relativePath")) + return candidates.firstOrNull { it.exists() } + ?: error("Project file not found: $relativePath") + } +}