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 8cd1c00b..c637bc52 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 @@ -1,14 +1,55 @@ package kr.co.vividnext.sodalive.v2.main.content +import android.content.Intent import android.os.Bundle import android.view.View +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager 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 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.ToastMessage import kr.co.vividnext.sodalive.databinding.FragmentV2MainContentBinding +import kr.co.vividnext.sodalive.databinding.ViewSectionTitleBinding +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.ContentAudioCardUiModel +import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerSection +import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerUiModel +import kr.co.vividnext.sodalive.v2.main.content.model.ContentCommentedAudioSection +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.toContentBannerIntent +import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerRoute +import kr.co.vividnext.sodalive.v2.main.content.ui.CONTENT_RECOMMENDED_GRID_SPAN_COUNT +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 +import kr.co.vividnext.sodalive.v2.main.content.ui.ContentNewAndHotAdapter +import kr.co.vividnext.sodalive.v2.main.content.ui.ContentOriginalSeriesAdapter +import kr.co.vividnext.sodalive.v2.main.content.ui.addContentGridItemSpacing +import kr.co.vividnext.sodalive.v2.main.content.ui.addContentHorizontalItemSpacing +import kr.co.vividnext.sodalive.v2.widget.AudioContentCardSize +import org.koin.androidx.viewmodel.ext.android.viewModel class ContentMainFragment : BaseFragment( FragmentV2MainContentBinding::inflate ) { + private val contentMainViewModel: ContentMainViewModel by viewModel() + private val loadingDialog: LoadingDialog by lazy { LoadingDialog(requireActivity(), layoutInflater) } + private val originalSeriesAdapter = ContentOriginalSeriesAdapter { openSeriesDetail(it) } + private val latestAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } + private val newAndHotAdapter = ContentNewAndHotAdapter { openAudioContentDetail(it) } + private val freeAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } + private val pointAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Medium) { openAudioContentDetail(it) } + private val commentedAudioAdapter = ContentCommentedAudioAdapter { openAudioContentDetail(it) } + private val recommendedAudioAdapter = ContentAudioCardAdapter(AudioContentCardSize.Large) { openAudioContentDetail(it) } + private var bannerBinder: ContentBannerBinder? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.textTabBarContent.root.setMenus( @@ -16,29 +57,188 @@ class ContentMainFragment : BaseFragment( selectedIndex = 0 ) setUpSectionTitles() + setUpAdapters() + bindObservers() + contentMainViewModel.loadRecommendations() + } + + private fun setUpAdapters() { + bannerBinder = ContentBannerBinder(binding.rvContentBanners).apply { + setOnBannerClick { onBannerClick(it) } + } + binding.rvContentOriginalSeries.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = originalSeriesAdapter + addContentHorizontalItemSpacing() + } + binding.rvContentLatestAudios.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = latestAudioAdapter + addContentHorizontalItemSpacing() + } + binding.rvContentNewAndHotAudios.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = newAndHotAdapter + addContentHorizontalItemSpacing() + } + binding.rvContentFreeAudios.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = freeAudioAdapter + addContentHorizontalItemSpacing() + } + binding.rvContentPointAudios.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = pointAudioAdapter + addContentHorizontalItemSpacing() + } + binding.rvContentMostCommentedAudios.apply { + layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) + adapter = commentedAudioAdapter + addContentHorizontalItemSpacing() + } + binding.rvContentRecommendedAudios.apply { + layoutManager = GridLayoutManager(requireContext(), CONTENT_RECOMMENDED_GRID_SPAN_COUNT) + adapter = recommendedAudioAdapter + addContentGridItemSpacing() + } + } + + private fun bindObservers() { + contentMainViewModel.recommendationsStateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + is AudioRecommendationsUiState.Content -> bindContent(state) + AudioRecommendationsUiState.Empty, + is AudioRecommendationsUiState.Error -> bindContent(emptyContent()) + + AudioRecommendationsUiState.Loading -> Unit + } + } + contentMainViewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> + if (isLoading) { + loadingDialog.show(screenWidth) + } else { + loadingDialog.dismiss() + } + } + contentMainViewModel.toastLiveData.observe(viewLifecycleOwner) { toastMessage -> + toastMessage?.let(::showToast) + } + } + + private fun bindContent(content: AudioRecommendationsUiState.Content) { + bindBannerSection(content.banners) + bindOriginalSeriesSection(content.originalSeries) + bindLatestAudioSection(content.latestAudios) + bindNewAndHotSection(content.newAndHotAudios) + bindFreeAudioSection(content.freeAudios) + bindPointAudioSection(content.pointAudios) + bindCommentedAudioSection(content.mostCommentedAudios) + bindRecommendedAudioSection(content.recommendedAudios) + } + + private fun bindBannerSection(section: ContentBannerSection) { + binding.llContentBannerSection.visibility = section.items.toSectionVisibility() + bannerBinder?.bind(section) + } + + private fun bindOriginalSeriesSection(section: ContentOriginalSeriesSection) { + binding.llContentOriginalSeriesSection.visibility = section.items.toSectionVisibility() + originalSeriesAdapter.submitItems(section.items) + } + + private fun bindLatestAudioSection(section: ContentAudioCardSection) { + binding.llContentLatestAudioSection.visibility = section.items.toSectionVisibility() + latestAudioAdapter.submitItems(section.items) + } + + private fun bindNewAndHotSection(section: ContentAudioCardSection) { + binding.llContentNewAndHotSection.visibility = section.items.toSectionVisibility() + newAndHotAdapter.submitItems(section.items) + } + + private fun bindFreeAudioSection(section: ContentAudioCardSection) { + binding.llContentFreeAudioSection.visibility = section.items.toSectionVisibility() + freeAudioAdapter.submitItems(section.items) + } + + private fun bindPointAudioSection(section: ContentAudioCardSection) { + binding.llContentPointAudioSection.visibility = section.items.toSectionVisibility() + pointAudioAdapter.submitItems(section.items) + } + + private fun bindCommentedAudioSection(section: ContentCommentedAudioSection) { + binding.llContentMostCommentedAudioSection.visibility = section.items.toSectionVisibility() + commentedAudioAdapter.submitItems(section.items) + } + + private fun bindRecommendedAudioSection(section: ContentAudioCardSection) { + binding.llContentRecommendedAudioSection.visibility = section.items.toSectionVisibility() + recommendedAudioAdapter.submitItems(section.items) } private fun setUpSectionTitles() { - binding.viewContentOriginalSeriesTitle.tvSectionTitle.setText( - R.string.content_recommendation_section_original_series - ) - binding.viewContentLatestAudioTitle.tvSectionTitle.setText( - R.string.content_recommendation_section_latest_audio - ) - binding.viewContentNewAndHotTitle.tvSectionTitle.setText( - R.string.content_recommendation_section_new_and_hot - ) - binding.viewContentFreeAudioTitle.tvSectionTitle.setText( - R.string.content_recommendation_section_free_audio - ) - binding.viewContentPointAudioTitle.tvSectionTitle.setText( - R.string.content_recommendation_section_point_audio - ) - binding.viewContentMostCommentedAudioTitle.tvSectionTitle.setText( - R.string.content_recommendation_section_most_commented_audio - ) - binding.viewContentRecommendedAudioTitle.tvSectionTitle.setText( - R.string.content_recommendation_section_recommended_audio + binding.viewContentOriginalSeriesTitle.setTitle(R.string.content_recommendation_section_original_series) + binding.viewContentLatestAudioTitle.setTitle(R.string.content_recommendation_section_latest_audio) + binding.viewContentNewAndHotTitle.setTitle(R.string.content_recommendation_section_new_and_hot) + binding.viewContentFreeAudioTitle.setTitle(R.string.content_recommendation_section_free_audio) + binding.viewContentPointAudioTitle.setTitle(R.string.content_recommendation_section_point_audio) + binding.viewContentMostCommentedAudioTitle.setTitle(R.string.content_recommendation_section_most_commented_audio) + binding.viewContentRecommendedAudioTitle.setTitle(R.string.content_recommendation_section_recommended_audio) + } + + private fun ViewSectionTitleBinding.setTitle(titleResId: Int) { + tvSectionTitle.setText(titleResId) + ivSectionTitleChevron.visibility = View.GONE + } + + private fun onBannerClick(item: ContentBannerUiModel) { + val route = item.toContentBannerRoute() ?: return + startActivity(route.toContentBannerIntent(requireContext())) + } + + private fun openAudioContentDetail(item: ContentAudioCardUiModel) { + openAudioContentDetail(item.audioContentId) + } + + private fun openAudioContentDetail(item: ContentCommentedAudioUiModel) { + openAudioContentDetail(item.audioContentId) + } + + private fun openAudioContentDetail(audioContentId: Long) { + if (audioContentId <= 0L) return + startActivity( + Intent(requireContext(), AudioContentDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId) + } ) } + + private fun openSeriesDetail(item: ContentOriginalSeriesUiModel) { + val seriesId = item.seriesId.takeIf { it > 0L } ?: return + startActivity( + Intent(requireContext(), SeriesDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_SERIES_ID, seriesId) + } + ) + } + + private fun showToast(toastMessage: ToastMessage) { + toastMessage.message?.let { message -> showToast(message) } + ?: toastMessage.resId?.let { resId -> showToast(getString(resId)) } + } + + private fun emptyContent(): AudioRecommendationsUiState.Content { + return AudioRecommendationsUiState.Content( + banners = ContentBannerSection(emptyList()), + originalSeries = ContentOriginalSeriesSection(emptyList()), + latestAudios = ContentAudioCardSection(emptyList()), + newAndHotAudios = ContentAudioCardSection(emptyList()), + freeAudios = ContentAudioCardSection(emptyList()), + pointAudios = ContentAudioCardSection(emptyList()), + mostCommentedAudios = ContentCommentedAudioSection(emptyList()), + recommendedAudios = ContentAudioCardSection(emptyList()) + ) + } + + private fun List<*>.toSectionVisibility(): Int = if (isEmpty()) View.GONE else View.VISIBLE } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentRecyclerItemLayoutParams.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentRecyclerItemLayoutParams.kt new file mode 100644 index 00000000..60ade9c5 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentRecyclerItemLayoutParams.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.v2.main.content.ui + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import kr.co.vividnext.sodalive.extensions.dpToPx + +fun RecyclerView.addContentHorizontalItemSpacing() { + if (itemDecorationCount == 0) addItemDecoration(ContentHorizontalItemDecoration()) +} + +fun RecyclerView.addContentGridItemSpacing() { + if (itemDecorationCount == 0) addItemDecoration(ContentGridItemDecoration()) +} + +private class ContentHorizontalItemDecoration : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) + if (position != RecyclerView.NO_POSITION && position < (parent.adapter?.itemCount ?: 0) - 1) { + outRect.right = HORIZONTAL_ITEM_GAP_DP.dpToPx().toInt() + } + } +} + +private class ContentGridItemDecoration : RecyclerView.ItemDecoration() { + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { + val position = parent.getChildAdapterPosition(view) + if (position == RecyclerView.NO_POSITION) return + val isLeftColumn = position % GRID_SPAN_COUNT == 0 + val halfGap = GRID_ITEM_GAP_DP.dpToPx().toInt() / 2 + outRect.left = if (isLeftColumn) 0 else halfGap + outRect.right = if (isLeftColumn) halfGap else 0 + if (position >= GRID_SPAN_COUNT) outRect.top = GRID_ITEM_VERTICAL_GAP_DP.dpToPx().toInt() + } +} + +private const val HORIZONTAL_ITEM_GAP_DP = 8 +private const val GRID_ITEM_GAP_DP = 8 +private const val GRID_ITEM_VERTICAL_GAP_DP = 28 +const val CONTENT_RECOMMENDED_GRID_SPAN_COUNT = 2 +private const val GRID_SPAN_COUNT = CONTENT_RECOMMENDED_GRID_SPAN_COUNT 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 new file mode 100644 index 00000000..14e3f93b --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragmentSourceTest.kt @@ -0,0 +1,178 @@ +package kr.co.vividnext.sodalive.v2.main.content + +import android.app.Application +import android.content.Context +import kr.co.vividnext.sodalive.BuildConfig +import kr.co.vividnext.sodalive.audio_content.series.detail.SeriesDetailActivity +import kr.co.vividnext.sodalive.common.Constants +import kr.co.vividnext.sodalive.settings.event.EventDetailActivity +import kr.co.vividnext.sodalive.settings.event.EventItem +import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelActivity +import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerRoute +import kr.co.vividnext.sodalive.v2.main.content.model.ContentBannerUiModel +import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerIntent +import kr.co.vividnext.sodalive.v2.main.content.model.toContentBannerRoute +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class ContentMainFragmentSourceTest { + + @Test + fun `ContentMainFragment는 Phase 4~6 adapter와 ViewModel observer를 연결한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt" + ).readText() + + assertTrue(source.contains("private val contentMainViewModel: ContentMainViewModel by viewModel()")) + assertTrue(source.contains("ContentBannerBinder(binding.rvContentBanners)")) + assertTrue(source.contains("ContentOriginalSeriesAdapter")) + assertTrue(source.contains("ContentAudioCardAdapter")) + assertTrue(source.contains("ContentNewAndHotAdapter")) + assertTrue(source.contains("ContentCommentedAudioAdapter")) + assertTrue(source.contains("recommendationsStateLiveData.observe(viewLifecycleOwner)")) + assertTrue(source.contains("contentMainViewModel.loadRecommendations()")) + assertTrue(source.contains("LoadingDialog(requireActivity(), layoutInflater)")) + assertTrue(source.contains("loadingDialog.show(screenWidth)")) + assertTrue(source.contains("toastMessage?.let(::showToast)")) + } + + @Test + fun `content 추천 source는 오디오와 시리즈 routing extra를 사용한다`() { + val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt" + ).readText() + + assertTrue(source.contains("AudioContentDetailActivity::class.java")) + assertTrue(source.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, audioContentId)")) + assertTrue(source.contains("SeriesDetailActivity::class.java")) + assertTrue(source.contains("putExtra(Constants.EXTRA_SERIES_ID, seriesId)")) + assertTrue(source.contains("toContentBannerRoute()")) + assertTrue(source.contains("toContentBannerIntent(requireContext())")) + } + + @Test + fun `content 추천 layout은 Phase 4~6 item layout과 제외 섹션 정책을 지킨다`() { + val fragmentLayout = projectFile("app/src/main/res/layout/fragment_v2_main_content.xml").readText() + val audioCardLayout = projectFile("app/src/main/res/layout/view_audio_content_card.xml").readText() + + assertTrue(fragmentLayout.contains("@+id/rv_content_banners")) + assertTrue(fragmentLayout.contains("@+id/rv_content_original_series")) + assertTrue(fragmentLayout.contains("@+id/rv_content_latest_audios")) + assertTrue(fragmentLayout.contains("@+id/rv_content_new_and_hot_audios")) + assertTrue(fragmentLayout.contains("@+id/rv_content_free_audios")) + assertTrue(fragmentLayout.contains("@+id/rv_content_point_audios")) + assertTrue(fragmentLayout.contains("@+id/rv_content_most_commented_audios")) + assertTrue(fragmentLayout.contains("@+id/rv_content_recommended_audios")) + assertFalse(fragmentLayout.contains("recommendation_series")) + assertFalse(fragmentLayout.contains("keyword_audio")) + assertTrue(audioCardLayout.contains("@+id/iv_audio_content_adult_badge")) + assertTrue(audioCardLayout.contains("android:visibility=\"gone\"")) + } + + @Test + fun `Phase 4~5 adapter source는 grouping과 badge comment 정책을 포함한다`() { + val cardView = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt" + ).readText() + val newAndHotAdapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentNewAndHotAdapter.kt" + ).readText() + val commentedAdapter = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ui/ContentCommentedAudioAdapter.kt" + ).readText() + + assertTrue(cardView.contains("fun setAdultVisible(isVisible: Boolean)")) + assertTrue(newAndHotAdapter.contains("items.chunked(NEW_AND_HOT_GROUP_SIZE)")) + assertTrue(newAndHotAdapter.contains("private const val NEW_AND_HOT_GROUP_SIZE = 3")) + assertTrue(commentedAdapter.contains("layoutContentCommentArea.isVisible = item.showLatestComment")) + assertTrue(commentedAdapter.contains("ivContentCommentProfile.loadUrl")) + } + + @Test + fun `content banner route uses event creator series link priority`() { + val eventItem = EventItem(id = 1L, thumbnailImageUrl = "https://example.com/event.png") + + assertEquals( + ContentBannerRoute.Event(eventItem), + banner( + eventItem = eventItem, + creatorId = 2L, + seriesId = 3L, + link = "https://example.com" + ).toContentBannerRoute() + ) + assertEquals( + ContentBannerRoute.Creator(2L), + banner(creatorId = 2L, seriesId = 3L, link = "https://example.com").toContentBannerRoute() + ) + assertEquals( + ContentBannerRoute.Series(3L), + banner(seriesId = 3L, link = "https://example.com").toContentBannerRoute() + ) + assertEquals( + ContentBannerRoute.Link("https://example.com", isWebUrl = true), + banner(link = "https://example.com").toContentBannerRoute() + ) + } + + @Test + fun `content banner route creates expected intents`() { + val context = RuntimeEnvironment.getApplication() as Context + val eventItem = EventItem(id = 1L, thumbnailImageUrl = "https://example.com/event.png") + val eventIntent = ContentBannerRoute.Event(eventItem).toContentBannerIntent(context) + val creatorIntent = ContentBannerRoute.Creator(2L).toContentBannerIntent(context) + val seriesIntent = ContentBannerRoute.Series(3L).toContentBannerIntent(context) + val webIntent = ContentBannerRoute.Link("https://example.com", isWebUrl = true).toContentBannerIntent(context) + val deepLinkIntent = ContentBannerRoute.Link( + url = "${BuildConfig.APPSCHEME}://series/3", + isWebUrl = false + ).toContentBannerIntent(context) + + assertEquals(EventDetailActivity::class.java.name, eventIntent.component?.className) + assertEquals(eventItem, eventIntent.getParcelableExtra(Constants.EXTRA_EVENT)) + assertEquals(CreatorChannelActivity::class.java.name, creatorIntent.component?.className) + assertEquals(2L, creatorIntent.getLongExtra(CreatorChannelActivity.EXTRA_CREATOR_ID, 0L)) + assertEquals(SeriesDetailActivity::class.java.name, seriesIntent.component?.className) + assertEquals(3L, seriesIntent.getLongExtra(Constants.EXTRA_SERIES_ID, 0L)) + assertEquals(android.content.Intent.ACTION_VIEW, webIntent.action) + assertEquals("https://example.com", webIntent.data.toString()) + assertEquals(android.content.Intent.ACTION_VIEW, deepLinkIntent.action) + assertEquals("${BuildConfig.APPSCHEME}://series/3", deepLinkIntent.data.toString()) + } + + @Test + fun `content banner route ignores invalid links`() { + assertEquals(null, banner(link = "").toContentBannerRoute()) + assertEquals(null, banner(link = "not a url").toContentBannerRoute()) + assertEquals(null, banner(link = "example.com/path").toContentBannerRoute()) + assertEquals(null, banner(link = "mailto:test@example.com").toContentBannerRoute()) + } + + private fun banner( + eventItem: EventItem? = null, + creatorId: Long? = null, + seriesId: Long? = null, + link: String? = null + ) = ContentBannerUiModel( + imageUrl = "https://example.com/banner.png", + eventItem = eventItem, + creatorId = creatorId, + seriesId = seriesId, + link = link + ) + + private fun projectFile(relativePath: String): File { + val candidates = listOf(File(relativePath), File("../$relativePath")) + return candidates.firstOrNull { it.exists() } + ?: error("Project file not found: $relativePath") + } +}