diff --git a/feature/characters/domain/src/test/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCaseTest.kt b/feature/characters/domain/src/test/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCaseTest.kt new file mode 100644 index 0000000..637ceab --- /dev/null +++ b/feature/characters/domain/src/test/kotlin/com/example/architecture/feature/characters/domain/usecase/GetCharactersPageUseCaseTest.kt @@ -0,0 +1,79 @@ +package com.example.architecture.feature.characters.domain.usecase + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import com.example.architecture.core.domain.DataError +import com.example.architecture.core.domain.Result +import com.example.architecture.feature.characters.domain.CharacterRepository +import com.example.architecture.feature.characters.domain.model.Character +import com.example.architecture.feature.characters.domain.model.CharacterDetails +import com.example.architecture.feature.characters.domain.model.CharacterStatus +import com.example.architecture.feature.characters.domain.model.CharactersPage +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +/** + * Tests for the (thin pass-through) [GetCharactersPageUseCase]: it must forward the requested page to + * the repository and return its result verbatim — success and error alike. Pure JVM test on the + * JUnit 5 platform (see DomainModuleConventionPlugin); collaborator is a hand-written fake. + */ +class GetCharactersPageUseCaseTest { + + @Test + fun `returns the repository page on success`() = runBlocking { + val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2) + val useCase = GetCharactersPageUseCase(FakeCharacterRepository(pageResult = Result.Success(page))) + + val result = useCase(page = 1) + + assertThat(result).isEqualTo(Result.Success(page)) + } + + @Test + fun `propagates the repository error`() = runBlocking { + val useCase = GetCharactersPageUseCase( + FakeCharacterRepository(pageResult = Result.Error(DataError.Network.SERVER_ERROR)), + ) + + val result = useCase(page = 1) + + assertThat(result).isInstanceOf(Result.Error::class) + assertThat((result as Result.Error).error).isEqualTo(DataError.Network.SERVER_ERROR) + } + + @Test + fun `forwards the requested page number`() = runBlocking { + val fake = FakeCharacterRepository( + pageResult = Result.Success(CharactersPage(characters = emptyList(), nextPage = null)), + ) + val useCase = GetCharactersPageUseCase(fake) + + useCase(page = 7) + + assertThat(fake.lastRequestedPage).isEqualTo(7) + } + + private class FakeCharacterRepository( + private val pageResult: Result, + ) : CharacterRepository { + var lastRequestedPage: Int? = null + private set + + override suspend fun getCharacters(page: Int): Result { + lastRequestedPage = page + return pageResult + } + + override suspend fun getCharacterDetails(id: Int): Result = + Result.Error(DataError.Network.NOT_FOUND) + } + + private fun domainCharacter(id: Int) = Character( + id = id, + name = "Character $id", + status = CharacterStatus.ALIVE, + species = "Human", + imageUrl = "https://example.com/$id.png", + ) +} diff --git a/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModelTest.kt b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModelTest.kt new file mode 100644 index 0000000..aefc690 --- /dev/null +++ b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterDetailViewModelTest.kt @@ -0,0 +1,115 @@ +package com.example.architecture.feature.characters.presentation + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isSameInstanceAs +import com.example.architecture.core.domain.DataError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Unit tests for [CharacterDetailViewModel]. The character id arrives via [SavedStateHandle] (written + * by type-safe navigation), which is constructed directly here — proving the VM needs no navigation + * dependency. Collaborator is a [FakeCharacterRepository]; assertions use AssertK; the back event is + * observed with Turbine. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CharacterDetailViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val repository = FakeCharacterRepository() + + @BeforeEach + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(characterId: Int = 1) = + CharacterDetailViewModel(SavedStateHandle(mapOf("characterId" to characterId)), repository) + + @Test + fun `loads details on init`() = runTest(dispatcher.scheduler) { + repository.setDetails(characterDetails(1)) + + val viewModel = viewModel(characterId = 1) + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.isLoading).isFalse() + assertThat(state.error).isNull() + assertThat(state.details).isNotNull() + assertThat(state.details?.name).isEqualTo("Character 1") + } + + @Test + fun `load failure surfaces an error and no details`() = runTest(dispatcher.scheduler) { + repository.failWith = DataError.Network.SERVER_ERROR + + val viewModel = viewModel(characterId = 1) + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.error).isNotNull() + assertThat(state.details).isNull() + assertThat(state.isLoading).isFalse() + } + + @Test + fun `retry after a failure clears the error and loads details`() = runTest(dispatcher.scheduler) { + repository.failWith = DataError.Network.NO_INTERNET + val viewModel = viewModel(characterId = 1) + advanceUntilIdle() + assertThat(viewModel.state.value.error).isNotNull() + + // The next attempt will succeed. + repository.failWith = null + repository.setDetails(characterDetails(1)) + viewModel.onAction(CharacterDetailAction.OnRetry) + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.error).isNull() + assertThat(state.details).isNotNull() + } + + @Test + fun `back click emits NavigateBack`() = runTest(dispatcher.scheduler) { + repository.setDetails(characterDetails(1)) + val viewModel = viewModel(characterId = 1) + advanceUntilIdle() + + viewModel.events.test { + viewModel.onAction(CharacterDetailAction.OnBackClick) + advanceUntilIdle() + assertThat(awaitItem()).isSameInstanceAs(CharacterDetailEvent.NavigateBack) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `missing character id fails fast`() { + // The route contract: type-safe nav must have written characterId into SavedStateHandle. + assertThrows { + CharacterDetailViewModel(SavedStateHandle(), repository) + } + } +} diff --git a/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModelTest.kt b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModelTest.kt new file mode 100644 index 0000000..cecd9aa --- /dev/null +++ b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/CharacterListViewModelTest.kt @@ -0,0 +1,215 @@ +package com.example.architecture.feature.characters.presentation + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import assertk.assertions.prop +import com.example.architecture.core.domain.DataError +import com.example.architecture.feature.characters.domain.usecase.GetCharactersPageUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Unit tests for [CharacterListViewModel] — driven entirely through its public MVI surface + * (State/Action/Event), so they prove the VM correct regardless of which renderer hosts it. + * + * Uses [StandardTestDispatcher] (not Unconfined) so launched work is queued until `advanceUntilIdle`, + * which lets the duplicate-paging test observe the *synchronous* loading-flag guard before any + * coroutine runs. Collaborator is a [FakeCharacterRepository] (a fake, not a mock); `state`/`events` + * are observed with Turbine; assertions use AssertK. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CharacterListViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val repository = FakeCharacterRepository() + + @BeforeEach + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(savedStateHandle: SavedStateHandle = SavedStateHandle()) = + CharacterListViewModel(GetCharactersPageUseCase(repository), savedStateHandle) + + @Test + fun `loads the first page on init`() = runTest(dispatcher.scheduler) { + repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2) + + val viewModel = viewModel() + + viewModel.state.test { + // restore() flips isLoading synchronously during construction, before the coroutine runs. + assertThat(awaitItem()).isEqualTo(CharacterListState(isLoading = true)) + + advanceUntilIdle() + + val loaded = awaitItem() + assertThat(loaded).prop(CharacterListState::characters).hasSize(2) + assertThat(loaded).prop(CharacterListState::isLoading).isFalse() + assertThat(loaded).prop(CharacterListState::currentPage).isEqualTo(1) + assertThat(loaded).prop(CharacterListState::endReached).isFalse() + assertThat(loaded).prop(CharacterListState::error).isNull() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `initial load failure emits a snackbar event and a full-screen error`() = + runTest(dispatcher.scheduler) { + repository.failWith = DataError.Network.NO_INTERNET + + val viewModel = viewModel() + + viewModel.events.test { + advanceUntilIdle() + assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class) + cancelAndIgnoreRemainingEvents() + } + advanceUntilIdle() + + assertThat(viewModel.state.value).prop(CharacterListState::error).isNotNull() + assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(0) + } + + @Test + fun `does not load past the last page`() = runTest(dispatcher.scheduler) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null) // last page + + val viewModel = viewModel() + advanceUntilIdle() // init → page 1 + + viewModel.onAction(CharacterListAction.OnLoadNextPage) + advanceUntilIdle() // → page 2, end reached + + assertThat(viewModel.state.value).prop(CharacterListState::endReached).isTrue() + assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2) + + val callsBefore = repository.getCharactersCallCount + viewModel.onAction(CharacterListAction.OnLoadNextPage) + advanceUntilIdle() // guarded by endReached → no request + + assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore) + } + + @Test + fun `rapid duplicate next-page actions load the page only once`() = runTest(dispatcher.scheduler) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3) + + val viewModel = viewModel() + advanceUntilIdle() // init → page 1 + val callsBefore = repository.getCharactersCallCount + + // Both fire before any launched coroutine runs; the second sees the synchronously-set + // isLoadingNextPage flag and is guarded out. + viewModel.onAction(CharacterListAction.OnLoadNextPage) + viewModel.onAction(CharacterListAction.OnLoadNextPage) + advanceUntilIdle() + + assertThat(repository.getCharactersCallCount).isEqualTo(callsBefore + 1) + assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2) + } + + @Test + fun `ignores a next-page request while the initial load is in flight`() = + runTest(dispatcher.scheduler) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + + val viewModel = viewModel() + // restore() set isLoading = true synchronously; its coroutine hasn't run yet, so this + // OnLoadNextPage hits the `isLoading` guard in loadNextPage() and is dropped. + viewModel.onAction(CharacterListAction.OnLoadNextPage) + advanceUntilIdle() + + // Only the single initial load ran — the guarded next-page request never fired. + assertThat(repository.getCharactersCallCount).isEqualTo(1) + } + + @Test + fun `retry after a failed initial load rebuilds the list`() = runTest(dispatcher.scheduler) { + repository.failWith = DataError.Network.NO_INTERNET + val viewModel = viewModel() + + viewModel.events.test { + advanceUntilIdle() + // The initial-load failure surfaces as a snackbar; consuming it is also how the + // rendezvous-Channel send in restore() completes so state can settle. + assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class) + assertThat(viewModel.state.value).prop(CharacterListState::characters).isEmpty() + + // Empty branch of retry(): the repository recovers, OnRetry rebuilds from page 1. + repository.failWith = null + repository.setPage(page = 1, characters = listOf(character(1), character(2)), nextPage = 2) + viewModel.onAction(CharacterListAction.OnRetry) + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + + assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2) + assertThat(viewModel.state.value).prop(CharacterListState::error).isNull() + } + + @Test + fun `retry after a failed next page re-requests that page`() = runTest(dispatcher.scheduler) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + val viewModel = viewModel() + advanceUntilIdle() // page 1 loaded (no event) + + viewModel.events.test { + // Page 2 isn't configured yet → next-page load fails; list keeps page 1, shows an error. + viewModel.onAction(CharacterListAction.OnLoadNextPage) + advanceUntilIdle() + assertThat(awaitItem()).isInstanceOf(CharacterListEvent.ShowSnackbar::class) + assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(1) + + // Non-empty branch of retry(): with page 2 now available, OnRetry re-requests page 2 and + // appends it (currentPage stayed 1 because loadPage only advances on success). + repository.setPage(page = 2, characters = listOf(character(2)), nextPage = null) + viewModel.onAction(CharacterListAction.OnRetry) + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + + assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2) + assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2) + assertThat(viewModel.state.value).prop(CharacterListState::error).isNull() + } + + @Test + fun `restores up to the saved page after process death`() = runTest(dispatcher.scheduler) { + repository.setPage(page = 1, characters = listOf(character(1)), nextPage = 2) + repository.setPage(page = 2, characters = listOf(character(2)), nextPage = 3) + // Navigation/SavedStateHandle persisted the last loaded page across process death. + val savedStateHandle = SavedStateHandle(mapOf("currentPage" to 2)) + + val viewModel = viewModel(savedStateHandle) + advanceUntilIdle() + + // Both pages are rebuilt (1 then 2), and currentPage is restored. + assertThat(viewModel.state.value).prop(CharacterListState::characters).hasSize(2) + assertThat(viewModel.state.value).prop(CharacterListState::currentPage).isEqualTo(2) + } +} diff --git a/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/FakeCharacterRepository.kt b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/FakeCharacterRepository.kt new file mode 100644 index 0000000..683a352 --- /dev/null +++ b/feature/characters/presentation/src/test/kotlin/com/example/architecture/feature/characters/presentation/FakeCharacterRepository.kt @@ -0,0 +1,74 @@ +package com.example.architecture.feature.characters.presentation + +import com.example.architecture.core.domain.DataError +import com.example.architecture.core.domain.Result +import com.example.architecture.feature.characters.domain.CharacterRepository +import com.example.architecture.feature.characters.domain.model.Character +import com.example.architecture.feature.characters.domain.model.CharacterDetails +import com.example.architecture.feature.characters.domain.model.CharacterStatus +import com.example.architecture.feature.characters.domain.model.CharactersPage + +/** + * In-memory [CharacterRepository] for ViewModel tests — a **fake**, not a mock: it has real behaviour + * (returns configured pages/details, counts calls, can be flipped to fail) so tests assert against a + * working collaborator instead of recording interactions. Configure pages via [setPage]/[setDetails]; + * set [failWith] to make every call fail with a specific [DataError]. + */ +class FakeCharacterRepository : CharacterRepository { + + /** When non-null, every call fails with this error (overrides any configured data). */ + var failWith: DataError? = null + + var getCharactersCallCount = 0 + private set + var getCharacterDetailsCallCount = 0 + private set + + private val pages = mutableMapOf() + private val details = mutableMapOf() + + fun setPage(page: Int, characters: List, nextPage: Int?) { + pages[page] = CharactersPage(characters = characters, nextPage = nextPage) + } + + fun setDetails(value: CharacterDetails) { + details[value.id] = value + } + + override suspend fun getCharacters(page: Int): Result { + getCharactersCallCount++ + failWith?.let { return Result.Error(it) } + val pageData = pages[page] ?: return Result.Error(DataError.Network.NOT_FOUND) + return Result.Success(pageData) + } + + override suspend fun getCharacterDetails(id: Int): Result { + getCharacterDetailsCallCount++ + failWith?.let { return Result.Error(it) } + val value = details[id] ?: return Result.Error(DataError.Network.NOT_FOUND) + return Result.Success(value) + } +} + +/** Minimal list-item domain fixture. */ +fun character(id: Int): Character = Character( + id = id, + name = "Character $id", + status = CharacterStatus.ALIVE, + species = "Human", + imageUrl = "https://example.com/$id.png", +) + +/** Minimal detail domain fixture. */ +fun characterDetails(id: Int): CharacterDetails = CharacterDetails( + id = id, + name = "Character $id", + status = CharacterStatus.ALIVE, + species = "Human", + type = "Genetic experiment", + gender = "Male", + origin = "Earth (C-137)", + location = "Citadel of Ricks", + imageUrl = "https://example.com/$id.png", + episodeCount = 10, +)