From ae6f18828cbe5db1a82ad321453bffed7f356ab6 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 00:55:17 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20StorageScrapFragment=20Compose=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B0=8F=20ViewModel=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XML/RecyclerView+DataBinding 기반 StorageScrapFragment를 Jetpack Compose로 포팅. 동작은 그대로 유지하고 구현 방식만 교체. StorageViewModel unit test 10개(MockK+Turbine+coroutines-test)도 함께 추가해 회귀 안전망을 마련. --- app/build.gradle | 1 + .../storage/StorageScrapFragment.kt | 267 ++++++------------ .../storage/StorageScrapScreen.kt | 213 ++++++++++++++ .../storage/adapter/StorageScrapAdapter.kt | 71 ----- .../callback/listener/OnHeartButtonClick.kt | 5 - .../callback/listener/OnScrapItemClick.kt | 7 - .../res/layout/fragment_storage_scrap.xml | 136 --------- .../main/res/layout/item_storage_scrap.xml | 86 ------ app/src/main/res/values/strings.xml | 2 + .../storage/StorageViewModelTest.kt | 222 +++++++++++++++ gradle/libs.versions.toml | 4 + 11 files changed, 531 insertions(+), 483 deletions(-) create mode 100644 app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt delete mode 100644 app/src/main/java/com/runnect/runnect/presentation/storage/adapter/StorageScrapAdapter.kt delete mode 100644 app/src/main/java/com/runnect/runnect/util/callback/listener/OnHeartButtonClick.kt delete mode 100644 app/src/main/java/com/runnect/runnect/util/callback/listener/OnScrapItemClick.kt delete mode 100644 app/src/main/res/layout/fragment_storage_scrap.xml delete mode 100644 app/src/main/res/layout/item_storage_scrap.xml create mode 100644 app/src/test/java/com/runnect/runnect/presentation/storage/StorageViewModelTest.kt diff --git a/app/build.gradle b/app/build.gradle index 42145f303..a68646dc2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -191,6 +191,7 @@ dependencies { testImplementation libs.mockk testImplementation libs.turbine testImplementation libs.kotlinx.coroutines.test + testImplementation libs.androidx.core.testing androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core } diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt index a45e0b8a3..6c1b45b54 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt @@ -1,119 +1,109 @@ package com.runnect.runnect.presentation.storage -import android.content.ContentValues import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater import android.view.View -import androidx.core.view.isVisible +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager import com.runnect.runnect.R -import com.runnect.runnect.binding.BindingFragment import com.runnect.runnect.domain.entity.MyScrapCourse -import com.runnect.runnect.databinding.FragmentStorageScrapBinding import com.runnect.runnect.presentation.MainActivity import com.runnect.runnect.presentation.detail.CourseDetailActivity +import com.runnect.runnect.presentation.detail.CourseDetailRootScreen import com.runnect.runnect.presentation.event.ScreenRefreshEvent import com.runnect.runnect.presentation.event.ScreenRefreshEventBus -import com.runnect.runnect.presentation.detail.CourseDetailRootScreen import com.runnect.runnect.presentation.state.UiStateV2 -import com.runnect.runnect.presentation.storage.adapter.StorageScrapAdapter +import com.runnect.runnect.presentation.ui.theme.RunnectTheme import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName import com.runnect.runnect.util.analytics.EventName.Param -import com.runnect.runnect.util.custom.deco.GridSpacingItemDecoration -import com.runnect.runnect.util.callback.ItemCount -import com.runnect.runnect.util.callback.listener.OnHeartButtonClick -import com.runnect.runnect.util.callback.listener.OnScrapItemClick -import com.runnect.runnect.util.extension.showSnackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint -class StorageScrapFragment : - BindingFragment(R.layout.fragment_storage_scrap), - OnHeartButtonClick, - OnScrapItemClick, - ItemCount { +class StorageScrapFragment : Fragment() { @Inject lateinit var screenRefreshEventBus: ScreenRefreshEventBus - val viewModel: StorageViewModel by viewModels() - private lateinit var storageScrapAdapter: StorageScrapAdapter + private val viewModel: StorageViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + RunnectTheme { + val getState by viewModel.myScrapCourseGetState.observeAsState() + val scrapState by viewModel.courseScrapState.observeAsState() + + var courses by remember { mutableStateOf(emptyList()) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(getState) { + when (val current = getState) { + is UiStateV2.Success -> { + courses = current.data + Analytics.logEvent( + EventName.VIEW_STORAGE_SCRAP, + Param.COURSE_COUNT to current.data.size + ) + } + + is UiStateV2.Failure -> errorMessage = current.msg + else -> Unit + } + } + + LaunchedEffect(scrapState) { + when (val current = scrapState) { + is UiStateV2.Success -> { + courses = courses.filterNot { + it.publicCourseId.toLong() == current.data.publicCourseId + } + } + + is UiStateV2.Failure -> errorMessage = current.msg + else -> Unit + } + } + + StorageScrapScreen( + state = StorageScrapUiState( + courses = courses, + isLoading = getState is UiStateV2.Loading, + errorMessage = errorMessage + ), + onRefresh = { viewModel.getMyScrapCourses() }, + onScrapItemClick = { course -> navigateToCourseDetail(course) }, + onHeartClick = { course -> + viewModel.postCourseScrap(id = course.publicCourseId, scrapTF = false) + }, + onGoToScrapClick = { navigateToDiscover() } + ) + } + } + } + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.lifecycleOwner = viewLifecycleOwner - Analytics.logEvent(EventName.VIEW_STORAGE_SCRAP) - getMyScrapCourses() - initLayout() - initAdapter() - addListener() - addObserver() - } - - fun getMyScrapCourses() { viewModel.getMyScrapCourses() - } - - private fun initLayout() { - binding.rvStorageScrap.apply { - val context = context ?: return - layoutManager = GridLayoutManager(context, 2) - addItemDecoration( - GridSpacingItemDecoration( - context = context, - spanCount = 2, - horizontalSpaceSize = 6, - topSpaceSize = 20 - ) - ) - } - } - - private fun initAdapter() { - storageScrapAdapter = StorageScrapAdapter( - onScrapItemClick = this, - onHeartButtonClick = this, - itemCount = this - ).apply { - binding.rvStorageScrap.adapter = this - } - } - - private fun addListener() { - initGoToScrapButtonClickListener() - initRefreshLayoutListener() - } - - private fun initGoToScrapButtonClickListener() { - binding.btnStorageNoScrap.setOnClickListener { - val intent = Intent(activity, MainActivity::class.java).apply { - putExtra(EXTRA_FRAGMENT_REPLACEMENT_DIRECTION, "fromMyScrap") - } - startActivity(intent) - requireActivity().overridePendingTransition( - R.anim.slide_in_right, - R.anim.slide_out_left - ) - } - } - - private fun initRefreshLayoutListener() { - binding.refreshLayout.setOnRefreshListener { - getMyScrapCourses() - binding.refreshLayout.isRefreshing = false - } - } - - private fun addObserver() { - setupItemSizeObserver() - setupMyScrapCourseGetStateObserver() - setupCourseScrapStateObserver() collectScreenRefreshEvents() } @@ -121,107 +111,28 @@ class StorageScrapFragment : viewLifecycleOwner.lifecycleScope.launch { screenRefreshEventBus.events.collect { event -> if (event is ScreenRefreshEvent.RefreshStorageScrap) { - getMyScrapCourses() + viewModel.getMyScrapCourses() } } } } - private fun setupCourseScrapStateObserver() { - viewModel.courseScrapState.observe(viewLifecycleOwner) { state -> - when (state) { - is UiStateV2.Loading -> { - showLoadingProgressBar() - } - - is UiStateV2.Success -> { - dismissLoadingProgressBar() - storageScrapAdapter.removeCourseItem() - } - - is UiStateV2.Failure -> { - dismissLoadingProgressBar() - context?.showSnackbar( - anchorView = binding.root, - message = state.msg - ) - } - - else -> {} - } - } - } - - private fun setupItemSizeObserver() { - viewModel.itemSize.observe(viewLifecycleOwner) { itemSize -> - val isEmpty = (itemSize == 0) - updateEmptyView(isEmpty, itemSize) + private fun navigateToCourseDetail(course: MyScrapCourse) { + val intent = Intent(activity, CourseDetailActivity::class.java).apply { + putExtra(EXTRA_PUBLIC_COURSE_ID, course.publicCourseId) + putExtra(EXTRA_ROOT_SCREEN, CourseDetailRootScreen.COURSE_STORAGE_SCRAP) } + startActivity(intent) + requireActivity().overridePendingTransition( + R.anim.slide_in_right, + R.anim.slide_out_left + ) } - private fun updateEmptyView(isEmpty: Boolean, itemSize: Int) { - binding.apply { - clMyDrawNoScrap.isVisible = isEmpty - rvStorageScrap.isVisible = !isEmpty - tvStorageScrapCount.isVisible = !isEmpty - tvStorageScrapCount.text = if (!isEmpty) "총 코스 ${itemSize}개" else "" + private fun navigateToDiscover() { + val intent = Intent(activity, MainActivity::class.java).apply { + putExtra(EXTRA_FRAGMENT_REPLACEMENT_DIRECTION, "fromMyScrap") } - } - - private fun setupMyScrapCourseGetStateObserver() { - viewModel.myScrapCourseGetState.observe(viewLifecycleOwner) { state -> - when (state) { - is UiStateV2.Loading -> { - showLoadingProgressBar() - } - - is UiStateV2.Success -> { - dismissLoadingProgressBar() - - val scrapCourses = state.data - updateEmptyView(scrapCourses.isEmpty(), scrapCourses.size) - storageScrapAdapter.submitList(scrapCourses) - Analytics.logEvent( - EventName.VIEW_STORAGE_SCRAP, - Param.COURSE_COUNT to scrapCourses.size - ) - } - - is UiStateV2.Failure -> { - dismissLoadingProgressBar() - context?.showSnackbar( - anchorView = binding.root, - message = state.msg - ) - } - - else -> {} - } - } - } - - private fun showLoadingProgressBar() { - binding.pbStorageScrapLoading.isVisible = true - } - - private fun dismissLoadingProgressBar() { - binding.pbStorageScrapLoading.isVisible = false - } - - override fun calcItemSize(itemCount: Int) { - viewModel.itemSize.value = itemCount - } - - override fun scrapCourse(id: Int, scrapTF: Boolean) { - viewModel.postCourseScrap(id, scrapTF) - } - - override fun selectItem(item: MyScrapCourse) { - Timber.tag(ContentValues.TAG).d("코스 아이디 : ${item.publicCourseId}") - - val intent = Intent(activity, CourseDetailActivity::class.java) - intent.putExtra(EXTRA_PUBLIC_COURSE_ID, item.publicCourseId) - intent.putExtra(EXTRA_ROOT_SCREEN, CourseDetailRootScreen.COURSE_STORAGE_SCRAP) startActivity(intent) requireActivity().overridePendingTransition( R.anim.slide_in_right, diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt new file mode 100644 index 000000000..ad975b274 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt @@ -0,0 +1,213 @@ +package com.runnect.runnect.presentation.storage + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.runnect.runnect.R +import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.presentation.ui.theme.G1 +import com.runnect.runnect.presentation.ui.theme.G2 +import com.runnect.runnect.presentation.ui.theme.G4 +import com.runnect.runnect.presentation.ui.theme.M1 +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import com.runnect.runnect.presentation.ui.theme.White + +data class StorageScrapUiState( + val courses: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StorageScrapScreen( + state: StorageScrapUiState, + onRefresh: () -> Unit, + onScrapItemClick: (MyScrapCourse) -> Unit, + onHeartClick: (MyScrapCourse) -> Unit, + onGoToScrapClick: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.errorMessage) { + state.errorMessage?.let { snackbarHostState.showSnackbar(it) } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + PullToRefreshBox( + isRefreshing = state.isLoading, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (state.courses.isEmpty()) { + EmptyScrapView(onGoToScrapClick = onGoToScrapClick) + } else { + Column(modifier = Modifier.fillMaxSize()) { + ScrapCountHeader(count = state.courses.size) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(horizontal = 15.dp, vertical = 20.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.fillMaxSize() + ) { + items(state.courses, key = { it.id }) { course -> + ScrapCourseItem( + course = course, + onClick = { onScrapItemClick(course) }, + onHeartClick = { onHeartClick(course) } + ) + } + } + } + } + } + } +} + +@Composable +private fun ScrapCountHeader(count: Int) { + val textStyle = RunnectTheme.textStyle + Box( + modifier = Modifier + .fillMaxWidth() + .background(White) + .padding(horizontal = 16.dp, vertical = 7.dp) + ) { + Text( + text = stringResource(R.string.storage_total_course_count, count), + style = textStyle.regular12, + color = G2 + ) + } +} + +@Composable +private fun ScrapCourseItem( + course: MyScrapCourse, + onClick: () -> Unit, + onHeartClick: () -> Unit +) { + val textStyle = RunnectTheme.textStyle + Column(modifier = Modifier.clickable(onClick = onClick)) { + AsyncImage( + model = course.image, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(162f / 114f) + .clip(RoundedCornerShape(5.dp)) + .background(G4) + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = course.title, + style = textStyle.medium14, + color = G1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.size(4.dp)) + Box( + modifier = Modifier + .size(21.dp, 18.dp) + .clickable(onClick = onHeartClick) + ) { + Image( + painter = painterResource(R.drawable.discover_course_scrap_on), + contentDescription = null + ) + } + } + Text( + text = "${course.city} ${course.region}", + style = textStyle.regular12, + color = G2 + ) + } +} + +@Composable +private fun EmptyScrapView(onGoToScrapClick: () -> Unit) { + val textStyle = RunnectTheme.textStyle + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 64.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.no_course), + contentDescription = null + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.storage_scrap_empty_guide), + style = textStyle.medium14, + color = G2, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(22.dp)) + Button( + onClick = onGoToScrapClick, + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + colors = ButtonDefaults.buttonColors( + containerColor = M1, + contentColor = White + ), + shape = RoundedCornerShape(10.dp) + ) { + Text( + text = stringResource(R.string.storage_scrap_make_scrap), + style = textStyle.semiBold15 + ) + } + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/adapter/StorageScrapAdapter.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/adapter/StorageScrapAdapter.kt deleted file mode 100644 index 14c31d292..000000000 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/adapter/StorageScrapAdapter.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.runnect.runnect.presentation.storage.adapter - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.runnect.runnect.domain.entity.MyScrapCourse -import com.runnect.runnect.databinding.ItemStorageScrapBinding -import com.runnect.runnect.util.callback.ItemCount -import com.runnect.runnect.util.callback.diff.ItemDiffCallback -import com.runnect.runnect.util.callback.listener.OnHeartButtonClick -import com.runnect.runnect.util.callback.listener.OnScrapItemClick - -class StorageScrapAdapter( - private val onScrapItemClick: OnScrapItemClick, - private val onHeartButtonClick: OnHeartButtonClick, - private val itemCount: ItemCount -) : ListAdapter(diffUtil) { - private var clickedItemPosition = -1 - - inner class ItemViewHolder(val binding: ItemStorageScrapBinding) : - RecyclerView.ViewHolder(binding.root) { - fun onBind(course: MyScrapCourse) { - binding.storageScrap = course - binding.ivItemStorageScrapHeart.isSelected = true - - initCourseItemClickListener(binding.root, course) - initHeartButtonClickListener(binding.ivItemStorageScrapHeart, course) - } - - private fun initCourseItemClickListener(view: View, course: MyScrapCourse) { - view.setOnClickListener { - onScrapItemClick.selectItem(course) - } - } - - private fun initHeartButtonClickListener(view: ImageView, course: MyScrapCourse) { - view.setOnClickListener { - clickedItemPosition = absoluteAdapterPosition - onHeartButtonClick.scrapCourse(course.publicCourseId, !it.isSelected) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = ItemStorageScrapBinding.inflate(inflater) - return ItemViewHolder(binding) - } - - override fun onBindViewHolder(holder: ItemViewHolder, position: Int) { - holder.onBind(currentList[position]) - } - - fun removeCourseItem() { - if (clickedItemPosition == -1) return - val newList = currentList.toMutableList() - newList.removeAt(clickedItemPosition) - submitList(newList) - itemCount.calcItemSize(newList.size) - } - - companion object { - private val diffUtil = ItemDiffCallback( - onItemsTheSame = { old, new -> old.publicCourseId == new.publicCourseId }, - onContentsTheSame = { old, new -> old == new } - ) - } -} diff --git a/app/src/main/java/com/runnect/runnect/util/callback/listener/OnHeartButtonClick.kt b/app/src/main/java/com/runnect/runnect/util/callback/listener/OnHeartButtonClick.kt deleted file mode 100644 index f088757fd..000000000 --- a/app/src/main/java/com/runnect/runnect/util/callback/listener/OnHeartButtonClick.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.runnect.runnect.util.callback.listener - -interface OnHeartButtonClick { - fun scrapCourse(id: Int, scrapTF:Boolean) -} \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/util/callback/listener/OnScrapItemClick.kt b/app/src/main/java/com/runnect/runnect/util/callback/listener/OnScrapItemClick.kt deleted file mode 100644 index 173a9b65b..000000000 --- a/app/src/main/java/com/runnect/runnect/util/callback/listener/OnScrapItemClick.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.runnect.runnect.util.callback.listener - -import com.runnect.runnect.domain.entity.MyScrapCourse - -interface OnScrapItemClick { - fun selectItem(item: MyScrapCourse) -} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_storage_scrap.xml b/app/src/main/res/layout/fragment_storage_scrap.xml deleted file mode 100644 index d6d0176ce..000000000 --- a/app/src/main/res/layout/fragment_storage_scrap.xml +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_storage_scrap.xml b/app/src/main/res/layout/item_storage_scrap.xml deleted file mode 100644 index 1da4408ef..000000000 --- a/app/src/main/res/layout/item_storage_scrap.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 376984363..eed4c21b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,6 +66,8 @@ 스크랩 코스 코스 그리기 스크랩하기 + 총 코스 %1$d개 + 아직 스크랩한 코스가 없어요\n 코스를 스크랩 해주세요 시작하기 총 거리 km diff --git a/app/src/test/java/com/runnect/runnect/presentation/storage/StorageViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/storage/StorageViewModelTest.kt new file mode 100644 index 000000000..8f498e81a --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/storage/StorageViewModelTest.kt @@ -0,0 +1,222 @@ +package com.runnect.runnect.presentation.storage + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.runnect.runnect.data.dto.request.RequestPostCourseScrap +import com.runnect.runnect.data.dto.request.RequestPutMyDrawCourse +import com.runnect.runnect.domain.entity.MyDrawCourse +import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.domain.entity.PostScrap +import com.runnect.runnect.domain.repository.CourseRepository +import com.runnect.runnect.domain.repository.StorageRepository +import com.runnect.runnect.presentation.state.UiState +import com.runnect.runnect.presentation.state.UiStateV2 +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class StorageViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var storageRepository: StorageRepository + private lateinit var courseRepository: CourseRepository + private lateinit var viewModel: StorageViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + storageRepository = mockk() + courseRepository = mockk() + viewModel = StorageViewModel(storageRepository, courseRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun myDrawCourse(id: Int, title: String) = MyDrawCourse( + courseId = id, + image = null, + city = "서울", + region = "강남", + title = title + ) + + @Test + fun `getMyDrawList 성공 시 코스 목록과 상태가 갱신된다`() = runTest(testDispatcher) { + val courses = listOf(myDrawCourse(1, "코스1"), myDrawCourse(2, "코스2")) + coEvery { storageRepository.getMyDrawCourse() } returns flow { + delay(1) + emit(Result.success(courses)) + } + + val states = mutableListOf() + viewModel.myDrawCourseGetState.observeForever { states.add(it) } + + viewModel.getMyDrawList() + advanceUntilIdle() + + assertEquals(listOf(UiState.Empty, UiState.Loading, UiState.Success), states) + assertEquals(courses, viewModel.myDrawCourses) + } + + @Test + fun `getMyDrawList 실패 시 에러 메시지와 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + coEvery { storageRepository.getMyDrawCourse() } returns flow { + delay(1) + emit(Result.failure(RuntimeException("네트워크 오류"))) + } + + val states = mutableListOf() + viewModel.myDrawCourseGetState.observeForever { states.add(it) } + + viewModel.getMyDrawList() + advanceUntilIdle() + + assertEquals(listOf(UiState.Empty, UiState.Loading, UiState.Failure), states) + assertEquals("네트워크 오류", viewModel.errorMessage.value) + } + + @Test + fun `deleteMyDrawCourse 성공 시 선택한 코스만 목록에서 제거된다`() = runTest(testDispatcher) { + val courses = listOf(myDrawCourse(1, "코스1"), myDrawCourse(2, "코스2")) + coEvery { storageRepository.getMyDrawCourse() } returns flow { emit(Result.success(courses)) } + coEvery { + storageRepository.deleteMyDrawCourse(RequestPutMyDrawCourse(courseIdList = listOf(1))) + } returns flow { + delay(1) + emit(Result.success(Unit)) + } + + viewModel.getMyDrawList() + advanceUntilIdle() + viewModel.modifyItemsToDelete(1) + + val states = mutableListOf() + viewModel.myDrawCourseDeleteState.observeForever { states.add(it) } + + viewModel.deleteMyDrawCourse() + advanceUntilIdle() + + assertEquals(listOf(UiState.Loading, UiState.Success), states) + assertEquals(listOf(courses[1]), viewModel.myDrawCourses) + } + + @Test + fun `deleteMyDrawCourse 실패 시 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + coEvery { + storageRepository.deleteMyDrawCourse(RequestPutMyDrawCourse(courseIdList = listOf(1))) + } returns flow { + delay(1) + emit(Result.failure(RuntimeException("삭제 실패"))) + } + viewModel.modifyItemsToDelete(1) + + val states = mutableListOf() + viewModel.myDrawCourseDeleteState.observeForever { states.add(it) } + + viewModel.deleteMyDrawCourse() + advanceUntilIdle() + + assertEquals(listOf(UiState.Loading, UiState.Failure), states) + } + + @Test + fun `getMyScrapCourses 성공 시 스크랩 목록과 itemSize가 갱신된다`() = runTest(testDispatcher) { + val scrapCourses = listOf( + MyScrapCourse(courseId = 1, id = 1, publicCourseId = 10, image = null, city = "서울", region = "강남", title = "스크랩1") + ) + coEvery { storageRepository.getMyScrapCourse() } returns flow { + delay(1) + emit(Result.success(scrapCourses)) + } + + val states = mutableListOf>?>() + viewModel.myScrapCourseGetState.observeForever { states.add(it) } + + viewModel.getMyScrapCourses() + advanceUntilIdle() + + assertEquals(listOf(UiStateV2.Loading, UiStateV2.Success(scrapCourses)), states) + assertEquals(1, viewModel.itemSize.value) + } + + @Test + fun `getMyScrapCourses 실패 시 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + coEvery { storageRepository.getMyScrapCourse() } returns flow { + delay(1) + emit(Result.failure(RuntimeException("스크랩 조회 실패"))) + } + + val states = mutableListOf>?>() + viewModel.myScrapCourseGetState.observeForever { states.add(it) } + + viewModel.getMyScrapCourses() + advanceUntilIdle() + + assertEquals(UiStateV2.Failure("스크랩 조회 실패"), states.last()) + } + + @Test + fun `postCourseScrap 성공 시 Success 상태로 갱신된다`() = runTest(testDispatcher) { + val postScrap = PostScrap(publicCourseId = 10L, scrapCount = 3L, scrapTF = true) + coEvery { + courseRepository.postCourseScrap(RequestPostCourseScrap(publicCourseId = 10, scrapTF = "true")) + } returns flow { + delay(1) + emit(Result.success(postScrap)) + } + + val states = mutableListOf>() + viewModel.courseScrapState.observeForever { states.add(it) } + + viewModel.postCourseScrap(id = 10, scrapTF = true) + advanceUntilIdle() + + assertEquals(listOf(UiStateV2.Loading, UiStateV2.Success(postScrap)), states) + } + + @Test + fun `modifyItemsToDelete는 같은 id를 다시 호출하면 선택을 해제한다`() = runTest(testDispatcher) { + viewModel.modifyItemsToDelete(1) + viewModel.modifyItemsToDelete(2) + assertEquals(listOf(1, 2), viewModel.itemsToDeleteLiveData.value) + + viewModel.modifyItemsToDelete(1) + assertEquals(listOf(2), viewModel.itemsToDeleteLiveData.value) + } + + @Test + fun `clearItemsToDelete는 선택 목록을 비운다`() = runTest(testDispatcher) { + viewModel.modifyItemsToDelete(1) + + viewModel.clearItemsToDelete() + + assertEquals(emptyList(), viewModel.itemsToDeleteLiveData.value) + } + + @Test + fun `saveClickedCourseId는 clickedCourseId를 갱신한다`() = runTest(testDispatcher) { + viewModel.saveClickedCourseId(7) + + assertEquals(7, viewModel.clickedCourseId) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc1de1294..057211dc5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -88,6 +88,7 @@ androidx-junit = "1.3.0" espresso = "3.7.0" mockk = "1.13.13" turbine = "1.2.0" +androidx-core-testing = "2.2.0" [libraries] # AndroidX @@ -112,6 +113,7 @@ compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } @@ -185,6 +187,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +androidx-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidx-core-testing" } [bundles] firebase = [ @@ -208,6 +211,7 @@ compose = [ "compose-ui-graphics", "compose-ui-tooling-preview", "compose-material3", + "compose-runtime-livedata", ] compose-debug = [ "compose-ui-tooling", From cab9c8a637970f2dbc34172c7497b313630ed78e Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 02:06:37 +0900 Subject: [PATCH 02/18] =?UTF-8?q?ci:=20PR=20CI=EC=97=90=20unit=20test=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=8B=A8=EA=B3=84=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=ED=99=95=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 assembleDebug만 돌고 테스트 검증 없이 빌드 체크만 했음. testDebugUnitTest 스텝을 추가해 모든 PR에서 테스트 결과가 GitHub Actions 체크로 객관적으로 남도록 하고, 트리거도 develop 베이스가 아닌 PR에도 적용되도록 확대. --- .github/workflows/CI.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1f0b65c95..1d374a89e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -5,8 +5,7 @@ on: branches: [ develop ] pull_request: - branches: [ develop ] - + defaults: run: shell: bash @@ -99,5 +98,8 @@ jobs: - name: Change gradlew permissions run: chmod +x ./gradlew - - name: Build + - name: Run unit tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Build run: ./gradlew assembleDebug --stacktrace From 2321b189a868d3d2066912ca9198c55d093a0818 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 02:18:14 +0900 Subject: [PATCH 03/18] =?UTF-8?q?fix:=20CI=20JDK=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9D=84=2021=EB=A1=9C=20=EB=A7=9E=EC=B6=A4=20(gradle-daemon-j?= =?UTF-8?q?vm.properties=EC=99=80=20=EC=9D=BC=EC=B9=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gradle/gradle-daemon-jvm.properties는 JetBrains JDK 21을 요구하는데 CI는 Temurin 17로 고정돼 있어 불일치. 이 차이가 원인으로 보이는 kapt+DataBinding NullPointerException(ProcessDataBinding.getSupportedOptions, processingEnv null)이 CI에서만 재현됐음. JDK 버전을 21로 맞춰 검증. --- .github/workflows/CI.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1d374a89e..f1b38e37a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -19,10 +19,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: set up JDK 17 + - name: set up JDK 21 uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: temurin cache: gradle From a807d665007b23f1424ef547bf5a33e1c71c5ff9 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 15:01:51 +0900 Subject: [PATCH 04/18] =?UTF-8?q?fix:=20CodeRabbit=EC=9D=B4=20=EB=B0=9C?= =?UTF-8?q?=EA=B2=AC=ED=95=9C=20Compose=20=ED=8F=AC=ED=8C=85=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=202=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 화면이 초기 로딩 중에도 잠깐 보이던 문제: 원본 XML은 로딩 중 그리드/빈화면 둘 다 숨겼는데 Compose 버전은 courses 기본값이 빈 리스트라 로딩 중에도 EmptyScrapView가 떴음. isLoading 조건 추가로 수정. - 동일한 에러가 연속으로 발생하면 두 번째 스낵바가 안 뜨던 문제: LiveData는 같은 값이어도 항상 옵저버를 호출하지만 Compose State는 동일 키면 LaunchedEffect가 재실행되지 않아서 생긴 차이. 에러를 보여준 뒤 즉시 errorMessage를 null로 리셋해서 1회성 이벤트로 만듦. --- .../runnect/presentation/storage/StorageScrapFragment.kt | 3 ++- .../runnect/presentation/storage/StorageScrapScreen.kt | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt index 6c1b45b54..76776db97 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt @@ -93,7 +93,8 @@ class StorageScrapFragment : Fragment() { onHeartClick = { course -> viewModel.postCourseScrap(id = course.publicCourseId, scrapTF = false) }, - onGoToScrapClick = { navigateToDiscover() } + onGoToScrapClick = { navigateToDiscover() }, + onErrorShown = { errorMessage = null } ) } } diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt index ad975b274..aeb7b15e0 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt @@ -63,11 +63,15 @@ fun StorageScrapScreen( onScrapItemClick: (MyScrapCourse) -> Unit, onHeartClick: (MyScrapCourse) -> Unit, onGoToScrapClick: () -> Unit, + onErrorShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(state.errorMessage) { - state.errorMessage?.let { snackbarHostState.showSnackbar(it) } + state.errorMessage?.let { + snackbarHostState.showSnackbar(it) + onErrorShown() + } } Scaffold( @@ -80,7 +84,7 @@ fun StorageScrapScreen( .fillMaxSize() .padding(paddingValues) ) { - if (state.courses.isEmpty()) { + if (!state.isLoading && state.courses.isEmpty()) { EmptyScrapView(onGoToScrapClick = onGoToScrapClick) } else { Column(modifier = Modifier.fillMaxSize()) { From 8d786bc6409042e8f5c682a68a70a842cfc2399a Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 15:19:54 +0900 Subject: [PATCH 05/18] =?UTF-8?q?test:=20StorageScrapScreen=20Compose=20UI?= =?UTF-8?q?=20test=203=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검증 매트릭스의 ⚠️ 미검증 항목(카드탭 이동, 빈 화면 진입) 두 곳을 ComposeTestRule로 채움. 빈 화면 테스트는 오늘 고친 로딩 중 깜빡임 회귀를 그대로 락인. --- .../storage/StorageScrapScreenTest.kt | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt diff --git a/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt b/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt new file mode 100644 index 000000000..1d6005cb9 --- /dev/null +++ b/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt @@ -0,0 +1,88 @@ +package com.runnect.runnect.presentation.storage + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class StorageScrapScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun course(title: String) = MyScrapCourse( + courseId = 1, + id = 1, + publicCourseId = 10, + image = null, + city = "서울", + region = "강남", + title = title + ) + + @Test + fun `카드탭_시_onScrapItemClick이_해당_코스로_호출된다`() { + val testCourse = course(title = "테스트 코스") + var clicked: MyScrapCourse? = null + + composeTestRule.setContent { + RunnectTheme { + StorageScrapScreen( + state = StorageScrapUiState(courses = listOf(testCourse)), + onRefresh = {}, + onScrapItemClick = { clicked = it }, + onHeartClick = {}, + onGoToScrapClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithText("테스트 코스").performClick() + + assertEquals(testCourse, clicked) + } + + @Test + fun `로딩_중에는_스크랩이_없어도_빈_화면이_보이지_않는다`() { + composeTestRule.setContent { + RunnectTheme { + StorageScrapScreen( + state = StorageScrapUiState(courses = emptyList(), isLoading = true), + onRefresh = {}, + onScrapItemClick = {}, + onHeartClick = {}, + onGoToScrapClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true) + .assertDoesNotExist() + } + + @Test + fun `로딩이_끝나고_스크랩이_없으면_빈_화면이_보인다`() { + composeTestRule.setContent { + RunnectTheme { + StorageScrapScreen( + state = StorageScrapUiState(courses = emptyList(), isLoading = false), + onRefresh = {}, + onScrapItemClick = {}, + onHeartClick = {}, + onGoToScrapClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true) + .assertIsDisplayed() + } +} From 135edb22c2e1f4d929001810fabde76228d517e8 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 15:27:13 +0900 Subject: [PATCH 06/18] =?UTF-8?q?ci:=20Compose=20UI=20test(androidTest)?= =?UTF-8?q?=EB=A5=BC=20=EC=97=90=EB=AE=AC=EB=A0=88=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EC=8B=A4=ED=96=89=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9E=A1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectedDebugAndroidTest는 기기/에뮬레이터가 필요해 기존 unit test 스텝으로 커버되지 않았음. reactivecircus/android-emulator-runner로 PR마다 자동 실행되도록 별도 잡(android-test)을 추가하고, 결과 XML을 아티팩트로 업로드. REMOTE_KEY_FORCE_UPDATE secret 누락 이슈가 풀려야 함께 정상 동작함. --- .github/workflows/CI.yml | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f1b38e37a..d17590f11 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -103,3 +103,105 @@ jobs: - name: Build run: ./gradlew assembleDebug --stacktrace + + android-test: + name: Run Compose UI Tests + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: temurin + cache: gradle + + - name: Write compile time google-services.json file + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + run: echo $GOOGLE_SERVICES_JSON > app/google-services.json + + - name: Touch local properties + run: touch local.properties + + - name: Access NAVER_CLIENT_ID + env: + NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} + run: echo "NAVER_CLIENT_ID = \"$NAVER_CLIENT_ID\"" >> local.properties + + - name: Access RUNNECT_NODE_URL + env: + RUNNECT_NODE_URL: ${{ secrets.RUNNECT_NODE_URL }} + run: echo "RUNNECT_NODE_URL=\"$RUNNECT_NODE_URL\"" >> local.properties + + - name: Access RUNNECT_DEV_URL + env: + RUNNECT_DEV_URL: ${{ secrets.RUNNECT_DEV_URL }} + run: echo "RUNNECT_DEV_URL=\"$RUNNECT_DEV_URL\"" >> local.properties + + - name: Access RUNNECT_PROD_URL + env: + RUNNECT_PROD_URL: ${{ secrets.RUNNECT_PROD_URL }} + run: echo "RUNNECT_PROD_URL=\"$RUNNECT_PROD_URL\"" >> local.properties + + - name: Access TMAP_BASE_URL + env: + TMAP_BASE_URL: ${{ secrets.TMAP_BASE_URL }} + run: echo "TMAP_BASE_URL=\"$TMAP_BASE_URL\"" >> local.properties + + - name: Access TMAP_API_KEY + env: + TMAP_API_KEY: ${{ secrets.TMAP_API_KEY }} + run: echo "TMAP_API_KEY=\"$TMAP_API_KEY\"" >> local.properties + + - name: Access GOOGLE_CLIENT_ID + env: + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + run: echo "GOOGLE_CLIENT_ID=\"$GOOGLE_CLIENT_ID\"" >> local.properties + + - name: Access REMOTE_KEY_APP_VERSION + env: + REMOTE_KEY_APP_VERSION: ${{ secrets.REMOTE_KEY_APP_VERSION }} + run: echo "REMOTE_KEY_APP_VERSION=\"$REMOTE_KEY_APP_VERSION\"" >> local.properties + + - name: Access KAKAO_CHANNEL_ID + env: + KAKAO_CHANNEL_ID: ${{ secrets.KAKAO_CHANNEL_ID }} + run: echo "KAKAO_CHANNEL_ID=\"$KAKAO_CHANNEL_ID\"" >> local.properties + + - name: Access REMOTE_KEY_FORCE_UPDATE + env: + REMOTE_KEY_FORCE_UPDATE: ${{ secrets.REMOTE_KEY_FORCE_UPDATE }} + run: echo "REMOTE_KEY_FORCE_UPDATE=\"$REMOTE_KEY_FORCE_UPDATE\"" >> local.properties + + - name: Add kakao_strings.xml + env: + KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + KAKAO_REDIRECTION_SCHEME: ${{ secrets.KAKAO_REDIRECTION_SCHEME }} + run: | + echo '' > app/src/main/res/values/kakao_strings.xml + echo '' >> app/src/main/res/values/kakao_strings.xml + echo ' $KAKAO_NATIVE_APP_KEY' >> app/src/main/res/values/kakao_strings.xml + echo ' $KAKAO_REDIRECTION_SCHEME' >> app/src/main/res/values/kakao_strings.xml + echo '' >> app/src/main/res/values/kakao_strings.xml + + - name: Change gradlew permissions + run: chmod +x ./gradlew + + - name: Run Compose UI tests on emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + script: ./gradlew connectedDebugAndroidTest --stacktrace + + - name: Upload instrumented test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-test-results + path: app/build/outputs/androidTest-results/connected/** From aa4231582980128e17081fe06ea0449f8e01b85f Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 15:54:33 +0900 Subject: [PATCH 07/18] =?UTF-8?q?fix:=20=EC=8A=A4=ED=81=AC=EB=9E=A9=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C(=ED=95=98=ED=8A=B8=ED=83=AD)=20=EC=8B=9C=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EB=90=9C=20=EB=A1=9C=EB=94=A9=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원본은 courseScrapState Loading 상태에서 프로그레스바를 띄웠지만 Compose 포팅 시 PullToRefreshBox의 isRefreshing이 myScrapCourseGetState에만 연결돼 하트탭 네트워크 대기 중 아무 피드백 없이 멈춰있다가 아이템이 사라지는 회귀가 있었음. before/after 영상 재검토로 발견. --- .../runnect/presentation/storage/StorageScrapFragment.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt index 76776db97..789549b6b 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt @@ -85,7 +85,8 @@ class StorageScrapFragment : Fragment() { StorageScrapScreen( state = StorageScrapUiState( courses = courses, - isLoading = getState is UiStateV2.Loading, + isLoading = getState is UiStateV2.Loading || + scrapState is UiStateV2.Loading, errorMessage = errorMessage ), onRefresh = { viewModel.getMyScrapCourses() }, From bd95fd5c29f6067ad624f78b1731aac27238e852 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 16:16:50 +0900 Subject: [PATCH 08/18] =?UTF-8?q?refactor:=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B2=B0=ED=95=A9=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20=EC=88=9C=EC=88=98=20=ED=95=A8=EC=88=98=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20=ED=9A=8C=EA=B7=80=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StorageScrapUiState.from()으로 getState/scrapState의 Loading 결합 판단을 Fragment 밖으로 빼서 에뮬레이터 없이 JVM 유닛 테스트로 고정. StorageScrapScreen에는 isLoading=true일 때 로딩 인디케이터가 실제로 보이는지 검증하는 Compose UI test 추가 (네트워크 타이밍에 의존하지 않음). --- .../storage/StorageScrapScreenTest.kt | 21 ++++++ .../storage/StorageScrapFragment.kt | 6 +- .../storage/StorageScrapScreen.kt | 17 ++++- .../storage/StorageScrapUiStateTest.kt | 73 +++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/runnect/runnect/presentation/storage/StorageScrapUiStateTest.kt diff --git a/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt b/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt index 1d6005cb9..c94c72df9 100644 --- a/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt +++ b/app/src/androidTest/java/com/runnect/runnect/presentation/storage/StorageScrapScreenTest.kt @@ -1,6 +1,8 @@ package com.runnect.runnect.presentation.storage +import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -85,4 +87,23 @@ class StorageScrapScreenTest { composeTestRule.onNodeWithText("아직 스크랩한 코스가 없어요", substring = true) .assertIsDisplayed() } + + @Test + fun `isLoading이_true이면_로딩_인디케이터가_보인다`() { + composeTestRule.setContent { + RunnectTheme { + StorageScrapScreen( + state = StorageScrapUiState(courses = listOf(course("코스")), isLoading = true), + onRefresh = {}, + onScrapItemClick = {}, + onHeartClick = {}, + onGoToScrapClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) + .assertIsDisplayed() + } } diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt index 789549b6b..5d9446bd8 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapFragment.kt @@ -83,10 +83,10 @@ class StorageScrapFragment : Fragment() { } StorageScrapScreen( - state = StorageScrapUiState( + state = StorageScrapUiState.from( + getState = getState, + scrapState = scrapState, courses = courses, - isLoading = getState is UiStateV2.Loading || - scrapState is UiStateV2.Loading, errorMessage = errorMessage ), onRefresh = { viewModel.getMyScrapCourses() }, diff --git a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt index aeb7b15e0..a160a33e9 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/storage/StorageScrapScreen.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.runnect.runnect.R import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.domain.entity.PostScrap +import com.runnect.runnect.presentation.state.UiStateV2 import com.runnect.runnect.presentation.ui.theme.G1 import com.runnect.runnect.presentation.ui.theme.G2 import com.runnect.runnect.presentation.ui.theme.G4 @@ -53,7 +55,20 @@ data class StorageScrapUiState( val courses: List = emptyList(), val isLoading: Boolean = false, val errorMessage: String? = null -) +) { + companion object { + fun from( + getState: UiStateV2>?, + scrapState: UiStateV2?, + courses: List, + errorMessage: String?, + ) = StorageScrapUiState( + courses = courses, + isLoading = getState is UiStateV2.Loading || scrapState is UiStateV2.Loading, + errorMessage = errorMessage + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/test/java/com/runnect/runnect/presentation/storage/StorageScrapUiStateTest.kt b/app/src/test/java/com/runnect/runnect/presentation/storage/StorageScrapUiStateTest.kt new file mode 100644 index 000000000..a4fa8f35a --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/storage/StorageScrapUiStateTest.kt @@ -0,0 +1,73 @@ +package com.runnect.runnect.presentation.storage + +import com.runnect.runnect.domain.entity.MyScrapCourse +import com.runnect.runnect.domain.entity.PostScrap +import com.runnect.runnect.presentation.state.UiStateV2 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageScrapUiStateTest { + + private fun course(title: String) = MyScrapCourse( + courseId = 1, + id = 1, + publicCourseId = 10, + image = null, + city = "서울", + region = "강남", + title = title + ) + + @Test + fun `목록_조회만_Loading이어도_isLoading은_true다`() { + val state = StorageScrapUiState.from( + getState = UiStateV2.Loading, + scrapState = null, + courses = emptyList(), + errorMessage = null + ) + + assertTrue(state.isLoading) + } + + @Test + fun `스크랩_토글만_Loading이어도_isLoading은_true다`() { + val state = StorageScrapUiState.from( + getState = UiStateV2.Success(emptyList()), + scrapState = UiStateV2.Loading, + courses = emptyList(), + errorMessage = null + ) + + assertTrue(state.isLoading) + } + + @Test + fun `둘다_Loading이_아니면_isLoading은_false다`() { + val state = StorageScrapUiState.from( + getState = UiStateV2.Success(emptyList()), + scrapState = UiStateV2.Success(PostScrap(publicCourseId = 1L, scrapCount = 0L, scrapTF = false)), + courses = emptyList(), + errorMessage = null + ) + + assertFalse(state.isLoading) + } + + @Test + fun `courses와_errorMessage는_그대로_전달된다`() { + val course = course(title = "테스트 코스") + + val state = StorageScrapUiState.from( + getState = UiStateV2.Failure("에러"), + scrapState = null, + courses = listOf(course), + errorMessage = "에러" + ) + + assertEquals(listOf(course), state.courses) + assertEquals("에러", state.errorMessage) + } +} From d47f44c0a2b37be9c5cc46945b3c91cb3d7cf411 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 16:50:48 +0900 Subject: [PATCH 09/18] =?UTF-8?q?fix:=20CI=20=EC=97=90=EB=AE=AC=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=9E=A1=EC=9D=84=20macos-latest(Apple=20?= =?UTF-8?q?Silicon)=EC=97=90=20=EB=A7=9E=EC=B6=B0=20arm64-v8a=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x86_64 시스템 이미지는 aarch64 호스트의 QEMU2에서 못 돌아서 에뮬레이터가 즉시 죽고 10분간 부팅 대기만 하다 타임아웃났음. CI 동작 자체가 아니라 잡 설정 문제였음. --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d17590f11..3ad345fae 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -195,7 +195,7 @@ jobs: with: api-level: 34 target: google_apis - arch: x86_64 + arch: arm64-v8a profile: pixel_6 script: ./gradlew connectedDebugAndroidTest --stacktrace From f41f46bd1b438069809bdd89152ac1fc57ae9e81 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 17:04:56 +0900 Subject: [PATCH 10/18] =?UTF-8?q?fix:=20=EC=95=88=EB=93=9C=EB=A1=9C?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=97=90=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20CI=20=EC=9E=A1=EC=9D=84=20ubuntu-latest+KVM?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS 러너는 HVF가 HV_UNSUPPORTED를 내면서 하드웨어 가속 자체가 막혀 에뮬레이터가 못 떴음. android-emulator-runner 공식 문서가 권장하는 ubuntu-latest + KVM 활성화 방식으로 변경. --- .github/workflows/CI.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3ad345fae..079ca183d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -106,7 +106,7 @@ jobs: android-test: name: Run Compose UI Tests - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 @@ -190,12 +190,18 @@ jobs: - name: Change gradlew permissions run: chmod +x ./gradlew + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run Compose UI tests on emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 target: google_apis - arch: arm64-v8a + arch: x86_64 profile: pixel_6 script: ./gradlew connectedDebugAndroidTest --stacktrace From fe308264f730178b713ab07daedac954cb53af1d Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 17:12:54 +0900 Subject: [PATCH 11/18] =?UTF-8?q?fix:=20ExampleInstrumentedTest=EC=9D=98?= =?UTF-8?q?=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=EB=90=9C=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=AA=85=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit com.example.runnect로 박혀있던 안드로이드 스튜디오 생성 샘플 테스트. connectedDebugAndroidTest가 CI에서 처음 실제로 도니까 드러난 기존 버그. --- .../java/com/runnect/runnect/ExampleInstrumentedTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidTest/java/com/runnect/runnect/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/runnect/runnect/ExampleInstrumentedTest.kt index 927d73c4c..b510dab40 100644 --- a/app/src/androidTest/java/com/runnect/runnect/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/runnect/runnect/ExampleInstrumentedTest.kt @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.example.runnect", appContext.packageName) + assertEquals("com.runnect.runnect", appContext.packageName) } } \ No newline at end of file From f780884752a008abde340aebb55d39bf44568926 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 17:24:22 +0900 Subject: [PATCH 12/18] =?UTF-8?q?ci:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=B3=84=20=EC=84=B1=EA=B3=B5/=EC=8B=A4=ED=8C=A8=EB=A5=BC=20PR?= =?UTF-8?q?=20=EC=B2=B4=ED=81=AC=EC=97=90=EC=84=9C=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=ED=95=98=EB=8F=84=EB=A1=9D=20test-reporter?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testLogging으로 유닛 테스트가 콘솔에 테스트명 단위 PASSED/FAILED를 찍게 하고, dorny/test-reporter로 유닛/Compose UI 테스트 결과를 각각 별도 체크(Unit Test Results / Compose UI Test Results)로 게시해서 어떤 테스트가 깨졌는지 PR에서 클릭 한 번으로 특정 가능하게 함. --- .github/workflows/CI.yml | 21 +++++++++++++++++++++ app/build.gradle | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 079ca183d..9ddc2e24b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -6,6 +6,11 @@ on: pull_request: +permissions: + contents: read + checks: write + pull-requests: write + defaults: run: shell: bash @@ -101,6 +106,14 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest --stacktrace + - name: Publish unit test results + if: always() + uses: dorny/test-reporter@v1 + with: + name: Unit Test Results + path: app/build/test-results/testDebugUnitTest/*.xml + reporter: java-junit + - name: Build run: ./gradlew assembleDebug --stacktrace @@ -205,6 +218,14 @@ jobs: profile: pixel_6 script: ./gradlew connectedDebugAndroidTest --stacktrace + - name: Publish Compose UI test results + if: always() + uses: dorny/test-reporter@v1 + with: + name: Compose UI Test Results + path: app/build/outputs/androidTest-results/connected/**/*.xml + reporter: java-junit + - name: Upload instrumented test results if: always() uses: actions/upload-artifact@v4 diff --git a/app/build.gradle b/app/build.gradle index a68646dc2..eaafa88ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -99,6 +99,12 @@ android { } } +tasks.withType(Test).configureEach { + testLogging { + events "passed", "skipped", "failed" + } +} + tasks.register('renameReleaseBundle') { def vName = libs.versions.versionName.get() def bundlePath = layout.buildDirectory.dir("outputs/bundle/release") From a8c293807742a37c9b3dee2c1ff9119cf85ca14a Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 17:36:11 +0900 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20test-reporter=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=9F=B0=20=EC=83=9D=EC=84=B1=EC=97=90=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20actions:read=20=EA=B6=8C=ED=95=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dorny/test-reporter가 테스트 결과 요약은 만들었지만 체크런 생성 API 호출 직전에 조용히 멈췄음. actions:read 권한 누락이 원인. --- .github/workflows/CI.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 9ddc2e24b..51c39fb74 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + actions: read checks: write pull-requests: write From 484725abc20abb2da97d365ef41b7c5f2636ec6c Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 17:44:46 +0900 Subject: [PATCH 14/18] =?UTF-8?q?fix:=20dorny/test-reporter=EB=A5=BC=20v3?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AC=EB=A0=A4=20=EC=B2=B4=ED=81=AC=EB=9F=B0=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EC=9E=A1=20=EC=84=9C=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EB=A7=8C=20=EC=B0=8D=ED=9E=88=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1은 Job Summary로만 동작해서 PR 체크 목록에 안 보였음. 공식 문서가 권장하는 v3로 올려서 실제 체크런으로 생성되게 함. --- .github/workflows/CI.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 51c39fb74..3e3fa49f0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -109,7 +109,7 @@ jobs: - name: Publish unit test results if: always() - uses: dorny/test-reporter@v1 + uses: dorny/test-reporter@v3 with: name: Unit Test Results path: app/build/test-results/testDebugUnitTest/*.xml @@ -221,7 +221,7 @@ jobs: - name: Publish Compose UI test results if: always() - uses: dorny/test-reporter@v1 + uses: dorny/test-reporter@v3 with: name: Compose UI Test Results path: app/build/outputs/androidTest-results/connected/**/*.xml From 5f5a0fd535461d96e48f158fb7efe338ad0c98b3 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 22:15:12 +0900 Subject: [PATCH 15/18] =?UTF-8?q?ci:=20dorny/test-reporter=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=9E=90=EC=B2=B4=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20PR=20=EC=BD=94=EB=A9=98=ED=8A=B8=EC=97=90?= =?UTF-8?q?=20=EA=B2=8C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dorny/test-reporter(v1/v3 둘 다)는 job summary는 만들지만 체크런 생성은 조용히 안 됐음(에러 없이 스킵, 원인 특정 못함). 더 기본적이고 통제 가능한 방식으로 전환: JUnit XML을 직접 파싱(.github/scripts/post_test_report.py) 해서 테스트별 ✅/❌ 마크다운을 PR 코멘트로 직접 게시(마커 기반으로 같은 코멘트를 갱신, 스팸 방지). --- .github/scripts/post_test_report.py | 49 +++++++++++++++++++++++++++++ .github/workflows/CI.yml | 44 +++++++++++++++++--------- 2 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 .github/scripts/post_test_report.py diff --git a/.github/scripts/post_test_report.py b/.github/scripts/post_test_report.py new file mode 100644 index 000000000..ae828261d --- /dev/null +++ b/.github/scripts/post_test_report.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Parse JUnit XML test results and print a markdown report to stdout.""" +import glob +import sys +import xml.etree.ElementTree as ET + + +def main() -> None: + title = sys.argv[1] + patterns = sys.argv[2:] + + paths = [] + for pattern in patterns: + paths.extend(sorted(glob.glob(pattern, recursive=True))) + + if not paths: + print(f"## {title}\n\n⚠️ 테스트 결과 파일을 찾을 수 없습니다.") + return + + passed = failed = skipped = 0 + suite_lines = [] + + for path in paths: + root = ET.parse(path).getroot() + suite_name = root.get("name", path) + cases = root.findall("testcase") + suite_lines.append(f"\n### {suite_name}") + for case in cases: + case_name = case.get("name") + if case.find("failure") is not None or case.find("error") is not None: + status = "❌" + failed += 1 + elif case.find("skipped") is not None: + status = "⏭️" + skipped += 1 + else: + status = "✅" + passed += 1 + suite_lines.append(f"- {status} {case_name}") + + total = passed + failed + skipped + badge = "✅" if failed == 0 else "❌" + print(f"## {badge} {title}\n") + print(f"**{total}개 중 {passed} 통과 / {failed} 실패 / {skipped} 스킵**") + print("".join(line + "\n" for line in suite_lines)) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3e3fa49f0..30a02fb6e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -107,13 +107,21 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest --stacktrace - - name: Publish unit test results - if: always() - uses: dorny/test-reporter@v3 - with: - name: Unit Test Results - path: app/build/test-results/testDebugUnitTest/*.xml - reporter: java-junit + - name: Comment unit test results on PR + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + MARKER="" + BODY="$MARKER"$'\n'"$(python3 .github/scripts/post_test_report.py 'Unit Test Results' 'app/build/test-results/testDebugUnitTest/*.xml')" + existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1) + if [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY" + else + gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY" + fi - name: Build run: ./gradlew assembleDebug --stacktrace @@ -219,13 +227,21 @@ jobs: profile: pixel_6 script: ./gradlew connectedDebugAndroidTest --stacktrace - - name: Publish Compose UI test results - if: always() - uses: dorny/test-reporter@v3 - with: - name: Compose UI Test Results - path: app/build/outputs/androidTest-results/connected/**/*.xml - reporter: java-junit + - name: Comment Compose UI test results on PR + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + MARKER="" + BODY="$MARKER"$'\n'"$(python3 .github/scripts/post_test_report.py 'Compose UI Test Results' 'app/build/outputs/androidTest-results/connected/**/*.xml')" + existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1) + if [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY" + else + gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY" + fi - name: Upload instrumented test results if: always() From dfdfd45ac59bf61e471b955f4f88166aeb2ba562 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 22:43:06 +0900 Subject: [PATCH 16/18] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=BD=94=EB=A9=98=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20testcase=EC=9D=98=20classname=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=A8=ED=95=91=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectedAndroidTest는 디바이스 1대당 XML 1개에 모든 클래스의 테스트를 합쳐서 쓰는데, testsuite 레벨 name은 첫 번째 클래스명만 가져서 나머지 클래스 테스트가 잘못 묶였음. --- .github/scripts/post_test_report.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/scripts/post_test_report.py b/.github/scripts/post_test_report.py index ae828261d..5953987f5 100644 --- a/.github/scripts/post_test_report.py +++ b/.github/scripts/post_test_report.py @@ -18,15 +18,13 @@ def main() -> None: return passed = failed = skipped = 0 - suite_lines = [] + by_class: dict[str, list[str]] = {} for path in paths: root = ET.parse(path).getroot() - suite_name = root.get("name", path) - cases = root.findall("testcase") - suite_lines.append(f"\n### {suite_name}") - for case in cases: + for case in root.findall("testcase"): case_name = case.get("name") + classname = case.get("classname", root.get("name", path)) if case.find("failure") is not None or case.find("error") is not None: status = "❌" failed += 1 @@ -36,13 +34,15 @@ def main() -> None: else: status = "✅" passed += 1 - suite_lines.append(f"- {status} {case_name}") + by_class.setdefault(classname, []).append(f"- {status} {case_name}") total = passed + failed + skipped badge = "✅" if failed == 0 else "❌" print(f"## {badge} {title}\n") print(f"**{total}개 중 {passed} 통과 / {failed} 실패 / {skipped} 스킵**") - print("".join(line + "\n" for line in suite_lines)) + for classname in sorted(by_class): + print(f"\n### {classname}") + print("\n".join(by_class[classname])) if __name__ == "__main__": From b9a66c40d3c8aafbb0716c6ebf67b4169c615694 Mon Sep 17 00:00:00 2001 From: unam98 Date: Thu, 25 Jun 2026 22:46:11 +0900 Subject: [PATCH 17/18] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=BD=94=EB=A9=98=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EA=B0=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EC=95=B5=EC=BB=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 본문 검증 매트릭스에서 특정 테스트의 최신 통과 여부로 바로 링크 걸 수 있게, classname+테스트명 해시로 만든 안정적인 anchor id를 각 줄에 부여. --- .github/scripts/post_test_report.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/scripts/post_test_report.py b/.github/scripts/post_test_report.py index 5953987f5..7d905eb87 100644 --- a/.github/scripts/post_test_report.py +++ b/.github/scripts/post_test_report.py @@ -1,10 +1,21 @@ #!/usr/bin/env python3 -"""Parse JUnit XML test results and print a markdown report to stdout.""" +"""Parse JUnit XML test results and print a markdown report to stdout. + +Each test line gets a stable anchor () so that other +documents (e.g. the PR description's verification matrix) can link +directly to a specific test's latest result. +""" import glob +import hashlib import sys import xml.etree.ElementTree as ET +def anchor_id(classname: str, case_name: str) -> str: + digest = hashlib.md5(f"{classname}::{case_name}".encode()).hexdigest()[:10] + return f"t-{digest}" + + def main() -> None: title = sys.argv[1] patterns = sys.argv[2:] @@ -34,7 +45,10 @@ def main() -> None: else: status = "✅" passed += 1 - by_class.setdefault(classname, []).append(f"- {status} {case_name}") + aid = anchor_id(classname, case_name) + by_class.setdefault(classname, []).append( + f'- {status} {case_name}' + ) total = passed + failed + skipped badge = "✅" if failed == 0 else "❌" From 15c93d5b895fac04af00db9620f0e2ec59573262 Mon Sep 17 00:00:00 2001 From: unam98 Date: Fri, 26 Jun 2026 22:13:53 +0900 Subject: [PATCH 18/18] =?UTF-8?q?ci:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=EC=97=90=EB=A7=8C=20PR=20?= =?UTF-8?q?=EC=BD=94=EB=A9=98=ED=8A=B8=20=EA=B2=8C=EC=8B=9C,=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=9C=20=EC=BD=94=EB=A9=98=ED=8A=B8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실패한 테스트만 출력하도록 post_test_report.py 단순화 - 전부 통과하면 기존 실패 코멘트를 삭제, 코멘트 없으면 무시 - 통과/실패 여부는 CI 체크 뱃지로 확인 가능하므로 성공 코멘트 불필요 --- .github/scripts/post_test_report.py | 53 +++++++++++------------------ .github/workflows/CI.yml | 34 +++++++++++------- 2 files changed, 41 insertions(+), 46 deletions(-) diff --git a/.github/scripts/post_test_report.py b/.github/scripts/post_test_report.py index 7d905eb87..fd91be59c 100644 --- a/.github/scripts/post_test_report.py +++ b/.github/scripts/post_test_report.py @@ -1,21 +1,14 @@ #!/usr/bin/env python3 -"""Parse JUnit XML test results and print a markdown report to stdout. +"""Parse JUnit XML test results and print failed tests to stdout. -Each test line gets a stable anchor () so that other -documents (e.g. the PR description's verification matrix) can link -directly to a specific test's latest result. +Prints nothing when all tests pass. Prints only failed tests when failures exist, +so the caller can decide whether to post a PR comment based on whether stdout is non-empty. """ import glob -import hashlib import sys import xml.etree.ElementTree as ET -def anchor_id(classname: str, case_name: str) -> str: - digest = hashlib.md5(f"{classname}::{case_name}".encode()).hexdigest()[:10] - return f"t-{digest}" - - def main() -> None: title = sys.argv[1] patterns = sys.argv[2:] @@ -25,38 +18,30 @@ def main() -> None: paths.extend(sorted(glob.glob(pattern, recursive=True))) if not paths: - print(f"## {title}\n\n⚠️ 테스트 결과 파일을 찾을 수 없습니다.") return - passed = failed = skipped = 0 - by_class: dict[str, list[str]] = {} + failed_by_class: dict[str, list[str]] = {} + total = failed = 0 for path in paths: root = ET.parse(path).getroot() for case in root.findall("testcase"): - case_name = case.get("name") - classname = case.get("classname", root.get("name", path)) + total += 1 if case.find("failure") is not None or case.find("error") is not None: - status = "❌" failed += 1 - elif case.find("skipped") is not None: - status = "⏭️" - skipped += 1 - else: - status = "✅" - passed += 1 - aid = anchor_id(classname, case_name) - by_class.setdefault(classname, []).append( - f'- {status} {case_name}' - ) - - total = passed + failed + skipped - badge = "✅" if failed == 0 else "❌" - print(f"## {badge} {title}\n") - print(f"**{total}개 중 {passed} 통과 / {failed} 실패 / {skipped} 스킵**") - for classname in sorted(by_class): - print(f"\n### {classname}") - print("\n".join(by_class[classname])) + classname = case.get("classname", root.get("name", path)) + failed_by_class.setdefault(classname, []).append( + f"- ❌ {case.get('name')}" + ) + + if failed == 0: + return + + print(f"## ❌ {title} — {total}개 중 {failed}개 실패\n") + for classname in sorted(failed_by_class): + print(f"### {classname}") + print("\n".join(failed_by_class[classname])) + print() if __name__ == "__main__": diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 30a02fb6e..d99b53971 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -107,7 +107,7 @@ jobs: - name: Run unit tests run: ./gradlew testDebugUnitTest --stacktrace - - name: Comment unit test results on PR + - name: Comment unit test failures on PR if: always() && github.event_name == 'pull_request' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -115,12 +115,17 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | MARKER="" - BODY="$MARKER"$'\n'"$(python3 .github/scripts/post_test_report.py 'Unit Test Results' 'app/build/test-results/testDebugUnitTest/*.xml')" + REPORT="$(python3 .github/scripts/post_test_report.py 'Unit Test Results' 'app/build/test-results/testDebugUnitTest/*.xml')" existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1) - if [ -n "$existing_id" ]; then - gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY" - else - gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY" + if [ -n "$REPORT" ]; then + BODY="$MARKER"$'\n'"$REPORT" + if [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY" + else + gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY" + fi + elif [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X DELETE fi - name: Build @@ -227,7 +232,7 @@ jobs: profile: pixel_6 script: ./gradlew connectedDebugAndroidTest --stacktrace - - name: Comment Compose UI test results on PR + - name: Comment Compose UI test failures on PR if: always() && github.event_name == 'pull_request' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -235,12 +240,17 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} run: | MARKER="" - BODY="$MARKER"$'\n'"$(python3 .github/scripts/post_test_report.py 'Compose UI Test Results' 'app/build/outputs/androidTest-results/connected/**/*.xml')" + REPORT="$(python3 .github/scripts/post_test_report.py 'Compose UI Test Results' 'app/build/outputs/androidTest-results/connected/**/*.xml')" existing_id=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1) - if [ -n "$existing_id" ]; then - gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY" - else - gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY" + if [ -n "$REPORT" ]; then + BODY="$MARKER"$'\n'"$REPORT" + if [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X PATCH -f body="$BODY" + else + gh api "repos/$REPO/issues/$PR_NUMBER/comments" -X POST -f body="$BODY" + fi + elif [ -n "$existing_id" ]; then + gh api "repos/$REPO/issues/comments/$existing_id" -X DELETE fi - name: Upload instrumented test results