Initial commit
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-11 11:03:01 +02:00
commit d1ff0e30ba
138 changed files with 5658 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.architecture.domain.module)
}
dependencies {
implementation(project(":core:domain"))
}

View File

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

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
}

View File

@@ -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?,
)

View File

@@ -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)
}

View File

@@ -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",
)
}