fix(home): 홈 추천 후속 검증을 반영한다

This commit is contained in:
2026-07-31 16:29:06 +09:00
parent a106548f87
commit d592038edd
28 changed files with 1986 additions and 33 deletions

View File

@@ -20,6 +20,7 @@ import kr.co.vividnext.sodalive.v2.access.AccessRequirement
import kr.co.vividnext.sodalive.v2.access.ensureV2Access
import kr.co.vividnext.sodalive.v2.chat.action.ChatActionCommand
import kr.co.vividnext.sodalive.v2.chat.action.handleChatAction
import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog
import kr.co.vividnext.sodalive.v2.common.CreatorActivityType
import kr.co.vividnext.sodalive.v2.community.action.CommunityActionCommand
import kr.co.vividnext.sodalive.v2.community.action.handleCommunityAction
@@ -232,10 +233,26 @@ class HomeMainFragment : BaseFragment<FragmentV2MainHomeBinding>(
adapter = popularCommunityAdapter
}
onCheerFollowAllClick = { creatorIds ->
homeRecommendationViewModel.followCreators(SECTION_KEY_CHEER_CREATORS, creatorIds)
ensureV2Access(AccessRequirement.Login) {
showCheerCreatorsFollowAllConfirmDialog(creatorIds)
}
}
}
private fun showCheerCreatorsFollowAllConfirmDialog(creatorIds: List<Long>) {
V2ModalDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
title = getString(R.string.home_recommendation_follow_all_confirm_title),
desc = getString(R.string.home_recommendation_follow_all_confirm_description),
confirmButtonTitle = getString(R.string.home_recommendation_follow_all_confirm_button),
confirmButtonClick = {
homeRecommendationViewModel.followCreators(SECTION_KEY_CHEER_CREATORS, creatorIds)
},
cancelButtonTitle = getString(R.string.cancel)
).show(screenWidth)
}
private fun setUpCreatorRankingAdapter() {
binding.rvHomeCreatorRankings.apply {
layoutManager = CreatorRankingAdapter.createGridLayoutManager(requireContext())

View File

@@ -5,7 +5,6 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import coil.dispose
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.image.BlurTransformation
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostUiModel
@@ -51,9 +50,8 @@ class HomePopularCommunityAdapter(
view.setOnFeedClick { feedItem -> onClickItem(feedItem as FeedItem.Community) }
bindImage(item.item.creatorImageUrl, view.profileImageView())
bindImage(
imageUrl = item.item.imageUrl,
imageUrl = item.item.imageUrl.takeIf { !item.item.isLockedPaidPost() },
imageView = view.communityImageView(),
shouldBlur = item.item.price > 0 && !item.item.existOrdered,
onImageLoaded = { width, height -> view.applyCommunityImageSize(width, height) }
)
}
@@ -61,7 +59,6 @@ class HomePopularCommunityAdapter(
private fun bindImage(
imageUrl: String?,
imageView: android.widget.ImageView,
shouldBlur: Boolean = false,
onImageLoaded: ((Int, Int) -> Unit)? = null
) {
if (imageUrl.isNullOrBlank()) {
@@ -74,11 +71,12 @@ class HomePopularCommunityAdapter(
onImageLoaded?.invoke(result.drawable.intrinsicWidth, result.drawable.intrinsicHeight)
}
)
if (shouldBlur) {
transformations(BlurTransformation(imageView.context, 25f, 2.5f))
}
}
}
}
private fun FeedItem.Community.isLockedPaidPost(): Boolean {
return price > 0 && !existOrdered
}
}
}

View File

@@ -323,6 +323,9 @@
<string name="home_recommendation_activity_community">Community</string>
<string name="home_recommendation_follow_all">Follow all</string>
<string name="home_recommendation_follow_all_done">Following all</string>
<string name="home_recommendation_follow_all_confirm_title">Follow all</string>
<string name="home_recommendation_follow_all_confirm_description">Would you like to follow all creators in this list?</string>
<string name="home_recommendation_follow_all_confirm_button">Follow</string>
<string name="home_recommendation_more">More</string>
<string name="home_recommendation_collapse">Collapse</string>
<string name="common_error_unknown">An unknown error occurred. try again.</string>

View File

@@ -323,6 +323,9 @@
<string name="home_recommendation_activity_community">コミュニティ</string>
<string name="home_recommendation_follow_all">すべてフォロー</string>
<string name="home_recommendation_follow_all_done">すべてフォロー中</string>
<string name="home_recommendation_follow_all_confirm_title">すべてフォロー</string>
<string name="home_recommendation_follow_all_confirm_description">このリストのクリエイターをすべてフォローしますか?</string>
<string name="home_recommendation_follow_all_confirm_button">フォローする</string>
<string name="home_recommendation_more">もっと見る</string>
<string name="home_recommendation_collapse">折りたたむ</string>
<string name="common_error_unknown">不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。</string>

View File

