7
feature/characters/domain/build.gradle.kts
Normal file
7
feature/characters/domain/build.gradle.kts
Normal file
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.architecture.domain.module)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:domain"))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.architecture.feature.characters.domain
|
||||
|
||||
import com.example.architecture.core.domain.DataError
|
||||
import com.example.architecture.core.domain.Result
|
||||
import com.example.architecture.feature.characters.domain.model.CharacterDetails
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
|
||||
/**
|
||||
* Contract for the characters data layer. Lives in domain so presentation never depends on data.
|
||||
* Returns the [DataError] supertype because an implementation may merge sources (e.g. an
|
||||
* offline-first repository combining network + local).
|
||||
*/
|
||||
interface CharacterRepository {
|
||||
suspend fun getCharacters(page: Int): Result<CharactersPage, DataError>
|
||||
|
||||
suspend fun getCharacterDetails(id: Int): Result<CharacterDetails, DataError>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** A character as shown in the list. */
|
||||
data class Character(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: CharacterStatus,
|
||||
val species: String,
|
||||
val imageUrl: String,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** Full character profile shown on the detail screen. */
|
||||
data class CharacterDetails(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val status: CharacterStatus,
|
||||
val species: String,
|
||||
val type: String,
|
||||
val gender: String,
|
||||
val origin: String,
|
||||
val location: String,
|
||||
val imageUrl: String,
|
||||
val episodeCount: Int,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** Life status of a character. Mapped from the API's string in the data layer. */
|
||||
enum class CharacterStatus {
|
||||
ALIVE,
|
||||
DEAD,
|
||||
UNKNOWN,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.architecture.feature.characters.domain.model
|
||||
|
||||
/** One page of characters plus the next page index ([nextPage] is null when there are no more). */
|
||||
data class CharactersPage(
|
||||
val characters: List<Character>,
|
||||
val nextPage: Int?,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.architecture.feature.characters.domain.usecase
|
||||
|
||||
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.CharactersPage
|
||||
|
||||
/**
|
||||
* Loads one page of characters.
|
||||
*
|
||||
* **When to add a UseCase (convention note):** introduce a UseCase when a screen needs business
|
||||
* logic that does NOT belong in the ViewModel - non-trivial rules, or *composition* of several
|
||||
* repositories/sources into one domain operation. When the ViewModel would merely forward a single
|
||||
* repository call, skipping the UseCase and injecting the repository directly is perfectly fine.
|
||||
*
|
||||
* This particular UseCase is a **thin pass-through, included for illustration**: it adds no logic
|
||||
* beyond delegating to [CharacterRepository]. It earns its place only as a showcase of the
|
||||
* convention (domain-owned, `operator fun invoke`, constructor-injected). In a real app you would
|
||||
* grow it the moment list loading gained real behaviour (filtering, merging a local cache, …) - or
|
||||
* delete it and let the ViewModel call the repository.
|
||||
*/
|
||||
class GetCharactersPageUseCase(
|
||||
private val characterRepository: CharacterRepository,
|
||||
) {
|
||||
suspend operator fun invoke(page: Int): Result<CharactersPage, DataError> =
|
||||
characterRepository.getCharacters(page)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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.CharacterStatus
|
||||
import com.example.architecture.feature.characters.domain.model.CharactersPage
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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); the [CharacterRepository] collaborator is a
|
||||
* MockK mock, stubbed with `coEvery` and verified with `coVerify`.
|
||||
*/
|
||||
class GetCharactersPageUseCaseTest {
|
||||
|
||||
private val repository = mockk<CharacterRepository>()
|
||||
private val useCase = GetCharactersPageUseCase(repository)
|
||||
|
||||
@Test
|
||||
fun `returns the repository page on success`() = runTest {
|
||||
val page = CharactersPage(characters = listOf(domainCharacter(1)), nextPage = 2)
|
||||
coEvery { repository.getCharacters(1) } returns Result.Success(page)
|
||||
|
||||
val result = useCase(page = 1)
|
||||
|
||||
assertThat(result).isEqualTo(Result.Success(page))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `propagates the repository error`() = runTest {
|
||||
coEvery { repository.getCharacters(1) } returns 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`() = runTest {
|
||||
coEvery { repository.getCharacters(any()) } returns
|
||||
Result.Success(CharactersPage(characters = emptyList(), nextPage = null))
|
||||
|
||||
useCase(page = 7)
|
||||
|
||||
coVerify(exactly = 1) { repository.getCharacters(7) }
|
||||
}
|
||||
|
||||
private fun domainCharacter(id: Int) = Character(
|
||||
id = id,
|
||||
name = "Character $id",
|
||||
status = CharacterStatus.ALIVE,
|
||||
species = "Human",
|
||||
imageUrl = "https://example.com/$id.png",
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user