From 0ea0fbf8be3a0ba312fefaeaf5efce9a4c7607a0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 22:38:59 -0400 Subject: [PATCH] feat(android): LibraryViewModel + UiState + MainDispatcherExtension (M8 phase 5.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/library/ui/LibraryUiState.kt | 26 ++++++ .../minstrel/library/ui/LibraryViewModel.kt | 40 +++++++++ .../library/ui/LibraryViewModelTest.kt | 85 +++++++++++++++++++ .../testutil/MainDispatcherExtension.kt | 40 +++++++++ 4 files changed, 191 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryUiState.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/testutil/MainDispatcherExtension.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryUiState.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryUiState.kt new file mode 100644 index 00000000..3e2e51ea --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryUiState.kt @@ -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, + val albums: List, + ) : LibraryUiState + data class Error(val message: String) : LibraryUiState +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt new file mode 100644 index 00000000..2f7e0f86 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryViewModel.kt @@ -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 = + 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, + ) +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt new file mode 100644 index 00000000..01000eb0 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/library/ui/LibraryViewModelTest.kt @@ -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() + 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() + 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() + 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() + 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() + } + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/testutil/MainDispatcherExtension.kt b/android/app/src/test/java/com/fabledsword/minstrel/testutil/MainDispatcherExtension.kt new file mode 100644 index 00000000..803d2c17 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/testutil/MainDispatcherExtension.kt @@ -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() + } +}