diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/LoadingOrPromptTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/LoadingOrPromptTest.kt index 5bc8dfe..4ca18d7 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/LoadingOrPromptTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/LoadingOrPromptTest.kt @@ -50,4 +50,19 @@ class LoadingOrPromptTest { composeTestRule.onNodeWithText("Finding your random movie").assertIsDisplayed() } + + @Test + fun animation_completes_full_cycle() { + composeTestRule.mainClock.autoAdvance = false + composeTestRule.setContent { + LoadingOrPrompt(isLoading = true) + } + + composeTestRule.mainClock.advanceTimeBy(4000) + + // Verify components are still displayed after animation + composeTestRule.onNodeWithTag("test-loading-indicator").assertIsDisplayed() + composeTestRule.onNodeWithText("Rolling the dice", substring = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Finding your random movie").assertIsDisplayed() + } } diff --git a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt index 33320d7..8e8ad50 100644 --- a/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt +++ b/composeApp/src/androidInstrumentedTest/kotlin/com/randomboxd/feature/random_film/presentation/RandomFilmScreenTest.kt @@ -23,6 +23,7 @@ import com.nacchofer31.randomboxd.random_film.domain.model.FilmGenre import com.nacchofer31.randomboxd.random_film.domain.model.UserName import com.nacchofer31.randomboxd.random_film.presentation.RandomFilmScreen import com.nacchofer31.randomboxd.random_film.presentation.RandomFilmScreenRoot +import com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Rule @@ -267,8 +268,9 @@ class RandomFilmScreenTest { releaseYear = 2000, imageUrl = "test-image-url", ), + numberOfResults = 2, ) { action -> - if (action is com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction.OnRerollClicked) { + if (action is RandomFilmAction.OnRerollClicked) { rerollClicked = true } } @@ -302,7 +304,7 @@ class RandomFilmScreenTest { imageUrl = "test-image-url", ), ) { action -> - if (action is com.nacchofer31.randomboxd.random_film.presentation.viewmodel.RandomFilmAction.OnFilmClicked) { + if (action is RandomFilmAction.OnFilmClicked) { filmClicked = true } } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensions.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensions.kt new file mode 100644 index 0000000..1e85437 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensions.kt @@ -0,0 +1,29 @@ +package com.nacchofer31.randomboxd.core.domain + +/** + * Returns a random element from the set, excluding the specified element if possible. + * Comparison is done by the provided key function instead of object equality. + * If [exclude] is null, returns a random element without filtering. + * If the set contains only the excluded element, returns a random element. + * + * @param exclude The element to exclude from the random selection, or null to not exclude anything + * @param keySelector Function to extract the comparison key from each element + * @return A random element from the set, preferably different from [exclude] + * @throws NoSuchElementException if the set is empty + */ +fun Set.randomExcluding( + exclude: T? = null, + keySelector: (T) -> K, +): T { + if (isEmpty()) throw NoSuchElementException("Set is empty.") + if (exclude == null) return random() + + val excludeKey = keySelector(exclude) + val candidates = filter { keySelector(it) != excludeKey } + return if (candidates.isNotEmpty()) { + candidates.random() + } else { + // Only the excluded element exists, return it + random() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt index 6cae40a..730350d 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/components/FilmPoster.kt @@ -185,9 +185,11 @@ fun FilmPoster( fontSize = 17.sp, textAlign = TextAlign.Center, ) - RerollButton( - onClick = onRerollClick, - ) + if (numberOfResults > 1) { + RerollButton( + onClick = onRerollClick, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt index 41e2b1f..8b20d94 100644 --- a/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/nacchofer31/randomboxd/random_film/presentation/viewmodel/RandomFilmViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nacchofer31.randomboxd.core.domain.DispatcherProvider import com.nacchofer31.randomboxd.core.domain.ResultData +import com.nacchofer31.randomboxd.core.domain.randomExcluding import com.nacchofer31.randomboxd.random_film.domain.model.Film import com.nacchofer31.randomboxd.random_film.domain.model.FilmGenre import com.nacchofer31.randomboxd.random_film.domain.model.FilmSearchMode @@ -26,6 +27,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Suppress("OPT_IN_USAGE") class RandomFilmViewModel( @@ -74,7 +76,9 @@ class RandomFilmViewModel( else -> { val userName = internalState.value.userName.trim() - userNameRepository.addUserName(userName) + withContext(dispatchers.io) { + userNameRepository.addUserName(userName) + } repository.getRandomMovies(userName, internalState.value.selectedGenres) } } @@ -92,7 +96,7 @@ class RandomFilmViewModel( inAppReviewRepository.requestInAppReview() } cachedResultFilms = result.data - var filmResult = repository.extractResultMovie(result.data.random()) + var filmResult = repository.extractResultMovie(result.data.randomExcluding(null) { it.name }) return@update when (filmResult) { is ResultData.Success -> { current.copy( @@ -150,12 +154,14 @@ class RandomFilmViewModel( if (internalState.value.userNameSearchList.contains(action.userName.username)) { addOrRemoveUserNameToList(action.userName.username) } - userNameRepository.deleteUserName(action.userName) + withContext(dispatchers.io) { + userNameRepository.deleteUserName(action.userName) + } } } is RandomFilmAction.OnUserNameAdded -> { - viewModelScope.launch { userNameRepository.addUserName(action.username.trim()) } + viewModelScope.launch(dispatchers.io) { userNameRepository.addUserName(action.username.trim()) } } is RandomFilmAction.OnAddOrRemoveUserNameSearchList -> { @@ -183,7 +189,12 @@ class RandomFilmViewModel( } is RandomFilmAction.OnGenreSelectionApplied -> { - internalState.update { it.copy(selectedGenres = action.genres, showGenreBottomSheet = false) } + internalState.update { + it.copy( + selectedGenres = action.genres, + showGenreBottomSheet = false, + ) + } } is RandomFilmAction.OnRerollClicked -> { @@ -240,16 +251,16 @@ class RandomFilmViewModel( } } - private fun rerollMovie() { - internalState.update { - it.copy(isLoading = true) - } - + private fun rerollMovie() = viewModelScope.launch { + internalState.update { + it.copy(isLoading = true) + } + val rerolledFilm = cachedResultFilms.randomExcluding(internalState.value.resultFilm) { it.name } val filmResult = - repository.extractResultMovie( - cachedResultFilms.random(), - ) + withContext(dispatchers.io) { + repository.extractResultMovie(rerolledFilm) + } internalState.update { when (filmResult) { is ResultData.Success -> it.copy(isLoading = false, resultFilm = filmResult.data, resultError = null, numberOfResults = cachedResultFilms.size) @@ -257,5 +268,4 @@ class RandomFilmViewModel( } } } - } } diff --git a/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensionsTest.kt b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensionsTest.kt new file mode 100644 index 0000000..e8243d2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nacchofer31/randomboxd/core/domain/SetExtensionsTest.kt @@ -0,0 +1,74 @@ +package com.nacchofer31.randomboxd.core.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SetExtensionsTest { + data class TestItem( + val id: Int, + val name: String, + ) + + @Test + fun `randomExcluding throws NoSuchElementException when set is empty`() { + val emptySet = emptySet() + + assertFailsWith { + emptySet.randomExcluding(keySelector = { it.id }) + } + } + + @Test + fun `randomExcluding returns random element when exclude is null`() { + val set = setOf(TestItem(1, "one"), TestItem(2, "two"), TestItem(3, "three")) + + val result = set.randomExcluding(exclude = null, keySelector = { it.id }) + + assertTrue(result in set) + } + + @Test + fun `randomExcluding returns element different from excluded using keySelector`() { + val excluded = TestItem(2, "two") + val set = setOf(TestItem(1, "one"), excluded, TestItem(3, "three")) + + repeat(10) { + val result = set.randomExcluding(exclude = excluded, keySelector = { it.id }) + assertTrue(result.id != excluded.id) + } + } + + @Test + fun `randomExcluding returns excluded element when it is the only one`() { + val onlyItem = TestItem(1, "one") + val set = setOf(onlyItem) + + val result = set.randomExcluding(exclude = onlyItem, keySelector = { it.id }) + + assertEquals(onlyItem, result) + } + + @Test + fun `randomExcluding uses keySelector for comparison not object equality`() { + val excluded = TestItem(1, "original") + val differentObject = TestItem(1, "different") + val set = setOf(excluded, differentObject, TestItem(2, "two")) + + repeat(10) { + val result = set.randomExcluding(exclude = excluded, keySelector = { it.id }) + assertTrue(result.id != 1, "Should exclude all items with id=1") + } + } + + @Test + fun `randomExcluding works with single element set and null exclude`() { + val item = TestItem(1, "one") + val set = setOf(item) + + val result = set.randomExcluding(exclude = null, keySelector = { it.id }) + + assertEquals(item, result) + } +}