diff --git a/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt b/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt index eb9dd4f4..6c87febb 100644 --- a/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt @@ -8,9 +8,11 @@ import com.gamss.android.data.local.auth.TokenProvider import com.gamss.android.data.local.auth.TokenProviderImpl import com.gamss.android.data.repository.AuthRepositoryImpl import com.gamss.android.data.repository.ConversationRepositoryImpl +import com.gamss.android.data.repository.TokenUsageRefreshNotifierImpl import com.gamss.android.data.repository.UserRepositoryImpl import com.gamss.android.domain.conversation.ConversationRepository import com.gamss.android.domain.repository.AuthRepository +import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.repository.UserRepository import dagger.Binds import dagger.Module @@ -45,6 +47,11 @@ internal abstract class RepositoryModule { conversationRepositoryImpl: ConversationRepositoryImpl ): ConversationRepository + @Binds + abstract fun bindTokenUsageRefreshNotifier( + tokenUsageRefreshNotifierImpl: TokenUsageRefreshNotifierImpl, + ): TokenUsageRefreshNotifier + @Binds abstract fun bindAuthTokenLocalDataSource( encryptedAuthTokenLocalDataSource: EncryptedAuthTokenLocalDataSource, diff --git a/data/src/main/java/com/gamss/android/data/remote/user/UserService.kt b/data/src/main/java/com/gamss/android/data/remote/user/UserService.kt index 8734239b..d8844395 100644 --- a/data/src/main/java/com/gamss/android/data/remote/user/UserService.kt +++ b/data/src/main/java/com/gamss/android/data/remote/user/UserService.kt @@ -2,8 +2,10 @@ package com.gamss.android.data.remote.user import com.gamss.android.data.remote.model.response.ApiResponse import com.gamss.android.data.remote.user.model.request.UpdateNicknameRequest +import com.gamss.android.data.remote.user.model.response.DailyTokenUsageDataResponse import retrofit2.http.Body import retrofit2.http.DELETE +import retrofit2.http.GET import retrofit2.http.PATCH internal interface UserService { @@ -13,4 +15,7 @@ internal interface UserService { @DELETE("/api/members/me") suspend fun secessionUser(): ApiResponse + + @GET("/api/members/me/token-usage") + suspend fun getDailyTokenUsage(): ApiResponse } diff --git a/data/src/main/java/com/gamss/android/data/remote/user/model/response/DailyTokenUsageDataResponse.kt b/data/src/main/java/com/gamss/android/data/remote/user/model/response/DailyTokenUsageDataResponse.kt new file mode 100644 index 00000000..b2d2111b --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/user/model/response/DailyTokenUsageDataResponse.kt @@ -0,0 +1,17 @@ +package com.gamss.android.data.remote.user.model.response + +import com.gamss.android.domain.model.DailyTokenUsage +import kotlinx.serialization.Serializable + +@Serializable +internal data class DailyTokenUsageDataResponse( + val usedTokens: Long, + val dailyLimit: Long? = null, + val exceeded: Boolean, +) { + fun toDomain() = DailyTokenUsage( + usedTokens = usedTokens, + dailyLimit = dailyLimit, + exceeded = exceeded, + ) +} diff --git a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt new file mode 100644 index 00000000..aec852c6 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt @@ -0,0 +1,25 @@ +package com.gamss.android.data.repository + +import com.gamss.android.domain.repository.TokenUsageRefreshNotifier +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class TokenUsageRefreshNotifierImpl @Inject constructor() : TokenUsageRefreshNotifier { + + /** 신호는 마지막 한 번만 의미가 있다. 버퍼가 차면 오래된 쪽을 버려 tryEmit 이 실패하지 않게 한다. */ + private val events = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val refreshEvents: Flow = events.asSharedFlow() + + override fun requestRefresh() { + events.tryEmit(Unit) + } +} diff --git a/data/src/main/java/com/gamss/android/data/repository/UserRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/UserRepositoryImpl.kt index 4729caca..23c2c1a6 100644 --- a/data/src/main/java/com/gamss/android/data/repository/UserRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/UserRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.gamss.android.data.repository import com.gamss.android.core.common.AppResult import com.gamss.android.data.remote.user.UserService import com.gamss.android.data.remote.user.model.request.UpdateNicknameRequest +import com.gamss.android.domain.model.DailyTokenUsage import com.gamss.android.domain.repository.UserRepository import javax.inject.Inject import javax.inject.Singleton @@ -24,4 +25,11 @@ internal class UserRepositoryImpl @Inject constructor( userService.secessionUser() } } + + override suspend fun getDailyTokenUsage(): AppResult { + return runCatchingApiCall { + val response = userService.getDailyTokenUsage() + checkNotNull(response.data) { "No available daily token usage data" }.toDomain() + } + } } diff --git a/data/src/test/java/com/gamss/android/data/repository/AuthRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/AuthRepositoryImplTest.kt index eb920209..9f319bb5 100644 --- a/data/src/test/java/com/gamss/android/data/repository/AuthRepositoryImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/AuthRepositoryImplTest.kt @@ -17,14 +17,10 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test -import retrofit2.HttpException -import retrofit2.Response import java.io.IOException @OptIn(ExperimentalCoroutinesApi::class) @@ -193,9 +189,4 @@ class AuthRepositoryImplTest { refreshToken = "new-refresh-token", ), ) - - private fun httpException(statusCode: Int): HttpException { - val errorBody = "{}".toResponseBody("application/json".toMediaType()) - return HttpException(Response.error>(statusCode, errorBody)) - } } diff --git a/data/src/test/java/com/gamss/android/data/repository/HttpExceptions.kt b/data/src/test/java/com/gamss/android/data/repository/HttpExceptions.kt new file mode 100644 index 00000000..ce983dd7 --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/repository/HttpExceptions.kt @@ -0,0 +1,11 @@ +package com.gamss.android.data.repository + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.HttpException +import retrofit2.Response + +internal fun httpException(statusCode: Int): HttpException { + val errorBody = "{}".toResponseBody("application/json".toMediaType()) + return HttpException(Response.error(statusCode, errorBody)) +} diff --git a/data/src/test/java/com/gamss/android/data/repository/UserRepositoryImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/UserRepositoryImplTest.kt new file mode 100644 index 00000000..fe65e81c --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/repository/UserRepositoryImplTest.kt @@ -0,0 +1,57 @@ +package com.gamss.android.data.repository + +import com.gamss.android.core.common.AppResult +import com.gamss.android.data.remote.model.response.ApiResponse +import com.gamss.android.data.remote.user.UserService +import com.gamss.android.data.remote.user.model.response.DailyTokenUsageDataResponse +import com.gamss.android.domain.model.SessionExpiredException +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class UserRepositoryImplTest { + + private val userService: UserService = mockk() + + @Test + fun `token usage data is mapped to the domain model`() = runTest { + coEvery { userService.getDailyTokenUsage() } returns ApiResponse( + success = true, + data = DailyTokenUsageDataResponse( + usedTokens = 12_000, + dailyLimit = 100_000, + exceeded = false, + ), + ) + + val result = repository().getDailyTokenUsage() + + assertTrue(result is AppResult.Success) + assertEquals(12_000, (result as AppResult.Success).data.usedTokens) + assertEquals(100_000L, result.data.dailyLimit) + } + + @Test + fun `missing data is returned as failure`() = runTest { + coEvery { userService.getDailyTokenUsage() } returns ApiResponse(success = true) + + val result = repository().getDailyTokenUsage() + + assertTrue(result is AppResult.Failure) + } + + @Test + fun `unauthorized response is mapped to session expiration`() = runTest { + coEvery { userService.getDailyTokenUsage() } throws httpException(401) + + val result = repository().getDailyTokenUsage() + + assertTrue(result is AppResult.Failure) + assertTrue((result as AppResult.Failure).throwable is SessionExpiredException) + } + + private fun repository() = UserRepositoryImpl(userService) +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt b/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt new file mode 100644 index 00000000..0264bdfd --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt @@ -0,0 +1,7 @@ +package com.gamss.android.domain.model + +data class DailyTokenUsage( + val usedTokens: Long, + val dailyLimit: Long?, + val exceeded: Boolean, +) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt new file mode 100644 index 00000000..f8a40797 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt @@ -0,0 +1,9 @@ +package com.gamss.android.domain.repository + +import kotlinx.coroutines.flow.Flow + +interface TokenUsageRefreshNotifier { + val refreshEvents: Flow + + fun requestRefresh() +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/repository/UserRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/repository/UserRepository.kt index 969f99d8..03dafdb9 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/repository/UserRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/repository/UserRepository.kt @@ -1,9 +1,12 @@ package com.gamss.android.domain.repository import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.model.DailyTokenUsage interface UserRepository { suspend fun updateNickname(nickname: String): AppResult suspend fun secession(): AppResult + + suspend fun getDailyTokenUsage(): AppResult } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt new file mode 100644 index 00000000..6796ef89 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt @@ -0,0 +1,14 @@ +package com.gamss.android.domain.usecase + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.model.DailyTokenUsage +import com.gamss.android.domain.repository.UserRepository +import javax.inject.Inject + +class GetDailyTokenUsageUseCase @Inject constructor( + private val userRepository: UserRepository, +) : NoParamUseCase> { + + override suspend fun invoke(): AppResult = + userRepository.getDailyTokenUsage() +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index ac8a726f..795648be 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -10,6 +10,7 @@ import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.conversation.SendMessageUseCase import com.gamss.android.domain.conversation.nextCommentRevealGapMillis +import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -27,6 +28,7 @@ class ChatRoomViewModel @Inject constructor( private val sendMessage: SendMessageUseCase, private val getMessages: GetMessagesUseCase, private val summaryStore: ConversationSummaryStore, + private val tokenUsageRefreshNotifier: TokenUsageRefreshNotifier, ) : ViewModel(), ContainerHost { @@ -130,6 +132,7 @@ class ChatRoomViewModel @Inject constructor( } launchCommentReveal() sent.commentStatus.toUserMessage()?.let { postSideEffect(ChatRoomSideEffect.ShowToast(it)) } + tokenUsageRefreshNotifier.requestRefresh() // 요약기가 돌 수 있어 화면 갱신 뒤에 둔다. summaryStore.add(sent.message.content) } diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt index 24040d2d..86526d4f 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt @@ -10,10 +10,13 @@ import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.conversation.SendMessageUseCase import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.summary.DiarySummarizer import com.gamss.android.domain.summary.UtteranceTokenCounter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -163,18 +166,70 @@ class ChatRoomRevealTest { } } + @Test + fun 전송이_성공하면_토큰_사용량_갱신을_요청한다() = runTest { + val notifier = RecordingTokenUsageRefreshNotifier() + val viewModel = viewModel(FakeConversationRepository(commentCount = 1), notifier) + + viewModel.test(this) { + expectInitialState() + containerHost.onInputChange(INPUT) + skipItems(1) // input 반영 + containerHost.onSend() + skipItems(1) // isSending = true + awaitState() // 전송 성공 반영 + + assertEquals(1, notifier.refreshCount) + + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun 전송이_실패하면_토큰_사용량_갱신을_요청하지_않는다() = runTest { + val notifier = RecordingTokenUsageRefreshNotifier() + val repository = FakeConversationRepository(commentCount = 1, failing = true) + val viewModel = viewModel(repository, notifier) + + viewModel.test(this) { + expectInitialState() + containerHost.onInputChange(INPUT) + skipItems(1) // input 반영 + containerHost.onSend() + skipItems(1) // isSending = true + awaitState() // 실패 반영 + + assertEquals(0, notifier.refreshCount) + + cancelAndIgnoreRemainingItems() + } + } + private fun viewModel(commentCount: Int): ChatRoomViewModel = viewModel(FakeConversationRepository(commentCount)) - private fun viewModel(conversationRepository: FakeConversationRepository): ChatRoomViewModel { - return ChatRoomViewModel( - sendMessage = SendMessageUseCase(conversationRepository), - getMessages = GetMessagesUseCase(conversationRepository), - summaryStore = ConversationSummaryStore( - summarizer = PassThroughSummarizer, - tokenCounter = CharLengthTokenCounter, - ), - ) + private fun viewModel( + conversationRepository: FakeConversationRepository, + tokenUsageRefreshNotifier: TokenUsageRefreshNotifier = RecordingTokenUsageRefreshNotifier(), + ): ChatRoomViewModel = ChatRoomViewModel( + sendMessage = SendMessageUseCase(conversationRepository), + getMessages = GetMessagesUseCase(conversationRepository), + summaryStore = ConversationSummaryStore( + summarizer = PassThroughSummarizer, + tokenCounter = CharLengthTokenCounter, + ), + tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, + ) + + private class RecordingTokenUsageRefreshNotifier : TokenUsageRefreshNotifier { + var refreshCount = 0 + private set + + override val refreshEvents: Flow = emptyFlow() + + override fun requestRefresh() { + refreshCount++ + } } private object PassThroughSummarizer : DiarySummarizer { diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 6074a089..b7b6c89e 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -10,4 +10,7 @@ dependencies { implementation(projects.core.common) implementation(projects.core.ui) implementation(projects.domain) + + // orbit-test 가 전이로 가져오지만, 직접 쓰는 API 라 명시한다. + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt index 4632ae68..55a43be5 100644 --- a/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt @@ -7,10 +7,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -41,12 +46,25 @@ fun HomeScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding), - contentAlignment = Alignment.Center, ) { + TokenUsageIndicator( + tokenUsage = state.tokenUsage, + isLoading = state.isTokenUsageLoading, + // innerPadding 에 이미 상태바 inset 이 들어 있어 따로 더하지 않는다. + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp), + ) + if (state.isLoading) { - CircularProgressIndicator() + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) } else { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( text = state.greeting.ifEmpty { "GAMSS" }, style = MaterialTheme.typography.headlineLarge, @@ -65,3 +83,70 @@ fun HomeScreen( } } } + +@Composable +private fun TokenUsageIndicator( + tokenUsage: TokenUsageUiModel?, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + if (tokenUsage == null && !isLoading) return + + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + tonalElevation = 2.dp, + ) { + // 이미 아는 사용량이 있으면 갱신 중에도 지우지 않고 그대로 보여준다. + if (tokenUsage != null) { + TokenUsageContent(tokenUsage) + } else { + CircularProgressIndicator( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .size(18.dp), + strokeWidth = 2.dp, + ) + } + } +} + +@Composable +private fun TokenUsageContent(tokenUsage: TokenUsageUiModel) { + val displayText = tokenUsage.toDisplayText() + + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + ) { + Text( + text = "오늘 토큰", + style = MaterialTheme.typography.labelMedium, + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = displayText.headline, + style = MaterialTheme.typography.titleMedium, + ) + displayText.supportingText?.let { supportingText -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = supportingText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + tokenUsage.usageRatio?.let { usageRatio -> + Spacer(modifier = Modifier.height(10.dp)) + LinearProgressIndicator( + progress = { usageRatio.coerceIn(0f, 1f) }, + // fillMaxWidth 로 두면 배지 전체가 화면 폭까지 늘어난다. + modifier = Modifier.width(96.dp), + color = if (tokenUsage.exceeded) { + MaterialTheme.colorScheme.error + } else { + ProgressIndicatorDefaults.linearColor + }, + ) + } + } +} diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeState.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeState.kt index a59a7388..b37884a8 100644 --- a/feature/home/src/main/java/com/gamss/android/feature/home/HomeState.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeState.kt @@ -3,4 +3,6 @@ package com.gamss.android.feature.home data class HomeState( val isLoading: Boolean = false, val greeting: String = "", + val isTokenUsageLoading: Boolean = false, + val tokenUsage: TokenUsageUiModel? = null, ) diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt index d1f7c6cf..c104c486 100644 --- a/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt @@ -1,10 +1,15 @@ package com.gamss.android.feature.home import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.repository.TokenUsageRefreshNotifier +import com.gamss.android.domain.usecase.GetDailyTokenUsageUseCase import com.gamss.android.domain.usecase.LogoutUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @@ -12,18 +17,25 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val logoutUseCase: LogoutUseCase, + private val getDailyTokenUsageUseCase: GetDailyTokenUsageUseCase, + private val tokenUsageRefreshNotifier: TokenUsageRefreshNotifier, ) : ViewModel(), ContainerHost { override val container = container(HomeState()) + private var tokenUsageJob: Job? = null + init { loadGreeting() + refreshDailyTokenUsage() + viewModelScope.launch { + tokenUsageRefreshNotifier.refreshEvents.collect { refreshDailyTokenUsage() } + } } fun loadGreeting() = intent { reduce { state.copy(isLoading = true) } - // mock 데이터. 실제 UseCase/Repository 연동 시 교체한다. delay(500) val mockGreeting = "오늘 하루는 어땠나요?" @@ -37,4 +49,23 @@ class HomeViewModel @Inject constructor( is AppResult.Failure -> postSideEffect(HomeSideEffect.ShowToast("로그아웃에 실패했어요")) } } + + private fun refreshDailyTokenUsage() { + tokenUsageJob?.cancel() + tokenUsageJob = intent { + reduce { state.copy(isTokenUsageLoading = true) } + + val usage = when (val result = getDailyTokenUsageUseCase()) { + is AppResult.Success -> result.data.toUiModel() + is AppResult.Failure -> null + } + + reduce { + state.copy( + isTokenUsageLoading = false, + tokenUsage = usage ?: state.tokenUsage, + ) + } + } + } } diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/TokenUsageUiModel.kt b/feature/home/src/main/java/com/gamss/android/feature/home/TokenUsageUiModel.kt new file mode 100644 index 00000000..b774ecd3 --- /dev/null +++ b/feature/home/src/main/java/com/gamss/android/feature/home/TokenUsageUiModel.kt @@ -0,0 +1,38 @@ +package com.gamss.android.feature.home + +import com.gamss.android.domain.model.DailyTokenUsage +import java.util.Locale +import kotlin.math.roundToInt + +data class TokenUsageUiModel( + val usedTokens: Long, + /** 0 이하 한도는 무제한으로 보아 null 로 정규화한 값만 담는다. */ + val dailyLimit: Long?, + val exceeded: Boolean, +) { + val usageRatio: Float? get() = dailyLimit?.let { usedTokens.toFloat() / it } +} + +internal fun DailyTokenUsage.toUiModel() = TokenUsageUiModel( + usedTokens = usedTokens, + dailyLimit = dailyLimit?.takeIf { it > 0 }, + exceeded = exceeded, +) + +internal data class TokenUsageDisplayText( + val headline: String, + val supportingText: String?, +) + +internal fun TokenUsageUiModel.toDisplayText(locale: Locale = Locale.getDefault()): TokenUsageDisplayText { + val absoluteText = dailyLimit + ?.let { "${usedTokens.formatTokenCount(locale)} / ${it.formatTokenCount(locale)}" } + ?: usedTokens.formatTokenCount(locale) + + return TokenUsageDisplayText( + headline = usageRatio?.let { "${(it * 100).roundToInt()}%" } ?: absoluteText, + supportingText = absoluteText.takeIf { usageRatio != null }, + ) +} + +private fun Long.formatTokenCount(locale: Locale): String = String.format(locale, "%,d", this) diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTokenUsageRefreshTest.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTokenUsageRefreshTest.kt new file mode 100644 index 00000000..d770c86c --- /dev/null +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTokenUsageRefreshTest.kt @@ -0,0 +1,143 @@ +package com.gamss.android.feature.home + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.model.DailyTokenUsage +import com.gamss.android.domain.model.SessionState +import com.gamss.android.domain.repository.AuthRepository +import com.gamss.android.domain.repository.TokenUsageRefreshNotifier +import com.gamss.android.domain.repository.UserRepository +import com.gamss.android.domain.usecase.GetDailyTokenUsageUseCase +import com.gamss.android.domain.usecase.LogoutUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * 채팅에서 홈으로 이어지는 갱신 배선을 본다. 표시 형식은 [TokenUsageUiModelTest] 가 본다. + * orbit 의 test() 는 컨테이너를 초기 상태로 되돌려 init 의 첫 조회를 지우므로 실제 컨테이너를 그대로 쓴다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class HomeTokenUsageRefreshTest { + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun 갱신_이벤트가_오면_사용량을_다시_조회한다() = runTest { + val repository = FakeUserRepository(usedTokens = listOf(12_000, 20_000)) + val notifier = FakeTokenUsageRefreshNotifier() + val viewModel = viewModel(repository, notifier) + + val loaded = viewModel.container.stateFlow.first { it.tokenUsage != null } + assertEquals(12_000L, loaded.tokenUsage?.usedTokens) + + notifier.requestRefresh() + + repository.awaitCalls(2) + + val refreshed = viewModel.container.stateFlow.first { !it.isTokenUsageLoading } + assertEquals(20_000L, refreshed.tokenUsage?.usedTokens) + } + + @Test + fun 갱신이_실패하면_마지막으로_아는_사용량을_유지한다() = runTest { + val repository = FakeUserRepository(usedTokens = listOf(12_000), failAfterFirst = true) + val notifier = FakeTokenUsageRefreshNotifier() + val viewModel = viewModel(repository, notifier) + + viewModel.container.stateFlow.first { it.tokenUsage != null } + + notifier.requestRefresh() + repository.awaitCalls(2) + + val afterFailure = viewModel.container.stateFlow.first { !it.isTokenUsageLoading } + // 실패로 지워버리면 배지가 사라져 사용자가 한도 초과를 알 수 없다. + assertEquals(12_000L, afterFailure.tokenUsage?.usedTokens) + } + + private fun viewModel( + repository: FakeUserRepository, + notifier: TokenUsageRefreshNotifier, + ) = HomeViewModel( + logoutUseCase = LogoutUseCase(FakeAuthRepository), + getDailyTokenUsageUseCase = GetDailyTokenUsageUseCase(repository), + tokenUsageRefreshNotifier = notifier, + ) + + private class FakeTokenUsageRefreshNotifier : TokenUsageRefreshNotifier { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + override val refreshEvents: Flow = events + + override fun requestRefresh() { + events.tryEmit(Unit) + } + } + + private class FakeUserRepository( + private val usedTokens: List, + private val failAfterFirst: Boolean = false, + ) : UserRepository { + private val calls = MutableStateFlow(0) + + val callCount: Int get() = calls.value + + suspend fun awaitCalls(count: Int) { + calls.first { it >= count } + } + + override suspend fun updateNickname(nickname: String): AppResult = AppResult.Success(nickname) + + override suspend fun secession(): AppResult = AppResult.Success(Unit) + + override suspend fun getDailyTokenUsage(): AppResult { + val index = calls.value + calls.value = index + 1 + if (failAfterFirst && index >= usedTokens.size) { + return AppResult.Failure(IllegalStateException("token usage unavailable")) + } + return AppResult.Success( + DailyTokenUsage( + usedTokens = usedTokens[index.coerceAtMost(usedTokens.lastIndex)], + dailyLimit = DAILY_LIMIT, + exceeded = false, + ), + ) + } + } + + private object FakeAuthRepository : AuthRepository { + override val sessionState: StateFlow = MutableStateFlow(SessionState.Authenticated) + + override suspend fun login(googleIdToken: String): AppResult = AppResult.Success(Unit) + + override suspend fun reissueTokens(): AppResult = AppResult.Success(Unit) + + override suspend fun restoreSession(): AppResult = AppResult.Success(Unit) + + override suspend fun logout(): AppResult = AppResult.Success(Unit) + } + + private companion object { + const val DAILY_LIMIT = 100_000L + } +} diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/TokenUsageUiModelTest.kt b/feature/home/src/test/java/com/gamss/android/feature/home/TokenUsageUiModelTest.kt new file mode 100644 index 00000000..b99567b1 --- /dev/null +++ b/feature/home/src/test/java/com/gamss/android/feature/home/TokenUsageUiModelTest.kt @@ -0,0 +1,75 @@ +package com.gamss.android.feature.home + +import com.gamss.android.domain.model.DailyTokenUsage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.Locale + +class TokenUsageUiModelTest { + + @Test + fun `maps finite daily usage without formatting presentation strings`() { + val uiModel = DailyTokenUsage( + usedTokens = 12_000, + dailyLimit = 100_000, + exceeded = false, + ).toUiModel() + + assertEquals(12_000L, uiModel.usedTokens) + assertEquals(100_000L, uiModel.dailyLimit) + assertEquals(0.12f, requireNotNull(uiModel.usageRatio), 0.0001f) + assertFalse(uiModel.exceeded) + } + + @Test + fun `keeps exceeded ratio for the presentation layer while progress can be capped`() { + val uiModel = DailyTokenUsage( + usedTokens = 125_000, + dailyLimit = 100_000, + exceeded = true, + ).toUiModel() + + val usageRatio = requireNotNull(uiModel.usageRatio) + + assertEquals(1.25f, usageRatio, 0.0001f) + assertEquals(1f, usageRatio.coerceIn(0f, 1f), 0.0001f) + } + + @Test + fun `unlimited or zero daily limit omits ratio`() { + listOf(null, 0).forEach { dailyLimit -> + val uiModel = DailyTokenUsage( + usedTokens = 12_000, + dailyLimit = dailyLimit, + exceeded = false, + ).toUiModel() + + assertNull(uiModel.dailyLimit) + assertNull(uiModel.usageRatio) + } + } + + @Test + fun `formatter shows percent with the absolute count and falls back when unlimited`() { + val finite = DailyTokenUsage(usedTokens = 12_000, dailyLimit = 100_000, exceeded = false) + .toUiModel() + .toDisplayText(Locale.US) + val unlimited = DailyTokenUsage(usedTokens = 12_000, dailyLimit = null, exceeded = false) + .toUiModel() + .toDisplayText(Locale.US) + + assertEquals(TokenUsageDisplayText("12%", "12,000 / 100,000"), finite) + assertEquals(TokenUsageDisplayText("12,000", null), unlimited) + } + + @Test + fun `formatter preserves exceeded percent above one hundred`() { + val displayText = DailyTokenUsage(125_000, 100_000, true) + .toUiModel() + .toDisplayText(Locale.US) + + assertEquals(TokenUsageDisplayText("125%", "125,000 / 100,000"), displayText) + } +}