diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt new file mode 100644 index 00000000..6b6ec3f7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt @@ -0,0 +1,183 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.base.BaseFragment +import kr.co.vividnext.sodalive.databinding.FragmentCreatorChannelLiveBinding +import kr.co.vividnext.sodalive.extensions.moneyFormat +import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelLiveResponse +import kr.co.vividnext.sodalive.v2.creator.channel.live.model.toLabelResId +import kr.co.vividnext.sodalive.v2.creator.channel.live.model.toReplayUiModel +import kr.co.vividnext.sodalive.v2.creator.channel.live.ui.CreatorChannelLiveReplayAdapter +import kr.co.vividnext.sodalive.v2.creator.channel.ui.formatCreatorChannelLiveDateTime +import org.koin.androidx.viewmodel.ext.android.viewModel + +class CreatorChannelLiveFragment : BaseFragment( + FragmentCreatorChannelLiveBinding::inflate +) { + + private val viewModel: CreatorChannelLiveViewModel by viewModel() + private val replayAdapter = CreatorChannelLiveReplayAdapter { item -> + host.onCreatorChannelLiveReplayClicked(item.audioContentId) + } + private var lastContentLayoutKey: CreatorChannelLiveContentLayoutKey? = null + private val creatorId: Long by lazy { arguments?.getLong(ARG_CREATOR_ID) ?: 0L } + private val host: Host + get() = requireActivity() as Host + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + bindLoading() + setupReplayList() + setupClickListeners() + observeViewModel() + } + + fun onCreatorChannelLiveTabSelected() { + if (creatorId > 0L) { + viewModel.loadLive(creatorId) + } + } + + override fun onDestroyView() { + binding.rvCreatorChannelLiveReplays.adapter = null + super.onDestroyView() + } + + private fun setupReplayList() = with(binding.rvCreatorChannelLiveReplays) { + layoutManager = LinearLayoutManager(requireContext()) + adapter = replayAdapter + } + + fun onCreatorChannelLiveScrolledToBottom() { + viewModel.loadMore() + } + + private fun setupClickListeners() { + binding.ivCreatorChannelLiveSort.setImageResource(R.drawable.ic_new_sort) + binding.btnCreatorChannelLiveRetry.setOnClickListener { + viewModel.retryLive() + } + } + + private fun observeViewModel() { + viewModel.liveStateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + CreatorChannelLiveUiState.Loading -> bindLoading() + CreatorChannelLiveUiState.Empty -> bindEmpty() + is CreatorChannelLiveUiState.Error -> bindError(state) + is CreatorChannelLiveUiState.Content -> bindContent(state) + } + } + } + + private fun bindLoading() = with(binding) { + lastContentLayoutKey = null + layoutCreatorChannelLiveSortBar.isVisible = false + layoutCreatorChannelLiveCurrentCard.isVisible = false + rvCreatorChannelLiveReplays.isVisible = false + tvCreatorChannelLiveEmptyMessage.isVisible = false + tvCreatorChannelLiveErrorMessage.isVisible = false + btnCreatorChannelLiveRetry.isVisible = false + } + + private fun bindEmpty() = with(binding) { + lastContentLayoutKey = null + layoutCreatorChannelLiveSortBar.isVisible = false + layoutCreatorChannelLiveCurrentCard.isVisible = false + rvCreatorChannelLiveReplays.isVisible = false + tvCreatorChannelLiveEmptyMessage.isVisible = true + tvCreatorChannelLiveErrorMessage.isVisible = false + btnCreatorChannelLiveRetry.isVisible = false + host.onCreatorChannelLiveContentChanged() + } + + private fun bindError(state: CreatorChannelLiveUiState.Error) = with(binding) { + lastContentLayoutKey = null + layoutCreatorChannelLiveSortBar.isVisible = false + layoutCreatorChannelLiveCurrentCard.isVisible = false + rvCreatorChannelLiveReplays.isVisible = false + tvCreatorChannelLiveEmptyMessage.isVisible = false + tvCreatorChannelLiveErrorMessage.isVisible = true + tvCreatorChannelLiveErrorMessage.text = state.message ?: getString(R.string.creator_channel_live_error_message) + btnCreatorChannelLiveRetry.isVisible = true + host.onCreatorChannelLiveContentChanged() + } + + private fun bindContent(state: CreatorChannelLiveUiState.Content) = with(binding) { + tvCreatorChannelLiveEmptyMessage.isVisible = false + tvCreatorChannelLiveErrorMessage.isVisible = false + btnCreatorChannelLiveRetry.isVisible = false + layoutCreatorChannelLiveSortBar.isVisible = true + tvCreatorChannelLiveTotalCount.text = state.liveReplayContentCount.moneyFormat() + tvCreatorChannelLiveSortLabel.setText(state.selectedSort.toLabelResId()) + bindCurrentLive(state.currentLive) + rvCreatorChannelLiveReplays.isVisible = true + replayAdapter.submitItems(state.liveReplayContents.map { it.toReplayUiModel() }) + notifyContentChangedIfLayoutChanged(state) + state.paginationErrorMessage?.let { + Toast.makeText(requireContext(), it, Toast.LENGTH_SHORT).show() + viewModel.consumePaginationErrorMessage() + } + } + + private fun notifyContentChangedIfLayoutChanged(state: CreatorChannelLiveUiState.Content) { + val contentLayoutKey = state.toContentLayoutKey() + if (contentLayoutKey == lastContentLayoutKey) return + + lastContentLayoutKey = contentLayoutKey + host.onCreatorChannelLiveContentChanged() + } + + private fun bindCurrentLive(live: CreatorChannelLiveResponse?) = with(binding) { + layoutCreatorChannelLiveCurrentCard.isVisible = live != null + if (live == null) return@with + + tvCreatorChannelLiveCurrentTitle.text = live.title + tvCreatorChannelLiveCurrentTime.text = formatCreatorChannelLiveDateTime(live.beginDateTimeUtc) + tvCreatorChannelLiveCurrentPrice.text = if (live.price > 0) { + live.price.moneyFormat() + } else { + getString(R.string.audio_content_tag_free) + } + layoutCreatorChannelLiveCurrentPrice.isVisible = true + ivCreatorChannelLiveCurrentPriceCash.isVisible = live.price > 0 + layoutCreatorChannelLiveCurrentCard.setOnClickListener { + host.onCreatorChannelCurrentLiveClicked(live) + } + } + + interface Host { + fun onCreatorChannelCurrentLiveClicked(live: CreatorChannelLiveResponse) + fun onCreatorChannelLiveReplayClicked(audioContentId: Long) + fun onCreatorChannelLiveContentChanged() + } + + companion object { + private const val ARG_CREATOR_ID: String = "arg_creator_id" + + fun newInstance(creatorId: Long): CreatorChannelLiveFragment { + return CreatorChannelLiveFragment().apply { + arguments = Bundle().apply { putLong(ARG_CREATOR_ID, creatorId) } + } + } + } +} + +private data class CreatorChannelLiveContentLayoutKey( + val liveReplayContentCount: Int, + val currentLive: CreatorChannelLiveResponse?, + val liveReplayContentIds: List +) + +private fun CreatorChannelLiveUiState.Content.toContentLayoutKey(): CreatorChannelLiveContentLayoutKey { + return CreatorChannelLiveContentLayoutKey( + liveReplayContentCount = liveReplayContentCount, + currentLive = currentLive, + liveReplayContentIds = liveReplayContents.map { it.audioContentId } + ) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/ui/CreatorChannelLiveReplayAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/ui/CreatorChannelLiveReplayAdapter.kt new file mode 100644 index 00000000..1094278e --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/ui/CreatorChannelLiveReplayAdapter.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.databinding.ItemCreatorChannelLiveReplayBinding +import kr.co.vividnext.sodalive.extensions.loadUrl +import kr.co.vividnext.sodalive.extensions.moneyFormat +import kr.co.vividnext.sodalive.v2.creator.channel.live.model.CreatorChannelLiveReplayStatus +import kr.co.vividnext.sodalive.v2.creator.channel.live.model.CreatorChannelLiveReplayUiModel +import kr.co.vividnext.sodalive.v2.widget.AudioContentTag + +class CreatorChannelLiveReplayAdapter( + private val onReplayClick: (CreatorChannelLiveReplayUiModel) -> Unit = {} +) : RecyclerView.Adapter() { + + private var items: List = emptyList() + + fun submitItems(items: List) { + this.items = items + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + ItemCreatorChannelLiveReplayBinding.inflate(LayoutInflater.from(parent.context), parent, false), + onReplayClick + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size + + class ViewHolder( + private val binding: ItemCreatorChannelLiveReplayBinding, + private val onReplayClick: (CreatorChannelLiveReplayUiModel) -> Unit + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: CreatorChannelLiveReplayUiModel) = with(binding) { + ivCreatorChannelLiveReplayThumbnail.loadUrl(item.imageUrl) + tvCreatorChannelLiveReplayTitle.text = item.title + tvCreatorChannelLiveReplayDuration.text = item.secondaryText.orEmpty() + tvCreatorChannelLiveReplayDuration.isVisible = !item.secondaryText.isNullOrBlank() + ivCreatorChannelLiveReplayAdultBadge.setImageResource(R.drawable.ic_new_shield_small) + ivCreatorChannelLiveReplayAdultBadge.isVisible = item.showAdultBadge + bindTag(ivCreatorChannelLiveReplayOriginalTag, AudioContentTag.Original, item.tags) + bindTag(ivCreatorChannelLiveReplayFirstTag, AudioContentTag.First, item.tags) + bindTag(ivCreatorChannelLiveReplayPointTag, AudioContentTag.Point, item.tags) + tvCreatorChannelLiveReplayFreeTag.isVisible = AudioContentTag.Free in item.tags + bindStatus(item.status) + root.setOnClickListener { onReplayClick(item) } + } + + private fun bindTag(view: View, tag: AudioContentTag, tags: Set) { + view.isVisible = tag in tags + } + + private fun bindStatus(status: CreatorChannelLiveReplayStatus) = with(binding) { + ivCreatorChannelLiveReplayPlay.setImageResource(R.drawable.ic_new_player_play) + when (status) { + CreatorChannelLiveReplayStatus.Play -> { + ivCreatorChannelLiveReplayPlay.isVisible = true + ivCreatorChannelLiveReplayCan.isVisible = false + layoutCreatorChannelLiveReplayActionText.isVisible = false + } + CreatorChannelLiveReplayStatus.Owned -> bindTextStatus(R.string.audio_content_badge_owned) + CreatorChannelLiveReplayStatus.Rented -> bindTextStatus(R.string.audio_content_badge_rented) + is CreatorChannelLiveReplayStatus.Price -> { + ivCreatorChannelLiveReplayPlay.isVisible = false + layoutCreatorChannelLiveReplayActionText.isVisible = true + ivCreatorChannelLiveReplayCan.isVisible = true + tvCreatorChannelLiveReplayActionText.text = status.price.moneyFormat() + } + } + } + + private fun bindTextStatus(textResId: Int) = with(binding) { + ivCreatorChannelLiveReplayPlay.isVisible = true + layoutCreatorChannelLiveReplayActionText.isVisible = true + ivCreatorChannelLiveReplayCan.isVisible = false + tvCreatorChannelLiveReplayActionText.setText(textResId) + } + } +} diff --git a/app/src/main/res/layout/fragment_creator_channel_live.xml b/app/src/main/res/layout/fragment_creator_channel_live.xml new file mode 100644 index 00000000..d94b84a4 --- /dev/null +++ b/app/src/main/res/layout/fragment_creator_channel_live.xml @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_creator_channel_live_replay.xml b/app/src/main/res/layout/item_creator_channel_live_replay.xml new file mode 100644 index 00000000..4414936e --- /dev/null +++ b/app/src/main/res/layout/item_creator_channel_live_replay.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapterTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapterTest.kt new file mode 100644 index 00000000..1fcc9824 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/CreatorChannelPagerAdapterTest.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.v2.creator.channel + +import android.app.Application +import androidx.fragment.app.FragmentActivity +import kr.co.vividnext.sodalive.v2.creator.channel.live.CreatorChannelLiveFragment +import kr.co.vividnext.sodalive.v2.creator.channel.model.CreatorChannelTab +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class CreatorChannelPagerAdapterTest { + + @Test + fun `createFragment는 Home과 Live를 실제 Fragment로 생성하고 나머지는 placeholder를 유지한다`() { + val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get() + val adapter = CreatorChannelPagerAdapter(activity, creatorId = 123L) + + assertTrue(adapter.createFragment(CreatorChannelTab.Home.ordinal) is CreatorChannelHomeFragment) + assertTrue(adapter.createFragment(CreatorChannelTab.Live.ordinal) is CreatorChannelLiveFragment) + CreatorChannelTab.entries + .filterNot { it == CreatorChannelTab.Home || it == CreatorChannelTab.Live } + .forEach { tab -> + assertTrue(adapter.createFragment(tab.ordinal) is CreatorChannelPlaceholderFragment) + } + assertEquals(CreatorChannelTab.entries.size, adapter.itemCount) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragmentLayoutTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragmentLayoutTest.kt new file mode 100644 index 00000000..5c95e42d --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragmentLayoutTest.kt @@ -0,0 +1,183 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live + +import android.app.Application +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ApplicationProvider +import kr.co.vividnext.sodalive.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class CreatorChannelLiveFragmentLayoutTest { + + @Test + fun `라이브 fragment layout은 sort current live list empty error owner CTA를 제공한다`() { + val root = inflateView(R.layout.fragment_creator_channel_live) + val sortBar = requireNotNull(root.findViewById(R.id.layout_creator_channel_live_sort_bar)) + val currentLiveCard = requireNotNull(root.findViewById(R.id.layout_creator_channel_live_current_card)) + val replayList = requireNotNull(root.findViewById(R.id.rv_creator_channel_live_replays)) + val emptyMessage = requireNotNull(root.findViewById(R.id.tv_creator_channel_live_empty_message)) + val errorMessage = requireNotNull(root.findViewById(R.id.tv_creator_channel_live_error_message)) + val retryButton = requireNotNull(root.findViewById(R.id.btn_creator_channel_live_retry)) + val ownerCta = requireNotNull(root.findViewById(R.id.layout_creator_channel_live_owner_cta)) + + assertSame(root, sortBar.parent) + assertSame(root, replayList.parent) + assertSame(root, emptyMessage.parent) + assertSame(root, errorMessage.parent) + assertSame(root, retryButton.parent) + assertSame(root, ownerCta.parent) + assertNotNull(currentLiveCard.findViewById(R.id.tv_creator_channel_live_current_title)) + assertEquals(false, replayList.clipToPadding) + } + + @Test + fun `라이브 sort bar는 전체 count 정렬 label sort icon을 제공한다`() { + val root = inflateView(R.layout.fragment_creator_channel_live) + val sortBar = requireNotNull(root.findViewById(R.id.layout_creator_channel_live_sort_bar)) + + assertNotNull(sortBar.findViewById(R.id.tv_creator_channel_live_total_label)) + assertNotNull(sortBar.findViewById(R.id.tv_creator_channel_live_total_count)) + assertNotNull(sortBar.findViewById(R.id.tv_creator_channel_live_sort_label)) + assertNotNull(sortBar.findViewById(R.id.iv_creator_channel_live_sort)) + } + + @Test + fun `라이브 다시듣기 item layout은 썸네일 tag title duration action 영역을 제공한다`() { + val item = inflateView(R.layout.item_creator_channel_live_replay) + + assertNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_thumbnail)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_adult_badge)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_original_tag)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_first_tag)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_point_tag)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_live_replay_free_tag)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_live_replay_title)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_live_replay_duration)) + assertNotNull(item.findViewById(R.id.layout_creator_channel_live_replay_action)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_play)) + assertNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_can)) + assertNotNull(item.findViewById(R.id.layout_creator_channel_live_replay_action_text)) + assertNotNull(item.findViewById(R.id.tv_creator_channel_live_replay_action_text)) + } + + @Test + fun `라이브 layout은 가격 영역을 유연한 너비와 bar cash icon으로 제공한다`() { + val fragmentLayout = projectFile("app/src/main/res/layout/fragment_creator_channel_live.xml").readText() + val itemLayout = projectFile("app/src/main/res/layout/item_creator_channel_live_replay.xml").readText() + val root = inflateView(R.layout.fragment_creator_channel_live) + val item = inflateView(R.layout.item_creator_channel_live_replay) + val currentPrice = requireNotNull(root.findViewById(R.id.layout_creator_channel_live_current_price)) + val currentPriceCash = requireNotNull(root.findViewById(R.id.iv_creator_channel_live_current_price_cash)) + val replayAction = requireNotNull(item.findViewById(R.id.layout_creator_channel_live_replay_action)) + val replayPlay = requireNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_play)) + val actionText = requireNotNull(item.findViewById(R.id.layout_creator_channel_live_replay_action_text)) + val adultBadge = requireNotNull(item.findViewById(R.id.iv_creator_channel_live_replay_adult_badge)) + val adultBadgeParams = adultBadge.layoutParams as ViewGroup.MarginLayoutParams + + assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, currentPrice.layoutParams.width) + assertNotNull(currentPriceCash) + assertEquals(ViewGroup.LayoutParams.WRAP_CONTENT, replayAction.layoutParams.width) + assertEquals(dp(28), replayPlay.layoutParams.width) + assertEquals(dp(28), replayPlay.layoutParams.height) + assertEquals(dp(6), replayPlay.paddingStart) + assertEquals(dp(8), (actionText.layoutParams as ViewGroup.MarginLayoutParams).topMargin) + assertEquals(dp(6), adultBadgeParams.topMargin) + assertEquals(dp(6), adultBadgeParams.marginEnd) + assertTrue(fragmentLayout.contains("@drawable/ic_bar_cash")) + assertTrue(itemLayout.contains("@drawable/ic_bar_cash")) + assertTrue(itemLayout.contains("@drawable/bg_creator_channel_live_replay_play")) + assertTrue(itemLayout.contains("android:orientation=\"vertical\"")) + assertFalse(fragmentLayout.contains("@drawable/ic_can")) + assertFalse(itemLayout.contains("@drawable/ic_can")) + assertFalse( + fragmentLayout.contains( + "android:id=\"@+id/layout_creator_channel_live_current_price\"\n" + + " android:layout_width=\"60dp\"" + ) + ) + assertFalse( + itemLayout.contains( + "android:id=\"@+id/layout_creator_channel_live_replay_action\"\n" + + " android:layout_width=\"60dp\"" + ) + ) + } + + @Test + fun `라이브 fragment와 adapter source는 필수 drawable과 retry loadMore click 연결을 포함한다`() { + val fragment = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/CreatorChannelLiveFragment.kt" + ).readText() + val adapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/live/ui/CreatorChannelLiveReplayAdapter.kt" + ).readText() + val layout = projectFile("app/src/main/res/layout/fragment_creator_channel_live.xml").readText() + val eagerLoadOnViewCreated = """ + onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupReplayList() + setupClickListeners() + observeViewModel() + if (creatorId > 0L) + """.trimIndent() + + assertTrue(layout.contains("android:layout_height=\"match_parent\"")) + assertTrue(fragment.contains("R.drawable.ic_new_sort")) + assertTrue(fragment.contains("super.onViewCreated(view, savedInstanceState)\n bindLoading()")) + assertTrue(fragment.contains("retryLive()")) + assertTrue(fragment.contains("fun onCreatorChannelLiveTabSelected()")) + assertTrue(fragment.contains("viewModel.loadLive(creatorId)")) + assertFalse(fragment.contains(eagerLoadOnViewCreated)) + assertTrue(fragment.contains("fun onCreatorChannelLiveScrolledToBottom()")) + assertTrue(fragment.contains("viewModel.loadMore()")) + assertTrue(fragment.contains("host.onCreatorChannelLiveContentChanged()")) + assertTrue(fragment.contains("notifyContentChangedIfLayoutChanged(state)")) + assertTrue(fragment.contains("if (contentLayoutKey == lastContentLayoutKey) return")) + assertTrue(fragment.contains("viewModel.consumePaginationErrorMessage()")) + assertTrue(fragment.contains("bindEmpty() = with(binding)")) + assertTrue(fragment.contains("bindError(state: CreatorChannelLiveUiState.Error) = with(binding)")) + assertTrue(fragment.contains("formatCreatorChannelLiveDateTime(live.beginDateTimeUtc)")) + assertTrue(fragment.contains("ivCreatorChannelLiveCurrentPriceCash.isVisible = live.price > 0")) + assertTrue(fragment.contains("R.string.audio_content_tag_free")) + assertTrue(fragment.contains("onCreatorChannelCurrentLiveClicked")) + assertTrue(fragment.contains("onCreatorChannelLiveContentChanged")) + assertFalse(fragment.contains("addOnScrollListener(object : RecyclerView.OnScrollListener()")) + assertTrue(adapter.contains("R.drawable.ic_new_shield_small")) + assertTrue(adapter.contains("R.drawable.ic_new_player_play")) + assertTrue(adapter.contains("ivCreatorChannelLiveReplayPlay.isVisible = true")) + assertTrue(adapter.contains("layoutCreatorChannelLiveReplayActionText.isVisible = false")) + assertTrue(adapter.contains("layoutCreatorChannelLiveReplayActionText.isVisible = true")) + } + + private fun inflateView(layoutResId: Int): View { + val context = ApplicationProvider.getApplicationContext() + return LayoutInflater.from(context).inflate(layoutResId, null, false) + } + + private fun dp(value: Int): Int { + val context = ApplicationProvider.getApplicationContext() + return (value * context.resources.displayMetrics.density).toInt() + } + + private fun projectFile(relativePath: String): File { + val candidates = listOf(File(relativePath), File("../$relativePath")) + return candidates.firstOrNull { it.exists() } + ?: error("Project file not found: $relativePath") + } +}