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