@@ -322,6 +322,9 @@
<string name="home_recommendation_activity_community">커뮤니티</string>
<string name="home_recommendation_follow_all">모두 팔로우 하기</string>
<string name="home_recommendation_follow_all_done">모두 팔로우 완료</string>
<string name="home_recommendation_follow_all_confirm_title">모두 팔로우</string>
<string name="home_recommendation_follow_all_confirm_description">목록의 크리에이터를 모두 팔로우하시겠어요?</string>
<string name="home_recommendation_follow_all_confirm_button">팔로우 하기</string>
<string name="home_recommendation_more">더보기</string>
<string name="home_recommendation_collapse">접기</string>
<string name="common_error_unknown">알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.</string>

View File

@@ -1022,18 +1022,6 @@ class HomeMainFragmentLayoutTest {
assertEquals(null, aiCharacter(creatorId = -1L).toHomeRecommendationAiCharacterRoute())
}
@Test
fun `home popular community adapter applies blur when locked paid post image is loaded`() {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/v2/main/home/ui/HomePopularCommunityAdapter.kt"
).readText()
assertTrue(source.contains("imageUrl = item.item.imageUrl"))
assertTrue(source.contains("shouldBlur = item.item.price > 0 && !item.item.existOrdered"))
assertTrue(source.contains("BlurTransformation(imageView.context, 25f, 2.5f)"))
assertFalse(source.contains("item.item.imageUrl.takeIf { item.item.price <= 0 || item.item.existOrdered }"))
}
@Test
fun `home live section height follows its items`() {
val root = inflateView(R.layout.fragment_v2_main_home)
@@ -1298,6 +1286,73 @@ class HomeMainFragmentLayoutTest {
assertFalse(source.contains("followCreators(genreSectionKey"))
}
@Test
fun `응원 크리에이터 모두 팔로우는 API 요청 전에 확인창을 연다`() {
val source = homeMainFragmentSource()
assertTrue(source.contains("import kr.co.vividnext.sodalive.v2.components.modal.V2ModalDialog"))
assertTrue(source.contains("private fun showCheerCreatorsFollowAllConfirmDialog(creatorIds: List<Long>)"))
assertTrue(
source.contains(
"onCheerFollowAllClick = { creatorIds ->\n" +
" ensureV2Access(AccessRequirement.Login) {\n" +
" showCheerCreatorsFollowAllConfirmDialog(creatorIds)\n" +
" }\n" +
" }"
)
)
assertTrue(source.contains("V2ModalDialog("))
assertTrue(source.contains("title = getString(R.string.home_recommendation_follow_all_confirm_title)"))
assertTrue(source.contains("desc = getString(R.string.home_recommendation_follow_all_confirm_description)"))
assertTrue(
source.contains(
"confirmButtonTitle = getString(R.string.home_recommendation_follow_all_confirm_button)"
)
)
assertTrue(source.contains("cancelButtonTitle = getString(R.string.cancel)"))
assertTrue(
source.contains(
"confirmButtonClick = {\n" +
" homeRecommendationViewModel.followCreators(SECTION_KEY_CHEER_CREATORS, creatorIds)\n" +
" }"
)
)
assertTrue(source.contains(".show(screenWidth)"))
}
@Test
fun `모두 팔로우 확인창 문자열은 다국어로 제공한다`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val koreanContext = context.createConfigurationContext(
Configuration(context.resources.configuration).apply { setLocale(Locale.KOREAN) }
)
val englishContext = context.createConfigurationContext(
Configuration(context.resources.configuration).apply { setLocale(Locale.ENGLISH) }
)
val japaneseContext = context.createConfigurationContext(
Configuration(context.resources.configuration).apply { setLocale(Locale.JAPANESE) }
)
assertEquals("모두 팔로우", koreanContext.getString(R.string.home_recommendation_follow_all_confirm_title))
assertEquals(
"목록의 크리에이터를 모두 팔로우하시겠어요?",
koreanContext.getString(R.string.home_recommendation_follow_all_confirm_description)
)
assertEquals("팔로우 하기", koreanContext.getString(R.string.home_recommendation_follow_all_confirm_button))
assertEquals("Follow all", englishContext.getString(R.string.home_recommendation_follow_all_confirm_title))
assertEquals(
"Would you like to follow all creators in this list?",
englishContext.getString(R.string.home_recommendation_follow_all_confirm_description)
)
assertEquals("Follow", englishContext.getString(R.string.home_recommendation_follow_all_confirm_button))
assertEquals("すべてフォロー", japaneseContext.getString(R.string.home_recommendation_follow_all_confirm_title))
assertEquals(
"このリストのクリエイターをすべてフォローしますか?",
japaneseContext.getString(R.string.home_recommendation_follow_all_confirm_description)
)
assertEquals("フォローする", japaneseContext.getString(R.string.home_recommendation_follow_all_confirm_button))
}
@Test
fun `home recommendation viewmodel does not keep android context`() {
val viewModelSource = homeRecommendationViewModelSource()

View File

@@ -195,6 +195,21 @@ class HomeMainFragmentLoginGuardSourceTest {
assertTrue(clickSource.contains("CreatorActionCommand.Profile(route.creatorId)"))
}
@Test
fun `HomeMainFragment 응원 크리에이터 모두 팔로우는 확인창보다 로그인 가드를 먼저 실행한다`() {
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("onCheerFollowAllClick = { creatorIds ->"))
assertBefore(
setupSource,
"ensureV2Access(AccessRequirement.Login)",
"showCheerCreatorsFollowAllConfirmDialog(creatorIds)"
)
}
@Test
fun `HomeMainFragment loading dialog는 통합 loading state로 제어한다`() {
val source = projectFile(

View File

@@ -0,0 +1,153 @@
package kr.co.vividnext.sodalive.v2.main.home
import android.app.Application
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.load
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.ImageLoaderProvider
import kr.co.vividnext.sodalive.extensions.loadUrl
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPaidStatus
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostSection
import kr.co.vividnext.sodalive.v2.main.home.model.HomeRecommendationPopularCommunityPostUiModel
import kr.co.vividnext.sodalive.v2.main.home.ui.HomePopularCommunityAdapter
import kr.co.vividnext.sodalive.v2.widget.feed.FeedItem
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class HomePopularCommunityAdapterTest {
@Before
fun setUp() {
if (!ImageLoaderProvider.isInitialized) {
ImageLoaderProvider.init(RuntimeEnvironment.getApplication())
}
mockkStatic("kr.co.vividnext.sodalive.extensions.ImageExtensionsKt")
every { any<ImageView>().loadUrl(any(), any()) } just Runs
}
@After
fun tearDown() {
unmockkStatic("kr.co.vividnext.sodalive.extensions.ImageExtensionsKt")
}
@Test
fun `유료 미구매 bind는 원본을 로드하지 않고 이전 요청과 drawable을 제거한다`() {
val adapter = adapterWith(
communityPost(
imageUrl = "https://example.com/locked.png",
price = 30,
existOrdered = false
)
)
val holder = createViewHolder(adapter)
val imageView = holder.itemView.findViewById<ImageView>(R.id.iv_feed_community_image)
val disposable = imageView.load(ColorDrawable(Color.BLUE), ImageLoaderProvider.imageLoader)
assertFalse(disposable.isDisposed)
adapter.onBindViewHolder(holder, 0)
verify(exactly = 0) { imageView.loadUrl(any(), any()) }
assertTrue(disposable.isDisposed)
assertNull(imageView.drawable)
assertEquals(
View.VISIBLE,
holder.itemView.findViewById<View>(R.id.ll_feed_community_paid_overlay).visibility
)
assertEquals(
"30",
holder.itemView.findViewById<TextView>(R.id.tv_feed_community_price).text.toString()
)
}
@Test
fun `무료와 구매 완료 bind는 원본 이미지 URL을 로더에 전달한다`() {
val freeImageUrl = "https://example.com/free.png"
val purchasedImageUrl = "https://example.com/purchased.png"
val adapter = adapterWith(
communityPost(imageUrl = freeImageUrl, price = 0, existOrdered = false),
communityPost(imageUrl = purchasedImageUrl, price = 30, existOrdered = true)
)
val holder = createViewHolder(adapter)
val imageView = holder.itemView.findViewById<ImageView>(R.id.iv_feed_community_image)
adapter.onBindViewHolder(holder, 0)
adapter.onBindViewHolder(holder, 1)
verify(exactly = 1) { imageView.loadUrl(freeImageUrl, any()) }
verify(exactly = 1) { imageView.loadUrl(purchasedImageUrl, any()) }
assertEquals(View.GONE, holder.itemView.findViewById<View>(R.id.ll_feed_community_paid_overlay).visibility)
}
private fun adapterWith(
vararg items: HomeRecommendationPopularCommunityPostUiModel
): HomePopularCommunityAdapter {
return HomePopularCommunityAdapter(onClickItem = {}).apply {
submitSection(HomeRecommendationPopularCommunityPostSection(items.toList()))
}
}
private fun createViewHolder(
adapter: HomePopularCommunityAdapter
): HomePopularCommunityAdapter.CommunityViewHolder {
val context = RuntimeEnvironment.getApplication()
val parent = RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
}
return adapter.onCreateViewHolder(parent, 0)
}
private fun communityPost(
imageUrl: String,
price: Int,
existOrdered: Boolean
): HomeRecommendationPopularCommunityPostUiModel {
val paidStatus = when {
price == 0 -> HomeRecommendationPaidStatus.Free
existOrdered -> HomeRecommendationPaidStatus.Purchased
else -> HomeRecommendationPaidStatus.Paid(price)
}
return HomeRecommendationPopularCommunityPostUiModel(
item = FeedItem.Community(
feedId = "feed-1",
creatorId = "creator-1",
creatorName = "크리에이터",
creatorImageUrl = "",
postId = "post-1",
bodyText = "본문",
keywordText = "",
createdAtText = "방금 전",
commentCount = 1,
likeCount = 2,
imageUrl = imageUrl,
audioUrl = null,
price = price,
existOrdered = existOrdered,
showKeyword = false
),
paidStatus = paidStatus
)
}
}