From fbe25e4bd3b668bbf344becfb9ce4ec9d3fde7bd Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sun, 27 Sep 2026 21:02:51 +0900 Subject: [PATCH 01/22] =?UTF-8?q?fix:=20Home=20=EC=B7=A8=ED=96=A5=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=83=81?= =?UTF-8?q?=EB=8B=A8=20=EC=BD=98=ED=85=90=EC=B8=A0=EC=99=80=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../into/websoso/ui/main/home/HomeFragment.kt | 46 +- .../websoso/ui/main/home/HomeSectionLoader.kt | 54 ++ .../ui/main/home/HomeSectionRelease.kt | 21 + .../websoso/ui/main/home/HomeViewModel.kt | 168 +++--- .../websoso/ui/main/home/model/HomeUiState.kt | 8 + app/src/main/res/layout/fragment_home.xml | 38 ++ .../ui/main/home/HomeSectionLoaderTest.kt | 169 ++++++ .../ui/main/home/HomeSectionReleaseTest.kt | 121 +++++ .../websoso/ui/main/home/HomeViewModelTest.kt | 481 ++++++++++++++++++ 9 files changed, 1024 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt create mode 100644 app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt create mode 100644 app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt create mode 100644 app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt create mode 100644 app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt index 92ccd920d..a4ef39cae 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt @@ -23,6 +23,8 @@ import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.collectWithLifecycle import com.into.websoso.core.common.util.tracker.Tracker import com.into.websoso.core.resource.R.string.home_rising_feed_for_user +import com.into.websoso.core.resource.R.string.load_fail_title +import com.into.websoso.core.resource.R.string.load_load_title import com.into.websoso.databinding.FragmentHomeBinding import com.into.websoso.ui.detailExplore.DetailExploreActivity import com.into.websoso.ui.feedDetail.FeedDetailActivity @@ -31,6 +33,8 @@ import com.into.websoso.ui.main.home.adpater.PopularFeedsAdapter import com.into.websoso.ui.main.home.adpater.PopularNovelsAdapter import com.into.websoso.ui.main.home.adpater.RecommendedNovelsByUserTasteAdapter import com.into.websoso.ui.main.home.dialog.TermsAgreementDialogFragment +import com.into.websoso.ui.main.home.model.HomeTasteStatus +import com.into.websoso.ui.main.home.model.HomeUiState import com.into.websoso.ui.normalExplore.NormalExploreActivity import com.into.websoso.ui.notification.NotificationActivity import com.into.websoso.ui.novelDetail.NovelDetailActivity @@ -45,6 +49,7 @@ class HomeFragment : BaseFragment(fragment_home) { private val homeViewModel: HomeViewModel by viewModels() private val mainViewModel: MainViewModel by activityViewModels() + private var renderedState: HomeUiState? = null private val singleEventHandler: SingleEventHandler by lazy { SingleEventHandler.from() } @@ -80,7 +85,7 @@ class HomeFragment : BaseFragment(fragment_home) { ProfileEditSuccess.RESULT_OK -> { mainViewModel.updateUserInfo() - homeViewModel.updateNovel() + homeViewModel.updateNovel(preferencesChanged = true) } } } @@ -102,6 +107,7 @@ class HomeFragment : BaseFragment(fragment_home) { bindViewModel() setupAdapter() setupItemDecoration() + binding.btnHomeTasteRetry.setOnClickListener { homeViewModel.retryTaste() } setupObserver() setupDotsIndicator() onSettingPreferenceGenreClick() @@ -162,11 +168,18 @@ class HomeFragment : BaseFragment(fragment_home) { !uiState.loading -> { binding.wllHome.setWebsosoLoadingVisibility(false) - popularNovelsAdapter.submitList(uiState.popularNovels) - popularFeedsAdapter.submitList(uiState.popularFeeds) - updateRecommendedNovelByUserTasteVisibility(uiState.recommendedNovelsByUserTaste.isEmpty()) - recommendedNovelsByUserTasteAdapter.submitList(uiState.recommendedNovelsByUserTaste) + if (renderedState?.popularNovels !== uiState.popularNovels) { + popularNovelsAdapter.submitList(uiState.popularNovels) + } + if (renderedState?.popularFeeds !== uiState.popularFeeds) { + popularFeedsAdapter.submitList(uiState.popularFeeds) + } + if (renderedState?.recommendedNovelsByUserTaste !== uiState.recommendedNovelsByUserTaste) { + recommendedNovelsByUserTasteAdapter.submitList(uiState.recommendedNovelsByUserTaste) + } + renderTasteStatus(uiState) updateHasNotificationUnread(uiState.isNotificationUnread) + renderedState = uiState } } } @@ -193,18 +206,25 @@ class HomeFragment : BaseFragment(fragment_home) { } } - private fun updateRecommendedNovelByUserTasteVisibility(isRecommendNovelByUserTasteEmpty: Boolean) { + private fun renderTasteStatus(state: HomeUiState) { with(binding) { - if (isRecommendNovelByUserTasteEmpty) { - clHomeUserRecommendNovel.visibility = View.GONE - clHomeRecommendNovel.visibility = View.VISIBLE - } else { - clHomeUserRecommendNovel.visibility = View.VISIBLE - clHomeRecommendNovel.visibility = View.GONE - } + val empty = state.tasteStatus == HomeTasteStatus.EMPTY + val showContent = state.tasteStatus == HomeTasteStatus.CONTENT + clHomeRecommendNovel.visibility = if (empty) View.VISIBLE else View.GONE + clHomeUserRecommendNovel.visibility = if (empty) View.GONE else View.VISIBLE + rvRecommendNovelByUserTaste.visibility = if (showContent) View.VISIBLE else View.GONE + llHomeTasteStatus.visibility = if (showContent || empty) View.GONE else View.VISIBLE + val error = state.tasteStatus == HomeTasteStatus.ERROR + tvHomeTasteStatus.setText(if (error) load_fail_title else load_load_title) + btnHomeTasteRetry.visibility = if (error) View.VISIBLE else View.GONE } } + override fun onDestroyView() { + renderedState = null + super.onDestroyView() + } + private fun updateHasNotificationUnread(hasUnread: Boolean) { binding.ivHomeNotification.isSelected = hasUnread } diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt new file mode 100644 index 000000000..2f5aa80ef --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt @@ -0,0 +1,54 @@ +package com.into.websoso.ui.main.home + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import retrofit2.HttpException + +internal enum class HomeSection { + POPULAR, + FEEDS, + TASTE, +} + +internal fun isGlobalHomeFailure( + section: HomeSection, + error: Exception, +): Boolean = section != HomeSection.TASTE || isSessionFailure(error) + +internal fun isSessionFailure(error: Throwable): Boolean = error is HttpException && (error.code() == 401 || error.code() == 403) + +/** Requests belong to the Home ViewModel; replacing one never cancels another section. */ +internal class HomeSectionLoader( + private val scope: CoroutineScope, +) { + private val jobs = mutableMapOf() + + fun load( + section: HomeSection, + request: suspend () -> T, + success: (T) -> Unit, + failure: (Exception) -> Unit, + ): Deferred { + jobs[section]?.cancel() + return scope + .async { + try { + val result = request() + currentCoroutineContext().ensureActive() + success(result) + true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + currentCoroutineContext().ensureActive() + failure(error) + false + } + }.also { jobs[section] = it } + } +} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt new file mode 100644 index 000000000..460db4a16 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt @@ -0,0 +1,21 @@ +package com.into.websoso.ui.main.home + +import com.into.websoso.ui.main.home.model.HomeUiState + +/** Main-thread only. Upper successes open Home once; subsequent updates retain content. */ +internal class HomeSectionRelease { + var pending = HomeUiState() + private set + private val waitingForUpper = mutableSetOf(HomeSection.POPULAR, HomeSection.FEEDS) + + fun update( + section: HomeSection, + state: HomeUiState, + ): HomeUiState? { + pending = state + waitingForUpper.remove(section) + if (waitingForUpper.isNotEmpty()) return null + pending = state.copy(loading = false) + return pending + } +} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index caf547121..6213c29c5 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -6,22 +6,22 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.into.websoso.data.model.PopularFeedEntity -import com.into.websoso.data.model.PopularNovelsEntity -import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity import com.into.websoso.data.model.TermsAgreementEntity import com.into.websoso.data.repository.FeedRepository import com.into.websoso.data.repository.NotificationRepository import com.into.websoso.data.repository.NovelRepository import com.into.websoso.data.repository.PushMessageRepository import com.into.websoso.data.repository.UserRepository +import com.into.websoso.ui.main.home.model.HomeTasteStatus import com.into.websoso.ui.main.home.model.HomeUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Deferred import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import javax.inject.Inject @@ -53,6 +53,10 @@ class HomeViewModel savedStateHandle["isTermsAgreementChecked"] = value } + private val sectionLoader = HomeSectionLoader(viewModelScope) + private val sectionRelease = HomeSectionRelease() + private var hasSessionFailure = false + init { updateHomeData(true) updateNotificationUnread() @@ -71,38 +75,86 @@ class HomeViewModel } private suspend fun fetchUserHomeData() { - coroutineScope { - val popularNovelsDeferred = - async { runCatching { novelRepository.fetchPopularNovels() } } - val popularFeedsDeferred = - async { runCatching { feedRepository.fetchPopularFeeds() } } - val recommendedNovelsDeferred = - async { runCatching { novelRepository.fetchRecommendedNovelsByUserTaste() } } - - val popularNovelsResult = popularNovelsDeferred.await() - val popularFeedsResult = popularFeedsDeferred.await() - val recommendedNovelsResult = recommendedNovelsDeferred.await() + val requests = listOf(loadPopularNovels(), loadPopularFeeds(), loadTasteNovels()) + requests.joinAll() + if (requests.all { !it.isCancelled && it.await() }) recoverGlobalError() + } - val failure = popularNovelsResult.exceptionOrNull() - ?: popularFeedsResult.exceptionOrNull() - ?: recommendedNovelsResult.exceptionOrNull() - if (failure != null) { - handleFailureState() - return@coroutineScope - } + private fun loadPopularNovels(): Deferred = + sectionLoader.load( + section = HomeSection.POPULAR, + request = novelRepository::fetchPopularNovels, + success = { result -> + publishContent(HomeSection.POPULAR, sectionRelease.pending.copy(popularNovels = result.popularNovels)) + }, + failure = { handleFailureState(it) }, + ) - val popularNovels = popularNovelsResult.getOrThrow() - val popularFeeds = popularFeedsResult.getOrThrow() - val recommendedNovels = recommendedNovelsResult.getOrThrow() + private fun loadPopularFeeds(recoverError: Boolean = false): Deferred = + sectionLoader.load( + section = HomeSection.FEEDS, + request = feedRepository::fetchPopularFeeds, + success = { result -> + publishContent(HomeSection.FEEDS, sectionRelease.pending.copy(popularFeeds = result.toHomePopularFeedPages())) + if (recoverError) recoverGlobalError() + }, + failure = { handleFailureState(it) }, + ) - _uiState.value = uiState.value?.copy( - loading = false, - error = false, - popularNovels = popularNovels.popularNovels, - popularFeeds = popularFeeds.toHomePopularFeedPages(), - recommendedNovelsByUserTaste = recommendedNovels.tasteNovels, + private fun loadTasteNovels(preferencesChanged: Boolean = false): Deferred { + val previous = sectionRelease.pending + val keepVisible = !preferencesChanged && + (previous.tasteStatus == HomeTasteStatus.CONTENT || previous.tasteStatus == HomeTasteStatus.EMPTY) + if (!keepVisible) { + publishContent( + HomeSection.TASTE, + previous.copy( + tasteStatus = HomeTasteStatus.LOADING, + recommendedNovelsByUserTaste = if (preferencesChanged) emptyList() else previous.recommendedNovelsByUserTaste, + ), ) } + return sectionLoader.load( + section = HomeSection.TASTE, + request = novelRepository::fetchRecommendedNovelsByUserTaste, + success = { result -> + publishContent( + HomeSection.TASTE, + sectionRelease.pending.copy( + recommendedNovelsByUserTaste = result.tasteNovels, + tasteStatus = if (result.tasteNovels.isEmpty()) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, + ), + ) + }, + failure = { error -> + if (isGlobalHomeFailure(HomeSection.TASTE, error)) { + handleFailureState(error) + } else { + publishContent(HomeSection.TASTE, sectionRelease.pending.copy(tasteStatus = HomeTasteStatus.ERROR)) + } + }, + ) + } + + private fun publishContent( + section: HomeSection, + state: HomeUiState, + ) { + val previous = _uiState.value ?: return + val ready = sectionRelease.update(section, state) ?: return + val updated = ready.copy(error = previous.error, isNotificationUnread = previous.isNotificationUnread) + if (updated != previous) _uiState.value = updated + } + + // Match the existing recovery events; a content response cannot establish a new session. + private fun recoverGlobalError() { + val previous = _uiState.value ?: return + if (!previous.error || hasSessionFailure) return + _uiState.value = sectionRelease.pending.copy( + loading = previous.loading, + error = false, + isNotificationUnread = previous.isNotificationUnread, + ) } private fun checkIsNotificationPermissionFirstLaunched() { @@ -138,7 +190,7 @@ class HomeViewModel val failure = popularNovelsResult.exceptionOrNull() ?: popularFeedsResult.exceptionOrNull() if (failure != null) { - handleFailureState() + handleFailureState(failure) return@coroutineScope } @@ -154,49 +206,29 @@ class HomeViewModel } } - private fun handleFailureState() { + private fun handleFailureState( + error: Throwable, + preserveLoading: Boolean = false, + ) { + hasSessionFailure = hasSessionFailure || isSessionFailure(error) _uiState.value = uiState.value?.copy( - loading = false, + loading = preserveLoading && uiState.value?.loading == true, error = true, ) } fun updateFeed() { - viewModelScope.launch { - runCatching { - feedRepository.fetchPopularFeeds() - }.onSuccess { popularFeeds -> - _uiState.value = uiState.value?.copy( - error = false, - popularFeeds = popularFeeds.toHomePopularFeedPages(), - ) - }.onFailure { - _uiState.value = uiState.value?.copy(error = true) - } - } + loadPopularFeeds(recoverError = true) } - fun updateNovel() { - viewModelScope.launch { - runCatching { - listOf( - async { novelRepository.fetchPopularNovels() }, - async { novelRepository.fetchRecommendedNovelsByUserTaste() }, - ).awaitAll() - }.onSuccess { responses -> - val popularNovels = responses[0] as PopularNovelsEntity - val recommendedNovels = responses[1] as RecommendedNovelsByUserTasteEntity + fun updateNovel(preferencesChanged: Boolean = false) { + loadPopularNovels() + loadTasteNovels(preferencesChanged) + } - _uiState.value = uiState.value?.copy( - popularNovels = popularNovels.popularNovels, - recommendedNovelsByUserTaste = recommendedNovels.tasteNovels, - ) - }.onFailure { - _uiState.value = uiState.value?.copy( - error = true, - ) - } - } + fun retryTaste() { + if (uiState.value?.error == true || sectionRelease.pending.tasteStatus != HomeTasteStatus.ERROR) return + loadTasteNovels() } fun updateNotificationUnread() { @@ -207,10 +239,8 @@ class HomeViewModel _uiState.value = uiState.value?.copy( isNotificationUnread = isNotificationUnread, ) - }.onFailure { - _uiState.value = uiState.value?.copy( - error = true, - ) + }.onFailure { error -> + handleFailureState(error, preserveLoading = true) } } } diff --git a/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt b/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt index ac485f897..c4c621a12 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/model/HomeUiState.kt @@ -7,8 +7,16 @@ import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity.Recommende data class HomeUiState( val loading: Boolean = true, val error: Boolean = false, + val tasteStatus: HomeTasteStatus = HomeTasteStatus.LOADING, val isNotificationUnread: Boolean = false, val popularNovels: List = listOf(), val popularFeeds: List> = listOf(), val recommendedNovelsByUserTaste: List = listOf(), ) + +enum class HomeTasteStatus { + LOADING, + CONTENT, + EMPTY, + ERROR, +} diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 599e0a029..238bc9ecf 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -250,6 +250,44 @@ app:spanCount="2" tools:listitem="@layout/item_recommended_novel_by_user_taste" /> + + + + + + + + (code, "synthetic".toResponseBody())) + + assertFalse(isGlobalHomeFailure(HomeSection.TASTE, http(500))) + assertFalse(isGlobalHomeFailure(HomeSection.TASTE, IOException("offline"))) + assertTrue(isGlobalHomeFailure(HomeSection.TASTE, http(401))) + assertTrue(isGlobalHomeFailure(HomeSection.TASTE, http(403))) + assertTrue(isGlobalHomeFailure(HomeSection.POPULAR, http(500))) + assertTrue(isGlobalHomeFailure(HomeSection.FEEDS, IOException("offline"))) + } + + @Test + fun `retrying failed taste leaves in flight upper requests alone`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val upper = CompletableDeferred() + val displayed = mutableListOf() + val calls = mutableListOf() + val pending = listOf(HomeSection.POPULAR, HomeSection.FEEDS).map { section -> + loader.load( + section, + { + calls.add(section) + upper.await() + }, + displayed::add, + ) { error("unexpected failure") } + } + var failures = 0 + loader + .load( + HomeSection.TASTE, + { + calls.add(HomeSection.TASTE) + throw IOException("offline") + }, + displayed::add, + ) { + failures++ + }.join() + loader + .load( + HomeSection.TASTE, + { + calls.add(HomeSection.TASTE) + "recovered" + }, + displayed::add, + ) { + error("unexpected failure") + }.join() + assertEquals(listOf("recovered"), displayed) + assertEquals(1, failures) + assertTrue(pending.none { it.isCancelled || it.isCompleted }) + upper.complete("upper") + pending.forEach { it.join() } + assertEquals(listOf(HomeSection.POPULAR, HomeSection.FEEDS, HomeSection.TASTE, HomeSection.TASTE), calls) + assertEquals(3, displayed.size) + } + + @Test + fun `ready sections publish before delayed taste`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val taste = CompletableDeferred() + val displayed = mutableListOf() + val waiting = loader.load(HomeSection.TASTE, { taste.await() }, displayed::add) { error("unexpected failure") } + loader.load(HomeSection.POPULAR, { "popular" }, displayed::add) { error("unexpected failure") }.join() + loader.load(HomeSection.FEEDS, { "feeds" }, displayed::add) { error("unexpected failure") }.join() + assertEquals(listOf("popular", "feeds"), displayed) + assertFalse(waiting.isCompleted) + taste.complete("taste") + waiting.join() + assertEquals(listOf("popular", "feeds", "taste"), displayed) + } + + @Test + fun `one request failure does not cancel another section`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val popular = CompletableDeferred() + val displayed = mutableListOf() + val failures = mutableListOf() + val waiting = loader.load(HomeSection.POPULAR, { popular.await() }, displayed::add) { error("unexpected failure") } + loader.load(HomeSection.TASTE, { error("synthetic") }, { error("unexpected success") }) { failures.add("taste") }.join() + assertEquals(listOf("taste"), failures) + assertFalse(waiting.isCancelled) + popular.complete("popular") + waiting.join() + assertEquals(listOf("popular"), displayed) + } + + @Test + fun `superseded response that ignores cancellation cannot overwrite latest result`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val oldResponse = CompletableDeferred() + val displayed = mutableListOf() + val old = loader.load(HomeSection.FEEDS, { withContext(NonCancellable) { oldResponse.await() } }, displayed::add) { + error("cancelled request must not publish failure") + } + loader.load(HomeSection.FEEDS, { "new" }, displayed::add) { error("unexpected failure") }.join() + oldResponse.complete("old") + old.join() + assertTrue(old.isCancelled) + assertEquals(listOf("new"), displayed) + } + + @Test + fun `late failure from superseded request cannot hide new content`() = + runBlocking { + val loader = HomeSectionLoader(CoroutineScope(coroutineContext + Dispatchers.Unconfined)) + val gate = CompletableDeferred() + val displayed = mutableListOf() + val failures = mutableListOf() + val old = loader.load( + HomeSection.TASTE, + { + withContext(NonCancellable) { + gate.await() + error("old failure") + } + }, + { error("unexpected success") }, + failures::add, + ) + loader.load(HomeSection.TASTE, { "new" }, displayed::add, failures::add).join() + gate.complete(Unit) + old.join() + assertEquals(listOf("new"), displayed) + assertTrue(failures.isEmpty()) + } + + @Test + fun `leaving Home cancels every owned request without publishing errors`() = + runBlocking { + val owner = SupervisorJob() + val loader = HomeSectionLoader(CoroutineScope(owner + Dispatchers.Unconfined)) + val gate = CompletableDeferred() + val displayed = mutableListOf() + val failures = mutableListOf() + val jobs = HomeSection.entries.map { section -> loader.load(section, { gate.await() }, displayed::add, failures::add) } + owner.cancelAndJoin() + assertTrue(jobs.all { it.isCancelled }) + assertTrue(displayed.isEmpty()) + assertTrue(failures.isEmpty()) + } +} diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt new file mode 100644 index 000000000..53c6232af --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt @@ -0,0 +1,121 @@ +package com.into.websoso.ui.main.home + +import com.into.websoso.data.model.PopularFeedEntity +import com.into.websoso.data.model.PopularNovelsEntity.PopularNovelEntity +import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity.RecommendedNovelByUserTasteEntity +import com.into.websoso.ui.main.home.model.HomeTasteStatus +import com.into.websoso.ui.main.home.model.HomeUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class HomeSectionReleaseTest { + @Test + fun `upper content waits for both upper results but never for taste`() { + val release = HomeSectionRelease() + assertNull(release.update(HomeSection.POPULAR, release.pending.copy(popularNovels = popular))) + + val state = release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = feeds))!! + + assertFalse(state.loading) + assertEquals(HomeTasteStatus.LOADING, state.tasteStatus) + assertEquals(popular, state.popularNovels) + assertEquals(feeds, state.popularFeeds) + } + + @Test + fun `all completion orders publish upper content and retain ten taste novels`() { + listOf("pft", "ptf", "fpt", "ftp", "tpf", "tfp").forEach { order -> + val release = HomeSectionRelease() + val displayed = mutableListOf() + order.forEach { section -> + val state = release.pending + val result = when (section) { + 'p' -> release.update(HomeSection.POPULAR, state.copy(popularNovels = popular)) + 'f' -> release.update(HomeSection.FEEDS, state.copy(popularFeeds = feeds)) + else -> release.update( + HomeSection.TASTE, + state.copy(tasteStatus = HomeTasteStatus.CONTENT, recommendedNovelsByUserTaste = taste), + ) + } + result?.let(displayed::add) + } + + assertEquals(order, if (order.last() == 't') 2 else 1, displayed.size) + assertEquals(10, displayed.last().recommendedNovelsByUserTaste.size) + assertEquals(popular, displayed.first().popularNovels) + assertEquals(feeds, displayed.first().popularFeeds) + } + } + + @Test + fun `early taste failure stays buffered until both upper results succeed`() { + val release = HomeSectionRelease() + assertNull(release.update(HomeSection.TASTE, release.pending.copy(tasteStatus = HomeTasteStatus.ERROR))) + assertNull(release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = feeds))) + + val state = release.update(HomeSection.POPULAR, release.pending.copy(popularNovels = popular))!! + + assertFalse(state.error) + assertFalse(state.loading) + assertEquals(HomeTasteStatus.ERROR, state.tasteStatus) + } + + @Test + fun `error retry and empty recovery keep upper list identities`() { + val release = readyUpper() + val initial = release.pending + listOf(HomeTasteStatus.ERROR, HomeTasteStatus.LOADING, HomeTasteStatus.EMPTY).forEach { status -> + val state = release.update(HomeSection.TASTE, release.pending.copy(tasteStatus = status))!! + + assertFalse(state.loading) + assertFalse(state.error) + assertSame(initial.popularNovels, state.popularNovels) + assertSame(initial.popularFeeds, state.popularFeeds) + assertEquals(status, state.tasteStatus) + } + } + + @Test + fun `feed and novel refreshes keep published taste and the other upper list`() { + val release = readyUpper() + release.update( + HomeSection.TASTE, + release.pending.copy(tasteStatus = HomeTasteStatus.CONTENT, recommendedNovelsByUserTaste = taste), + ) + val refreshedFeeds = feeds.map { page -> page.map { it.copy(likeCount = 2) } } + val feedState = release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = refreshedFeeds))!! + assertSame(popular, feedState.popularNovels) + assertSame(taste, feedState.recommendedNovelsByUserTaste) + + val refreshedPopular = popular.map { it.copy(title = "updated") } + val novelState = release.update(HomeSection.POPULAR, release.pending.copy(popularNovels = refreshedPopular))!! + assertSame(refreshedFeeds, novelState.popularFeeds) + assertSame(taste, novelState.recommendedNovelsByUserTaste) + assertFalse(novelState.loading) + } + + @Test + fun `a new Home owner starts with loading rather than previous content or empty`() { + val previous = readyUpper() + previous.update(HomeSection.TASTE, previous.pending.copy(tasteStatus = HomeTasteStatus.EMPTY)) + val next = HomeSectionRelease() + + assertEquals(HomeUiState(), next.pending) + assertNull(next.update(HomeSection.FEEDS, next.pending.copy(popularFeeds = feeds))) + } + + private fun readyUpper(): HomeSectionRelease = + HomeSectionRelease().apply { + update(HomeSection.POPULAR, pending.copy(popularNovels = popular)) + update(HomeSection.FEEDS, pending.copy(popularFeeds = feeds)) + } + + private val popular = listOf( + PopularNovelEntity("author", null, null, "fantasy", false, emptyList(), null, "description", 1, "image", "title"), + ) + private val feeds = listOf(listOf(PopularFeedEntity(1, "content", 0, 0, false, true, "title", "image", "fantasy"))) + private val taste = (1L..10L).map { RecommendedNovelByUserTasteEntity(it, "title $it", "author", "image", 0, 0.0, 0) } +} diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt new file mode 100644 index 000000000..5ceb2e530 --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -0,0 +1,481 @@ +package com.into.websoso.ui.main.home + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.preferencesOf +import androidx.lifecycle.Observer +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModelStore +import com.into.websoso.data.remote.api.AuthApi +import com.into.websoso.data.remote.api.FeedApi +import com.into.websoso.data.remote.api.NotificationApi +import com.into.websoso.data.remote.api.NovelApi +import com.into.websoso.data.remote.api.PushMessageApi +import com.into.websoso.data.remote.api.UserApi +import com.into.websoso.data.remote.response.NotificationUnreadResponseDto +import com.into.websoso.data.remote.response.PopularFeedsResponseDto +import com.into.websoso.data.remote.response.PopularFeedsResponseDto.PopularFeedResponseDto +import com.into.websoso.data.remote.response.PopularNovelsResponseDto +import com.into.websoso.data.remote.response.PopularNovelsResponseDto.PopularNovelResponseDto +import com.into.websoso.data.remote.response.RecommendedNovelsByUserTasteResponseDto +import com.into.websoso.data.remote.response.RecommendedNovelsByUserTasteResponseDto.RecommendedNovelByUserTasteResponseDto +import com.into.websoso.data.remote.response.TermsAgreementResponseDto +import com.into.websoso.data.repository.AuthRepository +import com.into.websoso.data.repository.FeedRepository +import com.into.websoso.data.repository.NotificationRepository +import com.into.websoso.data.repository.NovelRepository +import com.into.websoso.data.repository.PushMessageRepository +import com.into.websoso.data.repository.UserRepository +import com.into.websoso.ui.main.home.model.HomeTasteStatus +import com.into.websoso.ui.main.home.model.HomeUiState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val owners = mutableListOf() + private val calls = mutableMapOf() + private var tasteRequest: suspend () -> RecommendedNovelsByUserTasteResponseDto = { taste(10) } + private var popularRequest: suspend () -> PopularNovelsResponseDto = { popular() } + private var feedRequest: suspend () -> PopularFeedsResponseDto = { feeds() } + private var notificationRequest: suspend () -> NotificationUnreadResponseDto = { NotificationUnreadResponseDto(true) } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + ArchTaskExecutor.getInstance().setDelegate( + object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }, + ) + } + + @After + fun tearDown() { + owners.forEach(ViewModelStore::clear) + Dispatchers.resetMain() + ArchTaskExecutor.getInstance().setDelegate(null) + } + + @Test + fun `actual view model publishes upper before taste and keeps unread notification`() = + runTest(dispatcher) { + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + val vm = createViewModel() + runCurrent() + + val upper = vm.uiState.value!! + assertFalse(upper.loading) + assertEquals(1, upper.popularNovels.size) + assertEquals(HomeTasteStatus.LOADING, upper.tasteStatus) + assertTrue(upper.isNotificationUnread) + + pending.complete(taste(10)) + runCurrent() + val complete = vm.uiState.value!! + assertEquals(10, complete.recommendedNovelsByUserTaste.size) + assertEquals(HomeTasteStatus.CONTENT, complete.tasteStatus) + assertTrue(complete.isNotificationUnread) + assertEquals(false, vm.isNotificationPermissionFirstLaunched.value) + } + + @Test + fun `actual retry is taste only rejects duplicate input and recovers empty without hiding upper`() = + runTest(dispatcher) { + tasteRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + val before = vm.uiState.value!! + assertFalse(before.error) + assertEquals(HomeTasteStatus.ERROR, before.tasteStatus) + val upperCalls = calls.filterKeys { it != "getRecommendedNovelsByUserTaste" } + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + + vm.retryTaste() + vm.retryTaste() + runCurrent() + assertEquals(2, calls["getRecommendedNovelsByUserTaste"]) + assertEquals(upperCalls, calls.filterKeys { it != "getRecommendedNovelsByUserTaste" }) + assertSame(before.popularNovels, vm.uiState.value!!.popularNovels) + assertSame(before.popularFeeds, vm.uiState.value!!.popularFeeds) + assertFalse(vm.uiState.value!!.loading) + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + + pending.complete(taste(0)) + runCurrent() + assertEquals(HomeTasteStatus.EMPTY, vm.uiState.value!!.tasteStatus) + assertFalse(vm.uiState.value!!.error) + } + + @Test + fun `updateFeed and updateNovel retain other data and discard replaced taste results`() = + runTest(dispatcher) { + val vm = createViewModel() + runCurrent() + val initialTaste = vm.uiState.value!!.recommendedNovelsByUserTaste + vm.updateFeed() + runCurrent() + assertSame(initialTaste, vm.uiState.value!!.recommendedNovelsByUserTaste) + + val old = CompletableDeferred() + tasteRequest = { withContext(NonCancellable) { old.await() } } + vm.updateNovel() + runCurrent() + assertSame(initialTaste, vm.uiState.value!!.recommendedNovelsByUserTaste) + assertEquals(HomeTasteStatus.CONTENT, vm.uiState.value!!.tasteStatus) + assertFalse(vm.uiState.value!!.loading) + + tasteRequest = { taste(1) } + vm.updateNovel() + runCurrent() + old.complete(taste(8)) + runCurrent() + val latest = vm.uiState.value!! + assertEquals(1, latest.recommendedNovelsByUserTaste.size) + assertEquals(HomeTasteStatus.CONTENT, latest.tasteStatus) + assertEquals(2, calls["getPopularFeeds"]) + assertEquals(3, calls["getPopularNovels"]) + } + + @Test + fun `authentication failures are global and cannot be cleared by a later content success or retry`() = + runTest(dispatcher) { + listOf(401, 403).forEach { code -> + val upper = CompletableDeferred() + popularRequest = { upper.await() } + tasteRequest = { throw http(code) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + val count = calls["getRecommendedNovelsByUserTaste"] + vm.retryTaste() + upper.complete(popular()) + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertEquals(count, calls["getRecommendedNovelsByUserTaste"]) + tasteRequest = { taste(10) } + vm.updateNovel() + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + } + } + + @Test + fun `notification failure remains global while taste and upper responses complete`() = + runTest(dispatcher) { + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + notificationRequest = { throw http(401) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + pending.complete(taste(10)) + runCurrent() + assertTrue(vm.uiState.value!!.error) + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + } + + @Test + fun `initial complete success and feed refresh recover ordinary global failures but novel and notification success do not`() = + runTest(dispatcher) { + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + notificationRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + pending.complete(taste(10)) + runCurrent() + val complete = vm.uiState.value!! + assertFalse(complete.error) + assertEquals(10, complete.recommendedNovelsByUserTaste.size) + + vm.updateNotificationUnread() + runCurrent() + assertTrue(vm.uiState.value!!.error) + notificationRequest = { NotificationUnreadResponseDto(false) } + vm.updateNotificationUnread() + vm.updateNovel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertFalse(vm.uiState.value!!.isNotificationUnread) + + vm.updateFeed() + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertFalse(vm.uiState.value!!.isNotificationUnread) + assertEquals(HomeTasteStatus.CONTENT, vm.uiState.value!!.tasteStatus) + } + + @Test + fun `feed failure can recover on next feed success without a global retry button`() = + runTest(dispatcher) { + val vm = createViewModel() + runCurrent() + feedRequest = { throw http(500) } + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + val tasteBefore = calls["getRecommendedNovelsByUserTaste"] + + feedRequest = { feeds() } + vm.updateFeed() + runCurrent() + val recovered = vm.uiState.value!! + assertFalse(recovered.error) + assertEquals(tasteBefore, calls["getRecommendedNovelsByUserTaste"]) + assertTrue(recovered.popularFeeds.isNotEmpty()) + } + + @Test + fun `feed refresh can recover after failed initial upper request and novel success alone does not recover`() = + runTest(dispatcher) { + popularRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + + vm.updateFeed() + runCurrent() + val feedRecovered = vm.uiState.value!! + assertFalse(feedRecovered.error) + assertFalse(feedRecovered.loading) + assertTrue(feedRecovered.popularFeeds.isNotEmpty()) + + vm.updateNovel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + popularRequest = { popular() } + vm.updateNovel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + vm.updateFeed() + runCurrent() + val complete = vm.uiState.value!! + assertFalse(complete.error) + assertTrue(complete.popularNovels.isNotEmpty()) + } + + @Test + fun `initial taste failure does not count as complete success or let a later taste success clear global error`() = + runTest(dispatcher) { + notificationRequest = { throw http(500) } + tasteRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + + tasteRequest = { taste(10) } + vm.updateNovel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + vm.updateFeed() + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertEquals(HomeTasteStatus.CONTENT, vm.uiState.value!!.tasteStatus) + } + + @Test + fun `replacing an initial request does not turn its cancellation into complete initial success`() = + runTest(dispatcher) { + val old = CompletableDeferred() + tasteRequest = { withContext(NonCancellable) { old.await() } } + notificationRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + tasteRequest = { taste(2) } + vm.updateNovel() + runCurrent() + old.complete(taste(10)) + runCurrent() + val updated = vm.uiState.value!! + assertTrue(updated.error) + assertEquals(2, updated.recommendedNovelsByUserTaste.size) + vm.updateFeed() + runCurrent() + assertFalse(vm.uiState.value!!.error) + } + + @Test + fun `same preference empty refresh stays visibly empty until an empty or content response`() = + runTest(dispatcher) { + listOf(0, 3).forEach { count -> + tasteRequest = { taste(0) } + val vm = createViewModel() + runCurrent() + val statuses = mutableListOf() + val observer = Observer { statuses.add(it.tasteStatus) } + vm.uiState.observeForever(observer) + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + + // Background refresh and NovelDetailBack both use the default, unchanged preferences. + vm.updateNovel() + runCurrent() + assertTrue(statuses.all { it == HomeTasteStatus.EMPTY }) + pending.complete(taste(count)) + runCurrent() + val updated = vm.uiState.value!! + assertFalse(statuses.contains(HomeTasteStatus.LOADING)) + assertEquals(if (count == 0) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, statuses.last()) + assertEquals(count, updated.recommendedNovelsByUserTaste.size) + vm.uiState.removeObserver(observer) + } + } + + @Test + fun `profile edit invalidates both empty CTA and existing content while new preferences load`() = + runTest(dispatcher) { + listOf(0, 10).forEach { oldCount -> + tasteRequest = { taste(oldCount) } + val vm = createViewModel() + runCurrent() + val upper = vm.uiState.value!!.popularFeeds + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + + vm.updateNovel(preferencesChanged = true) + val invalidated = vm.uiState.value!! + assertEquals(HomeTasteStatus.LOADING, invalidated.tasteStatus) + assertTrue(invalidated.recommendedNovelsByUserTaste.isEmpty()) + runCurrent() + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + assertSame(upper, vm.uiState.value!!.popularFeeds) + assertFalse(vm.uiState.value!!.loading) + val newCount = if (oldCount == 0) 2 else 0 + pending.complete(taste(newCount)) + runCurrent() + val updated = vm.uiState.value!! + assertEquals(if (newCount == 0) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, updated.tasteStatus) + assertEquals(newCount, updated.recommendedNovelsByUserTaste.size) + } + } + + @Test + fun `cleared owner never receives late data and reentry creates fresh state`() = + runTest(dispatcher) { + val pending = CompletableDeferred() + tasteRequest = { withContext(NonCancellable) { pending.await() } } + val old = createViewModel() + runCurrent() + val left = old.uiState.value + owners.last().clear() + tasteRequest = { taste(0) } + val next = createViewModel() + runCurrent() + pending.complete(taste(10)) + runCurrent() + assertSame(left, old.uiState.value) + assertEquals(HomeTasteStatus.EMPTY, next.uiState.value!!.tasteStatus) + } + + @Test + fun `terms and permission state remain available after grouped publication`() = + runTest(dispatcher) { + val vm = createViewModel(termsChecked = false) + runCurrent() + assertTrue(vm.showTermsAgreementDialog.value) + assertEquals(1, calls["getTermsAgreement"]) + vm.updateTermsAgreementDialogState() + assertFalse(vm.showTermsAgreementDialog.value) + vm.updateIsNotificationPermissionFirstLaunched(true) + runCurrent() + assertEquals(true, vm.isNotificationPermissionFirstLaunched.value) + assertEquals(HomeTasteStatus.CONTENT, vm.uiState.value!!.tasteStatus) + // The existing preferences flow rechecks outstanding terms after a setting changes. + assertTrue(vm.showTermsAgreementDialog.value) + assertEquals(2, calls["getTermsAgreement"]) + } + + private fun createViewModel(termsChecked: Boolean = true): HomeViewModel { + val storage = object : DataStore { + override val data = MutableStateFlow( + preferencesOf( + UserRepository.TERMS_AGREEMENT_CHECKED_KEY to termsChecked, + PushMessageRepository.NOTIFICATION_PERMISSION_FIRST_LAUNCHED_KEY to false, + ), + ) + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + transform(data.value).also { data.value = it } + } + val user = UserRepository(api(), storage) + return HomeViewModel( + NovelRepository(api()), + FeedRepository(api()), + PushMessageRepository(user, AuthRepository(api()), storage, api()), + NotificationRepository(api()), + user, + SavedStateHandle(), + ).also { vm -> + owners.add(ViewModelStore().apply { put("home", vm) }) + } + } + + // Only Retrofit interfaces are doubled; repositories and the ViewModel are production code. + @Suppress("UNCHECKED_CAST") + private inline fun api(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, args -> + val call: suspend () -> Any = { + calls[method.name] = (calls[method.name] ?: 0) + 1 + when (method.name) { + "getPopularNovels" -> popularRequest() + "getPopularFeeds" -> feedRequest() + "getRecommendedNovelsByUserTaste" -> tasteRequest() + "getNotificationUnread" -> notificationRequest() + "getTermsAgreement" -> TermsAgreementResponseDto(false, false, false) + else -> error("Unexpected API call: ${method.name}") + } + } + call.startCoroutineUninterceptedOrReturn(args.last() as Continuation) + } as T + + private fun http(code: Int): HttpException = HttpException(Response.error(code, "synthetic".toResponseBody())) + + private fun popular(): PopularNovelsResponseDto = + PopularNovelsResponseDto(listOf(PopularNovelResponseDto(novelId = 1, novelImage = "image", title = "title"))) + + private fun feeds(): PopularFeedsResponseDto = + PopularFeedsResponseDto( + listOf(PopularFeedResponseDto(1, "content", 0, 0, false, true, "title", "image", "fantasy")), + ) + + private fun taste(count: Int): RecommendedNovelsByUserTasteResponseDto = + RecommendedNovelsByUserTasteResponseDto( + (1L..count.toLong()).map { RecommendedNovelByUserTasteResponseDto(it, "title", "author", "image", 0, 0.0, 0) }, + ) +} From 9c61410f9b4ba9ae46ba8fe3f7713054b7e00366 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sun, 27 Sep 2026 22:44:50 +0900 Subject: [PATCH 02/22] =?UTF-8?q?test:=20=EC=84=B8=EC=85=98=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=ED=9B=84=20Home=20=EC=83=81=ED=83=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=EB=A5=BC=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../websoso/ui/main/home/HomeViewModelTest.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt index 5ceb2e530..c2203cf20 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -403,6 +403,38 @@ class HomeViewModelTest { assertEquals(HomeTasteStatus.EMPTY, next.uiState.value!!.tasteStatus) } + @Test + fun `a new Home owner after authentication recovery does not inherit the expired session failure`() = + runTest(dispatcher) { + listOf(401, 403).forEach { code -> + tasteRequest = { throw http(code) } + val expired = createViewModel() + runCurrent() + assertTrue(expired.uiState.value!!.error) + // Login and Main navigation clear the old task; simulate its ViewModelStore boundary. + owners.last().clear() + tasteRequest = { taste(10) } + val recovered = createViewModel() + runCurrent() + val ready = recovered.uiState.value!! + assertFalse(ready.error) + assertFalse(ready.loading) + assertEquals(HomeTasteStatus.CONTENT, ready.tasteStatus) + assertEquals(10, ready.recommendedNovelsByUserTaste.size) + + // A later ordinary error remains recoverable in the new session. + feedRequest = { throw http(500) } + recovered.updateFeed() + runCurrent() + assertTrue(recovered.uiState.value!!.error) + feedRequest = { feeds() } + recovered.updateFeed() + runCurrent() + assertFalse(recovered.uiState.value!!.error) + assertTrue(expired.uiState.value!!.error) + } + } + @Test fun `terms and permission state remain available after grouped publication`() = runTest(dispatcher) { From 6e0c9868055d98a4543bbab66d39fb43364d0f4f Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sat, 3 Oct 2026 01:22:32 +0900 Subject: [PATCH 03/22] =?UTF-8?q?fix:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B8=80=EB=A1=9C=EB=B2=8C=20=EC=97=90=EB=9F=AC=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeSectionRelease.kt`: - 에러 복구 시 상태를 동기화하고 로딩 여부를 업데이트하는 `recover` 함수 추가 - 복구 시 로딩 중이 아니라면 대기 중인 섹션 목록(`waitingForUpper`)을 비우도록 로직 수정 - `HomeViewModel.kt`: - `recoverGlobalError`에서 `sectionRelease.recover`를 호출하여 에러 상태 해제 및 로딩 상태를 안전하게 전환하도록 변경 - `HomeViewModelTest.kt`: - **부분 복구 시나리오 테스트**: 글로벌 에러 이후 복구되었을 때 취향 추천 재시도 및 피드 새로고침이 UI에 올바르게 반영되는지 검증 - **초기 로딩 중 복구 테스트**: 초기 로딩 과정에서 에러 복구가 발생하더라도 필수 섹션 데이터가 모두 로드될 때까지 대기 상태를 유지하는지 검증하는 테스트 추가 --- .../ui/main/home/HomeSectionRelease.kt | 8 +- .../websoso/ui/main/home/HomeViewModel.kt | 3 +- .../websoso/ui/main/home/HomeViewModelTest.kt | 80 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt index 460db4a16..036de6b99 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt @@ -2,7 +2,7 @@ package com.into.websoso.ui.main.home import com.into.websoso.ui.main.home.model.HomeUiState -/** Main-thread only. Upper successes open Home once; subsequent updates retain content. */ +/** Main-thread only. Upper successes or recovery displaying content open Home once. */ internal class HomeSectionRelease { var pending = HomeUiState() private set @@ -18,4 +18,10 @@ internal class HomeSectionRelease { pending = state.copy(loading = false) return pending } + + fun recover(loading: Boolean): HomeUiState { + if (!loading) waitingForUpper.clear() + pending = pending.copy(loading = loading) + return pending + } } diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index 6213c29c5..4babd47f4 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -150,8 +150,7 @@ class HomeViewModel private fun recoverGlobalError() { val previous = _uiState.value ?: return if (!previous.error || hasSessionFailure) return - _uiState.value = sectionRelease.pending.copy( - loading = previous.loading, + _uiState.value = sectionRelease.recover(loading = previous.loading).copy( error = false, isNotificationUnread = previous.isNotificationUnread, ) diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt index c2203cf20..1f89d7621 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -290,6 +290,86 @@ class HomeViewModelTest { assertTrue(complete.popularNovels.isNotEmpty()) } + @Test + fun `partial recovery keeps later taste retry and feed refresh visible`() = + runTest(dispatcher) { + popularRequest = { throw http(500) } + tasteRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + + vm.updateFeed() + runCurrent() + val recovered = vm.uiState.value!! + assertFalse(recovered.error) + assertFalse(recovered.loading) + assertEquals(HomeTasteStatus.ERROR, recovered.tasteStatus) + + val pending = CompletableDeferred() + tasteRequest = { pending.await() } + vm.retryTaste() + vm.retryTaste() + runCurrent() + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + assertEquals(2, calls["getRecommendedNovelsByUserTaste"]) + + pending.complete(taste(2)) + runCurrent() + val retried = vm.uiState.value!! + assertEquals(HomeTasteStatus.CONTENT, retried.tasteStatus) + assertEquals(2, retried.recommendedNovelsByUserTaste.size) + + val updatedFeed = feeds().popularFeeds.single().copy(likeCount = 1) + feedRequest = { PopularFeedsResponseDto(listOf(updatedFeed)) } + vm.updateFeed() + runCurrent() + val refreshed = vm.uiState.value!! + val refreshedPage = refreshed.popularFeeds.single() + assertEquals(1, refreshedPage.single().likeCount) + assertSame(retried.recommendedNovelsByUserTaste, refreshed.recommendedNovelsByUserTaste) + assertTrue(refreshed.popularNovels.isEmpty()) + assertFalse(refreshed.error) + assertFalse(refreshed.loading) + assertTrue(refreshed.isNotificationUnread) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(3, calls["getPopularFeeds"]) + assertEquals(2, calls["getRecommendedNovelsByUserTaste"]) + } + + @Test + fun `recovery during initial loading still waits for both upper results`() = + runTest(dispatcher) { + val upper = CompletableDeferred() + val pendingTaste = CompletableDeferred() + popularRequest = { upper.await() } + tasteRequest = { pendingTaste.await() } + notificationRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertTrue(vm.uiState.value!!.loading) + + vm.updateFeed() + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertTrue(vm.uiState.value!!.loading) + + pendingTaste.complete(taste(2)) + runCurrent() + assertTrue(vm.uiState.value!!.loading) + + upper.complete(popular()) + runCurrent() + val ready = vm.uiState.value!! + assertFalse(ready.error) + assertFalse(ready.loading) + assertEquals(1, ready.popularNovels.size) + assertTrue(ready.popularFeeds.isNotEmpty()) + assertEquals(HomeTasteStatus.CONTENT, ready.tasteStatus) + assertEquals(2, ready.recommendedNovelsByUserTaste.size) + } + @Test fun `initial taste failure does not count as complete success or let a later taste success clear global error`() = runTest(dispatcher) { From d706b9eb8242dc1644039c8fda507b56618e58b6 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sat, 3 Oct 2026 01:42:10 +0900 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20=EC=84=B8=EC=85=98=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=20=ED=8C=90=EC=A0=95=20=EC=A1=B0=EA=B1=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(403=20Forbidden=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeSectionLoader.kt`: - `isSessionFailure` 판단 기준에서 403 응답을 제외하고 401(Unauthorized)만 유지 - `HomeSection.TASTE` 영역에서 403 에러 발생 시 글로벌 에러가 아닌 로컬 에러로 처리되도록 수정 - `HomeSectionLoaderTest.kt`: 403 응답이 `TASTE` 섹션에서는 로컬 에러로, 그 외 섹션에서는 글로벌 에러로 처리되는지 확인하는 테스트 케이스 추가 - `HomeViewModelTest.kt`: - 403 응답 발생 시 상위 섹션 요청 없이 로컬에서 재시도 및 복구가 가능한지 검증하는 테스트 추가 - `popular`, `feeds`, `unread` 등 섹션에서 403 에러 발생 후 정상 응답 시 복구되는 시나리오 테스트 추가 - 세션 만료 테스트 케이스에서 403 응답 관련 로직 제거 및 401 응답 중심으로 정비 --- .../websoso/ui/main/home/HomeSectionLoader.kt | 2 +- .../ui/main/home/HomeSectionLoaderTest.kt | 8 +- .../websoso/ui/main/home/HomeViewModelTest.kt | 137 ++++++++++++------ 3 files changed, 103 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt index 2f5aa80ef..27e47c5cf 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt @@ -20,7 +20,7 @@ internal fun isGlobalHomeFailure( error: Exception, ): Boolean = section != HomeSection.TASTE || isSessionFailure(error) -internal fun isSessionFailure(error: Throwable): Boolean = error is HttpException && (error.code() == 401 || error.code() == 403) +internal fun isSessionFailure(error: Throwable): Boolean = error is HttpException && error.code() == 401 /** Requests belong to the Home ViewModel; replacing one never cancels another section. */ internal class HomeSectionLoader( diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt index f6745e012..147a53a61 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt @@ -19,15 +19,19 @@ import java.io.IOException class HomeSectionLoaderTest { @Test - fun `only ordinary taste failures stay local while authentication and upper failures remain global`() { + fun `taste access denial stays local while unauthorized and upper failures remain global`() { fun http(code: Int) = HttpException(Response.error(code, "synthetic".toResponseBody())) assertFalse(isGlobalHomeFailure(HomeSection.TASTE, http(500))) assertFalse(isGlobalHomeFailure(HomeSection.TASTE, IOException("offline"))) assertTrue(isGlobalHomeFailure(HomeSection.TASTE, http(401))) - assertTrue(isGlobalHomeFailure(HomeSection.TASTE, http(403))) + assertFalse(isGlobalHomeFailure(HomeSection.TASTE, http(403))) assertTrue(isGlobalHomeFailure(HomeSection.POPULAR, http(500))) assertTrue(isGlobalHomeFailure(HomeSection.FEEDS, IOException("offline"))) + assertTrue(isGlobalHomeFailure(HomeSection.POPULAR, http(403))) + assertTrue(isGlobalHomeFailure(HomeSection.FEEDS, http(403))) + assertTrue(isSessionFailure(http(401))) + assertFalse(isSessionFailure(http(403))) } @Test diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt index 1f89d7621..11708ca30 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -169,26 +169,83 @@ class HomeViewModelTest { } @Test - fun `authentication failures are global and cannot be cleared by a later content success or retry`() = + fun `unauthorized response stays global after later content success or retry`() = runTest(dispatcher) { - listOf(401, 403).forEach { code -> - val upper = CompletableDeferred() - popularRequest = { upper.await() } - tasteRequest = { throw http(code) } + val upper = CompletableDeferred() + popularRequest = { upper.await() } + tasteRequest = { throw http(401) } + val vm = createViewModel() + runCurrent() + assertTrue(vm.uiState.value!!.error) + val count = calls["getRecommendedNovelsByUserTaste"] + vm.retryTaste() + upper.complete(popular()) + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertEquals(count, calls["getRecommendedNovelsByUserTaste"]) + tasteRequest = { taste(10) } + vm.updateNovel() + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + } + + @Test + fun `forbidden taste response can be retried locally without requesting upper sections`() = + runTest(dispatcher) { + tasteRequest = { throw http(403) } + val vm = createViewModel() + runCurrent() + val before = vm.uiState.value!! + assertFalse(before.error) + assertFalse(before.loading) + assertEquals(HomeTasteStatus.ERROR, before.tasteStatus) + + vm.retryTaste() + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertEquals(HomeTasteStatus.ERROR, vm.uiState.value!!.tasteStatus) + + tasteRequest = { taste(2) } + vm.retryTaste() + runCurrent() + val recovered = vm.uiState.value!! + assertFalse(recovered.error) + assertEquals(HomeTasteStatus.CONTENT, recovered.tasteStatus) + assertEquals(2, recovered.recommendedNovelsByUserTaste.size) + assertSame(before.popularNovels, recovered.popularNovels) + assertSame(before.popularFeeds, recovered.popularFeeds) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + assertEquals(3, calls["getRecommendedNovelsByUserTaste"]) + } + + @Test + fun `forbidden upper and unread failures do not prevent recovery in the same Home owner`() = + runTest(dispatcher) { + listOf("popular", "feeds", "unread").forEach { source -> val vm = createViewModel() runCurrent() - assertTrue(vm.uiState.value!!.error) - val count = calls["getRecommendedNovelsByUserTaste"] - vm.retryTaste() - upper.complete(popular()) + assertFalse(vm.uiState.value!!.error) + + popularRequest = { throw http(403) } + feedRequest = { throw http(403) } + notificationRequest = { throw http(403) } + when (source) { + "popular" -> vm.updateNovel() + "feeds" -> vm.updateFeed() + else -> vm.updateNotificationUnread() + } runCurrent() - assertTrue(vm.uiState.value!!.error) - assertEquals(count, calls["getRecommendedNovelsByUserTaste"]) - tasteRequest = { taste(10) } - vm.updateNovel() + assertTrue(source, vm.uiState.value!!.error) + + popularRequest = { popular() } + feedRequest = { feeds() } + notificationRequest = { NotificationUnreadResponseDto(true) } vm.updateFeed() runCurrent() - assertTrue(vm.uiState.value!!.error) + assertFalse(source, vm.uiState.value!!.error) + assertFalse(source, vm.uiState.value!!.loading) } } @@ -486,33 +543,31 @@ class HomeViewModelTest { @Test fun `a new Home owner after authentication recovery does not inherit the expired session failure`() = runTest(dispatcher) { - listOf(401, 403).forEach { code -> - tasteRequest = { throw http(code) } - val expired = createViewModel() - runCurrent() - assertTrue(expired.uiState.value!!.error) - // Login and Main navigation clear the old task; simulate its ViewModelStore boundary. - owners.last().clear() - tasteRequest = { taste(10) } - val recovered = createViewModel() - runCurrent() - val ready = recovered.uiState.value!! - assertFalse(ready.error) - assertFalse(ready.loading) - assertEquals(HomeTasteStatus.CONTENT, ready.tasteStatus) - assertEquals(10, ready.recommendedNovelsByUserTaste.size) - - // A later ordinary error remains recoverable in the new session. - feedRequest = { throw http(500) } - recovered.updateFeed() - runCurrent() - assertTrue(recovered.uiState.value!!.error) - feedRequest = { feeds() } - recovered.updateFeed() - runCurrent() - assertFalse(recovered.uiState.value!!.error) - assertTrue(expired.uiState.value!!.error) - } + tasteRequest = { throw http(401) } + val expired = createViewModel() + runCurrent() + assertTrue(expired.uiState.value!!.error) + // Login and Main navigation clear the old task; simulate its ViewModelStore boundary. + owners.last().clear() + tasteRequest = { taste(10) } + val recovered = createViewModel() + runCurrent() + val ready = recovered.uiState.value!! + assertFalse(ready.error) + assertFalse(ready.loading) + assertEquals(HomeTasteStatus.CONTENT, ready.tasteStatus) + assertEquals(10, ready.recommendedNovelsByUserTaste.size) + + // A later ordinary error remains recoverable in the new session. + feedRequest = { throw http(500) } + recovered.updateFeed() + runCurrent() + assertTrue(recovered.uiState.value!!.error) + feedRequest = { feeds() } + recovered.updateFeed() + runCurrent() + assertFalse(recovered.uiState.value!!.error) + assertTrue(expired.uiState.value!!.error) } @Test From 41eb884d6cba01d5baa494ea18d6a640ef28d5b3 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sat, 3 Oct 2026 02:29:43 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B7=A8=ED=96=A5=20=EA=B8=B0=EB=B0=98=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=9E=91=ED=92=88=20=EA=B0=B1=EC=8B=A0=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeFragment.kt`: `tasteRefreshFailed` Flow를 구독하여 취향 추천 갱신 실패 시 에러 토스트 메시지 노출 로직 추가 - `HomeViewModel.kt`: - 추천 작품 갱신 실패 알림을 위한 `tasteRefreshFailed` SharedFlow 추가 - `updateTasteSection`: 갱신 실패 시 기존 콘텐츠 유지 여부(`keepVisible`) 및 에러 코드(403 등)에 따라 알림 송신 또는 에러 상태 전환 로직 구현 - `HomeViewModelTest.kt`: - 취향 추천 갱신 실패 시 기존 콘텐츠 유지 및 알림 발생 여부 검증 테스트 추가 - 선호도 변경에 따른 갱신 실패 시 에러 상태 전환 및 재시도 로직 검증 테스트 추가 - 401, 403 에러 발생 시의 예외 처리 및 알림 미발생 케이스 검증 테스트 추가 --- .../into/websoso/ui/main/home/HomeFragment.kt | 9 ++ .../websoso/ui/main/home/HomeViewModel.kt | 10 ++ .../websoso/ui/main/home/HomeViewModelTest.kt | 132 ++++++++++++++++++ core/resource/src/main/res/values/strings.xml | 1 + 4 files changed, 152 insertions(+) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt index a4ef39cae..ab82d0b0b 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt @@ -21,8 +21,11 @@ import com.into.websoso.core.common.ui.model.ResultFrom.NovelDetailBack import com.into.websoso.core.common.ui.model.ResultFrom.ProfileEditSuccess import com.into.websoso.core.common.util.SingleEventHandler import com.into.websoso.core.common.util.collectWithLifecycle +import com.into.websoso.core.common.util.showWebsosoToast import com.into.websoso.core.common.util.tracker.Tracker +import com.into.websoso.core.resource.R.drawable.ic_novel_rating_alert import com.into.websoso.core.resource.R.string.home_rising_feed_for_user +import com.into.websoso.core.resource.R.string.home_taste_refresh_failed import com.into.websoso.core.resource.R.string.load_fail_title import com.into.websoso.core.resource.R.string.load_load_title import com.into.websoso.databinding.FragmentHomeBinding @@ -184,6 +187,12 @@ class HomeFragment : BaseFragment(fragment_home) { } } + homeViewModel.tasteRefreshFailed.collectWithLifecycle(viewLifecycleOwner) { + val state = homeViewModel.uiState.value ?: return@collectWithLifecycle + if (state.loading || state.error) return@collectWithLifecycle + showWebsosoToast(requireContext(), getString(home_taste_refresh_failed), ic_novel_rating_alert) + } + homeViewModel.isNotificationPermissionFirstLaunched.observe(viewLifecycleOwner) { isFirstLaunch -> if (isFirstLaunch) { showNotificationPermissionDialog() diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index 4babd47f4..7c03528cc 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -18,11 +18,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import retrofit2.HttpException import javax.inject.Inject @HiltViewModel @@ -39,6 +43,10 @@ class HomeViewModel private val _uiState: MutableLiveData = MutableLiveData(HomeUiState()) val uiState: LiveData get() = _uiState + // Foreground notice only; returning to Home must not replay an old refresh failure. + private val _tasteRefreshFailed = MutableSharedFlow(extraBufferCapacity = 1) + val tasteRefreshFailed: SharedFlow = _tasteRefreshFailed.asSharedFlow() + private val _isNotificationPermissionFirstLaunched: MutableLiveData = MutableLiveData() val isNotificationPermissionFirstLaunched: LiveData get() = _isNotificationPermissionFirstLaunched @@ -129,6 +137,8 @@ class HomeViewModel failure = { error -> if (isGlobalHomeFailure(HomeSection.TASTE, error)) { handleFailureState(error) + } else if (keepVisible && (error !is HttpException || error.code() != 403)) { + _tasteRefreshFailed.tryEmit(Unit) } else { publishContent(HomeSection.TASTE, sectionRelease.pending.copy(tasteStatus = HomeTasteStatus.ERROR)) } diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt index 11708ca30..76956d54d 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent @@ -51,6 +52,7 @@ import org.junit.Before import org.junit.Test import retrofit2.HttpException import retrofit2.Response +import java.io.IOException import java.lang.reflect.Proxy import kotlin.coroutines.Continuation import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn @@ -494,6 +496,136 @@ class HomeViewModelTest { } } + @Test + fun `same preference refresh failures keep content or empty until a later success`() = + runTest(dispatcher) { + listOf(0, 10).forEach { count -> + listOf(IOException("offline"), http(500)).forEach { failure -> + tasteRequest = { taste(count) } + val vm = createViewModel() + runCurrent() + var notices = 0 + val collector = backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + runCurrent() + val before = vm.uiState.value!! + val tasteCalls = calls["getRecommendedNovelsByUserTaste"]!! + tasteRequest = { throw failure } + + vm.updateNovel() + runCurrent() + val retained = vm.uiState.value!! + assertFalse(retained.error) + assertEquals(before.tasteStatus, retained.tasteStatus) + assertSame(before.recommendedNovelsByUserTaste, retained.recommendedNovelsByUserTaste) + assertEquals(tasteCalls + 1, calls["getRecommendedNovelsByUserTaste"]) + assertEquals(1, notices) + + collector.cancel() + runCurrent() + val restarted = backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + runCurrent() + assertEquals(1, notices) + + val nextCount = if (count == 0) 2 else 0 + tasteRequest = { taste(nextCount) } + vm.updateNovel() + runCurrent() + val refreshed = vm.uiState.value!! + assertEquals(if (nextCount == 0) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, refreshed.tasteStatus) + assertEquals(nextCount, refreshed.recommendedNovelsByUserTaste.size) + assertEquals(tasteCalls + 2, calls["getRecommendedNovelsByUserTaste"]) + assertEquals(1, notices) + restarted.cancel() + } + } + } + + @Test + fun `preference change failure never restores previous content or empty and can be retried`() = + runTest(dispatcher) { + listOf(0, 10).forEach { count -> + tasteRequest = { taste(count) } + val vm = createViewModel() + runCurrent() + var notices = 0 + val collector = backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + runCurrent() + tasteRequest = { throw IOException("offline") } + + vm.updateNovel(preferencesChanged = true) + val loading = vm.uiState.value!! + assertEquals(HomeTasteStatus.LOADING, loading.tasteStatus) + assertTrue(loading.recommendedNovelsByUserTaste.isEmpty()) + runCurrent() + val failed = vm.uiState.value!! + assertFalse(failed.error) + assertEquals(HomeTasteStatus.ERROR, failed.tasteStatus) + assertTrue(failed.recommendedNovelsByUserTaste.isEmpty()) + assertEquals(0, notices) + + val upperCalls = calls.filterKeys { it != "getRecommendedNovelsByUserTaste" } + tasteRequest = { taste(2) } + vm.retryTaste() + runCurrent() + val retried = vm.uiState.value!! + assertEquals(HomeTasteStatus.CONTENT, retried.tasteStatus) + assertEquals(2, retried.recommendedNovelsByUserTaste.size) + assertEquals(upperCalls, calls.filterKeys { it != "getRecommendedNovelsByUserTaste" }) + collector.cancel() + } + } + + @Test + fun `unauthorized and forbidden refreshes are not hidden by previously visible taste content`() = + runTest(dispatcher) { + listOf(401, 403).forEach { code -> + tasteRequest = { taste(2) } + val vm = createViewModel() + runCurrent() + var notices = 0 + val collector = backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + runCurrent() + tasteRequest = { throw http(code) } + + vm.updateNovel() + runCurrent() + assertEquals(code == 401, vm.uiState.value!!.error) + if (code == 403) assertEquals(HomeTasteStatus.ERROR, vm.uiState.value!!.tasteStatus) + assertEquals(0, notices) + collector.cancel() + } + } + + @Test + fun `superseded refresh failure cannot restore invalidated taste or emit a refresh notice`() = + runTest(dispatcher) { + val vm = createViewModel() + runCurrent() + var notices = 0 + backgroundScope.launch(dispatcher) { vm.tasteRefreshFailed.collect { notices++ } } + val old = CompletableDeferred() + tasteRequest = { + withContext(NonCancellable) { + old.await() + throw IOException("old refresh failed") + } + } + vm.updateNovel() + runCurrent() + + tasteRequest = { throw http(500) } + vm.updateNovel(preferencesChanged = true) + runCurrent() + old.complete(Unit) + runCurrent() + + val failed = vm.uiState.value!! + assertFalse(failed.error) + assertEquals(HomeTasteStatus.ERROR, failed.tasteStatus) + assertTrue(failed.recommendedNovelsByUserTaste.isEmpty()) + assertEquals(0, notices) + } + @Test fun `profile edit invalidates both empty CTA and existing content while new preferences load`() = runTest(dispatcher) { diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index a18bd759d..019ff10ad 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -241,6 +241,7 @@ + 오늘의 발견 + 지금 뜨는 글 %s님을 위한 추천글 + 취향 추천을 새로 불러오지 못했어요 관심 등록한 작품의 최신 글이에요 로맨스, 로판, 판타지, 현판 등\n선호장르를 기반으로 웹소설을 추천해드려요! 선호장르를 기반으로 추천해드려요 From bbbd5d4147e38243bd0b8c93327a7c3fe7f239ab Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sat, 3 Oct 2026 03:30:10 +0900 Subject: [PATCH 06/22] =?UTF-8?q?feat:=20=ED=99=88=20=EC=B7=A8=ED=96=A5=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=A1=9C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?UI=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeFragment.kt`: - 에러 상태에 따라 `tvHomeTasteStatus`의 텍스트 색상을 동적으로 변경하도록 수정 (`gray_300_52515F` 또는 `gray_200_949399`) - 로드 실패 시 노출되는 문구를 `home_taste_load_failed`로 변경 - `fragment_home.xml`: - `tvHomeTasteStatus`의 텍스트 정렬을 중앙(`center`)으로 설정 - 재시도 버튼(`btn_home_taste_retry`)의 배경, 패딩, 여백, 텍스트 스타일 및 색상 등 전반적인 UI 속성 고도화 - `strings.xml`: 네트워크 로드 실패 문구(`home_taste_load_failed`) 및 다시 불러오기 버튼 문구(`home_taste_reload`) 추가 --- .../into/websoso/ui/main/home/HomeFragment.kt | 10 ++++++++-- app/src/main/res/layout/fragment_home.xml | 17 ++++++++++++----- core/resource/src/main/res/values/strings.xml | 2 ++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt index ab82d0b0b..247e9de67 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt @@ -8,9 +8,12 @@ import android.view.View import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import com.google.firebase.messaging.FirebaseMessaging +import com.into.websoso.R.color.gray_200_949399 +import com.into.websoso.R.color.gray_300_52515F import com.into.websoso.R.layout.fragment_home import com.into.websoso.core.common.ui.base.BaseFragment import com.into.websoso.core.common.ui.model.ResultFrom.FeedDetailBack @@ -25,8 +28,8 @@ import com.into.websoso.core.common.util.showWebsosoToast import com.into.websoso.core.common.util.tracker.Tracker import com.into.websoso.core.resource.R.drawable.ic_novel_rating_alert import com.into.websoso.core.resource.R.string.home_rising_feed_for_user +import com.into.websoso.core.resource.R.string.home_taste_load_failed import com.into.websoso.core.resource.R.string.home_taste_refresh_failed -import com.into.websoso.core.resource.R.string.load_fail_title import com.into.websoso.core.resource.R.string.load_load_title import com.into.websoso.databinding.FragmentHomeBinding import com.into.websoso.ui.detailExplore.DetailExploreActivity @@ -224,7 +227,10 @@ class HomeFragment : BaseFragment(fragment_home) { rvRecommendNovelByUserTaste.visibility = if (showContent) View.VISIBLE else View.GONE llHomeTasteStatus.visibility = if (showContent || empty) View.GONE else View.VISIBLE val error = state.tasteStatus == HomeTasteStatus.ERROR - tvHomeTasteStatus.setText(if (error) load_fail_title else load_load_title) + tvHomeTasteStatus.setText(if (error) home_taste_load_failed else load_load_title) + tvHomeTasteStatus.setTextColor( + ContextCompat.getColor(requireContext(), if (error) gray_300_52515F else gray_200_949399), + ) btnHomeTasteRetry.visibility = if (error) View.VISIBLE else View.GONE } } diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 238bc9ecf..af6724d2e 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -269,6 +269,7 @@ android:layout_height="wrap_content" android:accessibilityLiveRegion="polite" android:text="@string/load_load_title" + android:textAlignment="center" android:textAppearance="@style/body2" android:textColor="@color/gray_200_949399" /> @@ -276,15 +277,21 @@ android:id="@+id/btn_home_taste_retry" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:background="?attr/selectableItemBackground" + android:layout_marginTop="16dp" + android:background="@drawable/bg_load_primary_100_radius_8dp" + android:elevation="0dp" + android:foreground="?attr/selectableItemBackground" android:minWidth="64dp" android:minHeight="48dp" - android:paddingHorizontal="16dp" - android:text="@string/novel_notification_retry" + android:paddingHorizontal="38dp" + android:paddingVertical="14dp" + android:stateListAnimator="@null" + android:text="@string/home_taste_reload" android:textAllCaps="false" android:textAppearance="@style/label1" - android:textColor="@color/primary_100_6A5DFD" - android:visibility="gone" /> + android:textColor="@color/white" + android:visibility="gone" + app:backgroundTint="@null" /> diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 019ff10ad..d06530671 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -241,6 +241,8 @@ + 오늘의 발견 + 지금 뜨는 글 %s님을 위한 추천글 + 네트워크 연결에 실패했어요 + 작품 다시 불러오기 취향 추천을 새로 불러오지 못했어요 관심 등록한 작품의 최신 글이에요 로맨스, 로판, 판타지, 현판 등\n선호장르를 기반으로 웹소설을 추천해드려요! From e9a93e80f1986cc0caba47bb83dad300a296ce13 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sat, 3 Oct 2026 03:40:09 +0900 Subject: [PATCH 07/22] =?UTF-8?q?refactor:=20HomeSectionReleaseTest=20?= =?UTF-8?q?=EB=82=B4=20=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85=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 --- .../ui/main/home/HomeSectionReleaseTest.kt | 93 ++++++++++++++++--- 1 file changed, 80 insertions(+), 13 deletions(-) diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt index 53c6232af..bd3080966 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt @@ -15,7 +15,12 @@ class HomeSectionReleaseTest { @Test fun `upper content waits for both upper results but never for taste`() { val release = HomeSectionRelease() - assertNull(release.update(HomeSection.POPULAR, release.pending.copy(popularNovels = popular))) + assertNull( + release.update( + HomeSection.POPULAR, + release.pending.copy(popularNovels = popular), + ), + ) val state = release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = feeds))!! @@ -34,10 +39,15 @@ class HomeSectionReleaseTest { val state = release.pending val result = when (section) { 'p' -> release.update(HomeSection.POPULAR, state.copy(popularNovels = popular)) + 'f' -> release.update(HomeSection.FEEDS, state.copy(popularFeeds = feeds)) + else -> release.update( HomeSection.TASTE, - state.copy(tasteStatus = HomeTasteStatus.CONTENT, recommendedNovelsByUserTaste = taste), + state.copy( + tasteStatus = HomeTasteStatus.CONTENT, + recommendedNovelsByUserTaste = taste, + ), ) } result?.let(displayed::add) @@ -53,10 +63,16 @@ class HomeSectionReleaseTest { @Test fun `early taste failure stays buffered until both upper results succeed`() { val release = HomeSectionRelease() - assertNull(release.update(HomeSection.TASTE, release.pending.copy(tasteStatus = HomeTasteStatus.ERROR))) + assertNull( + release.update( + HomeSection.TASTE, + release.pending.copy(tasteStatus = HomeTasteStatus.ERROR), + ), + ) assertNull(release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = feeds))) - val state = release.update(HomeSection.POPULAR, release.pending.copy(popularNovels = popular))!! + val state = + release.update(HomeSection.POPULAR, release.pending.copy(popularNovels = popular))!! assertFalse(state.error) assertFalse(state.loading) @@ -67,8 +83,13 @@ class HomeSectionReleaseTest { fun `error retry and empty recovery keep upper list identities`() { val release = readyUpper() val initial = release.pending - listOf(HomeTasteStatus.ERROR, HomeTasteStatus.LOADING, HomeTasteStatus.EMPTY).forEach { status -> - val state = release.update(HomeSection.TASTE, release.pending.copy(tasteStatus = status))!! + listOf( + HomeTasteStatus.ERROR, + HomeTasteStatus.LOADING, + HomeTasteStatus.EMPTY, + ).forEach { status -> + val state = + release.update(HomeSection.TASTE, release.pending.copy(tasteStatus = status))!! assertFalse(state.loading) assertFalse(state.error) @@ -83,15 +104,22 @@ class HomeSectionReleaseTest { val release = readyUpper() release.update( HomeSection.TASTE, - release.pending.copy(tasteStatus = HomeTasteStatus.CONTENT, recommendedNovelsByUserTaste = taste), + release.pending.copy( + tasteStatus = HomeTasteStatus.CONTENT, + recommendedNovelsByUserTaste = taste, + ), ) val refreshedFeeds = feeds.map { page -> page.map { it.copy(likeCount = 2) } } - val feedState = release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = refreshedFeeds))!! + val feedState = + release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = refreshedFeeds))!! assertSame(popular, feedState.popularNovels) assertSame(taste, feedState.recommendedNovelsByUserTaste) val refreshedPopular = popular.map { it.copy(title = "updated") } - val novelState = release.update(HomeSection.POPULAR, release.pending.copy(popularNovels = refreshedPopular))!! + val novelState = release.update( + HomeSection.POPULAR, + release.pending.copy(popularNovels = refreshedPopular), + )!! assertSame(refreshedFeeds, novelState.popularFeeds) assertSame(taste, novelState.recommendedNovelsByUserTaste) assertFalse(novelState.loading) @@ -100,7 +128,10 @@ class HomeSectionReleaseTest { @Test fun `a new Home owner starts with loading rather than previous content or empty`() { val previous = readyUpper() - previous.update(HomeSection.TASTE, previous.pending.copy(tasteStatus = HomeTasteStatus.EMPTY)) + previous.update( + HomeSection.TASTE, + previous.pending.copy(tasteStatus = HomeTasteStatus.EMPTY), + ) val next = HomeSectionRelease() assertEquals(HomeUiState(), next.pending) @@ -114,8 +145,44 @@ class HomeSectionReleaseTest { } private val popular = listOf( - PopularNovelEntity("author", null, null, "fantasy", false, emptyList(), null, "description", 1, "image", "title"), + PopularNovelEntity( + "author", + null, + null, + "fantasy", + false, + emptyList(), + null, + "description", + 1, + "image", + "title", + ), + ) + private val feeds = listOf( + listOf( + PopularFeedEntity( + 1, + "content", + 0, + 0, + false, + true, + "title", + "image", + "fantasy", + ), + ), ) - private val feeds = listOf(listOf(PopularFeedEntity(1, "content", 0, 0, false, true, "title", "image", "fantasy"))) - private val taste = (1L..10L).map { RecommendedNovelByUserTasteEntity(it, "title $it", "author", "image", 0, 0.0, 0) } + private val taste = (1L..10L).map { + RecommendedNovelByUserTasteEntity( + it, + "title $it", + "author", + "image", + 0, + 0.0, + 0, + ) + } } From 45c8c7fe76f771519150d36b44f638944802eac2 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sat, 3 Oct 2026 13:37:02 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=9E=A5=EB=A5=B4=20=EC=B7=A8?= =?UTF-8?q?=ED=96=A5=20=EB=B3=80=EA=B2=BD=20=EC=97=AC=EB=B6=80=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EB=B0=8F=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ProfileEditUiModel.kt`: `ProfileEditResult.Success`를 `data class`로 변경하여 장르 취향 변경 여부(`genrePreferencesChanged`) 필드 추가 - `ProfileEditViewModel.kt`: 프로필 업데이트 시 기존 취향과 비교하여 변경 여부를 계산하고 결과를 `Success` 상태에 담아 전달 - `ProfileEditActivity.kt`: 수정 성공 시 `EXTRA_GENRE_PREFERENCES_CHANGED` 키를 통해 변경 여부 데이터를 `Intent` 결과로 반환 - `HomeFragment.kt`: 프로필 수정 결과로부터 장르 취향 변경 여부를 확인하여 `homeViewModel.updateNovel` 호출 시 반영하도록 수정 --- .../into/websoso/ui/main/home/HomeFragment.kt | 7 +- .../ui/profileEdit/ProfileEditActivity.kt | 7 +- .../ui/profileEdit/ProfileEditViewModel.kt | 4 +- .../profileEdit/model/ProfileEditUiModel.kt | 4 +- .../profileEdit/ProfileEditViewModelTest.kt | 210 ++++++++++++++++++ 5 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt index 247e9de67..1318a6a05 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt @@ -91,7 +91,12 @@ class HomeFragment : BaseFragment(fragment_home) { ProfileEditSuccess.RESULT_OK -> { mainViewModel.updateUserInfo() - homeViewModel.updateNovel(preferencesChanged = true) + homeViewModel.updateNovel( + preferencesChanged = result.data?.getBooleanExtra( + ProfileEditActivity.EXTRA_GENRE_PREFERENCES_CHANGED, + false, + ) == true, + ) } } } diff --git a/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditActivity.kt b/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditActivity.kt index a612c0ef6..f75127e8b 100644 --- a/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditActivity.kt @@ -196,8 +196,9 @@ class ProfileEditActivity : BaseActivity(activity_pr private fun handleProfileEditResult(profileEditResult: ProfileEditResult) { when (profileEditResult) { - ProfileEditResult.Success -> { - setResult(ProfileEditSuccess.RESULT_OK) + is ProfileEditResult.Success -> { + val result = Intent().putExtra(EXTRA_GENRE_PREFERENCES_CHANGED, profileEditResult.genrePreferencesChanged) + setResult(ProfileEditSuccess.RESULT_OK, result) showWebsosoToast( this, getString(profile_edit_success), @@ -298,6 +299,8 @@ class ProfileEditActivity : BaseActivity(activity_pr } companion object { + const val EXTRA_GENRE_PREFERENCES_CHANGED = "EXTRA_GENRE_PREFERENCES_CHANGED" + private const val PROFILE_EDIT_CHARACTER_BOTTOM_SHEET_DIALOG = "PROFILE_EDIT_CHARACTER_BOTTOM_SHEET_DIALOG" diff --git a/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditViewModel.kt b/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditViewModel.kt index 3d1be8fbf..604b8efd9 100644 --- a/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/profileEdit/ProfileEditViewModel.kt @@ -194,7 +194,9 @@ class ProfileEditViewModel ) }.onSuccess { _profileEditUiState.value = profileEditUiState.value?.copy( - profileEditResult = ProfileEditResult.Success, + profileEditResult = ProfileEditResult.Success( + genrePreferencesChanged = previousProfile.genrePreferences.toSet() != currentProfile.genrePreferences.toSet(), + ), ) }.onFailure { _profileEditUiState.value = profileEditUiState.value?.copy( diff --git a/app/src/main/java/com/into/websoso/ui/profileEdit/model/ProfileEditUiModel.kt b/app/src/main/java/com/into/websoso/ui/profileEdit/model/ProfileEditUiModel.kt index b57ae477e..b78f6c723 100644 --- a/app/src/main/java/com/into/websoso/ui/profileEdit/model/ProfileEditUiModel.kt +++ b/app/src/main/java/com/into/websoso/ui/profileEdit/model/ProfileEditUiModel.kt @@ -21,7 +21,9 @@ data class NicknameModel( sealed interface ProfileEditResult { data object Loading : ProfileEditResult - data object Success : ProfileEditResult + data class Success( + val genrePreferencesChanged: Boolean, + ) : ProfileEditResult data object Error : ProfileEditResult } diff --git a/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt new file mode 100644 index 000000000..43e17e745 --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt @@ -0,0 +1,210 @@ +package com.into.websoso.ui.profileEdit + +import androidx.arch.core.executor.ArchTaskExecutor +import androidx.arch.core.executor.TaskExecutor +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.preferencesOf +import androidx.lifecycle.ViewModelStore +import com.into.websoso.data.remote.api.AvatarApi +import com.into.websoso.data.remote.api.UserApi +import com.into.websoso.data.remote.request.UserProfileEditRequestDto +import com.into.websoso.data.remote.response.AvatarsResponseDto +import com.into.websoso.data.remote.response.MyProfileResponseDto +import com.into.websoso.data.remote.response.UserNicknameValidityResponseDto +import com.into.websoso.data.repository.AvatarRepository +import com.into.websoso.data.repository.UserRepository +import com.into.websoso.domain.usecase.CheckNicknameValidityUseCase +import com.into.websoso.ui.profileEdit.model.AvatarModel +import com.into.websoso.ui.profileEdit.model.Genre +import com.into.websoso.ui.profileEdit.model.ProfileEditResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileEditViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val owners = mutableListOf() + private val savedRequests = mutableListOf() + private var saveRequest: suspend () -> Unit = {} + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + ArchTaskExecutor.getInstance().setDelegate( + object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }, + ) + } + + @After + fun tearDown() { + owners.forEach(ViewModelStore::clear) + Dispatchers.resetMain() + ArchTaskExecutor.getInstance().setDelegate(null) + } + + @Test + fun `nickname introduction and avatar edits do not report a genre change`() = + runTest(dispatcher) { + listOf("nickname", "introduction", "avatar").forEach { field -> + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + when (field) { + "nickname" -> { + vm.updateNickname("newname") + vm.checkNicknameValidity("newname") + } + + "introduction" -> vm.updateIntroduction("new introduction") + "avatar" -> { + vm.updateSelectedAvatar(AvatarModel(avatarId = 2)) + vm.updateRepresentativeAvatar() + } + } + runCurrent() + vm.updateProfile() + runCurrent() + + val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertFalse(result.genrePreferencesChanged) + assertEquals(listOf("fantasy", "romance"), savedRequests.last().genrePreferences) + } + assertEquals(3, savedRequests.size) + } + + @Test + fun `adding removing or clearing genres reports a genre change`() = + runTest(dispatcher) { + listOf(listOf(Genre.MYSTERY), listOf(Genre.FANTASY), listOf(Genre.FANTASY, Genre.ROMANCE)).forEach { toggles -> + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + toggles.forEach(vm::updateSelectedGenres) + vm.updateProfile() + runCurrent() + + val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertTrue(result.genrePreferencesChanged) + } + assertTrue(savedRequests.last().genrePreferences.isEmpty()) + } + + @Test + fun `restoring the same genres in a different order does not report a change`() = + runTest(dispatcher) { + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + vm.updateSelectedGenres(Genre.FANTASY) + vm.updateSelectedGenres(Genre.FANTASY) + vm.updateIntroduction("new introduction") + vm.updateProfile() + runCurrent() + + val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertFalse(result.genrePreferencesChanged) + assertEquals(listOf("romance", "fantasy"), savedRequests.single().genrePreferences) + } + + @Test + fun `success describes the saved request even if selection changes while saving`() = + runTest(dispatcher) { + listOf(false, true).forEach { submittedChange -> + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + val pending = CompletableDeferred() + saveRequest = { pending.await() } + if (submittedChange) vm.updateSelectedGenres(Genre.MYSTERY) + vm.updateIntroduction("new introduction") + vm.updateProfile() + runCurrent() + assertFalse(vm.profileEditUiState.value!!.profileEditResult is ProfileEditResult.Success) + + vm.updateSelectedGenres(Genre.MYSTERY) + pending.complete(Unit) + runCurrent() + + val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertEquals(submittedChange, result.genrePreferencesChanged) + assertEquals(submittedChange, savedRequests.last().genrePreferences.contains("mystery")) + } + } + + @Test + fun `failed save does not publish success and a successful retry reports the saved change`() = + runTest(dispatcher) { + val vm = createViewModel() + vm.updateUserProfile() + runCurrent() + vm.updateSelectedGenres(Genre.MYSTERY) + saveRequest = { throw IOException("offline") } + vm.updateProfile() + runCurrent() + assertFalse(vm.profileEditUiState.value!!.profileEditResult is ProfileEditResult.Success) + + saveRequest = {} + vm.updateProfile() + runCurrent() + val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + assertTrue(result.genrePreferencesChanged) + assertEquals(2, savedRequests.size) + } + + private fun createViewModel(): ProfileEditViewModel { + val storage = object : DataStore { + override val data = MutableStateFlow(preferencesOf()) + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + transform(data.value).also { data.value = it } + } + return ProfileEditViewModel( + CheckNicknameValidityUseCase(), + UserRepository(api(), storage), + AvatarRepository(api()), + ).also { vm -> owners.add(ViewModelStore().apply { put("profile", vm) }) } + } + + @Suppress("UNCHECKED_CAST") + private inline fun api(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, args -> + val call: suspend () -> Any = { + when (method.name) { + "getMyProfile" -> MyProfileResponseDto("nickname", "introduction", "", listOf("fantasy", "romance")) + "getAvatars" -> AvatarsResponseDto(emptyList()) + "getNicknameValidity" -> UserNicknameValidityResponseDto(true) + "patchProfile" -> { + savedRequests.add(args.first() as UserProfileEditRequestDto) + saveRequest() + } + + else -> error("Unexpected API call: ${method.name}") + } + } + call.startCoroutineUninterceptedOrReturn(args.last() as Continuation) + } as T +} From 5cc03d4cbd127927b2492d8787d81631ccfa9e01 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sat, 3 Oct 2026 16:20:20 +0900 Subject: [PATCH 09/22] =?UTF-8?q?refactor:=20=ED=99=88=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=EC=9D=98=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=84=EC=86=8C=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?`isGlobalHomeFailure`=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeSectionLoader.kt`: - 불필요한 `isGlobalHomeFailure` 함수를 삭제하고, 401(Unauthorized) 에러 여부만 확인하는 `isSessionFailure`만 유지 - `HomeViewModel.kt`: - Taste 섹션의 에러 처리 시 `isGlobalHomeFailure` 대신 `isSessionFailure`를 사용하도록 로직 수정 - `HomeSectionLoaderTest.kt`: - `isGlobalHomeFailure` 삭제에 따라 관련 테스트 케이스를 정리하고 `isSessionFailure`에 대한 검증 로직을 단순화 및 강화 --- .../into/websoso/ui/main/home/HomeSectionLoader.kt | 5 ----- .../com/into/websoso/ui/main/home/HomeViewModel.kt | 2 +- .../websoso/ui/main/home/HomeSectionLoaderTest.kt | 12 +++--------- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt index 27e47c5cf..f70e116d9 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionLoader.kt @@ -15,11 +15,6 @@ internal enum class HomeSection { TASTE, } -internal fun isGlobalHomeFailure( - section: HomeSection, - error: Exception, -): Boolean = section != HomeSection.TASTE || isSessionFailure(error) - internal fun isSessionFailure(error: Throwable): Boolean = error is HttpException && error.code() == 401 /** Requests belong to the Home ViewModel; replacing one never cancels another section. */ diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index 7c03528cc..8ec7ed6ca 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -135,7 +135,7 @@ class HomeViewModel ) }, failure = { error -> - if (isGlobalHomeFailure(HomeSection.TASTE, error)) { + if (isSessionFailure(error)) { handleFailureState(error) } else if (keepVisible && (error !is HttpException || error.code() != 403)) { _tasteRefreshFailed.tryEmit(Unit) diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt index 147a53a61..a665d7364 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionLoaderTest.kt @@ -19,19 +19,13 @@ import java.io.IOException class HomeSectionLoaderTest { @Test - fun `taste access denial stays local while unauthorized and upper failures remain global`() { + fun `only unauthorized response is a session failure`() { fun http(code: Int) = HttpException(Response.error(code, "synthetic".toResponseBody())) - assertFalse(isGlobalHomeFailure(HomeSection.TASTE, http(500))) - assertFalse(isGlobalHomeFailure(HomeSection.TASTE, IOException("offline"))) - assertTrue(isGlobalHomeFailure(HomeSection.TASTE, http(401))) - assertFalse(isGlobalHomeFailure(HomeSection.TASTE, http(403))) - assertTrue(isGlobalHomeFailure(HomeSection.POPULAR, http(500))) - assertTrue(isGlobalHomeFailure(HomeSection.FEEDS, IOException("offline"))) - assertTrue(isGlobalHomeFailure(HomeSection.POPULAR, http(403))) - assertTrue(isGlobalHomeFailure(HomeSection.FEEDS, http(403))) assertTrue(isSessionFailure(http(401))) assertFalse(isSessionFailure(http(403))) + assertFalse(isSessionFailure(http(500))) + assertFalse(isSessionFailure(IOException("offline"))) } @Test From c1d6968bcd9463b3653a7ee0396d4ed7a0e7efe7 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sun, 4 Oct 2026 03:03:34 +0900 Subject: [PATCH 10/22] =?UTF-8?q?refactor:=20fragment=5Fhome.xml=20?= =?UTF-8?q?=EB=82=B4=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=94=8C=20=ED=9A=A8=EA=B3=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/res/layout/fragment_home.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index af6724d2e..705a975a5 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -280,7 +280,6 @@ android:layout_marginTop="16dp" android:background="@drawable/bg_load_primary_100_radius_8dp" android:elevation="0dp" - android:foreground="?attr/selectableItemBackground" android:minWidth="64dp" android:minHeight="48dp" android:paddingHorizontal="38dp" From 6dbb4c4591218211bd8ce2f06ccff74abc2959ec Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sun, 4 Oct 2026 19:36:33 +0900 Subject: [PATCH 11/22] =?UTF-8?q?refactor:=20Home=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=20=EB=A1=9C=EB=94=A9=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeSectionRelease.kt`: - `HomeUiState` 직접 관리 대신 특정 섹션 로드 완료 여부만 추적하도록 변경 (`update` 반환 타입을 `Boolean`으로 변경) - 미사용 `pending` 상태 제거 및 로직 단순화 - `HomeViewModel.kt`: - `HomeUiState`를 ViewModel에서 단일 진실 공급원(SSOT)으로 관리하도록 수정 - `updateContent`, `updateState` 유틸리티 함수를 도입하여 상태 업데이트 로직의 일관성 확보 - 여러 섹션이 로드되는 동안 개별 데이터를 `uiState`에 즉시 반영하되, 필수 섹션(인기 작품, 피드)이 모두 로드될 때까지 `loading` 상태를 유지하도록 개선 - `HomeSectionReleaseTest.kt` & `HomeViewModelTest.kt`: - 변경된 로직에 맞춰 테스트 케이스 업데이트 및 로딩 순서에 따른 상태 검증 테스트 추가 --- .../ui/main/home/HomeSectionRelease.kt | 20 +- .../websoso/ui/main/home/HomeViewModel.kt | 92 ++++---- .../ui/main/home/HomeSectionReleaseTest.kt | 204 ++++-------------- .../websoso/ui/main/home/HomeViewModelTest.kt | 105 +++++++++ 4 files changed, 205 insertions(+), 216 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt index 036de6b99..19c9ba66d 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeSectionRelease.kt @@ -1,27 +1,15 @@ package com.into.websoso.ui.main.home -import com.into.websoso.ui.main.home.model.HomeUiState - -/** Main-thread only. Upper successes or recovery displaying content open Home once. */ +/** Main-thread only. Tracks when Home can open; content is owned by HomeViewModel. */ internal class HomeSectionRelease { - var pending = HomeUiState() - private set private val waitingForUpper = mutableSetOf(HomeSection.POPULAR, HomeSection.FEEDS) - fun update( - section: HomeSection, - state: HomeUiState, - ): HomeUiState? { - pending = state + fun update(section: HomeSection): Boolean { waitingForUpper.remove(section) - if (waitingForUpper.isNotEmpty()) return null - pending = state.copy(loading = false) - return pending + return waitingForUpper.isEmpty() } - fun recover(loading: Boolean): HomeUiState { + fun recover(loading: Boolean) { if (!loading) waitingForUpper.clear() - pending = pending.copy(loading = loading) - return pending } } diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index 8ec7ed6ca..555c6962c 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -40,6 +40,7 @@ class HomeViewModel private val userRepository: UserRepository, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { + // Received data is kept here even while loading; the Fragment waits before rendering it. private val _uiState: MutableLiveData = MutableLiveData(HomeUiState()) val uiState: LiveData get() = _uiState @@ -93,7 +94,7 @@ class HomeViewModel section = HomeSection.POPULAR, request = novelRepository::fetchPopularNovels, success = { result -> - publishContent(HomeSection.POPULAR, sectionRelease.pending.copy(popularNovels = result.popularNovels)) + updateContent(HomeSection.POPULAR) { it.copy(popularNovels = result.popularNovels) } }, failure = { handleFailureState(it) }, ) @@ -103,36 +104,34 @@ class HomeViewModel section = HomeSection.FEEDS, request = feedRepository::fetchPopularFeeds, success = { result -> - publishContent(HomeSection.FEEDS, sectionRelease.pending.copy(popularFeeds = result.toHomePopularFeedPages())) + updateContent(HomeSection.FEEDS) { it.copy(popularFeeds = result.toHomePopularFeedPages()) } if (recoverError) recoverGlobalError() }, failure = { handleFailureState(it) }, ) private fun loadTasteNovels(preferencesChanged: Boolean = false): Deferred { - val previous = sectionRelease.pending + val previousStatus = _uiState.value?.tasteStatus val keepVisible = !preferencesChanged && - (previous.tasteStatus == HomeTasteStatus.CONTENT || previous.tasteStatus == HomeTasteStatus.EMPTY) + (previousStatus == HomeTasteStatus.CONTENT || previousStatus == HomeTasteStatus.EMPTY) if (!keepVisible) { - publishContent( - HomeSection.TASTE, - previous.copy( + updateContent(HomeSection.TASTE) { current -> + current.copy( tasteStatus = HomeTasteStatus.LOADING, - recommendedNovelsByUserTaste = if (preferencesChanged) emptyList() else previous.recommendedNovelsByUserTaste, - ), - ) + recommendedNovelsByUserTaste = if (preferencesChanged) emptyList() else current.recommendedNovelsByUserTaste, + ) + } } return sectionLoader.load( section = HomeSection.TASTE, request = novelRepository::fetchRecommendedNovelsByUserTaste, success = { result -> - publishContent( - HomeSection.TASTE, - sectionRelease.pending.copy( + updateContent(HomeSection.TASTE) { current -> + current.copy( recommendedNovelsByUserTaste = result.tasteNovels, tasteStatus = if (result.tasteNovels.isEmpty()) HomeTasteStatus.EMPTY else HomeTasteStatus.CONTENT, - ), - ) + ) + } }, failure = { error -> if (isSessionFailure(error)) { @@ -140,30 +139,36 @@ class HomeViewModel } else if (keepVisible && (error !is HttpException || error.code() != 403)) { _tasteRefreshFailed.tryEmit(Unit) } else { - publishContent(HomeSection.TASTE, sectionRelease.pending.copy(tasteStatus = HomeTasteStatus.ERROR)) + updateContent(HomeSection.TASTE) { it.copy(tasteStatus = HomeTasteStatus.ERROR) } } }, ) } - private fun publishContent( + private fun updateContent( section: HomeSection, - state: HomeUiState, + transform: (HomeUiState) -> HomeUiState, ) { - val previous = _uiState.value ?: return - val ready = sectionRelease.update(section, state) ?: return - val updated = ready.copy(error = previous.error, isNotificationUnread = previous.isNotificationUnread) - if (updated != previous) _uiState.value = updated + updateState { current -> + val updated = transform(current) + if (sectionRelease.update(section)) updated.copy(loading = false) else updated + } + } + + private fun updateState(transform: (HomeUiState) -> HomeUiState) { + val current = _uiState.value ?: return + val updated = transform(current) + if (updated != current) _uiState.value = updated } // Match the existing recovery events; a content response cannot establish a new session. private fun recoverGlobalError() { - val previous = _uiState.value ?: return - if (!previous.error || hasSessionFailure) return - _uiState.value = sectionRelease.recover(loading = previous.loading).copy( - error = false, - isNotificationUnread = previous.isNotificationUnread, - ) + if (hasSessionFailure) return + updateState { current -> + if (!current.error) return@updateState current + sectionRelease.recover(loading = current.loading) + current.copy(error = false) + } } private fun checkIsNotificationPermissionFirstLaunched() { @@ -206,12 +211,14 @@ class HomeViewModel val popularNovels = popularNovelsResult.getOrThrow() val popularFeeds = popularFeedsResult.getOrThrow() - _uiState.value = uiState.value?.copy( - loading = false, - error = false, - popularNovels = popularNovels.popularNovels, - popularFeeds = popularFeeds.toHomePopularFeedPages(), - ) + updateState { current -> + current.copy( + loading = false, + error = false, + popularNovels = popularNovels.popularNovels, + popularFeeds = popularFeeds.toHomePopularFeedPages(), + ) + } } } @@ -220,10 +227,12 @@ class HomeViewModel preserveLoading: Boolean = false, ) { hasSessionFailure = hasSessionFailure || isSessionFailure(error) - _uiState.value = uiState.value?.copy( - loading = preserveLoading && uiState.value?.loading == true, - error = true, - ) + updateState { current -> + current.copy( + loading = preserveLoading && current.loading, + error = true, + ) + } } fun updateFeed() { @@ -236,7 +245,8 @@ class HomeViewModel } fun retryTaste() { - if (uiState.value?.error == true || sectionRelease.pending.tasteStatus != HomeTasteStatus.ERROR) return + val current = _uiState.value ?: return + if (current.error || current.tasteStatus != HomeTasteStatus.ERROR) return loadTasteNovels() } @@ -245,9 +255,7 @@ class HomeViewModel runCatching { notificationRepository.fetchNotificationUnread() }.onSuccess { isNotificationUnread -> - _uiState.value = uiState.value?.copy( - isNotificationUnread = isNotificationUnread, - ) + updateState { it.copy(isNotificationUnread = isNotificationUnread) } }.onFailure { error -> handleFailureState(error, preserveLoading = true) } diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt index bd3080966..e73a50c13 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeSectionReleaseTest.kt @@ -1,188 +1,76 @@ package com.into.websoso.ui.main.home -import com.into.websoso.data.model.PopularFeedEntity -import com.into.websoso.data.model.PopularNovelsEntity.PopularNovelEntity -import com.into.websoso.data.model.RecommendedNovelsByUserTasteEntity.RecommendedNovelByUserTasteEntity -import com.into.websoso.ui.main.home.model.HomeTasteStatus -import com.into.websoso.ui.main.home.model.HomeUiState -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test class HomeSectionReleaseTest { @Test fun `upper content waits for both upper results but never for taste`() { - val release = HomeSectionRelease() - assertNull( - release.update( - HomeSection.POPULAR, - release.pending.copy(popularNovels = popular), - ), - ) - - val state = release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = feeds))!! - - assertFalse(state.loading) - assertEquals(HomeTasteStatus.LOADING, state.tasteStatus) - assertEquals(popular, state.popularNovels) - assertEquals(feeds, state.popularFeeds) - } - - @Test - fun `all completion orders publish upper content and retain ten taste novels`() { - listOf("pft", "ptf", "fpt", "ftp", "tpf", "tfp").forEach { order -> + listOf(HomeSection.POPULAR, HomeSection.FEEDS).forEach { first -> val release = HomeSectionRelease() - val displayed = mutableListOf() - order.forEach { section -> - val state = release.pending - val result = when (section) { - 'p' -> release.update(HomeSection.POPULAR, state.copy(popularNovels = popular)) - - 'f' -> release.update(HomeSection.FEEDS, state.copy(popularFeeds = feeds)) - - else -> release.update( - HomeSection.TASTE, - state.copy( - tasteStatus = HomeTasteStatus.CONTENT, - recommendedNovelsByUserTaste = taste, - ), - ) - } - result?.let(displayed::add) - } - - assertEquals(order, if (order.last() == 't') 2 else 1, displayed.size) - assertEquals(10, displayed.last().recommendedNovelsByUserTaste.size) - assertEquals(popular, displayed.first().popularNovels) - assertEquals(feeds, displayed.first().popularFeeds) + val second = if (first == HomeSection.POPULAR) HomeSection.FEEDS else HomeSection.POPULAR + + assertFalse(release.update(first)) + assertFalse(release.update(first)) + assertTrue(release.update(second)) } } @Test - fun `early taste failure stays buffered until both upper results succeed`() { + fun `taste updates never open Home before both upper results`() { val release = HomeSectionRelease() - assertNull( - release.update( - HomeSection.TASTE, - release.pending.copy(tasteStatus = HomeTasteStatus.ERROR), - ), - ) - assertNull(release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = feeds))) - - val state = - release.update(HomeSection.POPULAR, release.pending.copy(popularNovels = popular))!! - - assertFalse(state.error) - assertFalse(state.loading) - assertEquals(HomeTasteStatus.ERROR, state.tasteStatus) + + assertFalse(release.update(HomeSection.TASTE)) + assertFalse(release.update(HomeSection.FEEDS)) + assertFalse(release.update(HomeSection.TASTE)) + assertTrue(release.update(HomeSection.POPULAR)) } @Test - fun `error retry and empty recovery keep upper list identities`() { - val release = readyUpper() - val initial = release.pending - listOf( - HomeTasteStatus.ERROR, - HomeTasteStatus.LOADING, - HomeTasteStatus.EMPTY, - ).forEach { status -> - val state = - release.update(HomeSection.TASTE, release.pending.copy(tasteStatus = status))!! - - assertFalse(state.loading) - assertFalse(state.error) - assertSame(initial.popularNovels, state.popularNovels) - assertSame(initial.popularFeeds, state.popularFeeds) - assertEquals(status, state.tasteStatus) + fun `all section updates remain released once Home opens`() { + val release = HomeSectionRelease() + release.update(HomeSection.POPULAR) + release.update(HomeSection.FEEDS) + + HomeSection.entries.forEach { section -> + assertTrue(release.update(section)) } } @Test - fun `feed and novel refreshes keep published taste and the other upper list`() { - val release = readyUpper() - release.update( - HomeSection.TASTE, - release.pending.copy( - tasteStatus = HomeTasteStatus.CONTENT, - recommendedNovelsByUserTaste = taste, - ), - ) - val refreshedFeeds = feeds.map { page -> page.map { it.copy(likeCount = 2) } } - val feedState = - release.update(HomeSection.FEEDS, release.pending.copy(popularFeeds = refreshedFeeds))!! - assertSame(popular, feedState.popularNovels) - assertSame(taste, feedState.recommendedNovelsByUserTaste) - - val refreshedPopular = popular.map { it.copy(title = "updated") } - val novelState = release.update( - HomeSection.POPULAR, - release.pending.copy(popularNovels = refreshedPopular), - )!! - assertSame(refreshedFeeds, novelState.popularFeeds) - assertSame(taste, novelState.recommendedNovelsByUserTaste) - assertFalse(novelState.loading) + fun `recovery displaying partial content releases later section updates`() { + val release = HomeSectionRelease() + assertFalse(release.update(HomeSection.FEEDS)) + + release.recover(loading = false) + + assertTrue(release.update(HomeSection.TASTE)) + assertTrue(release.update(HomeSection.FEEDS)) } @Test - fun `a new Home owner starts with loading rather than previous content or empty`() { - val previous = readyUpper() - previous.update( - HomeSection.TASTE, - previous.pending.copy(tasteStatus = HomeTasteStatus.EMPTY), - ) - val next = HomeSectionRelease() + fun `recovery during initial loading keeps waiting for the missing upper result`() { + val release = HomeSectionRelease() + assertFalse(release.update(HomeSection.FEEDS)) + + release.recover(loading = true) - assertEquals(HomeUiState(), next.pending) - assertNull(next.update(HomeSection.FEEDS, next.pending.copy(popularFeeds = feeds))) + assertFalse(release.update(HomeSection.TASTE)) + assertFalse(release.update(HomeSection.FEEDS)) + assertTrue(release.update(HomeSection.POPULAR)) } - private fun readyUpper(): HomeSectionRelease = - HomeSectionRelease().apply { - update(HomeSection.POPULAR, pending.copy(popularNovels = popular)) - update(HomeSection.FEEDS, pending.copy(popularFeeds = feeds)) - } + @Test + fun `a new Home owner starts with its own upper wait`() { + val previous = HomeSectionRelease() + previous.recover(loading = false) + assertTrue(previous.update(HomeSection.TASTE)) + + val next = HomeSectionRelease() - private val popular = listOf( - PopularNovelEntity( - "author", - null, - null, - "fantasy", - false, - emptyList(), - null, - "description", - 1, - "image", - "title", - ), - ) - private val feeds = listOf( - listOf( - PopularFeedEntity( - 1, - "content", - 0, - 0, - false, - true, - "title", - "image", - "fantasy", - ), - ), - ) - private val taste = (1L..10L).map { - RecommendedNovelByUserTasteEntity( - it, - "title $it", - "author", - "image", - 0, - 0.0, - 0, - ) + assertFalse(next.update(HomeSection.FEEDS)) + assertFalse(next.update(HomeSection.TASTE)) + assertTrue(next.update(HomeSection.POPULAR)) } } diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt index 76956d54d..f25d1a010 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -88,6 +88,111 @@ class HomeViewModelTest { ArchTaskExecutor.getInstance().setDelegate(null) } + @Test + fun `all response orders retain received data and unread state without releasing upper content early`() = + runTest(dispatcher) { + val orders = listOf("pft", "ptf", "fpt", "ftp", "tpf", "tfp").flatMap { order -> + (0..order.length).map { index -> order.substring(0, index) + "n" + order.substring(index) } + } + orders.forEach { order -> + calls.clear() + val popularResponse = CompletableDeferred() + val feedResponse = CompletableDeferred() + val tasteResponse = CompletableDeferred() + val unreadResponse = CompletableDeferred() + popularRequest = { popularResponse.await() } + feedRequest = { feedResponse.await() } + tasteRequest = { tasteResponse.await() } + notificationRequest = { unreadResponse.await() } + val vm = createViewModel() + val displayed = mutableListOf() + val observer = Observer { state -> + if (!state.loading && !state.error) displayed.add(state) + } + vm.uiState.observeForever(observer) + try { + runCurrent() + assertTrue(order, displayed.isEmpty()) + val received = mutableSetOf() + order.forEach { response -> + when (response) { + 'p' -> popularResponse.complete(popular()) + 'f' -> feedResponse.complete(feeds()) + 't' -> tasteResponse.complete(taste(10)) + 'n' -> unreadResponse.complete(NotificationUnreadResponseDto(true)) + } + received.add(response) + runCurrent() + val state = vm.uiState.value!! + val upperReady = 'p' in received && 'f' in received + assertEquals(order, !upperReady, state.loading) + assertEquals(order, 'n' in received, state.isNotificationUnread) + assertEquals(order, 'p' in received, state.popularNovels.isNotEmpty()) + assertEquals(order, 'f' in received, state.popularFeeds.isNotEmpty()) + assertEquals(order, if ('t' in received) 10 else 0, state.recommendedNovelsByUserTaste.size) + assertFalse(order, state.error) + if (!upperReady) assertTrue(order, displayed.isEmpty()) + } + assertTrue(order, displayed.isNotEmpty()) + displayed.forEach { state -> + assertEquals(order, 1, state.popularNovels.size) + assertEquals(order, 1, state.popularFeeds.size) + } + assertEquals(HomeTasteStatus.CONTENT, displayed.last().tasteStatus) + assertEquals(10, displayed.last().recommendedNovelsByUserTaste.size) + assertTrue(displayed.last().isNotificationUnread) + listOf("getPopularNovels", "getPopularFeeds", "getRecommendedNovelsByUserTaste", "getNotificationUnread").forEach { + assertEquals(order, 1, calls[it]) + } + } finally { + vm.uiState.removeObserver(observer) + owners.last().clear() + } + } + } + + @Test + fun `early taste failure is stored while upper content still waits and retry preserves both upper lists`() = + runTest(dispatcher) { + val popularResponse = CompletableDeferred() + val feedResponse = CompletableDeferred() + popularRequest = { popularResponse.await() } + feedRequest = { feedResponse.await() } + tasteRequest = { throw http(500) } + val vm = createViewModel() + runCurrent() + + assertTrue(vm.uiState.value!!.loading) + assertFalse(vm.uiState.value!!.error) + assertEquals(HomeTasteStatus.ERROR, vm.uiState.value!!.tasteStatus) + feedResponse.complete(feeds()) + runCurrent() + val firstFeeds = vm.uiState.value!!.popularFeeds + assertTrue(firstFeeds.isNotEmpty()) + assertTrue(vm.uiState.value!!.loading) + + popularResponse.complete(popular()) + runCurrent() + val ready = vm.uiState.value!! + assertFalse(ready.loading) + assertFalse(ready.error) + assertEquals(HomeTasteStatus.ERROR, ready.tasteStatus) + assertSame(firstFeeds, ready.popularFeeds) + + tasteRequest = { taste(0) } + vm.retryTaste() + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + runCurrent() + val retried = vm.uiState.value!! + assertEquals(HomeTasteStatus.EMPTY, retried.tasteStatus) + assertSame(ready.popularNovels, retried.popularNovels) + assertSame(ready.popularFeeds, retried.popularFeeds) + assertTrue(retried.isNotificationUnread) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + assertEquals(2, calls["getRecommendedNovelsByUserTaste"]) + } + @Test fun `actual view model publishes upper before taste and keeps unread notification`() = runTest(dispatcher) { From 793b4a55acd4e3609aeee5b9ca0350f1f3889dee Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Sun, 4 Oct 2026 19:44:09 +0900 Subject: [PATCH 12/22] =?UTF-8?q?refactor:=20ProfileEditViewModelTest=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=80=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profileEdit/ProfileEditViewModelTest.kt | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt index 43e17e745..c9eb41b4e 100644 --- a/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/profileEdit/ProfileEditViewModelTest.kt @@ -79,7 +79,10 @@ class ProfileEditViewModelTest { vm.checkNicknameValidity("newname") } - "introduction" -> vm.updateIntroduction("new introduction") + "introduction" -> { + vm.updateIntroduction("new introduction") + } + "avatar" -> { vm.updateSelectedAvatar(AvatarModel(avatarId = 2)) vm.updateRepresentativeAvatar() @@ -89,7 +92,8 @@ class ProfileEditViewModelTest { vm.updateProfile() runCurrent() - val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success assertFalse(result.genrePreferencesChanged) assertEquals(listOf("fantasy", "romance"), savedRequests.last().genrePreferences) } @@ -99,7 +103,11 @@ class ProfileEditViewModelTest { @Test fun `adding removing or clearing genres reports a genre change`() = runTest(dispatcher) { - listOf(listOf(Genre.MYSTERY), listOf(Genre.FANTASY), listOf(Genre.FANTASY, Genre.ROMANCE)).forEach { toggles -> + listOf( + listOf(Genre.MYSTERY), + listOf(Genre.FANTASY), + listOf(Genre.FANTASY, Genre.ROMANCE), + ).forEach { toggles -> val vm = createViewModel() vm.updateUserProfile() runCurrent() @@ -107,7 +115,8 @@ class ProfileEditViewModelTest { vm.updateProfile() runCurrent() - val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success assertTrue(result.genrePreferencesChanged) } assertTrue(savedRequests.last().genrePreferences.isEmpty()) @@ -125,7 +134,8 @@ class ProfileEditViewModelTest { vm.updateProfile() runCurrent() - val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success assertFalse(result.genrePreferencesChanged) assertEquals(listOf("romance", "fantasy"), savedRequests.single().genrePreferences) } @@ -149,9 +159,13 @@ class ProfileEditViewModelTest { pending.complete(Unit) runCurrent() - val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success assertEquals(submittedChange, result.genrePreferencesChanged) - assertEquals(submittedChange, savedRequests.last().genrePreferences.contains("mystery")) + assertEquals( + submittedChange, + savedRequests.last().genrePreferences.contains("mystery"), + ) } } @@ -170,7 +184,8 @@ class ProfileEditViewModelTest { saveRequest = {} vm.updateProfile() runCurrent() - val result = vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success + val result = + vm.profileEditUiState.value!!.profileEditResult as ProfileEditResult.Success assertTrue(result.genrePreferencesChanged) assertEquals(2, savedRequests.size) } @@ -191,18 +206,37 @@ class ProfileEditViewModelTest { @Suppress("UNCHECKED_CAST") private inline fun api(): T = - Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, args -> + Proxy.newProxyInstance( + T::class.java.classLoader, + arrayOf(T::class.java), + ) { _, method, args -> val call: suspend () -> Any = { when (method.name) { - "getMyProfile" -> MyProfileResponseDto("nickname", "introduction", "", listOf("fantasy", "romance")) - "getAvatars" -> AvatarsResponseDto(emptyList()) - "getNicknameValidity" -> UserNicknameValidityResponseDto(true) + "getMyProfile" -> { + MyProfileResponseDto( + "nickname", + "introduction", + "", + listOf("fantasy", "romance"), + ) + } + + "getAvatars" -> { + AvatarsResponseDto(emptyList()) + } + + "getNicknameValidity" -> { + UserNicknameValidityResponseDto(true) + } + "patchProfile" -> { savedRequests.add(args.first() as UserProfileEditRequestDto) saveRequest() } - else -> error("Unexpected API call: ${method.name}") + else -> { + error("Unexpected API call: ${method.name}") + } } } call.startCoroutineUninterceptedOrReturn(args.last() as Continuation) From 0bd150e65afa4d3b17146b25916a81a289a4e237 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 00:09:06 +0900 Subject: [PATCH 13/22] =?UTF-8?q?fix:=20Home=20=EC=84=A0=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20=EC=A0=84=EB=8B=AC=EA=B3=BC=20=EC=88=98=EB=AA=85=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splash에서 시작한 상단 요청을 Home에 넘길 수 있도록 HomeStartupRequest를 추가한다. Splash 종료로 요청이 취소되거나 Home에서 같은 API를 다시 호출하는 것을 막기 위해 요청의 수명과 전달 상태를 별도로 관리한다. - 오늘의 발견과 추천글을 각각 Deferred로 시작하고 SupervisorJob으로 관리해 한 요청의 실패가 다른 요청을 자동으로 취소하지 않게 한다. - 무작위 요청 ID가 일치할 때만 요청 묶음을 한 번 전달한다. 인증 정보와 API 응답은 Intent에 넣지 않는다. - 결과를 기다리기 전후의 세션 식별값을 비교해 세션이 달라졌다면 이전 결과와 오류를 버린다. 현재 구현의 식별값은 access token이다. - 새 선요청은 이전 미전달 요청을 취소한다. 먼저 시작된 세션 조회가 늦게 끝나 새 요청을 덮어쓰는 경우도 방지한다. - 세션 조회 실패나 빈 세션에서는 선요청을 시작하지 않는다. 개별 요청 취소와 전달 전 요청 폐기를 제공한다. HomeStartupRequestTest 6개를 추가해 단일 전달, 세션 변경, 개별 취소, 미전달 요청 교체, 세션 조회 실패, 세션 조회 완료 순서 역전을 검증한다. 이 커밋은 요청 전달 기능을 추가하며 화면에서의 호출은 후속 커밋에 연결한다. --- .../ui/main/home/HomeStartupRequest.kt | 133 ++++++++++++++ .../ui/main/home/HomeStartupRequestTest.kt | 166 ++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt create mode 100644 app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt new file mode 100644 index 000000000..7fe131779 --- /dev/null +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt @@ -0,0 +1,133 @@ +package com.into.websoso.ui.main.home + +import androidx.annotation.MainThread +import com.into.websoso.data.account.AccountRepository +import com.into.websoso.data.model.PopularFeedEntity +import com.into.websoso.data.model.PopularNovelsEntity +import com.into.websoso.data.repository.FeedRepository +import com.into.websoso.data.repository.NovelRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** A single Splash-to-Home handoff. Only its opaque ID crosses Activity boundaries. */ +@Singleton +@MainThread +class HomeStartupRequest internal constructor( + private val sessionIdentity: suspend () -> String, + private val fetchPopular: suspend () -> PopularNovelsEntity, + private val fetchFeeds: suspend () -> List, + private val dispatcher: CoroutineDispatcher, +) { + @Inject + constructor( + accountRepository: AccountRepository, + novelRepository: NovelRepository, + feedRepository: FeedRepository, + ) : this( + accountRepository::accessToken, + novelRepository::fetchPopularNovels, + feedRepository::fetchPopularFeeds, + Dispatchers.Main.immediate, + ) + + private var pending: Pair? = null + private var generation = 0L + + suspend fun start(): String? { + val startedGeneration = ++generation + pending?.second?.cancel() + pending = null + // Reading the session is best-effort; a prefetch setup failure must not block navigation. + val identity = try { + sessionIdentity() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return null + } + currentCoroutineContext().ensureActive() + if (identity.isBlank() || startedGeneration != generation) return null + + val owner = SupervisorJob() + val scope = CoroutineScope(owner + dispatcher) + val requests = Requests( + identity, + sessionIdentity, + scope.async { fetchPopular() }, + scope.async { fetchFeeds() }, + ) + owner.complete() + return UUID.randomUUID().toString().also { pending = it to requests } + } + + internal fun take(id: String?): Requests? { + val entry = pending?.takeIf { it.first == id } ?: return null + pending = null + return entry.second + } + + fun discard(id: String?) { + take(id)?.cancel() + } + + internal class Requests( + private val identity: String, + private val sessionIdentity: suspend () -> String, + private val popular: Deferred, + private val feeds: Deferred>, + ) { + suspend fun popular(): PopularNovelsEntity? = await(popular) + + suspend fun feeds(): List? = await(feeds) + + fun cancelPopular() = popular.cancel() + + fun cancelFeeds() = feeds.cancel() + + fun cancel() { + cancelPopular() + cancelFeeds() + } + + private suspend fun await(request: Deferred): T? = + try { + if (sessionIdentity() != identity) { + cancel() + null + } else { + val result = request.await() + if (sessionIdentity() == identity) { + result + } else { + cancel() + null + } + } + } catch (cancelled: CancellationException) { + currentCoroutineContext().ensureActive() + null + } catch (error: Exception) { + currentCoroutineContext().ensureActive() + if (sessionIdentity() == identity) throw error + cancel() + null + } finally { + // Cancellation/replacement of this Home section also cancels its transferred request. + request.cancel() + } + } + + companion object { + const val KEY = "home_startup_request_id" + } +} diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt new file mode 100644 index 000000000..f82430eec --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt @@ -0,0 +1,166 @@ +package com.into.websoso.ui.main.home + +import com.into.websoso.data.model.PopularFeedEntity +import com.into.websoso.data.model.PopularNovelsEntity +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +@OptIn(ExperimentalCoroutinesApi::class) +class HomeStartupRequestTest { + private val dispatcher = StandardTestDispatcher() + private var identity = "session-a" + private val popular = PopularNovelsEntity(emptyList()) + private val feeds = emptyList() + + @Test + fun `completed responses are delivered once and unrelated IDs cannot claim them`() = + runTest(dispatcher) { + var calls = 0 + val startup = HomeStartupRequest( + { identity }, + { + calls++ + popular + }, + { + calls++ + feeds + }, + dispatcher, + ) + val id = startup.start() + runCurrent() + assertEquals(2, calls) + assertNull(startup.take("unrelated")) + val requests = startup.take(id)!! + assertNull(startup.take(id)) + assertSame(popular, requests.popular()) + assertSame(feeds, requests.feeds()) + assertEquals(2, calls) + } + + @Test + fun `session changes during handoff reject both an old result and an old failure`() = + runTest(dispatcher) { + listOf(false, true).forEach { fail -> + identity = "session-a" + val response = CompletableDeferred() + val startup = HomeStartupRequest({ identity }, { response.await() }, { feeds }, dispatcher) + val requests = startup.take(startup.start())!! + val result = async { requests.popular() } + runCurrent() + identity = "session-b" + if (fail) response.completeExceptionally(IOException("synthetic")) else response.complete(popular) + runCurrent() + assertNull(result.await()) + assertNull(requests.feeds()) + } + } + + @Test + fun `consumer cancellation cancels its in flight request without cancelling its sibling`() = + runTest(dispatcher) { + var popularCancelled = false + var feedsCancelled = false + val startup = HomeStartupRequest( + { identity }, + { + try { + CompletableDeferred().await() + } finally { + popularCancelled = true + } + }, + { + try { + CompletableDeferred>().await() + } finally { + feedsCancelled = true + } + }, + dispatcher, + ) + val requests = startup.take(startup.start())!! + val consumer = async { requests.popular() } + runCurrent() + consumer.cancel() + runCurrent() + assertTrue(consumer.isCancelled) + assertTrue(popularCancelled) + assertFalse(feedsCancelled) + requests.cancel() + runCurrent() + assertTrue(feedsCancelled) + } + + @Test + fun `new startup cancels unclaimed work and discarding an old ID leaves the new work alone`() = + runTest(dispatcher) { + var cancelled = 0 + val startup = HomeStartupRequest( + { identity }, + { + try { + CompletableDeferred().await() + } finally { + cancelled++ + } + }, + { feeds }, + dispatcher, + ) + val first = startup.start() + runCurrent() + val second = startup.start() + runCurrent() + assertEquals(1, cancelled) + assertNull(startup.take(first)) + startup.discard(first) + runCurrent() + assertEquals(1, cancelled) + startup.discard(second) + runCurrent() + assertEquals(2, cancelled) + } + + @Test + fun `missing session or failed session lookup never starts content requests`() = + runTest(dispatcher) { + listOf String>({ "" }, { throw IOException("synthetic") }).forEach { session -> + val startup = HomeStartupRequest(session, { error("unexpected request") }, { error("unexpected request") }, dispatcher) + assertNull(startup.start()) + runCurrent() + } + } + + @Test + fun `a superseded slow session lookup cannot replace a newer startup`() = + runTest(dispatcher) { + val session = CompletableDeferred() + var lookups = 0 + val startup = HomeStartupRequest( + { if (++lookups == 1) session.await() else identity }, + { popular }, + { feeds }, + dispatcher, + ) + val first = async { startup.start() } + runCurrent() + val second = startup.start() + session.complete(identity) + runCurrent() + assertNull(first.await()) + assertSame(popular, startup.take(second)!!.popular()) + } +} From cd85e0069236daa46192c97a53acd449746d0ad2 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 00:09:36 +0900 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20Home=20=EC=B5=9C=EC=B4=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=97=90=20=EC=84=A0=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HomeViewModel이 SavedStateHandle의 요청 ID로 선요청을 한 번 인계받고, 최초 상단 조회에서 이미 진행 중이거나 완료된 요청을 재사용하도록 한다. 선요청이 없거나 세션 변경으로 사용할 수 없으면 기존 Repository 조회를 실행한다. - 최초 오늘의 발견과 추천글 조회에만 선요청을 사용한다. 취향 요청은 Home에서 시작하며 기존 상단 표시 조건을 유지한다. - 선요청에서 발생한 API 오류는 기존 섹션/전역 오류 처리로 전달한다. 자동 재요청으로 오류를 숨기지 않고 401 세션 오류 상태를 유지한다. - 갱신할 때에는 해당 섹션의 선요청만 취소하고 새 요청으로 교체한다. 다른 섹션의 진행 중인 요청은 유지한다. - ViewModel 종료 시 인계받은 나머지 요청을 취소한다. HomeViewModelTest 4개를 추가해 상단 API 중복 호출 방지와 취향 대기 분리, 401 처리 유지, 세션 변경/소비된 ID의 일반 조회 전환, 섹션별 갱신과 ViewModel 종료 시 취소를 검증한다. 기존 취향 갱신의 403 정책은 유지한다. --- .../websoso/ui/main/home/HomeViewModel.kt | 28 +++-- .../websoso/ui/main/home/HomeViewModelTest.kt | 112 +++++++++++++++++- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index 555c6962c..af16c81f7 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -39,6 +39,7 @@ class HomeViewModel private val notificationRepository: NotificationRepository, private val userRepository: UserRepository, private val savedStateHandle: SavedStateHandle, + homeStartupRequest: HomeStartupRequest, ) : ViewModel() { // Received data is kept here even while loading; the Fragment waits before rendering it. private val _uiState: MutableLiveData = MutableLiveData(HomeUiState()) @@ -65,6 +66,7 @@ class HomeViewModel private val sectionLoader = HomeSectionLoader(viewModelScope) private val sectionRelease = HomeSectionRelease() private var hasSessionFailure = false + private val startupRequests = homeStartupRequest.take(savedStateHandle.remove(HomeStartupRequest.KEY)) init { updateHomeData(true) @@ -84,31 +86,43 @@ class HomeViewModel } private suspend fun fetchUserHomeData() { - val requests = listOf(loadPopularNovels(), loadPopularFeeds(), loadTasteNovels()) + val requests = listOf(loadPopularNovels(initial = true), loadPopularFeeds(initial = true), loadTasteNovels()) requests.joinAll() if (requests.all { !it.isCancelled && it.await() }) recoverGlobalError() } - private fun loadPopularNovels(): Deferred = - sectionLoader.load( + private fun loadPopularNovels(initial: Boolean = false): Deferred { + if (!initial) startupRequests?.cancelPopular() + return sectionLoader.load( section = HomeSection.POPULAR, - request = novelRepository::fetchPopularNovels, + request = { (if (initial) startupRequests?.popular() else null) ?: novelRepository.fetchPopularNovels() }, success = { result -> updateContent(HomeSection.POPULAR) { it.copy(popularNovels = result.popularNovels) } }, failure = { handleFailureState(it) }, ) + } - private fun loadPopularFeeds(recoverError: Boolean = false): Deferred = - sectionLoader.load( + private fun loadPopularFeeds( + recoverError: Boolean = false, + initial: Boolean = false, + ): Deferred { + if (!initial) startupRequests?.cancelFeeds() + return sectionLoader.load( section = HomeSection.FEEDS, - request = feedRepository::fetchPopularFeeds, + request = { (if (initial) startupRequests?.feeds() else null) ?: feedRepository.fetchPopularFeeds() }, success = { result -> updateContent(HomeSection.FEEDS) { it.copy(popularFeeds = result.toHomePopularFeedPages()) } if (recoverError) recoverGlobalError() }, failure = { handleFailureState(it) }, ) + } + + override fun onCleared() { + startupRequests?.cancel() + super.onCleared() + } private fun loadTasteNovels(preferencesChanged: Boolean = false): Deferred { val previousStatus = _uiState.value?.tasteStatus diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt index f25d1a010..479f81688 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -66,6 +66,15 @@ class HomeViewModelTest { private var popularRequest: suspend () -> PopularNovelsResponseDto = { popular() } private var feedRequest: suspend () -> PopularFeedsResponseDto = { feeds() } private var notificationRequest: suspend () -> NotificationUnreadResponseDto = { NotificationUnreadResponseDto(true) } + private var sessionIdentity = "session-a" + private val startupRequest by lazy { + HomeStartupRequest( + { sessionIdentity }, + { NovelRepository(api()).fetchPopularNovels() }, + { FeedRepository(api()).fetchPopularFeeds() }, + dispatcher, + ) + } @Before fun setUp() { @@ -825,7 +834,105 @@ class HomeViewModelTest { assertEquals(2, calls["getTermsAgreement"]) } - private fun createViewModel(termsChecked: Boolean = true): HomeViewModel { + @Test + fun `startup requests are consumed once and publish upper without waiting for taste`() = + runTest(dispatcher) { + val pendingPopular = CompletableDeferred() + val pendingTaste = CompletableDeferred() + popularRequest = { pendingPopular.await() } + tasteRequest = { pendingTaste.await() } + val id = startupRequest.start() + runCurrent() + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + assertFalse(calls.containsKey("getRecommendedNovelsByUserTaste")) + + val vm = createViewModel(startupId = id) + runCurrent() + assertTrue(vm.uiState.value!!.loading) + pendingPopular.complete(popular()) + runCurrent() + assertFalse(vm.uiState.value!!.loading) + assertEquals(HomeTasteStatus.LOADING, vm.uiState.value!!.tasteStatus) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + assertEquals(1, calls["getRecommendedNovelsByUserTaste"]) + } + + @Test + fun `prefetch failures use existing global failure policy without automatically retrying`() = + runTest(dispatcher) { + popularRequest = { throw http(401) } + val id = startupRequest.start() + runCurrent() + val vm = createViewModel(startupId = id) + runCurrent() + assertTrue(vm.uiState.value!!.error) + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + vm.updateFeed() + runCurrent() + assertTrue(vm.uiState.value!!.error) + } + + @Test + fun `changed session and missing startup requests fall back to ordinary Home loading`() = + runTest(dispatcher) { + val id = startupRequest.start() + runCurrent() + sessionIdentity = "session-b" + val vm = createViewModel(startupId = id) + runCurrent() + assertFalse(vm.uiState.value!!.error) + assertFalse(vm.uiState.value!!.loading) + assertEquals(2, calls["getPopularNovels"]) + assertEquals(2, calls["getPopularFeeds"]) + + createViewModel(startupId = id) + runCurrent() + assertEquals(3, calls["getPopularNovels"]) + assertEquals(3, calls["getPopularFeeds"]) + } + + @Test + fun `refresh replaces only its prefetched section and clearing Home cancels the remaining request`() = + runTest(dispatcher) { + var popularCancelled = false + var feedsCancelled = false + popularRequest = { + try { + CompletableDeferred().await() + } finally { + popularCancelled = true + } + } + feedRequest = { + try { + CompletableDeferred().await() + } finally { + feedsCancelled = true + } + } + val id = startupRequest.start() + runCurrent() + val vm = createViewModel(startupId = id) + runCurrent() + popularRequest = { popular() } + vm.updateNovel() + runCurrent() + assertTrue(popularCancelled) + assertFalse(feedsCancelled) + assertEquals(2, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + owners.last().clear() + runCurrent() + assertTrue(feedsCancelled) + } + + private fun createViewModel( + termsChecked: Boolean = true, + startupId: String? = null, + ): HomeViewModel { val storage = object : DataStore { override val data = MutableStateFlow( preferencesOf( @@ -844,7 +951,8 @@ class HomeViewModelTest { PushMessageRepository(user, AuthRepository(api()), storage, api()), NotificationRepository(api()), user, - SavedStateHandle(), + SavedStateHandle(mapOf(HomeStartupRequest.KEY to startupId)), + startupRequest, ).also { vm -> owners.add(ViewModelStore().apply { put("home", vm) }) } From 7785e94c41450ca98275236a4462ec5e636cc577 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 00:25:29 +0900 Subject: [PATCH 15/22] =?UTF-8?q?fix:=20Splash=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=9B=84=20Home=20=EC=83=81=EB=8B=A8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=84=A0=EC=9A=94=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상단 API 대기와 Main/Home 생성이 겹치도록 인증 성공 후 Splash에서 오늘의 발견과 추천글 요청을 시작한다. API 응답 완료를 기다리지 않고 Main으로 이동하며, 요청 ID를 HomeFragment arguments로 전달한다. - NavigateToMain 경로에서만 선요청을 시작한다. 로그인 화면 이동과 최소 버전 확인 흐름을 유지하고 컬렉션 딥링크 진입은 선요청을 건너뛴다. - Main으로 넘기지 못한 요청은 Splash에서 취소한다. - Home을 사용하지 않는 이동이나 Main 종료 시 미전달 요청을 정리한다. 설정 변경으로 Activity가 재생성되는 경우에는 요청을 유지한다. - 최초 Home이 요청을 인계받은 이후의 갱신과 취소는 HomeViewModel이 관리한다. 기존 Splash의 인증 전 고정 1초 대기는 유지한다. 이번 변경으로 겹치는 구간은 인증 완료 후 Main/Home 생성 시간이며, 취향 카드의 표시 순서는 변경하지 않는다. 완성된 변경 기준으로 앱 단위 테스트 87개, ktlintCheck와 변경 Kotlin 파일 검사, testDebugUnitTest 및 :app:assembleDebug를 통과했다. 고정 입력 debug 에뮬레이터 비교에서 상단 요청 시작이 앞당겨지는 것을 확인했다. 표시 시간은 실행 편차가 커 안정적인 전체 개선율, 정상 경로의 성능 회귀 여부와 원래 운영 지연 제보의 해결까지 확정하지 않는다. --- .../com/into/websoso/ui/main/MainActivity.kt | 27 ++++++++++++++++- .../into/websoso/ui/splash/SplashActivity.kt | 29 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt b/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt index ec42a6813..9b40bf53e 100644 --- a/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/main/MainActivity.kt @@ -8,6 +8,7 @@ import android.view.View import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.annotation.IntegerRes +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.firebase.messaging.FirebaseMessaging @@ -32,12 +33,18 @@ import com.into.websoso.ui.main.MainActivity.FragmentType.LIBRARY import com.into.websoso.ui.main.MainActivity.FragmentType.MY_PAGE import com.into.websoso.ui.main.feed.FeedFragment import com.into.websoso.ui.main.home.HomeFragment +import com.into.websoso.ui.main.home.HomeStartupRequest import com.into.websoso.ui.main.library.LibraryFragment import com.into.websoso.ui.main.myPage.MyPageFragment +import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : BaseActivity(activity_main) { + @Inject + lateinit var homeStartupRequest: Lazy + private val mainViewModel: MainViewModel by viewModels() private var backPressedTime: Long = 0L private var currentFragment: Fragment? = null @@ -52,6 +59,11 @@ class MainActivity : BaseActivity(activity_main) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (intent.getAdaptedSerializableExtra(DESTINATION_KEY)?.let { it != HOME } == true || + intent.getLongExtra(CollectionDeepLink.PENDING_COLLECTION_ID, 0L) > 0L + ) { + discardStartupRequest() + } setupBackButtonListener() setupBottomNavigationView() setupObserver() @@ -142,6 +154,7 @@ class MainActivity : BaseActivity(activity_main) { private fun replaceCurrentFragment(itemId: Int) { val tag = fragmentTags[itemId] ?: return + if (itemId != menu_home) discardStartupRequest() val isLibrary = itemId == menu_library val existingFragment = supportFragmentManager.findFragmentByTag(tag) val targetFragment = existingFragment ?: findOrCreateFragment(tag) @@ -175,7 +188,9 @@ class MainActivity : BaseActivity(activity_main) { private fun findOrCreateFragment(tag: String): Fragment = supportFragmentManager.findFragmentByTag(tag) ?: when (tag) { - HomeFragment.TAG -> HomeFragment() + HomeFragment.TAG -> HomeFragment().apply { + arguments = bundleOf(HomeStartupRequest.KEY to intent.getStringExtra(HomeStartupRequest.KEY)) + } FeedFragment.TAG -> FeedFragment() LibraryFragment.TAG -> LibraryFragment() MyPageFragment.TAG -> MyPageFragment() @@ -231,6 +246,16 @@ class MainActivity : BaseActivity(activity_main) { } } + private fun discardStartupRequest() { + intent.getStringExtra(HomeStartupRequest.KEY)?.let { homeStartupRequest.get().discard(it) } + intent.removeExtra(HomeStartupRequest.KEY) + } + + override fun onDestroy() { + if (!isChangingConfigurations) discardStartupRequest() + super.onDestroy() + } + companion object { private const val DESTINATION_KEY = "destination" const val IS_LOGIN_KEY = "isLogin" diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt index d36672109..7b715c75b 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.provider.Settings.Secure.ANDROID_ID import android.provider.Settings.Secure.getString import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope import com.into.websoso.BuildConfig import com.into.websoso.R import com.into.websoso.core.common.navigator.NavigatorProvider @@ -13,11 +14,14 @@ import com.into.websoso.core.common.ui.base.BaseActivity import com.into.websoso.core.common.util.collectWithLifecycle import com.into.websoso.databinding.ActivitySplashBinding import com.into.websoso.ui.collection.CollectionDeepLink +import com.into.websoso.ui.main.home.HomeStartupRequest import com.into.websoso.ui.splash.UiEffect.NavigateToLogin import com.into.websoso.ui.splash.UiEffect.NavigateToMain import com.into.websoso.ui.splash.UiEffect.ShowDialog import com.into.websoso.ui.splash.dialog.MinimumVersionDialogFragment +import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -25,6 +29,9 @@ class SplashActivity : BaseActivity(R.layout.activity_spl @Inject lateinit var websosoNavigator: NavigatorProvider + @Inject + lateinit var homeStartupRequest: Lazy + private val splashViewModel: SplashViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -65,12 +72,32 @@ class SplashActivity : BaseActivity(R.layout.activity_spl splashViewModel.uiEffect.collectWithLifecycle(this) { uiEffect -> when (uiEffect) { NavigateToLogin -> websosoNavigator.navigateToLoginActivity(::startDestination) - NavigateToMain -> websosoNavigator.navigateToMainActivity(::startDestination) + NavigateToMain -> websosoNavigator.navigateToMainActivity(::startHomeDestination) ShowDialog -> showMinimumVersionDialog() } } } + private fun startHomeDestination(destination: Intent) { + lifecycleScope.launch { + val target = CollectionDeepLink.forward(intent, destination) + var requestId: String? = null + var handedOff = false + try { + if (target.getLongExtra(CollectionDeepLink.PENDING_COLLECTION_ID, 0L) == 0L) { + requestId = homeStartupRequest.get().start() + requestId?.let { target.putExtra(HomeStartupRequest.KEY, it) } + } + // Authentication has completed. Start Home without awaiting either content response. + startActivity(target) + handedOff = true + finish() + } finally { + if (!handedOff && requestId != null) homeStartupRequest.get().discard(requestId) + } + } + } + private fun startDestination(destination: Intent) { startActivity(CollectionDeepLink.forward(intent, destination)) finish() From e23e0544bd68f6ea0227b0575e45c71f2585c369 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 09:54:29 +0900 Subject: [PATCH 16/22] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=ED=99=94=EB=A9=B4=20=EC=B5=9C=EC=86=8C=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=8B=9C=EA=B0=84=20=EB=B3=B4=EC=9E=A5=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=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 - `SplashViewModel.kt`: - `handleAutoLogin` 로직을 개선하여 토큰 갱신과 최소 노출 시간(1초) 대기가 병렬로 수행되도록 변경 (`coroutineScope` 및 `join` 활용) - 인증이 빠르게 완료되더라도 최소 노출 시간을 유지하며, 인증이 지연될 경우 인증 완료 즉시 화면을 전환하도록 수정 - `SplashViewModelTest.kt`: - 스플래시 로직 검증을 위한 단위 테스트 클래스 신규 생성 - 빠른/느린 인증 응답 속도에 따른 화면 전환 타이밍 검증 - 세션 부재 및 인증 실패 시 로그인 화면 이동 검증 - 강제 업데이트 필요 시 인증 중단 및 다이얼로그 노출 검증 - ViewModel clear 시 진행 중인 인증 작업 취소 여부 검증 --- .../into/websoso/ui/splash/SplashViewModel.kt | 30 +-- .../websoso/ui/splash/SplashViewModelTest.kt | 221 ++++++++++++++++++ 2 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt index c2b15bf72..11fe73b34 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt @@ -10,6 +10,7 @@ import com.into.websoso.ui.splash.UiEffect.NavigateToMain import com.into.websoso.ui.splash.UiEffect.ShowDialog import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.receiveAsFlow @@ -51,22 +52,21 @@ class SplashViewModel if (isRequired) _uiEffect.send(ShowDialog) } - private suspend fun handleAutoLogin() { - delay(1000) - - if (shouldRefresh()) { - _uiEffect.send(NavigateToLogin) - return - } - - accountRepository - .createTokens() - .onSuccess { - _uiEffect.send(NavigateToMain) - }.onFailure { - _uiEffect.send(NavigateToLogin) + private suspend fun handleAutoLogin() = + coroutineScope { + // Preserve the minimum display time while authentication runs alongside it. + val minimumDisplay = launch { delay(1000) } + val destination = if (shouldRefresh()) { + NavigateToLogin + } else { + accountRepository.createTokens().fold( + onSuccess = { NavigateToMain }, + onFailure = { NavigateToLogin }, + ) } - } + minimumDisplay.join() + _uiEffect.send(destination) + } private suspend fun shouldRefresh(): Boolean = accountRepository.accessToken().isBlank() || diff --git a/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt new file mode 100644 index 000000000..c89231b84 --- /dev/null +++ b/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt @@ -0,0 +1,221 @@ +package com.into.websoso.ui.splash + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.preferencesOf +import androidx.lifecycle.ViewModelStore +import com.into.websoso.data.account.AccountRepository +import com.into.websoso.data.account.datasource.AccountLocalDataSource +import com.into.websoso.data.account.datasource.AccountRemoteDataSource +import com.into.websoso.data.account.model.TokenEntity +import com.into.websoso.data.remote.api.UserApi +import com.into.websoso.data.remote.api.VersionApi +import com.into.websoso.data.remote.response.MinimumVersionResponseDto +import com.into.websoso.data.repository.UserRepository +import com.into.websoso.data.repository.VersionRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn + +@OptIn(ExperimentalCoroutinesApi::class) +class SplashViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val owners = mutableListOf() + private val calls = mutableListOf() + private var accessToken = "synthetic-access" + private var refreshToken = "synthetic-refresh" + private var versionRequest: suspend () -> MinimumVersionResponseDto = { + MinimumVersionResponseDto("0.0.0", "2026-10-05") + } + private var tokenRequest: suspend () -> TokenEntity = { + delay(300) + TokenEntity("synthetic-new-access", "synthetic-new-refresh") + } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + owners.forEach(ViewModelStore::clear) + Dispatchers.resetMain() + } + + @Test + fun `fast authentication overlaps the minimum display after version approval`() = + runTest(dispatcher) { + versionRequest = { + delay(200) + MinimumVersionResponseDto("0.0.0", "2026-10-05") + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + runCurrent() + assertFalse(calls.contains("postReissue")) + + advanceTimeBy(200) + runCurrent() + assertTrue(calls.contains("postReissue")) + advanceTimeBy(999) + runCurrent() + assertEquals("synthetic-new-access", accessToken) + assertFalse(destination.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.NavigateToMain, destination.await()) + assertEquals(1, calls.count { it == "postReissue" }) + } + + @Test + fun `slow authentication navigates when ready without another display delay`() = + runTest(dispatcher) { + tokenRequest = { + delay(1500) + TokenEntity("synthetic-new-access", "synthetic-new-refresh") + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + runCurrent() + assertTrue(calls.contains("postReissue")) + advanceTimeBy(1499) + runCurrent() + assertFalse(destination.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.NavigateToMain, destination.await()) + } + + @Test + fun `missing session waits for minimum display and navigates to login without refreshing`() = + runTest(dispatcher) { + accessToken = "" + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(999) + runCurrent() + assertFalse(destination.isCompleted) + assertFalse(calls.contains("postReissue")) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.NavigateToLogin, destination.await()) + } + + @Test + fun `failed authentication preserves the minimum display and login destination`() = + runTest(dispatcher) { + tokenRequest = { + delay(300) + throw IOException("synthetic failure") + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(999) + runCurrent() + assertFalse(destination.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.NavigateToLogin, destination.await()) + assertEquals(1, calls.count { it == "postReissue" }) + } + + @Test + fun `required update prevents authentication and navigation`() = + runTest(dispatcher) { + versionRequest = { MinimumVersionResponseDto("999999.0.0", "2026-10-05") } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.ShowDialog, destination.await()) + advanceTimeBy(2000) + runCurrent() + assertEquals(listOf("getMinimumVersion"), calls) + } + + @Test + fun `clearing Splash cancels authentication and prevents late navigation`() = + runTest(dispatcher) { + var cancelled = false + tokenRequest = { + try { + CompletableDeferred().await() + } finally { + cancelled = true + } + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + runCurrent() + assertTrue(calls.contains("postReissue")) + + owners.single().clear() + advanceTimeBy(2000) + runCurrent() + assertTrue(cancelled) + assertFalse(destination.isCompleted) + destination.cancel() + } + + private fun createViewModel(): SplashViewModel { + val storage = object : DataStore { + override val data = MutableStateFlow(preferencesOf()) + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = + transform(data.value).also { data.value = it } + } + return SplashViewModel( + VersionRepository(api()), + UserRepository(api(), storage), + AccountRepository(api(), api()), + ).also { vm -> owners.add(ViewModelStore().apply { put("splash", vm) }) } + } + + // Keep repositories real; replace only their external data sources with synthetic inputs. + @Suppress("UNCHECKED_CAST") + private inline fun api(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, args -> + val call: suspend () -> Any = { + calls.add(method.name) + when (method.name) { + "getMinimumVersion" -> versionRequest() + "postReissue" -> tokenRequest() + "selectAccessToken" -> accessToken + "selectRefreshToken" -> refreshToken + "updateAccessToken" -> accessToken = args.first() as String + "updateRefreshToken" -> refreshToken = args.first() as String + else -> error("Unexpected data source call: ${method.name}") + } + } + call.startCoroutineUninterceptedOrReturn(args.last() as Continuation) + } as T +} From f0379cbbbd20c0e59b328a8bea515e60c271f7aa Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 15:37:39 +0900 Subject: [PATCH 17/22] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=ED=99=94=EB=A9=B4=20=EB=82=B4=20=ED=99=88=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=82=AC=EC=A0=84=20=EC=9A=94=EC=B2=AD(Ho?= =?UTF-8?q?meStartupRequest)=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SplashViewModel.kt`: - `HomeStartupRequest`를 주입받아 자동 로그인 성공 시 홈 화면에 필요한 데이터를 미리 요청하도록 개선 - `UiEffect.NavigateToMain`에 `startupRequestId`를 포함하여 상태 전달 - 딥링크 진입 여부(`isHomeDestination`)에 따라 사전 요청 수행 여부 결정 및 관련 리소스 관리(`discardStartupRequest`) - 네비게이션 실패 및 뷰모델 클리어 시 진행 중인 요청을 취소하는 로직 추가 - `uiEffect`를 `Channel`에서 `MutableStateFlow`로 변경하여 상태 유지 보장 - `SplashActivity.kt`: - `SplashViewModel`의 `start()` 호출 시 딥링크 여부를 판단하여 전달 - `NavigateToMain` 수신 시 `startupRequestId`를 인텐트에 포함하여 메인 화면으로 전달 - UI 효과 처리 완료 및 네비게이션 실패 핸들링 로직 수정 - `SplashViewModelTest.kt`: - 백그라운드 세션 조회, 딥링크 진입, 네비게이션 실패 및 뷰모델 클리어 시나리오에 대한 단위 테스트 추가 및 기존 테스트 수정 --- .../into/websoso/ui/splash/SplashActivity.kt | 55 ++--- .../into/websoso/ui/splash/SplashViewModel.kt | 69 ++++-- .../websoso/ui/splash/SplashViewModelTest.kt | 228 +++++++++++++++++- 3 files changed, 305 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt index 7b715c75b..df725e587 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt @@ -6,7 +6,6 @@ import android.os.Bundle import android.provider.Settings.Secure.ANDROID_ID import android.provider.Settings.Secure.getString import androidx.activity.viewModels -import androidx.lifecycle.lifecycleScope import com.into.websoso.BuildConfig import com.into.websoso.R import com.into.websoso.core.common.navigator.NavigatorProvider @@ -19,9 +18,7 @@ import com.into.websoso.ui.splash.UiEffect.NavigateToLogin import com.into.websoso.ui.splash.UiEffect.NavigateToMain import com.into.websoso.ui.splash.UiEffect.ShowDialog import com.into.websoso.ui.splash.dialog.MinimumVersionDialogFragment -import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -29,9 +26,6 @@ class SplashActivity : BaseActivity(R.layout.activity_spl @Inject lateinit var websosoNavigator: NavigatorProvider - @Inject - lateinit var homeStartupRequest: Lazy - private val splashViewModel: SplashViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -60,6 +54,9 @@ class SplashActivity : BaseActivity(R.layout.activity_spl intent.putExtra(CollectionDeepLink.PENDING_COLLECTION_ID, collectionId) } } + splashViewModel.start( + isHomeDestination = intent.getLongExtra(CollectionDeepLink.PENDING_COLLECTION_ID, 0L) == 0L, + ) } @SuppressLint("HardwareIds") @@ -71,35 +68,35 @@ class SplashActivity : BaseActivity(R.layout.activity_spl private fun collectUiEffect() { splashViewModel.uiEffect.collectWithLifecycle(this) { uiEffect -> when (uiEffect) { - NavigateToLogin -> websosoNavigator.navigateToLoginActivity(::startDestination) - NavigateToMain -> websosoNavigator.navigateToMainActivity(::startHomeDestination) - ShowDialog -> showMinimumVersionDialog() - } - } - } + NavigateToLogin -> websosoNavigator.navigateToLoginActivity { startDestination(it, uiEffect) } + is NavigateToMain -> websosoNavigator.navigateToMainActivity( + startActivity = { startDestination(it, uiEffect) }, + ) - private fun startHomeDestination(destination: Intent) { - lifecycleScope.launch { - val target = CollectionDeepLink.forward(intent, destination) - var requestId: String? = null - var handedOff = false - try { - if (target.getLongExtra(CollectionDeepLink.PENDING_COLLECTION_ID, 0L) == 0L) { - requestId = homeStartupRequest.get().start() - requestId?.let { target.putExtra(HomeStartupRequest.KEY, it) } + ShowDialog -> { + showMinimumVersionDialog() + splashViewModel.onUiEffectHandled(uiEffect) } - // Authentication has completed. Start Home without awaiting either content response. - startActivity(target) - handedOff = true - finish() - } finally { - if (!handedOff && requestId != null) homeStartupRequest.get().discard(requestId) } } } - private fun startDestination(destination: Intent) { - startActivity(CollectionDeepLink.forward(intent, destination)) + private fun startDestination( + destination: Intent, + effect: UiEffect, + ) { + val target = CollectionDeepLink.forward(intent, destination) + if (effect is NavigateToMain) { + effect.startupRequestId?.let { target.putExtra(HomeStartupRequest.KEY, it) } + } + // Collected only while STARTED; do not suspend between receiving the effect and navigation. + try { + startActivity(target) + } catch (error: Exception) { + splashViewModel.onNavigationFailed() + throw error + } + splashViewModel.onUiEffectHandled(effect) finish() } diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt index 11fe73b34..89f669fbb 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt @@ -5,15 +5,17 @@ import androidx.lifecycle.viewModelScope import com.into.websoso.data.account.AccountRepository import com.into.websoso.data.repository.UserRepository import com.into.websoso.data.repository.VersionRepository +import com.into.websoso.ui.main.home.HomeStartupRequest import com.into.websoso.ui.splash.UiEffect.NavigateToLogin import com.into.websoso.ui.splash.UiEffect.NavigateToMain import com.into.websoso.ui.splash.UiEffect.ShowDialog +import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import javax.inject.Inject @@ -24,11 +26,22 @@ class SplashViewModel private val versionRepository: VersionRepository, private val userRepository: UserRepository, private val accountRepository: AccountRepository, + private val homeStartupRequest: Lazy, ) : ViewModel() { - private val _uiEffect = Channel(Channel.BUFFERED) - val uiEffect: Flow get() = _uiEffect.receiveAsFlow() + private val _uiEffect = MutableStateFlow(null) + val uiEffect: Flow get() = _uiEffect.filterNotNull() + private var hasStarted = false + private var isHomeDestination = true + private var startupRequestId: String? = null - init { + fun start(isHomeDestination: Boolean) { + this.isHomeDestination = isHomeDestination + if (!isHomeDestination) { + discardStartupRequest() + if (_uiEffect.value is NavigateToMain) _uiEffect.value = NavigateToMain(null) + } + if (hasStarted) return + hasStarted = true viewModelScope.launch { val isUpdateRequired = checkMinimumVersion() if (isUpdateRequired.not()) handleAutoLogin() @@ -49,25 +62,51 @@ class SplashViewModel runCatching { versionRepository.isUpdateRequired() }.getOrElse { false }.also { isRequired -> - if (isRequired) _uiEffect.send(ShowDialog) + if (isRequired) _uiEffect.value = ShowDialog } private suspend fun handleAutoLogin() = coroutineScope { // Preserve the minimum display time while authentication runs alongside it. val minimumDisplay = launch { delay(1000) } - val destination = if (shouldRefresh()) { - NavigateToLogin + val authenticated = if (shouldRefresh()) { + false } else { - accountRepository.createTokens().fold( - onSuccess = { NavigateToMain }, - onFailure = { NavigateToLogin }, - ) + accountRepository.createTokens().isSuccess } minimumDisplay.join() - _uiEffect.send(destination) + if (authenticated) { + startupRequestId = if (isHomeDestination) homeStartupRequest.get().start() else null + // A new deep link may arrive while the session lookup is suspended. + if (!isHomeDestination) discardStartupRequest() + _uiEffect.value = NavigateToMain(startupRequestId) + } else { + _uiEffect.value = NavigateToLogin + } } + fun onUiEffectHandled(effect: UiEffect) { + if (_uiEffect.value != effect) return + // Main now owns the ID, even if Home has not taken its requests yet. + if (effect is NavigateToMain) startupRequestId = null + _uiEffect.value = null + } + + fun onNavigationFailed() { + discardStartupRequest() + _uiEffect.value = null + } + + private fun discardStartupRequest() { + startupRequestId?.let { homeStartupRequest.get().discard(it) } + startupRequestId = null + } + + override fun onCleared() { + discardStartupRequest() + super.onCleared() + } + private suspend fun shouldRefresh(): Boolean = accountRepository.accessToken().isBlank() || accountRepository @@ -78,7 +117,9 @@ class SplashViewModel sealed interface UiEffect { data object NavigateToLogin : UiEffect - data object NavigateToMain : UiEffect + data class NavigateToMain( + val startupRequestId: String?, + ) : UiEffect data object ShowDialog : UiEffect } diff --git a/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt index c89231b84..c4b9499ad 100644 --- a/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt @@ -3,16 +3,24 @@ package com.into.websoso.ui.splash import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.preferencesOf +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.ViewModelStore +import com.into.websoso.core.common.util.collectWithLifecycle import com.into.websoso.data.account.AccountRepository import com.into.websoso.data.account.datasource.AccountLocalDataSource import com.into.websoso.data.account.datasource.AccountRemoteDataSource import com.into.websoso.data.account.model.TokenEntity +import com.into.websoso.data.model.PopularFeedEntity +import com.into.websoso.data.model.PopularNovelsEntity import com.into.websoso.data.remote.api.UserApi import com.into.websoso.data.remote.api.VersionApi import com.into.websoso.data.remote.response.MinimumVersionResponseDto import com.into.websoso.data.repository.UserRepository import com.into.websoso.data.repository.VersionRepository +import com.into.websoso.ui.main.home.HomeStartupRequest +import dagger.Lazy import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -29,6 +37,8 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -41,6 +51,7 @@ import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn class SplashViewModelTest { private val dispatcher = StandardTestDispatcher() private val owners = mutableListOf() + private val lifecycles = mutableListOf() private val calls = mutableListOf() private var accessToken = "synthetic-access" private var refreshToken = "synthetic-refresh" @@ -51,6 +62,20 @@ class SplashViewModelTest { delay(300) TokenEntity("synthetic-new-access", "synthetic-new-refresh") } + private var startupSession: suspend () -> String = { accessToken } + private var startedRequests = 0 + private var cancelledRequests = 0 + private val startup by lazy { + HomeStartupRequest( + sessionIdentity = { + calls.add("startupSession") + startupSession() + }, + fetchPopular = { pendingResponse() }, + fetchFeeds = { pendingResponse>() }, + dispatcher = dispatcher, + ) + } @Before fun setUp() { @@ -59,7 +84,9 @@ class SplashViewModelTest { @After fun tearDown() { + lifecycles.forEach { it.currentState = Lifecycle.State.DESTROYED } owners.forEach(ViewModelStore::clear) + dispatcher.scheduler.runCurrent() Dispatchers.resetMain() } @@ -86,7 +113,7 @@ class SplashViewModelTest { advanceTimeBy(1) runCurrent() assertTrue(destination.isCompleted) - assertEquals(UiEffect.NavigateToMain, destination.await()) + assertNotNull((destination.await() as UiEffect.NavigateToMain).startupRequestId) assertEquals(1, calls.count { it == "postReissue" }) } @@ -108,7 +135,7 @@ class SplashViewModelTest { advanceTimeBy(1) runCurrent() assertTrue(destination.isCompleted) - assertEquals(UiEffect.NavigateToMain, destination.await()) + assertNotNull((destination.await() as UiEffect.NavigateToMain).startupRequestId) } @Test @@ -126,6 +153,7 @@ class SplashViewModelTest { runCurrent() assertTrue(destination.isCompleted) assertEquals(UiEffect.NavigateToLogin, destination.await()) + assertFalse(calls.contains("startupSession")) } @Test @@ -146,6 +174,7 @@ class SplashViewModelTest { assertTrue(destination.isCompleted) assertEquals(UiEffect.NavigateToLogin, destination.await()) assertEquals(1, calls.count { it == "postReissue" }) + assertFalse(calls.contains("startupSession")) } @Test @@ -186,7 +215,194 @@ class SplashViewModelTest { destination.cancel() } - private fun createViewModel(): SplashViewModel { + @Test + fun `session lookup finishing in background waits for a recreated started collector and navigates once`() = + runTest(dispatcher) { + val session = CompletableDeferred() + startupSession = { session.await() } + val vm = createViewModel() + val effects = mutableListOf() + val firstActivity = activityLifecycle() + vm.uiEffect.collectWithLifecycle(firstActivity) { effects.add(it) } + firstActivity.lifecycle.currentState = Lifecycle.State.STARTED + advanceTimeBy(1000) + runCurrent() + assertTrue(calls.contains("startupSession")) + assertTrue(effects.isEmpty()) + + firstActivity.lifecycle.currentState = Lifecycle.State.CREATED + runCurrent() + session.complete(accessToken) + runCurrent() + assertEquals(2, startedRequests) + assertTrue(effects.isEmpty()) + + firstActivity.lifecycle.currentState = Lifecycle.State.DESTROYED + runCurrent() + val recreatedActivity = activityLifecycle() + vm.start(isHomeDestination = true) + vm.uiEffect.collectWithLifecycle(recreatedActivity) { effect -> + effects.add(effect) + vm.onUiEffectHandled(effect) + } + runCurrent() + assertTrue(effects.isEmpty()) + recreatedActivity.lifecycle.currentState = Lifecycle.State.STARTED + runCurrent() + val effect = effects.single() as UiEffect.NavigateToMain + assertNotNull(effect.startupRequestId) + assertEquals(1, calls.count { it == "postReissue" }) + assertEquals(1, calls.count { it == "startupSession" }) + + recreatedActivity.lifecycle.currentState = Lifecycle.State.CREATED + runCurrent() + recreatedActivity.lifecycle.currentState = Lifecycle.State.STARTED + runCurrent() + assertEquals(1, effects.size) + + // Main may receive the ID before Home is ready to take its requests. + owners.single().clear() + runCurrent() + assertEquals(0, cancelledRequests) + val transferred = startup.take(effect.startupRequestId) + assertNotNull(transferred) + transferred!!.cancel() + runCurrent() + assertEquals(2, cancelledRequests) + } + + @Test + fun `an unhandled destination is retained and clearing Splash discards its requests`() = + runTest(dispatcher) { + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + val effect = vm.uiEffect.first() as UiEffect.NavigateToMain + assertEquals(effect, vm.uiEffect.first()) + assertEquals(2, startedRequests) + + owners.single().clear() + runCurrent() + assertEquals(2, cancelledRequests) + assertNull(startup.take(effect.startupRequestId)) + } + + @Test + fun `failed navigation discards the untransferred request and destination`() = + runTest(dispatcher) { + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + val effect = vm.uiEffect.first() as UiEffect.NavigateToMain + + vm.onNavigationFailed() + runCurrent() + assertEquals(2, cancelledRequests) + assertNull(startup.take(effect.startupRequestId)) + val next = async { vm.uiEffect.first() } + runCurrent() + assertFalse(next.isCompleted) + next.cancel() + } + + @Test + fun `collection destination skips startup requests`() = + runTest(dispatcher) { + val vm = createViewModel(isHomeDestination = false) + advanceTimeBy(1000) + runCurrent() + assertEquals(UiEffect.NavigateToMain(null), vm.uiEffect.first()) + assertFalse(calls.contains("startupSession")) + assertEquals(0, startedRequests) + } + + @Test + fun `a deep link arriving during session lookup prevents transfer`() = + runTest(dispatcher) { + val session = CompletableDeferred() + startupSession = { session.await() } + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + assertTrue(calls.contains("startupSession")) + + vm.start(isHomeDestination = false) + session.complete(accessToken) + runCurrent() + assertEquals(UiEffect.NavigateToMain(null), vm.uiEffect.first()) + assertEquals(0, startedRequests) + } + + @Test + fun `a deep link arriving after preparation discards requests and replaces the pending ID`() = + runTest(dispatcher) { + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + val effect = vm.uiEffect.first() as UiEffect.NavigateToMain + + vm.start(isHomeDestination = false) + runCurrent() + assertEquals(UiEffect.NavigateToMain(null), vm.uiEffect.first()) + assertNull(startup.take(effect.startupRequestId)) + assertEquals(2, cancelledRequests) + assertEquals(1, calls.count { it == "postReissue" }) + } + + @Test + fun `clearing Splash during session lookup cancels preparation without a destination`() = + runTest(dispatcher) { + var cancelled = false + startupSession = { + try { + CompletableDeferred().await() + } finally { + cancelled = true + } + } + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(1000) + runCurrent() + assertTrue(calls.contains("startupSession")) + + owners.single().clear() + runCurrent() + assertTrue(cancelled) + assertEquals(0, startedRequests) + assertFalse(destination.isCompleted) + destination.cancel() + } + + @Test + fun `failed startup session lookup still navigates without a request ID`() = + runTest(dispatcher) { + startupSession = { throw IOException("synthetic setup failure") } + val vm = createViewModel() + advanceTimeBy(1000) + runCurrent() + assertEquals(UiEffect.NavigateToMain(null), vm.uiEffect.first()) + assertEquals(0, startedRequests) + } + + private fun activityLifecycle() = + object : LifecycleOwner { + override val lifecycle = LifecycleRegistry.createUnsafe(this).also { + it.currentState = Lifecycle.State.CREATED + lifecycles.add(it) + } + } + + private suspend fun pendingResponse(): T { + startedRequests++ + try { + return CompletableDeferred().await() + } finally { + cancelledRequests++ + } + } + + private fun createViewModel(isHomeDestination: Boolean = true): SplashViewModel { val storage = object : DataStore { override val data = MutableStateFlow(preferencesOf()) @@ -197,7 +413,11 @@ class SplashViewModelTest { VersionRepository(api()), UserRepository(api(), storage), AccountRepository(api(), api()), - ).also { vm -> owners.add(ViewModelStore().apply { put("splash", vm) }) } + Lazy { startup }, + ).also { vm -> + owners.add(ViewModelStore().apply { put("splash", vm) }) + vm.start(isHomeDestination) + } } // Keep repositories real; replace only their external data sources with synthetic inputs. From c4fe813cfc6860d91cf2d98ba8d691863c753377 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 17:16:59 +0900 Subject: [PATCH 18/22] =?UTF-8?q?refactor:=20SplashActivity=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SplashActivity.kt`: - `splashViewModel.start` 호출 시 `isHomeDestination` 매개변수 전달 로직의 줄바꿈을 조정하여 가독성 개선 - `collectUiEffect` 함수 내 `when` 식의 각 분기(`NavigateToLogin`, `NavigateToMain`)에 중괄호를 추가하고 코드 정렬 스타일 수정 --- .../into/websoso/ui/splash/SplashActivity.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt index df725e587..bdb6db0c7 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashActivity.kt @@ -55,7 +55,10 @@ class SplashActivity : BaseActivity(R.layout.activity_spl } } splashViewModel.start( - isHomeDestination = intent.getLongExtra(CollectionDeepLink.PENDING_COLLECTION_ID, 0L) == 0L, + isHomeDestination = intent.getLongExtra( + CollectionDeepLink.PENDING_COLLECTION_ID, + 0L, + ) == 0L, ) } @@ -68,10 +71,20 @@ class SplashActivity : BaseActivity(R.layout.activity_spl private fun collectUiEffect() { splashViewModel.uiEffect.collectWithLifecycle(this) { uiEffect -> when (uiEffect) { - NavigateToLogin -> websosoNavigator.navigateToLoginActivity { startDestination(it, uiEffect) } - is NavigateToMain -> websosoNavigator.navigateToMainActivity( - startActivity = { startDestination(it, uiEffect) }, - ) + NavigateToLogin -> { + websosoNavigator.navigateToLoginActivity { + startDestination( + it, + uiEffect, + ) + } + } + + is NavigateToMain -> { + websosoNavigator.navigateToMainActivity( + startActivity = { startDestination(it, uiEffect) }, + ) + } ShowDialog -> { showMinimumVersionDialog() From 93f52565774d8c7281a5939fec8f2c79f6be6571 Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Mon, 5 Oct 2026 19:03:44 +0900 Subject: [PATCH 19/22] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=ED=99=94=EB=A9=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=97=90=EC=A7=80=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SplashViewModel.kt`: - 자동 로그인 인증과 홈 화면 데이터 사전 요청(`HomeStartupRequest`)이 병렬로 실행되도록 구조 변경 - 인증 완료 후 최소 노출 시간(1초) 대기 중 딥링크 진입 등으로 인해 `startupRequestId`가 유효하지 않게 되는 경우를 고려하여, 대기 완료 시점에 최종 상태를 확인하고 네비게이션을 수행하도록 개선 - 인증 및 데이터 요청 로직의 실행 순서를 조정하여 불필요한 대기 시간 최적화 - `SplashViewModelTest.kt`: - 인증 성공 시 최소 노출 시간 동안 홈 데이터 요청이 정상적으로 시작되는지 검증하는 테스트 추가 - 최소 노출 시간 대기 중 ViewModel이 클리어되거나 딥링크가 진입하는 경우, 진행 중인 데이터 요청이 취소되고 올바른 목적지로 이동하는지에 대한 단위 테스트 케이스 추가 - 백그라운드 세션 조회 시나리오에 대한 시간 흐름 검증 로직 정밀화 --- .../into/websoso/ui/splash/SplashViewModel.kt | 9 ++- .../websoso/ui/splash/SplashViewModelTest.kt | 68 ++++++++++++++++++- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt b/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt index 89f669fbb..1215dcc0c 100644 --- a/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/splash/SplashViewModel.kt @@ -67,22 +67,21 @@ class SplashViewModel private suspend fun handleAutoLogin() = coroutineScope { - // Preserve the minimum display time while authentication runs alongside it. + // Overlap authentication and Home requests with the minimum display time. val minimumDisplay = launch { delay(1000) } val authenticated = if (shouldRefresh()) { false } else { accountRepository.createTokens().isSuccess } - minimumDisplay.join() if (authenticated) { startupRequestId = if (isHomeDestination) homeStartupRequest.get().start() else null // A new deep link may arrive while the session lookup is suspended. if (!isHomeDestination) discardStartupRequest() - _uiEffect.value = NavigateToMain(startupRequestId) - } else { - _uiEffect.value = NavigateToLogin } + minimumDisplay.join() + // Read startupRequestId here, not earlier: a deep link during the wait may have cleared it. + _uiEffect.value = if (authenticated) NavigateToMain(startupRequestId) else NavigateToLogin } fun onUiEffectHandled(effect: UiEffect) { diff --git a/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt index c4b9499ad..4252c5147 100644 --- a/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/splash/SplashViewModelTest.kt @@ -91,7 +91,7 @@ class SplashViewModelTest { } @Test - fun `fast authentication overlaps the minimum display after version approval`() = + fun `successful authentication starts Home requests during the minimum display after version approval`() = runTest(dispatcher) { versionRequest = { delay(200) @@ -105,9 +105,19 @@ class SplashViewModelTest { advanceTimeBy(200) runCurrent() assertTrue(calls.contains("postReissue")) - advanceTimeBy(999) + advanceTimeBy(299) + runCurrent() + assertFalse(calls.contains("startupSession")) + assertEquals(0, startedRequests) + + advanceTimeBy(1) runCurrent() assertEquals("synthetic-new-access", accessToken) + assertEquals(2, startedRequests) + assertFalse(destination.isCompleted) + + advanceTimeBy(699) + runCurrent() assertFalse(destination.isCompleted) advanceTimeBy(1) @@ -115,6 +125,7 @@ class SplashViewModelTest { assertTrue(destination.isCompleted) assertNotNull((destination.await() as UiEffect.NavigateToMain).startupRequestId) assertEquals(1, calls.count { it == "postReissue" }) + assertEquals(1, calls.count { it == "startupSession" }) } @Test @@ -215,6 +226,53 @@ class SplashViewModelTest { destination.cancel() } + @Test + fun `clearing Splash during the remaining minimum display cancels Home requests and navigation`() = + runTest(dispatcher) { + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(300) + runCurrent() + assertEquals(2, startedRequests) + assertFalse(destination.isCompleted) + + owners.single().clear() + runCurrent() + assertEquals(2, cancelledRequests) + + advanceTimeBy(1000) + runCurrent() + assertFalse(destination.isCompleted) + destination.cancel() + } + + @Test + fun `a deep link during the remaining minimum display cancels Home requests without early navigation`() = + runTest(dispatcher) { + val vm = createViewModel() + val destination = async { vm.uiEffect.first() } + advanceTimeBy(300) + runCurrent() + assertEquals(2, startedRequests) + assertFalse(destination.isCompleted) + + vm.start(isHomeDestination = false) + runCurrent() + assertEquals(2, cancelledRequests) + assertFalse(destination.isCompleted) + + advanceTimeBy(699) + runCurrent() + assertFalse(destination.isCompleted) + + advanceTimeBy(1) + runCurrent() + assertTrue(destination.isCompleted) + assertEquals(UiEffect.NavigateToMain(null), destination.await()) + assertEquals(1, calls.count { it == "postReissue" }) + assertEquals(1, calls.count { it == "startupSession" }) + } + @Test fun `session lookup finishing in background waits for a recreated started collector and navigates once`() = runTest(dispatcher) { @@ -225,7 +283,7 @@ class SplashViewModelTest { val firstActivity = activityLifecycle() vm.uiEffect.collectWithLifecycle(firstActivity) { effects.add(it) } firstActivity.lifecycle.currentState = Lifecycle.State.STARTED - advanceTimeBy(1000) + advanceTimeBy(300) runCurrent() assertTrue(calls.contains("startupSession")) assertTrue(effects.isEmpty()) @@ -237,6 +295,10 @@ class SplashViewModelTest { assertEquals(2, startedRequests) assertTrue(effects.isEmpty()) + advanceTimeBy(700) + runCurrent() + assertTrue(effects.isEmpty()) + firstActivity.lifecycle.currentState = Lifecycle.State.DESTROYED runCurrent() val recreatedActivity = activityLifecycle() From 192ecb4eec0cd5b16b138777beefc6c3073d736e Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 01:28:57 +0900 Subject: [PATCH 20/22] =?UTF-8?q?test:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94(StartupRequest)=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EC=A4=91=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EC=9A=94=EC=B2=AD=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EB=A1=9C=EC=A7=81=20=EA=B2=80=EC=A6=9D=20=ED=85=8C?= =?UTF-8?q?=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 - `HomeViewModelTest.kt`: - `HomeStartupRequest`가 완료되지 않은 상태에서 사용자가 수동으로 새로고침(`updateNovel`, `updateFeed`)을 호출할 경우, 기존의 초기화 작업이 취소되고 중복된 폴백(fallback) 요청이 발생하지 않는지 검증하는 단위 테스트 추가 - 테스트 시 정교한 코루틴 제어를 위해 `UnconfinedTestDispatcher`를 활용하도록 개선 - `createViewModel` 헬퍼 함수에서 커스텀 `HomeStartupRequest`를 주입받을 수 있도록 매개변수 추가 --- .../websoso/ui/main/home/HomeViewModelTest.kt | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt index 479f81688..1a683fd6e 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeViewModelTest.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -929,9 +930,43 @@ class HomeViewModelTest { assertTrue(feedsCancelled) } + @Test + fun `refreshing while startup requests are pending does not start fallback requests in replaced jobs`() = + runTest(dispatcher) { + // Resume Home and startup coroutines in place, as Dispatchers.Main.immediate can on the main thread. + val immediate = UnconfinedTestDispatcher(testScheduler) + Dispatchers.setMain(immediate) + val immediateStartupRequest = HomeStartupRequest( + { sessionIdentity }, + { NovelRepository(api()).fetchPopularNovels() }, + { FeedRepository(api()).fetchPopularFeeds() }, + immediate, + ) + popularRequest = { CompletableDeferred().await() } + feedRequest = { CompletableDeferred().await() } + val id = immediateStartupRequest.start() + runCurrent() + val vm = createViewModel(startupId = id, homeStartupRequest = immediateStartupRequest) + runCurrent() + assertEquals(1, calls["getPopularNovels"]) + assertEquals(1, calls["getPopularFeeds"]) + + popularRequest = { popular() } + vm.updateNovel() + runCurrent() + // Startup request + refresh only; the replaced initial job must not fetch again. + assertEquals(2, calls["getPopularNovels"]) + + feedRequest = { feeds() } + vm.updateFeed() + runCurrent() + assertEquals(2, calls["getPopularFeeds"]) + } + private fun createViewModel( termsChecked: Boolean = true, startupId: String? = null, + homeStartupRequest: HomeStartupRequest = startupRequest, ): HomeViewModel { val storage = object : DataStore { override val data = MutableStateFlow( @@ -952,7 +987,7 @@ class HomeViewModelTest { NotificationRepository(api()), user, SavedStateHandle(mapOf(HomeStartupRequest.KEY to startupId)), - startupRequest, + homeStartupRequest, ).also { vm -> owners.add(ViewModelStore().apply { put("home", vm) }) } From 8c574c948cf3f03cf45a27772e5a4854c7a2a12b Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 01:29:12 +0900 Subject: [PATCH 21/22] =?UTF-8?q?refactor:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=84=B9=EC=85=98=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=8B=9C=20=EC=82=AC=EC=A0=84=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD(startupRequests)=20=EC=B7=A8=EC=86=8C=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeViewModel.kt`: - `loadPopularNovels` 및 `loadPopularFeeds` 함수에서 기존 사전 요청을 취소하는 로직을 `sectionLoader.load` 호출 이후(`also` 블록)로 이동 - 섹션 로더가 새로운 작업을 할당받아 기존 대기 중인 요청을 안전하게 대체한 후 취소가 수행되도록 보장하여 로딩 안정성 개선 - 해당 함수들을 식(expression) 본문 형태로 리팩터링하여 코드 가독성 향상 --- .../websoso/ui/main/home/HomeViewModel.kt | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt index af16c81f7..cdbdd4d35 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeViewModel.kt @@ -91,33 +91,36 @@ class HomeViewModel if (requests.all { !it.isCancelled && it.await() }) recoverGlobalError() } - private fun loadPopularNovels(initial: Boolean = false): Deferred { - if (!initial) startupRequests?.cancelPopular() - return sectionLoader.load( - section = HomeSection.POPULAR, - request = { (if (initial) startupRequests?.popular() else null) ?: novelRepository.fetchPopularNovels() }, - success = { result -> - updateContent(HomeSection.POPULAR) { it.copy(popularNovels = result.popularNovels) } - }, - failure = { handleFailureState(it) }, - ) - } + private fun loadPopularNovels(initial: Boolean = false): Deferred = + sectionLoader + .load( + section = HomeSection.POPULAR, + request = { (if (initial) startupRequests?.popular() else null) ?: novelRepository.fetchPopularNovels() }, + success = { result -> + updateContent(HomeSection.POPULAR) { it.copy(popularNovels = result.popularNovels) } + }, + failure = { handleFailureState(it) }, + ).also { + // Cancel the transferred request only after the loader has cancelled the job awaiting it. + if (!initial) startupRequests?.cancelPopular() + } private fun loadPopularFeeds( recoverError: Boolean = false, initial: Boolean = false, - ): Deferred { - if (!initial) startupRequests?.cancelFeeds() - return sectionLoader.load( - section = HomeSection.FEEDS, - request = { (if (initial) startupRequests?.feeds() else null) ?: feedRepository.fetchPopularFeeds() }, - success = { result -> - updateContent(HomeSection.FEEDS) { it.copy(popularFeeds = result.toHomePopularFeedPages()) } - if (recoverError) recoverGlobalError() - }, - failure = { handleFailureState(it) }, - ) - } + ): Deferred = + sectionLoader + .load( + section = HomeSection.FEEDS, + request = { (if (initial) startupRequests?.feeds() else null) ?: feedRepository.fetchPopularFeeds() }, + success = { result -> + updateContent(HomeSection.FEEDS) { it.copy(popularFeeds = result.toHomePopularFeedPages()) } + if (recoverError) recoverGlobalError() + }, + failure = { handleFailureState(it) }, + ).also { + if (!initial) startupRequests?.cancelFeeds() + } override fun onCleared() { startupRequests?.cancel() From 2871a4e8a7b6efd71bcad9152743b8bf88ba547c Mon Sep 17 00:00:00 2001 From: devfeijoa Date: Tue, 6 Oct 2026 01:40:46 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20`HomeStartupRequest`=20=EB=82=B4?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EC=8B=9C=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8F=B4=EB=B0=B1=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `HomeStartupRequest.kt`: - 세션 ID 확인 로직을 `isSameSession()` 함수로 캡슐화 - 저장소 읽기 실패 등 세션 확인 중 발생하는 예외에 대해 `false`를 반환하도록 수정하여, 예외가 외부로 전파되지 않고 일반 홈 요청으로 안전하게 폴백(fallback)되도록 개선 - 세션 불일치 시 진행 중인 요청을 취소하고 `null`을 반환하는 로직의 가독성 및 안정성 보장 - `HomeStartupRequestTest.kt`: - 세션 조회 과정에서 저장소 오류(IOException)가 발생할 경우, 에러를 노출하는 대신 안전하게 폴백되는지 검증하는 단위 테스트 추가 --- .../ui/main/home/HomeStartupRequest.kt | 17 ++++++++++++++--- .../ui/main/home/HomeStartupRequestTest.kt | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt index 7fe131779..560be2312 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeStartupRequest.kt @@ -101,12 +101,12 @@ class HomeStartupRequest internal constructor( private suspend fun await(request: Deferred): T? = try { - if (sessionIdentity() != identity) { + if (!isSameSession()) { cancel() null } else { val result = request.await() - if (sessionIdentity() == identity) { + if (isSameSession()) { result } else { cancel() @@ -117,14 +117,25 @@ class HomeStartupRequest internal constructor( currentCoroutineContext().ensureActive() null } catch (error: Exception) { + // Only request failures reach here; session checks never throw. currentCoroutineContext().ensureActive() - if (sessionIdentity() == identity) throw error + if (isSameSession()) throw error cancel() null } finally { // Cancellation/replacement of this Home section also cancels its transferred request. request.cancel() } + + // A storage read failure means the session cannot be confirmed: fall back to an ordinary Home request. + private suspend fun isSameSession(): Boolean = + try { + sessionIdentity() == identity + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } } companion object { diff --git a/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt b/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt index f82430eec..48db4df77 100644 --- a/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt +++ b/app/src/test/java/com/into/websoso/ui/main/home/HomeStartupRequestTest.kt @@ -68,6 +68,25 @@ class HomeStartupRequestTest { } } + @Test + fun `session lookup failures after handoff fall back instead of surfacing a storage error`() = + runTest(dispatcher) { + // Lookup 1 is start(); 2 checks before waiting, 3 after the response, 4 in the failure path. + listOf(setOf(2), setOf(3, 4)).forEach { failingLookups -> + var lookups = 0 + val startup = HomeStartupRequest( + { if (++lookups in failingLookups) throw IOException("synthetic storage failure") else identity }, + { popular }, + { feeds }, + dispatcher, + ) + val requests = startup.take(startup.start())!! + runCurrent() + val result = runCatching { requests.popular() } + assertTrue("$failingLookups: ${result.exceptionOrNull()}", result.isSuccess && result.getOrNull() == null) + } + } + @Test fun `consumer cancellation cancels its in flight request without cancelling its sibling`() = runTest(dispatcher) {