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:
2026-05-22 22:38:59 -04:00
parent 9eaaf93f23
commit 0ea0fbf8be
4 changed files with 191 additions and 0 deletions
@@ -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()
}
}
}
@@ -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()
}
}