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 ----------------------------------------------------