feat(android): LibraryViewModel + UiState + MainDispatcherExtension (M8 phase 5.3)
First Hilt-injected ViewModel + sealed UiState pattern.
LibraryUiState (sealed interface): Loading / Empty / Success / Error.
The cases are exhaustive so Compose `when` blocks the compiler checks.
LibraryViewModel:
- combine(observeArtists, observeAlbums) → Success/Empty decision
- .catch translates upstream Flow exceptions to UiState.Error
- .stateIn(viewModelScope, WhileSubscribed(5_000), Loading) — the
standard Compose-friendly pattern; subscriptions tear down 5s after
the last collector to ride out config changes without hanging the
DAO Flow forever.
MainDispatcherExtension — JUnit 5 equivalent of the JUnit 4
MainDispatcherRule pattern (audit-deferred item; trigger met). Swaps
Dispatchers.Main for UnconfinedTestDispatcher in beforeEach +
resetMain in afterEach. Apply with `@ExtendWith`.
LibraryViewModelTest covers all four UiState cases — initial Loading,
empty cache (Empty), populated cache (Success), and an upstream Flow
exception (Error). MockK for the repo, Turbine for the Flow assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI state for the Library screen. Sealed interface — each composable
|
||||||
|
* branch handles exactly the cases it cares about; the compiler
|
||||||
|
* enforces exhaustiveness.
|
||||||
|
*
|
||||||
|
* - Loading: initial state before the first DAO emission.
|
||||||
|
* - Empty: DAO emitted but the cache holds no artists and no albums
|
||||||
|
* (fresh install, never synced).
|
||||||
|
* - Success: DAO emitted with content.
|
||||||
|
* - Error: an exception bubbled from the DAO/repository layer; the
|
||||||
|
* message is surfaced as-is in the empty-state copy.
|
||||||
|
*/
|
||||||
|
sealed interface LibraryUiState {
|
||||||
|
data object Loading : LibraryUiState
|
||||||
|
data object Empty : LibraryUiState
|
||||||
|
data class Success(
|
||||||
|
val artists: List<ArtistRef>,
|
||||||
|
val albums: List<AlbumRef>,
|
||||||
|
) : LibraryUiState
|
||||||
|
data class Error(val message: String) : LibraryUiState
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class LibraryViewModel @Inject constructor(
|
||||||
|
private val repository: LibraryRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState: StateFlow<LibraryUiState> =
|
||||||
|
combine(
|
||||||
|
repository.observeArtists(),
|
||||||
|
repository.observeAlbums(),
|
||||||
|
) { artists, albums ->
|
||||||
|
if (artists.isEmpty() && albums.isEmpty()) {
|
||||||
|
LibraryUiState.Empty
|
||||||
|
} else {
|
||||||
|
LibraryUiState.Success(artists, albums)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { e ->
|
||||||
|
emit(LibraryUiState.Error(e.message ?: "Library load failed"))
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = LibraryUiState.Loading,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.fabledsword.minstrel.library.ui
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.testutil.MainDispatcherExtension
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@ExtendWith(MainDispatcherExtension::class)
|
||||||
|
class LibraryViewModelTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state is Loading before any DAO emission`() = runTest {
|
||||||
|
val repo = mockk<LibraryRepository>()
|
||||||
|
every { repo.observeArtists() } returns MutableSharedFlow()
|
||||||
|
every { repo.observeAlbums() } returns MutableSharedFlow()
|
||||||
|
|
||||||
|
val vm = LibraryViewModel(repo)
|
||||||
|
|
||||||
|
assertEquals(LibraryUiState.Loading, vm.uiState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Empty when DAOs emit empty lists`() = runTest {
|
||||||
|
val repo = mockk<LibraryRepository>()
|
||||||
|
every { repo.observeArtists() } returns flowOf(emptyList())
|
||||||
|
every { repo.observeAlbums() } returns flowOf(emptyList())
|
||||||
|
|
||||||
|
val vm = LibraryViewModel(repo)
|
||||||
|
|
||||||
|
vm.uiState.test {
|
||||||
|
assertEquals(LibraryUiState.Loading, awaitItem())
|
||||||
|
assertEquals(LibraryUiState.Empty, awaitItem())
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Success when DAOs emit content`() = runTest {
|
||||||
|
val repo = mockk<LibraryRepository>()
|
||||||
|
every { repo.observeArtists() } returns
|
||||||
|
flowOf(listOf(ArtistRef(id = "a1", name = "Boards of Canada", sortName = "Boards")))
|
||||||
|
every { repo.observeAlbums() } returns
|
||||||
|
flowOf(listOf(AlbumRef(id = "al1", title = "Geogaddi", artistId = "a1")))
|
||||||
|
|
||||||
|
val vm = LibraryViewModel(repo)
|
||||||
|
|
||||||
|
vm.uiState.test {
|
||||||
|
assertEquals(LibraryUiState.Loading, awaitItem())
|
||||||
|
val success = awaitItem() as LibraryUiState.Success
|
||||||
|
assertEquals(1, success.artists.size)
|
||||||
|
assertEquals(1, success.albums.size)
|
||||||
|
assertEquals("a1", success.artists[0].id)
|
||||||
|
assertEquals("al1", success.albums[0].id)
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Error when an upstream Flow throws`() = runTest {
|
||||||
|
val repo = mockk<LibraryRepository>()
|
||||||
|
every { repo.observeArtists() } returns
|
||||||
|
flow { throw IllegalStateException("DAO blew up") }
|
||||||
|
every { repo.observeAlbums() } returns flowOf(emptyList())
|
||||||
|
|
||||||
|
val vm = LibraryViewModel(repo)
|
||||||
|
|
||||||
|
vm.uiState.test {
|
||||||
|
assertEquals(LibraryUiState.Loading, awaitItem())
|
||||||
|
val error = awaitItem() as LibraryUiState.Error
|
||||||
|
assertTrue(error.message.contains("DAO blew up"))
|
||||||
|
cancelAndConsumeRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package com.fabledsword.minstrel.testutil
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestDispatcher
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.jupiter.api.extension.AfterEachCallback
|
||||||
|
import org.junit.jupiter.api.extension.BeforeEachCallback
|
||||||
|
import org.junit.jupiter.api.extension.ExtensionContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JUnit 5 extension that swaps the kotlinx.coroutines `Dispatchers.Main`
|
||||||
|
* for a `TestDispatcher` for the duration of each test, then restores
|
||||||
|
* the original. Apply on a test class with:
|
||||||
|
*
|
||||||
|
* @ExtendWith(MainDispatcherExtension::class)
|
||||||
|
* class MyViewModelTest { ... }
|
||||||
|
*
|
||||||
|
* Without this, any ViewModel under test that schedules work on
|
||||||
|
* `viewModelScope` (which dispatches to Main by default) will hang or
|
||||||
|
* throw "Module with the Main dispatcher had failed to initialize".
|
||||||
|
*
|
||||||
|
* `UnconfinedTestDispatcher` is the default — coroutines run inline,
|
||||||
|
* which makes flow-based tests simpler to assert on.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class MainDispatcherExtension(
|
||||||
|
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
|
||||||
|
) : BeforeEachCallback, AfterEachCallback {
|
||||||
|
|
||||||
|
override fun beforeEach(context: ExtensionContext) {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun afterEach(context: ExtensionContext) {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user