From 2964e5bd77baf78091e4f876d188bc30a95bed1d Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:25:53 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=83=B7=20=EC=83=81=EC=84=B8=20=EC=B0=8C=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=BF=A8=ED=83=80=EC=9E=84=20=EA=B0=B1=EC=8B=A0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../photolog/detail/PhotologDetailScreen.kt | 1 - .../detail/PhotologDetailViewModel.kt | 45 +++-- .../detail/component/PhotologCardContent.kt | 4 +- .../detail/PhotologDetailViewModelTest.kt | 174 ++++++++++++++++++ 4 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt index bd5b0c9db..d87ad1f13 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailScreen.kt @@ -220,7 +220,6 @@ fun PhotologDetailScreen( PhotologCardContent( uiState = uiState, - isPokeDisabled = uiState.isPokeDisabled, onSwipe = onSwipe, onClickUpload = onClickUpload, onPoke = onPoke, diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt index 4df175816..39e87feb8 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt @@ -20,6 +20,8 @@ import com.twix.ui.base.BaseViewModel import com.twix.util.bus.GoalRefreshBus import com.twix.util.bus.PhotologRefreshBus import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest @@ -55,6 +57,7 @@ class PhotologDetailViewModel( savedStateHandle[NavRoutes.PhotologDetailRoute.ARG_IS_COMPLETED] ?: false private var lastReaction: GoalReactionType? = null + private var pokeCooldownJob: Job? = null private val reactionFlow = MutableSharedFlow( @@ -165,21 +168,17 @@ class PhotologDetailViewModel( private fun checkPokeCooldown() { viewModelScope.launch { val remaining = pokeGoalUseCase.remainingCooldown(argGoalId) - if (remaining > 0) reduce { copy(pokeCooldownRemaining = remaining) } + startPokeCooldown(remaining) } } private fun pokeToPartner() { viewModelScope.launch { - startPokeLoading() + reduce { copy(isPoking = true) } handlePokeResult(pokeGoalUseCase.invoke(argGoalId)) } } - private fun startPokeLoading() { - reduce { copy(isPoking = true) } - } - private suspend fun handlePokeResult(result: PokeGoalResult) { when (result) { is PokeGoalResult.Success -> handlePokeSuccess() @@ -189,20 +188,42 @@ class PhotologDetailViewModel( } private fun handlePokeSuccess() { - reduce { - copy( - isPoking = false, - pokeCooldownRemaining = PokeGoalUseCase.COOLDOWN_MS, - ) - } + startPokeCooldown(PokeGoalUseCase.COOLDOWN_MS) + reduce { copy(isPoking = false) } tryEmitSideEffect(PhotologDetailSideEffect.ShowPokeToast) } private fun handlePokeCooldown(remainingMs: Long) { + startPokeCooldown(remainingMs) reduce { copy(isPoking = false) } tryEmitSideEffect(PhotologDetailSideEffect.ShowPokeCooldownToast(remainingMs)) } + private fun startPokeCooldown(remainingMs: Long) { + pokeCooldownJob?.cancel() + + if (remainingMs <= 0L) { + clearPokeCooldown() + return + } + + reduce { copy(pokeCooldownRemaining = remainingMs) } + schedulePokeCooldownClear(remainingMs) + } + + private fun clearPokeCooldown() { + reduce { copy(pokeCooldownRemaining = 0L) } + pokeCooldownJob = null + } + + private fun schedulePokeCooldownClear(remainingMs: Long) { + pokeCooldownJob = + viewModelScope.launch { + delay(remainingMs) + clearPokeCooldown() + } + } + private suspend fun handlePokeError() { reduce { copy(isPoking = false) } showToast(R.string.toast_poke_goal_failed) diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/PhotologCardContent.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/PhotologCardContent.kt index b34ffe526..c0d3693d4 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/PhotologCardContent.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/component/PhotologCardContent.kt @@ -29,7 +29,6 @@ import kotlin.math.roundToInt @Composable internal fun PhotologCardContent( uiState: PhotologDetailUiState, - isPokeDisabled: Boolean, onSwipe: () -> Unit, onClickUpload: () -> Unit, onPoke: () -> Unit, @@ -63,7 +62,7 @@ internal fun PhotologCardContent( if (uiState.isDisplayedMyPhotolog) { onClickUpload } else { - { if (!isPokeDisabled) onPoke() } + { if (!uiState.isPoking) onPoke() } }, showActionButton = uiState.showActionButton, ) @@ -171,7 +170,6 @@ private fun PhotologCardContentPreview( TwixTheme { PhotologCardContent( uiState = uiState.copy(isLoading = true), - isPokeDisabled = false, onSwipe = {}, onClickUpload = {}, onPoke = {}, diff --git a/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt b/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt new file mode 100644 index 000000000..e9bea75ca --- /dev/null +++ b/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt @@ -0,0 +1,174 @@ +package com.twix.photolog.detail + +import androidx.lifecycle.SavedStateHandle +import com.twix.domain.model.enums.BetweenUs +import com.twix.domain.model.enums.GoalIconType +import com.twix.domain.model.enums.GoalReactionType +import com.twix.domain.model.photo.PhotoLogUploadInfo +import com.twix.domain.model.photo.PhotologParam +import com.twix.domain.model.photolog.GoalPhotolog +import com.twix.domain.model.photolog.PhotoLogs +import com.twix.domain.model.poke.PokeResult +import com.twix.domain.repository.PhotoLogRepository +import com.twix.domain.repository.PokeRepository +import com.twix.domain.usecase.PokeGoalUseCase +import com.twix.navigation.NavRoutes +import com.twix.photolog.detail.contract.PhotologDetailIntent +import com.twix.result.AppResult +import com.twix.util.bus.GoalRefreshBus +import com.twix.util.bus.PhotologRefreshBus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import java.time.LocalDate + +@OptIn(ExperimentalCoroutinesApi::class) +class PhotologDetailViewModelTest { + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `쿨타임이 만료되면 찌르기 비활성 상태가 해제된다`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + val goalId = 170L + val remainingMs = 1_000L + val pokeRepository = + FakePokeRepository().apply { + pokeHistory[goalId] = System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS + remainingMs + } + val viewModel = createViewModel(goalId = goalId, pokeRepository = pokeRepository) + + runCurrent() + assertThat(viewModel.uiState.value.pokeCooldownRemaining).isGreaterThan(0L) + assertThat(viewModel.uiState.value.isPokeDisabled).isTrue() + + advanceTimeBy(remainingMs + 1L) + advanceUntilIdle() + + assertThat(viewModel.uiState.value.pokeCooldownRemaining).isEqualTo(0L) + assertThat(viewModel.uiState.value.isPokeDisabled).isFalse() + } + + @Test + fun `찌르기 결과가 쿨타임이면 최신 잔여 시간을 상태에 반영한다`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(dispatcher) + val goalId = 171L + val pokeRepository = FakePokeRepository() + val viewModel = createViewModel(goalId = goalId, pokeRepository = pokeRepository) + advanceUntilIdle() + assertThat(viewModel.uiState.value.pokeCooldownRemaining).isEqualTo(0L) + + pokeRepository.pokeHistory[goalId] = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) + viewModel.dispatch(PhotologDetailIntent.Poke) + runCurrent() + + assertThat(viewModel.uiState.value.isPoking).isFalse() + assertThat(viewModel.uiState.value.pokeCooldownRemaining).isGreaterThan(0L) + assertThat(viewModel.uiState.value.isPokeDisabled).isTrue() + assertThat(pokeRepository.pokeGoalCallCount).isEqualTo(0) + } + + private fun createViewModel( + goalId: Long, + pokeRepository: FakePokeRepository, + targetDate: LocalDate = LocalDate.of(2026, 6, 6), + ): PhotologDetailViewModel = + PhotologDetailViewModel( + photologRepository = FakePhotoLogRepository(goalId, targetDate), + pokeGoalUseCase = PokeGoalUseCase(pokeRepository), + detailRefreshBus = PhotologRefreshBus(), + goalRefreshBus = GoalRefreshBus(), + savedStateHandle = + SavedStateHandle( + mapOf( + NavRoutes.PhotologDetailRoute.ARG_GOAL_ID to goalId, + NavRoutes.PhotologDetailRoute.ARG_DATE to targetDate.toString(), + NavRoutes.PhotologDetailRoute.ARG_BETWEEN_US to BetweenUs.PARTNER.name, + NavRoutes.PhotologDetailRoute.ARG_IS_COMPLETED to false, + ), + ), + ) +} + +private class FakePokeRepository : PokeRepository { + val pokeHistory = mutableMapOf() + var pokeGoalCallCount = 0 + var pokeGoalResult: AppResult = AppResult.Success(PokeResult(message = "ok")) + + override suspend fun pokeGoal(goalId: Long): AppResult { + pokeGoalCallCount += 1 + return pokeGoalResult + } + + override suspend fun savePokeHistory( + goalId: Long, + pokedAt: Long, + ) { + pokeHistory[goalId] = pokedAt + } + + override suspend fun findPokeHistory(goalId: Long): Long? = pokeHistory[goalId] +} + +private class FakePhotoLogRepository( + private val goalId: Long, + private val targetDate: LocalDate, +) : PhotoLogRepository { + override suspend fun getUploadUrl(goalId: Long): AppResult = error("Not used") + + override suspend fun uploadPhotolog(photologParam: PhotologParam): AppResult = error("Not used") + + override suspend fun uploadPhotologImage( + goalId: Long, + bytes: ByteArray, + contentType: String, + ): AppResult = error("Not used") + + override suspend fun fetchPhotologs( + targetDate: LocalDate, + goalId: Long?, + ): AppResult = + AppResult.Success( + PhotoLogs( + targetDate = this.targetDate.toString(), + myNickname = "me", + partnerNickname = "partner", + goals = + listOf( + GoalPhotolog( + goalId = this.goalId, + goalName = "goal", + icon = GoalIconType.DEFAULT, + myPhotolog = null, + partnerPhotolog = null, + ), + ), + ), + ) + + override suspend fun reactToPhotolog( + photologId: Long, + reaction: GoalReactionType, + ): AppResult = error("Not used") + + override suspend fun modifyPhotolog( + photologId: Long, + fileName: String, + comment: String, + ): AppResult = error("Not used") +} From 8e6004b07686c2f8bc42f61566a958401161e661 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=ED=82=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/twix/database/poke/PokeHistoryEntity.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core/database/src/main/java/com/twix/database/poke/PokeHistoryEntity.kt b/core/database/src/main/java/com/twix/database/poke/PokeHistoryEntity.kt index a8951aa66..5f135e0d7 100644 --- a/core/database/src/main/java/com/twix/database/poke/PokeHistoryEntity.kt +++ b/core/database/src/main/java/com/twix/database/poke/PokeHistoryEntity.kt @@ -1,10 +1,13 @@ package com.twix.database.poke import androidx.room.Entity -import androidx.room.PrimaryKey -@Entity(tableName = "poke_history") +@Entity( + tableName = "poke_history", + primaryKeys = ["goalId", "targetDate"], +) data class PokeHistoryEntity( - @PrimaryKey val goalId: Long, + val goalId: Long, + val targetDate: String, val pokedAt: Long, ) From 3cc539f63fe1173317372dc54edd1a00689a27df Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=EB=B3=84=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/twix/database/poke/PokeHistoryDao.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/database/src/main/java/com/twix/database/poke/PokeHistoryDao.kt b/core/database/src/main/java/com/twix/database/poke/PokeHistoryDao.kt index cc335012b..378a0bad2 100644 --- a/core/database/src/main/java/com/twix/database/poke/PokeHistoryDao.kt +++ b/core/database/src/main/java/com/twix/database/poke/PokeHistoryDao.kt @@ -9,6 +9,9 @@ interface PokeHistoryDao { @Upsert suspend fun upsert(entity: PokeHistoryEntity) - @Query("SELECT * FROM poke_history WHERE goalId = :goalId") - suspend fun findByGoalId(goalId: Long): PokeHistoryEntity? + @Query("SELECT * FROM poke_history WHERE goalId = :goalId AND targetDate = :targetDate") + suspend fun findPokeHistoryEntity( + goalId: Long, + targetDate: String, + ): PokeHistoryEntity? } From 9b715fcc7d0a2f2a1798155816a855493fbd33cd Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=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 --- .../java/com/twix/database/TwixDatabase.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/core/database/src/main/java/com/twix/database/TwixDatabase.kt b/core/database/src/main/java/com/twix/database/TwixDatabase.kt index 4e60703e9..65b4fcf43 100644 --- a/core/database/src/main/java/com/twix/database/TwixDatabase.kt +++ b/core/database/src/main/java/com/twix/database/TwixDatabase.kt @@ -2,13 +2,34 @@ package com.twix.database import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.twix.database.poke.PokeHistoryDao import com.twix.database.poke.PokeHistoryEntity @Database( entities = [PokeHistoryEntity::class], - version = 1, + version = 2, ) abstract class TwixDatabase : RoomDatabase() { abstract fun pokeHistoryDao(): PokeHistoryDao + + companion object { + val MIGRATION_1_2 = + object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS poke_history") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS poke_history ( + goalId INTEGER NOT NULL, + targetDate TEXT NOT NULL, + pokedAt INTEGER NOT NULL, + PRIMARY KEY(goalId, targetDate) + ) + """.trimIndent(), + ) + } + } + } } From d43d91ec0bf804d18917ae970c86a738fb4b5ea0 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=9E=88=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/twix/database/di/DatabaseModule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/database/src/main/java/com/twix/database/di/DatabaseModule.kt b/core/database/src/main/java/com/twix/database/di/DatabaseModule.kt index 4143e350a..5b793801a 100644 --- a/core/database/src/main/java/com/twix/database/di/DatabaseModule.kt +++ b/core/database/src/main/java/com/twix/database/di/DatabaseModule.kt @@ -13,7 +13,8 @@ val databaseModule = androidContext(), TwixDatabase::class.java, "twix-database", - ).build() + ).addMigrations(TwixDatabase.MIGRATION_1_2) + .build() } single { get().pokeHistoryDao() } } From 49f942887d13f0869d99467ab3e714c75776fcfc Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/twix/domain/repository/PokeRepository.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/domain/src/main/java/com/twix/domain/repository/PokeRepository.kt b/domain/src/main/java/com/twix/domain/repository/PokeRepository.kt index e96deea25..5bbd9b312 100644 --- a/domain/src/main/java/com/twix/domain/repository/PokeRepository.kt +++ b/domain/src/main/java/com/twix/domain/repository/PokeRepository.kt @@ -8,8 +8,12 @@ interface PokeRepository { suspend fun savePokeHistory( goalId: Long, + targetDate: String, pokedAt: Long, ) - suspend fun findPokeHistory(goalId: Long): Long? + suspend fun findPokeHistory( + goalId: Long, + targetDate: String, + ): Long? } From 4e6c4d609c458aaf54c70b9c76d2e791dc898f87 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=BF=A8=ED=83=80=EC=9E=84=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=B3=84=20=EA=B3=84=EC=82=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/twix/domain/usecase/PokeGoalUseCase.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt b/domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt index 1f155cb98..bd11de8bd 100644 --- a/domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt +++ b/domain/src/main/java/com/twix/domain/usecase/PokeGoalUseCase.kt @@ -7,21 +7,27 @@ import com.twix.result.AppResult class PokeGoalUseCase( private val pokeRepository: PokeRepository, ) { - suspend fun invoke(goalId: Long): PokeGoalResult { - val remainingMs = remainingCooldown(goalId) + suspend fun invoke( + goalId: Long, + targetDate: String, + ): PokeGoalResult { + val remainingMs = remainingCooldown(goalId, targetDate) if (remainingMs > 0) return PokeGoalResult.OnCooldown(remainingMs) return when (val result = pokeRepository.pokeGoal(goalId)) { is AppResult.Success -> { - pokeRepository.savePokeHistory(goalId, System.currentTimeMillis()) + pokeRepository.savePokeHistory(goalId, targetDate, System.currentTimeMillis()) PokeGoalResult.Success(result.data.message) } is AppResult.Error -> PokeGoalResult.Error } } - suspend fun remainingCooldown(goalId: Long): Long { - val pokedAt = pokeRepository.findPokeHistory(goalId) ?: return 0L + suspend fun remainingCooldown( + goalId: Long, + targetDate: String, + ): Long { + val pokedAt = pokeRepository.findPokeHistory(goalId, targetDate) ?: return 0L val currentTime = System.currentTimeMillis() val elapsedMs = currentTime - pokedAt val remainingMs = COOLDOWN_MS - elapsedMs From ccfe1ffee597021eda6f35dc5d473bd14a4eca66 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EB=82=A0=EC=A7=9C=EB=B3=84=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../twix/data/repository/DefaultPokeRepository.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/data/src/main/java/com/twix/data/repository/DefaultPokeRepository.kt b/data/src/main/java/com/twix/data/repository/DefaultPokeRepository.kt index 1b6114330..f901f6c90 100644 --- a/data/src/main/java/com/twix/data/repository/DefaultPokeRepository.kt +++ b/data/src/main/java/com/twix/data/repository/DefaultPokeRepository.kt @@ -20,10 +20,20 @@ class DefaultPokeRepository( override suspend fun savePokeHistory( goalId: Long, + targetDate: String, pokedAt: Long, ) { - pokeHistoryDao.upsert(PokeHistoryEntity(goalId = goalId, pokedAt = pokedAt)) + pokeHistoryDao.upsert( + PokeHistoryEntity( + goalId = goalId, + targetDate = targetDate, + pokedAt = pokedAt, + ), + ) } - override suspend fun findPokeHistory(goalId: Long): Long? = pokeHistoryDao.findByGoalId(goalId)?.pokedAt + override suspend fun findPokeHistory( + goalId: Long, + targetDate: String, + ): Long? = pokeHistoryDao.findPokeHistoryEntity(goalId, targetDate)?.pokedAt } From c08ba6baa4c05f436ca2c47b00b8da4764dcad26 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=ED=99=88=20?= =?UTF-8?q?=EC=B0=8C=EB=A5=B4=EA=B8=B0=20=EC=84=A0=ED=83=9D=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/main/src/main/java/com/twix/home/HomeViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/main/src/main/java/com/twix/home/HomeViewModel.kt b/feature/main/src/main/java/com/twix/home/HomeViewModel.kt index 3173db98b..fa70a6488 100644 --- a/feature/main/src/main/java/com/twix/home/HomeViewModel.kt +++ b/feature/main/src/main/java/com/twix/home/HomeViewModel.kt @@ -129,7 +129,7 @@ class HomeViewModel( private fun pokeGoal(goalId: Long) { viewModelScope.launch { - when (val result = pokeGoalUseCase.invoke(goalId)) { + when (val result = pokeGoalUseCase.invoke(goalId, currentState.selectedDate.toString())) { is PokeGoalResult.Success -> tryEmitSideEffect(HomeSideEffect.ShowPokeToast) is PokeGoalResult.OnCooldown -> From 9430f20f513d463e770ad0979829f94b697bb393 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=90=9B=20Fix:=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=83=B7=20=EC=83=81=EC=84=B8=20=EC=B0=8C=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/twix/photolog/detail/PhotologDetailViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt index 39e87feb8..20a197732 100644 --- a/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt +++ b/feature/photolog/detail/src/main/java/com/twix/photolog/detail/PhotologDetailViewModel.kt @@ -21,8 +21,8 @@ import com.twix.util.bus.GoalRefreshBus import com.twix.util.bus.PhotologRefreshBus import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce @@ -167,7 +167,7 @@ class PhotologDetailViewModel( private fun checkPokeCooldown() { viewModelScope.launch { - val remaining = pokeGoalUseCase.remainingCooldown(argGoalId) + val remaining = pokeGoalUseCase.remainingCooldown(argGoalId, argTargetDate.toString()) startPokeCooldown(remaining) } } @@ -175,7 +175,7 @@ class PhotologDetailViewModel( private fun pokeToPartner() { viewModelScope.launch { reduce { copy(isPoking = true) } - handlePokeResult(pokeGoalUseCase.invoke(argGoalId)) + handlePokeResult(pokeGoalUseCase.invoke(argGoalId, argTargetDate.toString())) } } From 780918dfaae2c36cd74091e3ee7851d4ebb5f99e Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=E2=9C=85=20Test:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20fake=20=EB=82=A0=EC=A7=9C=20=ED=82=A4=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/twix/domain/fake/FakePokeRepository.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt b/domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt index 3f0842b4c..84bf47fc1 100644 --- a/domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt +++ b/domain/src/test/java/com/twix/domain/fake/FakePokeRepository.kt @@ -5,8 +5,8 @@ import com.twix.domain.repository.PokeRepository import com.twix.result.AppResult class FakePokeRepository : PokeRepository { - val pokeHistory: MutableMap = mutableMapOf() - val savedPokeHistory: MutableMap = mutableMapOf() + val pokeHistory: MutableMap = mutableMapOf() + val savedPokeHistory: MutableMap = mutableMapOf() var pokeGoalResult: AppResult = AppResult.Success(PokeResult(message = "")) var pokeGoalCallCount: Int = 0 @@ -17,10 +17,19 @@ class FakePokeRepository : PokeRepository { override suspend fun savePokeHistory( goalId: Long, + targetDate: String, pokedAt: Long, ) { - savedPokeHistory[goalId] = pokedAt + savedPokeHistory[PokeHistoryKey(goalId, targetDate)] = pokedAt } - override suspend fun findPokeHistory(goalId: Long): Long? = pokeHistory[goalId] + override suspend fun findPokeHistory( + goalId: Long, + targetDate: String, + ): Long? = pokeHistory[PokeHistoryKey(goalId, targetDate)] + + data class PokeHistoryKey( + val goalId: Long, + val targetDate: String, + ) } From d039a4ce6a62240b4aee3b51c0f593a851572e5e Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 12/15] =?UTF-8?q?=E2=9C=85=20Test:=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=BF=A8=ED=83=80=EC=9E=84=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=B3=84=20=EA=B3=84=EC=82=B0=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/usecase/PokeGoalUseCaseTest.kt | 84 +++++++++++++++---- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt b/domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt index 7e95ef06b..67aa3f3fd 100644 --- a/domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt +++ b/domain/src/test/java/com/twix/domain/usecase/PokeGoalUseCaseTest.kt @@ -25,17 +25,19 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 1L + val targetDate = "2026-06-07" val serverMessage = "서버 응답 메시지" fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage)) - fakePokeRepository.pokeHistory[goalId] = null + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null // when - val result = useCase.invoke(goalId) + val result = useCase.invoke(goalId, targetDate) // then assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java) assertThat((result as PokeGoalResult.Success).message).isEqualTo(serverMessage) - assertThat(fakePokeRepository.savedPokeHistory[goalId]).isNotNull() + assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)]) + .isNotNull() } @Test @@ -43,15 +45,17 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 2L + val targetDate = "2026-06-07" fakePokeRepository.pokeGoalResult = AppResult.Error(AppError.Network()) - fakePokeRepository.pokeHistory[goalId] = null + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null // when - val result = useCase.invoke(goalId) + val result = useCase.invoke(goalId, targetDate) // then assertThat(result).isEqualTo(PokeGoalResult.Error) - assertThat(fakePokeRepository.savedPokeHistory[goalId]).isNull() + assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)]) + .isNull() } @Test @@ -59,11 +63,12 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 3L + val targetDate = "2026-06-07" val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) - fakePokeRepository.pokeHistory[goalId] = recentPokedAt + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = recentPokedAt // when - val result = useCase.invoke(goalId) + val result = useCase.invoke(goalId, targetDate) // then assertThat(result).isInstanceOf(PokeGoalResult.OnCooldown::class.java) @@ -76,13 +81,14 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 4L + val targetDate = "2026-06-07" val justExpiredPokedAt = System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS - 1 val serverMessage = "서버 응답 메시지" - fakePokeRepository.pokeHistory[goalId] = justExpiredPokedAt + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = justExpiredPokedAt fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage)) // when - val result = useCase.invoke(goalId) + val result = useCase.invoke(goalId, targetDate) // then assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java) @@ -94,10 +100,11 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 10L - fakePokeRepository.pokeHistory[goalId] = null + val targetDate = "2026-06-07" + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = null // when - val remaining = useCase.remainingCooldown(goalId) + val remaining = useCase.remainingCooldown(goalId, targetDate) // then assertThat(remaining).isEqualTo(0L) @@ -108,11 +115,12 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 11L + val targetDate = "2026-06-07" val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) - fakePokeRepository.pokeHistory[goalId] = recentPokedAt + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = recentPokedAt // when - val remaining = useCase.remainingCooldown(goalId) + val remaining = useCase.remainingCooldown(goalId, targetDate) // then assertThat(remaining).isGreaterThan(0L) @@ -123,13 +131,57 @@ class PokeGoalUseCaseTest { runTest { // given val goalId = 12L + val targetDate = "2026-06-07" val expiredPokedAt = System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS - 100 - fakePokeRepository.pokeHistory[goalId] = expiredPokedAt + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, targetDate)] = expiredPokedAt // when - val remaining = useCase.remainingCooldown(goalId) + val remaining = useCase.remainingCooldown(goalId, targetDate) // then assertThat(remaining).isEqualTo(0L) } + + @Test + fun `같은 목표라도 날짜가 다르면 remainingCooldown은 독립적으로 계산된다`() = + runTest { + // given + val goalId = 20L + val pokedDate = "2026-06-07" + val otherDate = "2026-06-08" + val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)] = recentPokedAt + + // when + val pokedDateRemaining = useCase.remainingCooldown(goalId, pokedDate) + val otherDateRemaining = useCase.remainingCooldown(goalId, otherDate) + + // then + assertThat(pokedDateRemaining).isGreaterThan(0L) + assertThat(otherDateRemaining).isEqualTo(0L) + } + + @Test + fun `같은 목표의 다른 날짜 쿨타임은 찌르기 요청을 막지 않는다`() = + runTest { + // given + val goalId = 21L + val pokedDate = "2026-06-07" + val otherDate = "2026-06-08" + val serverMessage = "서버 응답 메시지" + val recentPokedAt = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) + fakePokeRepository.pokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)] = recentPokedAt + fakePokeRepository.pokeGoalResult = AppResult.Success(PokeResult(message = serverMessage)) + + // when + val result = useCase.invoke(goalId, otherDate) + + // then + assertThat(result).isInstanceOf(PokeGoalResult.Success::class.java) + assertThat(fakePokeRepository.pokeGoalCallCount).isEqualTo(1) + assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, otherDate)]) + .isNotNull() + assertThat(fakePokeRepository.savedPokeHistory[FakePokeRepository.PokeHistoryKey(goalId, pokedDate)]) + .isNull() + } } From 526de07693440b1e690b6314d227dd3a8d28d3b1 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:45:00 +0900 Subject: [PATCH 13/15] =?UTF-8?q?=E2=9C=85=20Test:=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=83=B7=20=EC=83=81=EC=84=B8=20=EC=B0=8C=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=EB=B3=84=20=EC=BF=A8=ED=83=80=EC=9E=84=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/PhotologDetailViewModelTest.kt | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt b/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt index e9bea75ca..dec04d055 100644 --- a/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt +++ b/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt @@ -45,11 +45,13 @@ class PhotologDetailViewModelTest { Dispatchers.setMain(dispatcher) val goalId = 170L val remainingMs = 1_000L + val targetDate = LocalDate.of(2026, 6, 7) val pokeRepository = FakePokeRepository().apply { - pokeHistory[goalId] = System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS + remainingMs + pokeHistory[PokeHistoryKey(goalId, targetDate.toString())] = + System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS + remainingMs } - val viewModel = createViewModel(goalId = goalId, pokeRepository = pokeRepository) + val viewModel = createViewModel(goalId = goalId, pokeRepository = pokeRepository, targetDate = targetDate) runCurrent() assertThat(viewModel.uiState.value.pokeCooldownRemaining).isGreaterThan(0L) @@ -68,12 +70,14 @@ class PhotologDetailViewModelTest { val dispatcher = StandardTestDispatcher(testScheduler) Dispatchers.setMain(dispatcher) val goalId = 171L + val targetDate = LocalDate.of(2026, 6, 7) val pokeRepository = FakePokeRepository() - val viewModel = createViewModel(goalId = goalId, pokeRepository = pokeRepository) + val viewModel = createViewModel(goalId = goalId, pokeRepository = pokeRepository, targetDate = targetDate) advanceUntilIdle() assertThat(viewModel.uiState.value.pokeCooldownRemaining).isEqualTo(0L) - pokeRepository.pokeHistory[goalId] = System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) + pokeRepository.pokeHistory[PokeHistoryKey(goalId, targetDate.toString())] = + System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) viewModel.dispatch(PhotologDetailIntent.Poke) runCurrent() @@ -106,7 +110,7 @@ class PhotologDetailViewModelTest { } private class FakePokeRepository : PokeRepository { - val pokeHistory = mutableMapOf() + val pokeHistory = mutableMapOf() var pokeGoalCallCount = 0 var pokeGoalResult: AppResult = AppResult.Success(PokeResult(message = "ok")) @@ -117,14 +121,23 @@ private class FakePokeRepository : PokeRepository { override suspend fun savePokeHistory( goalId: Long, + targetDate: String, pokedAt: Long, ) { - pokeHistory[goalId] = pokedAt + pokeHistory[PokeHistoryKey(goalId, targetDate)] = pokedAt } - override suspend fun findPokeHistory(goalId: Long): Long? = pokeHistory[goalId] + override suspend fun findPokeHistory( + goalId: Long, + targetDate: String, + ): Long? = pokeHistory[PokeHistoryKey(goalId, targetDate)] } +private data class PokeHistoryKey( + val goalId: Long, + val targetDate: String, +) + private class FakePhotoLogRepository( private val goalId: Long, private val targetDate: LocalDate, From cfc3b1a5f2de78b65ff97d9e7f7519f73b35ed89 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 09:48:57 +0900 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=93=9D=20Docs:=20PR=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EB=A6=AC=EB=B7=B0=20=ED=9D=AC=EB=A7=9D=20?= =?UTF-8?q?=EA=B8=B0=ED=95=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 66239e3c4..26472b369 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,10 +2,6 @@ -## 리뷰/머지 희망 기한 (선택) - - - ## 작업내용 From d8f5256041a7266c503de7522354be3a2debb3c0 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 7 Jun 2026 10:02:10 +0900 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=94=A5=20Remove:=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detail/PhotologDetailViewModelTest.kt | 187 ------------------ 1 file changed, 187 deletions(-) delete mode 100644 feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt diff --git a/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt b/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt deleted file mode 100644 index dec04d055..000000000 --- a/feature/photolog/detail/src/test/java/com/twix/photolog/detail/PhotologDetailViewModelTest.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.twix.photolog.detail - -import androidx.lifecycle.SavedStateHandle -import com.twix.domain.model.enums.BetweenUs -import com.twix.domain.model.enums.GoalIconType -import com.twix.domain.model.enums.GoalReactionType -import com.twix.domain.model.photo.PhotoLogUploadInfo -import com.twix.domain.model.photo.PhotologParam -import com.twix.domain.model.photolog.GoalPhotolog -import com.twix.domain.model.photolog.PhotoLogs -import com.twix.domain.model.poke.PokeResult -import com.twix.domain.repository.PhotoLogRepository -import com.twix.domain.repository.PokeRepository -import com.twix.domain.usecase.PokeGoalUseCase -import com.twix.navigation.NavRoutes -import com.twix.photolog.detail.contract.PhotologDetailIntent -import com.twix.result.AppResult -import com.twix.util.bus.GoalRefreshBus -import com.twix.util.bus.PhotologRefreshBus -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import java.time.LocalDate - -@OptIn(ExperimentalCoroutinesApi::class) -class PhotologDetailViewModelTest { - @AfterEach - fun tearDown() { - Dispatchers.resetMain() - } - - @Test - fun `쿨타임이 만료되면 찌르기 비활성 상태가 해제된다`() = - runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - Dispatchers.setMain(dispatcher) - val goalId = 170L - val remainingMs = 1_000L - val targetDate = LocalDate.of(2026, 6, 7) - val pokeRepository = - FakePokeRepository().apply { - pokeHistory[PokeHistoryKey(goalId, targetDate.toString())] = - System.currentTimeMillis() - PokeGoalUseCase.COOLDOWN_MS + remainingMs - } - val viewModel = createViewModel(goalId = goalId, pokeRepository = pokeRepository, targetDate = targetDate) - - runCurrent() - assertThat(viewModel.uiState.value.pokeCooldownRemaining).isGreaterThan(0L) - assertThat(viewModel.uiState.value.isPokeDisabled).isTrue() - - advanceTimeBy(remainingMs + 1L) - advanceUntilIdle() - - assertThat(viewModel.uiState.value.pokeCooldownRemaining).isEqualTo(0L) - assertThat(viewModel.uiState.value.isPokeDisabled).isFalse() - } - - @Test - fun `찌르기 결과가 쿨타임이면 최신 잔여 시간을 상태에 반영한다`() = - runTest { - val dispatcher = StandardTestDispatcher(testScheduler) - Dispatchers.setMain(dispatcher) - val goalId = 171L - val targetDate = LocalDate.of(2026, 6, 7) - val pokeRepository = FakePokeRepository() - val viewModel = createViewModel(goalId = goalId, pokeRepository = pokeRepository, targetDate = targetDate) - advanceUntilIdle() - assertThat(viewModel.uiState.value.pokeCooldownRemaining).isEqualTo(0L) - - pokeRepository.pokeHistory[PokeHistoryKey(goalId, targetDate.toString())] = - System.currentTimeMillis() - (PokeGoalUseCase.COOLDOWN_MS / 2) - viewModel.dispatch(PhotologDetailIntent.Poke) - runCurrent() - - assertThat(viewModel.uiState.value.isPoking).isFalse() - assertThat(viewModel.uiState.value.pokeCooldownRemaining).isGreaterThan(0L) - assertThat(viewModel.uiState.value.isPokeDisabled).isTrue() - assertThat(pokeRepository.pokeGoalCallCount).isEqualTo(0) - } - - private fun createViewModel( - goalId: Long, - pokeRepository: FakePokeRepository, - targetDate: LocalDate = LocalDate.of(2026, 6, 6), - ): PhotologDetailViewModel = - PhotologDetailViewModel( - photologRepository = FakePhotoLogRepository(goalId, targetDate), - pokeGoalUseCase = PokeGoalUseCase(pokeRepository), - detailRefreshBus = PhotologRefreshBus(), - goalRefreshBus = GoalRefreshBus(), - savedStateHandle = - SavedStateHandle( - mapOf( - NavRoutes.PhotologDetailRoute.ARG_GOAL_ID to goalId, - NavRoutes.PhotologDetailRoute.ARG_DATE to targetDate.toString(), - NavRoutes.PhotologDetailRoute.ARG_BETWEEN_US to BetweenUs.PARTNER.name, - NavRoutes.PhotologDetailRoute.ARG_IS_COMPLETED to false, - ), - ), - ) -} - -private class FakePokeRepository : PokeRepository { - val pokeHistory = mutableMapOf() - var pokeGoalCallCount = 0 - var pokeGoalResult: AppResult = AppResult.Success(PokeResult(message = "ok")) - - override suspend fun pokeGoal(goalId: Long): AppResult { - pokeGoalCallCount += 1 - return pokeGoalResult - } - - override suspend fun savePokeHistory( - goalId: Long, - targetDate: String, - pokedAt: Long, - ) { - pokeHistory[PokeHistoryKey(goalId, targetDate)] = pokedAt - } - - override suspend fun findPokeHistory( - goalId: Long, - targetDate: String, - ): Long? = pokeHistory[PokeHistoryKey(goalId, targetDate)] -} - -private data class PokeHistoryKey( - val goalId: Long, - val targetDate: String, -) - -private class FakePhotoLogRepository( - private val goalId: Long, - private val targetDate: LocalDate, -) : PhotoLogRepository { - override suspend fun getUploadUrl(goalId: Long): AppResult = error("Not used") - - override suspend fun uploadPhotolog(photologParam: PhotologParam): AppResult = error("Not used") - - override suspend fun uploadPhotologImage( - goalId: Long, - bytes: ByteArray, - contentType: String, - ): AppResult = error("Not used") - - override suspend fun fetchPhotologs( - targetDate: LocalDate, - goalId: Long?, - ): AppResult = - AppResult.Success( - PhotoLogs( - targetDate = this.targetDate.toString(), - myNickname = "me", - partnerNickname = "partner", - goals = - listOf( - GoalPhotolog( - goalId = this.goalId, - goalName = "goal", - icon = GoalIconType.DEFAULT, - myPhotolog = null, - partnerPhotolog = null, - ), - ), - ), - ) - - override suspend fun reactToPhotolog( - photologId: Long, - reaction: GoalReactionType, - ): AppResult = error("Not used") - - override suspend fun modifyPhotolog( - photologId: Long, - fileName: String, - comment: String, - ): AppResult = error("Not used") -}