From 99ea3325cdaf0150f70e079fce3ffe1ba570e1ae Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:54:18 -0300 Subject: [PATCH] refactor(assistant): inject TimeProvider and IdGenerator seams AssistantViewModel built chat message timestamps and ids by calling `Date()` and `UUID.randomUUID()` directly, hidden dependencies on the wall clock and the RNG that made those fields untestable. Introduce two small seams (`TimeProvider`, `IdGenerator`) with default production implementations, provide them as singletons from AppModule, and inject them into the ViewModel. Production behavior is unchanged. AssistantViewModelTest now passes deterministic fakes instead of leaning on the system clock and random UUIDs. --- .../plainstudio/stackcasino/di/AppModule.kt | 19 +++++++++++++++--- .../feature/assistant/AssistantViewModel.kt | 14 +++++++------ .../stackcasino/util/IdGenerator.kt | 19 ++++++++++++++++++ .../stackcasino/util/TimeProvider.kt | 20 +++++++++++++++++++ .../assistant/AssistantViewModelTest.kt | 11 +++++++++- 5 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/plainstudio/stackcasino/util/IdGenerator.kt create mode 100644 app/src/main/java/com/plainstudio/stackcasino/util/TimeProvider.kt diff --git a/app/src/main/java/com/plainstudio/stackcasino/di/AppModule.kt b/app/src/main/java/com/plainstudio/stackcasino/di/AppModule.kt index 6669e10..51875ee 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/di/AppModule.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/di/AppModule.kt @@ -5,6 +5,10 @@ import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.firestore +import com.plainstudio.stackcasino.util.IdGenerator +import com.plainstudio.stackcasino.util.SystemTimeProvider +import com.plainstudio.stackcasino.util.TimeProvider +import com.plainstudio.stackcasino.util.UuidIdGenerator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,9 +18,10 @@ import javax.inject.Singleton /** * Application-scoped Hilt bindings. * - * Currently exposes only the Firebase singletons. They are not consumed - * yet; the providers exist so downstream cards (Login, Wallet, History, - * etc.) inject the SDK without touching DI plumbing. + * Exposes the Firebase singletons plus the app-wide testability seams + * ([TimeProvider], [IdGenerator]). The Firebase providers exist so + * downstream cards (Login, Wallet, History, etc.) inject the SDK + * without touching DI plumbing. * * FirebaseApp is initialized implicitly by FirebaseInitProvider (which * the google-services Gradle plugin registers in the merged manifest), @@ -36,4 +41,12 @@ object AppModule { @Provides @Singleton fun provideFirebaseFirestore(): FirebaseFirestore = Firebase.firestore + + @Provides + @Singleton + fun provideTimeProvider(): TimeProvider = SystemTimeProvider() + + @Provides + @Singleton + fun provideIdGenerator(): IdGenerator = UuidIdGenerator() } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt index 6eca01e..1e6bc23 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.plainstudio.stackcasino.domain.assistant.AssistantRepository import com.plainstudio.stackcasino.domain.assistant.ChatTurn import com.plainstudio.stackcasino.domain.assistant.Role +import com.plainstudio.stackcasino.util.IdGenerator +import com.plainstudio.stackcasino.util.TimeProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -12,9 +14,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.text.SimpleDateFormat -import java.util.Date import java.util.Locale -import java.util.UUID import javax.inject.Inject /** @@ -37,6 +37,8 @@ class AssistantViewModel @Inject constructor( private val repository: AssistantRepository, + private val timeProvider: TimeProvider, + private val idGenerator: IdGenerator, ) : ViewModel() { private val _uiState = MutableStateFlow(AssistantUiState.Welcome) val uiState: StateFlow = _uiState.asStateFlow() @@ -116,11 +118,11 @@ class AssistantViewModel copy(messages = messages + message) } - private fun nowLabel(): String = TIMESTAMP_FORMAT.format(Date()) + private fun nowLabel(): String = TIMESTAMP_FORMAT.format(timeProvider.now()) - // Random per-message handle so the LazyColumn key never collides - // on repeated identical questions sent inside the same minute. - private fun newId(): String = UUID.randomUUID().toString() + // Per-message handle so the LazyColumn key never collides on + // repeated identical questions sent inside the same minute. + private fun newId(): String = idGenerator.newId() companion object { const val MAX_USER_MESSAGE_LENGTH = 300 diff --git a/app/src/main/java/com/plainstudio/stackcasino/util/IdGenerator.kt b/app/src/main/java/com/plainstudio/stackcasino/util/IdGenerator.kt new file mode 100644 index 0000000..1961f34 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/util/IdGenerator.kt @@ -0,0 +1,19 @@ +package com.plainstudio.stackcasino.util + +import java.util.UUID + +/** + * Seam over random id generation so classes that key list items by id + * stay deterministic under unit tests. Production code gets + * [UuidIdGenerator]; tests inject a fake that returns predictable ids. + */ +fun interface IdGenerator { + fun newId(): String +} + +/** + * Default [IdGenerator] backed by random UUIDs. + */ +class UuidIdGenerator : IdGenerator { + override fun newId(): String = UUID.randomUUID().toString() +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/util/TimeProvider.kt b/app/src/main/java/com/plainstudio/stackcasino/util/TimeProvider.kt new file mode 100644 index 0000000..63d1c34 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/util/TimeProvider.kt @@ -0,0 +1,20 @@ +package com.plainstudio.stackcasino.util + +import java.util.Date + +/** + * Seam over the system clock so time-dependent classes (chat message + * timestamps, round labels, ...) stay deterministic under unit tests. + * Production code gets [SystemTimeProvider]; tests inject a fake that + * returns a fixed instant. + */ +fun interface TimeProvider { + fun now(): Date +} + +/** + * Default [TimeProvider] backed by the real wall clock. + */ +class SystemTimeProvider : TimeProvider { + override fun now(): Date = Date() +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModelTest.kt index cfee2b6..1ce6868 100644 --- a/app/src/test/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModelTest.kt +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/assistant/AssistantViewModelTest.kt @@ -5,6 +5,8 @@ import com.plainstudio.stackcasino.domain.assistant.AssistantRepository import com.plainstudio.stackcasino.domain.assistant.ChatTurn import com.plainstudio.stackcasino.domain.assistant.Role import com.plainstudio.stackcasino.testing.MainDispatcherRule +import com.plainstudio.stackcasino.util.IdGenerator +import com.plainstudio.stackcasino.util.TimeProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -15,6 +17,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import java.util.Date class AssistantViewModelTest { @get:Rule @@ -22,7 +25,13 @@ class AssistantViewModelTest { private val repository = mockk() - private fun viewModel(): AssistantViewModel = AssistantViewModel(repository) + // Deterministic seams so message ids and timestamps stay stable + // across runs instead of leaning on UUID / the wall clock. + private val fixedTime = TimeProvider { Date(0L) } + private var idCounter = 0 + private val sequentialIds = IdGenerator { "msg-${idCounter++}" } + + private fun viewModel(): AssistantViewModel = AssistantViewModel(repository, fixedTime, sequentialIds) // --- initial state ----------------------------------------------------