book-reader/app/src/test/java/com/aryan/reader/MainViewModelTest.kt
Aryan 6897496423
Add screen capture protection (#292)
Co-authored-by: Rahul Sharma <rahul.shwork@gmail.com>
2026-05-10 10:18:48 +05:30

988 lines
42 KiB
Kotlin

package com.aryan.reader
import android.app.Application
import android.content.SharedPreferences
import android.content.res.Resources
import android.net.Uri
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.credentials.CredentialManager
import androidx.work.WorkManager
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.aryan.reader.data.*
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: MainViewModel
private lateinit var mockApplication: Application
private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
private val ttsStateFlow = MutableStateFlow(TtsPlaybackManager.TtsState())
private val recentFilesFlow = MutableStateFlow<List<RecentFileItem>>(emptyList())
private val shelvesFlow = MutableStateFlow<List<ShelfEntity>>(emptyList())
private val shelfRefsFlow = MutableStateFlow<List<BookShelfCrossRef>>(emptyList())
private val tagsFlow = MutableStateFlow<List<TagEntity>>(emptyList())
private val tagRefsFlow = MutableStateFlow<List<BookTagCrossRef>>(emptyList())
@Before
fun setup() {
recentFilesFlow.value = emptyList()
shelvesFlow.value = emptyList()
shelfRefsFlow.value = emptyList()
tagsFlow.value = emptyList()
tagRefsFlow.value = emptyList()
billingStateFlow.value = ProUpgradeState()
customFontsFlow.value = emptyList()
ttsStateFlow.value = TtsPlaybackManager.TtsState()
mockkStatic(Log::class)
every { Log.isLoggable(any(), any()) } returns false
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
every { Log.w(any(), any<String>()) } returns 0
Dispatchers.setMain(testDispatcher)
mockApplication = mockk()
mockPrefs = mockk(relaxed = true)
mockEditor = mockk(relaxed = true)
val mockResources = mockk<Resources>(relaxed = true)
val testRoot = File("build/test-tmp/MainViewModelTest/${System.nanoTime()}")
val filesDir = File(testRoot, "files").apply { mkdirs() }
val cacheDir = File(testRoot, "cache").apply { mkdirs() }
val externalFilesDir = File(testRoot, "external-files").apply { mkdirs() }
every { mockApplication.applicationContext } returns mockApplication
every { mockApplication.getSharedPreferences(any(), any()) } returns mockPrefs
every { mockApplication.resources } returns mockResources
every { mockApplication.packageName } returns "com.aryan.reader"
every { mockApplication.filesDir } returns filesDir
every { mockApplication.cacheDir } returns cacheDir
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
every { mockPrefs.edit() } returns mockEditor
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
mockkObject(AppDatabase.Companion)
val mockDb = mockk<AppDatabase>(relaxed = true)
every { AppDatabase.getDatabase(any()) } returns mockDb
mockkObject(BookCacheDatabase.Companion)
val mockBookCacheDb = mockk<BookCacheDatabase>(relaxed = true)
every { mockBookCacheDb.bookCacheDao() } returns mockk<BookCacheDao>(relaxed = true)
every { BookCacheDatabase.getDatabase(any()) } returns mockBookCacheDb
mockkObject(WorkManager.Companion)
val mockWorkManager = mockk<WorkManager>(relaxed = true)
every { WorkManager.getInstance(any()) } returns mockWorkManager
mockkStatic(FirebaseAuth::class)
every { FirebaseAuth.getInstance() } returns mockk(relaxed = true)
mockkStatic(FirebaseFirestore::class)
every { FirebaseFirestore.getInstance() } returns mockk(relaxed = true)
mockkObject(CredentialManager.Companion)
every { CredentialManager.create(any()) } returns mockk(relaxed = true)
mockkStatic(BillingClient::class)
val mockBillingClient = mockk<BillingClient>(relaxed = true)
val mockBillingBuilder = mockk<BillingClient.Builder>(relaxed = true)
every { BillingClient.newBuilder(any()) } returns mockBillingBuilder
every { mockBillingBuilder.setListener(any()) } returns mockBillingBuilder
every { mockBillingBuilder.enablePendingPurchases(any()) } returns mockBillingBuilder
every { mockBillingBuilder.build() } returns mockBillingClient
every { mockBillingClient.isReady } returns false
every { mockBillingClient.startConnection(any()) } answers {
firstArg<com.android.billingclient.api.BillingClientStateListener>()
.onBillingSetupFinished(
BillingResult.newBuilder()
.setResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
.build()
)
}
mockkConstructor(AuthRepository::class)
mockkConstructor(RecentFilesRepository::class)
mockkConstructor(BillingClientWrapper::class)
mockkConstructor(RemoteConfigRepository::class)
mockkConstructor(FirestoreRepository::class)
mockkConstructor(FeedbackRepository::class)
mockkConstructor(FontsRepository::class)
mockkConstructor(TtsController::class)
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
every { anyConstructed<TtsController>().ttsState } returns ttsStateFlow
every { anyConstructed<TtsController>().connect() } just Runs
every { anyConstructed<TtsController>().release() } just Runs
every { anyConstructed<RecentFilesRepository>().getRecentFilesFlow() } returns recentFilesFlow
every { anyConstructed<RecentFilesRepository>().activeShelvesFlow } returns shelvesFlow
every { anyConstructed<RecentFilesRepository>().shelfCrossRefsFlow } returns shelfRefsFlow
every { anyConstructed<RecentFilesRepository>().tagsFlow } returns tagsFlow
every { anyConstructed<RecentFilesRepository>().tagCrossRefsFlow } returns tagRefsFlow
coEvery { anyConstructed<RecentFilesRepository>().migrateLegacyShelvesToRoom() } just Runs
coEvery { anyConstructed<RecentFilesRepository>().seedTagsIfEmpty(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().assignTagToBook(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().removeTagFromBook(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().removeBooksFromShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
viewModel = MainViewModel(mockApplication)
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `search query updates uiState when search is active`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setSearchActive(true)
viewModel.onSearchQueryChange("Moby Dick")
val state = viewModel.uiState.first {
it.searchQuery == "Moby Dick" && it.isSearchActive
}
assertEquals("Moby Dick", state.searchQuery)
assertTrue(state.isSearchActive)
}
@Test
fun `setSearchActive false clears the search query`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setSearchActive(true)
viewModel.onSearchQueryChange("Android")
viewModel.setSearchActive(false)
assertEquals("", viewModel.uiState.value.searchQuery)
assertFalse(viewModel.uiState.value.isSearchActive)
}
@Test
fun `search query change is ignored while search is inactive`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.onSearchQueryChange("Invisible")
assertEquals("", viewModel.uiState.value.searchQuery)
assertFalse(viewModel.uiState.value.isSearchActive)
}
@Test
fun `switching theme updates internal state and preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setAppThemeMode(AppThemeMode.DARK)
val state = viewModel.uiState.first { it.appThemeMode == AppThemeMode.DARK }
assertEquals(AppThemeMode.DARK, state.appThemeMode)
verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) }
}
@Test
fun `setTabsEnabled persists to shared preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setTabsEnabled(true)
val state = viewModel.uiState.first { it.isTabsEnabled }
assertTrue(state.isTabsEnabled)
verify { mockEditor.putBoolean("tabs_enabled", true) }
}
@Test
fun `setRenderMode persists mode without touching saved epub position`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setRenderMode(RenderMode.PAGINATED)
val state = viewModel.uiState.first { it.renderMode == RenderMode.PAGINATED }
assertEquals(RenderMode.PAGINATED, state.renderMode)
verify { mockEditor.putString(KEY_RENDER_MODE, RenderMode.PAGINATED.name) }
coVerify(exactly = 0) {
anyConstructed<RecentFilesRepository>().updateEpubReadingPosition(any(), any(), any(), any())
}
}
@Test
fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest {
val uriString = "content://books/one"
val uri = mockUri(uriString)
val locator = Locator(chapterIndex = 5, blockIndex = 77, charOffset = 14)
coEvery { anyConstructed<RecentFilesRepository>().getFileByUri(uriString) } returns RecentFileItem(
bookId = "book-1",
uriString = uriString,
type = FileType.EPUB,
displayName = "One.epub",
timestamp = 1L
)
coEvery {
anyConstructed<RecentFilesRepository>().updateEpubReadingPosition(any(), any(), any(), any())
} just Runs
viewModel.saveEpubReadingPosition(uri, locator, "/4/2/6:14", 37.25f)
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
anyConstructed<RecentFilesRepository>().updateEpubReadingPosition(
uriString = uriString,
locator = locator,
cfiForWebView = "/4/2/6:14",
progress = 37.25f
)
}
}
@Test
fun `setRecentFilesLimit persists and limits visible home recents`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val first = recentFile("first", isRecent = true)
val second = recentFile("second", isRecent = true)
recentFilesFlow.value = listOf(first, second)
viewModel.uiState.first { it.rawLibraryFiles.size == 2 }
viewModel.setRecentFilesLimit(1)
val state = viewModel.uiState.first { it.recentFiles.bookIds() == setOf("first") }
assertEquals(listOf("first"), state.recentFiles.map { it.bookId })
verify { mockEditor.putInt("recent_files_limit", 1) }
}
@Test
fun `strict file filter and external file behavior persist preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setStrictFileFilter(true)
viewModel.setExternalFileBehavior("KEEP")
val state = viewModel.uiState.first {
it.useStrictFileFilter && it.externalFileBehavior == "KEEP"
}
assertTrue(state.useStrictFileFilter)
assertEquals("KEEP", state.externalFileBehavior)
verify { mockEditor.putBoolean("use_strict_file_filter", true) }
verify { mockEditor.putString("external_file_behavior", "KEEP") }
}
@Test
fun `screen capture protection persists and updates state`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setScreenCaptureProtectionEnabled(true)
val enabledState = viewModel.uiState.first { it.isScreenCaptureProtectionEnabled }
assertTrue(enabledState.isScreenCaptureProtectionEnabled)
verify { mockEditor.putBoolean("screen_capture_protection_enabled", true) }
viewModel.setScreenCaptureProtectionEnabled(false)
val disabledState = viewModel.uiState.first { !it.isScreenCaptureProtectionEnabled }
assertFalse(disabledState.isScreenCaptureProtectionEnabled)
verify { mockEditor.putBoolean("screen_capture_protection_enabled", false) }
}
@Test
fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val beta = recentFile("beta", title = "Beta", timestamp = 3L)
val alpha = recentFile("alpha", title = "Alpha", timestamp = 1L)
val gamma = recentFile("gamma", title = "Gamma", timestamp = 2L, isRecent = false)
recentFilesFlow.value = listOf(beta, alpha, gamma)
viewModel.uiState.first { it.rawLibraryFiles.size == 3 }
viewModel.setSortOrder(SortOrder.TITLE_ASC)
val state = viewModel.uiState.first {
it.sortOrder == SortOrder.TITLE_ASC &&
it.allRecentFiles.map { item -> item.bookId } == listOf("alpha", "beta", "gamma")
}
assertEquals(listOf("alpha", "beta"), state.recentFiles.map { it.bookId })
assertEquals(listOf("alpha", "beta", "gamma"), state.allRecentFiles.map { it.bookId })
verify { mockEditor.putString("sort_order", SortOrder.TITLE_ASC.name) }
}
@Test
fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setMainScreenPage(99)
val state = viewModel.uiState.first { it.mainScreenStartPage == 1 }
assertEquals(1, state.mainScreenStartPage)
verify { mockEditor.putInt(KEY_MAIN_SCREEN_START_PAGE, 1) }
}
@Test
fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setLibraryScreenPage(99)
val expectedMaxPage = if (BuildConfig.IS_OFFLINE) 2 else 3
val state = viewModel.uiState.first { it.libraryScreenStartPage == expectedMaxPage }
assertEquals(expectedMaxPage, state.libraryScreenStartPage)
verify { mockEditor.putInt(KEY_LIBRARY_SCREEN_START_PAGE, expectedMaxPage) }
}
@Test
fun `create shelf dialog state opens and dismisses`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.showCreateShelfDialog()
val openedState = viewModel.uiState.first { it.showCreateShelfDialog }
assertTrue(openedState.showCreateShelfDialog)
viewModel.dismissCreateShelfDialog()
val dismissedState = viewModel.uiState.first { !it.showCreateShelfDialog }
assertFalse(dismissedState.showCreateShelfDialog)
}
@Test
fun `selectAllRecentFiles toggles only visible recent home items`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val recent = recentFile("recent", isRecent = true)
val notRecent = recentFile("not_recent", isRecent = false)
recentFilesFlow.value = listOf(recent, notRecent)
viewModel.uiState.first { it.rawLibraryFiles.size == 2 }
viewModel.selectAllRecentFiles()
val selectedState = viewModel.uiState.first {
it.contextualActionItems.bookIds() == setOf("recent")
}
assertEquals(setOf("recent"), selectedState.contextualActionItems.bookIds())
viewModel.selectAllRecentFiles()
val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() }
assertTrue(clearedState.contextualActionItems.isEmpty())
}
@Test
fun `selectAllLibraryFiles toggles all filtered library items`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val pdf = recentFile("pdf", type = FileType.PDF)
val epub = recentFile("epub", type = FileType.EPUB)
recentFilesFlow.value = listOf(pdf, epub)
viewModel.uiState.first { it.rawLibraryFiles.size == 2 }
viewModel.updateLibraryFilters(LibraryFilters(fileTypes = setOf(FileType.PDF)))
viewModel.uiState.first { it.allRecentFiles.bookIds() == setOf("pdf") }
viewModel.selectAllLibraryFiles()
val selectedState = viewModel.uiState.first {
it.contextualActionItems.bookIds() == setOf("pdf")
}
assertEquals(setOf("pdf"), selectedState.contextualActionItems.bookIds())
}
@Test
fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val first = recentFile("first")
val second = recentFile("second")
recentFilesFlow.value = listOf(first, second)
viewModel.uiState.first { it.rawLibraryFiles.size == 2 }
viewModel.selectAllLibraryFiles()
viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("first", "second") }
viewModel.selectAllLibraryFiles()
val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() }
assertTrue(clearedState.contextualActionItems.isEmpty())
}
@Test
fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("book")
recentFilesFlow.value = listOf(book)
viewModel.uiState.first { it.rawLibraryFiles.size == 1 }
viewModel.onRecentItemLongPress(book)
viewModel.togglePinForContextualItems(isHome = true)
val pinnedState = viewModel.uiState.first {
it.pinnedHomeBookIds == setOf("book") && it.contextualActionItems.isEmpty()
}
assertEquals(setOf("book"), pinnedState.pinnedHomeBookIds)
assertTrue(pinnedState.contextualActionItems.isEmpty())
verify { mockEditor.putStringSet("pinned_home_books", setOf("book")) }
}
@Test
fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("book")
recentFilesFlow.value = listOf(book)
viewModel.uiState.first { it.rawLibraryFiles.size == 1 }
viewModel.onRecentItemLongPress(book)
viewModel.togglePinForContextualItems(isHome = true)
viewModel.uiState.first { it.pinnedHomeBookIds == setOf("book") }
viewModel.onRecentItemLongPress(book)
viewModel.togglePinForContextualItems(isHome = true)
val state = viewModel.uiState.first {
it.pinnedHomeBookIds.isEmpty() && it.contextualActionItems.isEmpty()
}
assertTrue(state.pinnedHomeBookIds.isEmpty())
verify { mockEditor.putStringSet("pinned_home_books", emptySet<String>()) }
}
@Test
fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("book")
recentFilesFlow.value = listOf(book)
viewModel.uiState.first { it.rawLibraryFiles.size == 1 }
viewModel.onRecentItemLongPress(book)
viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("book") }
viewModel.clearContextualAction()
val state = viewModel.uiState.first { it.contextualActionItems.isEmpty() }
assertTrue(state.contextualActionItems.isEmpty())
assertTrue(state.pinnedHomeBookIds.isEmpty())
assertTrue(state.pinnedLibraryBookIds.isEmpty())
}
@Test
fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("library_book")
recentFilesFlow.value = listOf(book)
viewModel.uiState.first { it.rawLibraryFiles.size == 1 }
viewModel.onRecentItemLongPress(book)
viewModel.togglePinForContextualItems(isHome = false)
val state = viewModel.uiState.first {
it.pinnedLibraryBookIds == setOf("library_book") && it.contextualActionItems.isEmpty()
}
assertEquals(setOf("library_book"), state.pinnedLibraryBookIds)
assertTrue(state.pinnedHomeBookIds.isEmpty())
verify { mockEditor.putStringSet("pinned_library_books", setOf("library_book")) }
}
@Test
fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val filters = LibraryFilters(
fileTypes = setOf(FileType.PDF, FileType.EPUB),
sourceFolders = setOf("IN_APP_STORAGE", "content://sync"),
readStatus = ReadStatusFilter.COMPLETED,
tagIds = setOf("favorite")
)
viewModel.updateLibraryFilters(filters)
val state = viewModel.uiState.first { it.libraryFilters == filters }
assertEquals(filters, state.libraryFilters)
verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, setOf("PDF", "EPUB")) }
verify { mockEditor.putStringSet(KEY_FILTER_FOLDERS, filters.sourceFolders) }
verify { mockEditor.putString(KEY_FILTER_READ_STATUS, ReadStatusFilter.COMPLETED.name) }
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) }
}
@Test
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.updateLibraryFilters(
LibraryFilters(
fileTypes = setOf(FileType.PDF),
sourceFolders = setOf("content://sync"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf("favorite")
)
)
viewModel.uiState.first { it.libraryFilters.isActive }
viewModel.updateLibraryFilters(LibraryFilters())
val state = viewModel.uiState.first { !it.libraryFilters.isActive }
assertEquals(LibraryFilters(), state.libraryFilters)
verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, emptySet<String>()) }
verify { mockEditor.putStringSet(KEY_FILTER_FOLDERS, emptySet<String>()) }
verify { mockEditor.putString(KEY_FILTER_READ_STATUS, ReadStatusFilter.ALL.name) }
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, emptySet<String>()) }
}
@Test
fun `tag selection ignores empty targets and closes after opening`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.openTagSelection(emptySet())
assertTrue(viewModel.uiState.value.showTagSelectionDialogFor.isEmpty())
viewModel.openTagSelection(setOf("book"))
val openedState = viewModel.uiState.first { it.showTagSelectionDialogFor == setOf("book") }
assertEquals(setOf("book"), openedState.showTagSelectionDialogFor)
viewModel.closeTagSelection()
val closedState = viewModel.uiState.first { it.showTagSelectionDialogFor.isEmpty() }
assertTrue(closedState.showTagSelectionDialogFor.isEmpty())
}
@Test
fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.toggleTagForBooks("favorite", setOf(" book ", "", "other"), assign = true)
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().assignTagToBook("book", "favorite") }
coVerify { anyConstructed<RecentFilesRepository>().assignTagToBook("other", "favorite") }
viewModel.toggleTagForBooks("favorite", setOf("book"), assign = false)
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().removeTagFromBook("book", "favorite") }
viewModel.toggleTagForBooks(" ", setOf("book"), assign = true)
advanceUntilIdle()
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().assignTagToBook("book", " ") }
}
@Test
fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.showRenameShelfDialog("manual")
val renameState = viewModel.uiState.first { it.showRenameShelfDialogFor == "manual" }
assertEquals("manual", renameState.showRenameShelfDialogFor)
viewModel.dismissRenameShelfDialog()
viewModel.uiState.first { it.showRenameShelfDialogFor == null }
viewModel.showDeleteShelfDialog("manual")
val deleteState = viewModel.uiState.first { it.showDeleteShelfDialogFor == "manual" }
assertEquals("manual", deleteState.showDeleteShelfDialogFor)
viewModel.dismissDeleteShelfDialog()
val dismissedState = viewModel.uiState.first { it.showDeleteShelfDialogFor == null }
assertEquals(null, dismissedState.showDeleteShelfDialogFor)
}
@Test
fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
.shelves.first { it.id == "manual" }
val tagShelf = Shelf("tag_favorite", "Favorite", ShelfType.TAG, books = emptyList())
viewModel.onShelfLongPress(tagShelf)
assertTrue(viewModel.uiState.value.contextualActionShelfIds.isEmpty())
viewModel.onShelfLongPress(manualShelf)
val selectedState = viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") }
assertEquals(setOf("manual"), selectedState.contextualActionShelfIds)
viewModel.onShelfClick(manualShelf)
val clearedState = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() }
assertTrue(clearedState.contextualActionShelfIds.isEmpty())
}
@Test
fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
.shelves.first { it.id == "manual" }
viewModel.onShelfClick(manualShelf)
val state = viewModel.uiState.first {
it.viewingShelfId == "manual" && it.mainScreenStartPage == 1 && it.libraryScreenStartPage == 1
}
assertEquals("manual", state.viewingShelfId)
}
@Test
fun `shelf navigation sets library landing state and can be cleared`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
viewModel.navigateToShelf("manual")
val shelfState = viewModel.uiState.first {
it.viewingShelfId == "manual" && it.mainScreenStartPage == 1 && it.libraryScreenStartPage == 1
}
assertEquals("manual", shelfState.viewingShelfId)
viewModel.unselectShelf()
val clearedState = viewModel.uiState.first { it.viewingShelfId == null }
assertEquals(null, clearedState.viewingShelfId)
}
@Test
fun `clearShelfContextualAction clears selected shelves`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
val manualShelf = viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
.shelves.first { it.id == "manual" }
viewModel.onShelfLongPress(manualShelf)
viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") }
viewModel.clearShelfContextualAction()
val state = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() }
assertTrue(state.contextualActionShelfIds.isEmpty())
}
@Test
fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
shelvesFlow.value = listOf(
shelfEntity("manual", "Manual"),
shelfEntity("other", "Other")
)
val shelves = viewModel.uiState.first { state ->
state.shelves.any { it.id == "manual" } && state.shelves.any { it.id == "unshelved" }
}.shelves
val manual = shelves.first { it.id == "manual" }
val unshelved = shelves.first { it.id == "unshelved" }
viewModel.onShelfLongPress(manual)
viewModel.onShelfLongPress(unshelved)
viewModel.uiState.first { it.contextualActionShelfIds == setOf("manual") }
viewModel.deleteSelectedShelves()
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().deleteShelf("manual") }
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().deleteShelf("unshelved") }
val clearedState = viewModel.uiState.first { it.contextualActionShelfIds.isEmpty() }
assertTrue(clearedState.contextualActionShelfIds.isEmpty())
}
@Test
fun `add books mode resets selection and tracks source changes`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val shelved = recentFile("shelved")
val loose = recentFile("loose")
recentFilesFlow.value = listOf(shelved, loose)
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
shelfRefsFlow.value = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L))
viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
viewModel.navigateToShelf("manual")
viewModel.showAddBooksToShelf()
val addModeState = viewModel.uiState.first {
it.isAddingBooksToShelf && it.booksAvailableForAdding.bookIds() == setOf("loose")
}
assertEquals(AddBooksSource.UNSHELVED, addModeState.addBooksSource)
viewModel.setAddBooksSource(AddBooksSource.ALL_BOOKS)
viewModel.toggleBookSelectionForAdding("loose")
val selectedState = viewModel.uiState.first {
it.addBooksSource == AddBooksSource.ALL_BOOKS && it.booksSelectedForAdding == setOf("loose")
}
assertEquals(setOf("loose"), selectedState.booksSelectedForAdding)
verify { mockEditor.putString("add_books_source", AddBooksSource.ALL_BOOKS.name) }
viewModel.dismissAddBooksToShelf()
val dismissedState = viewModel.uiState.first {
!it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty()
}
assertFalse(dismissedState.isAddingBooksToShelf)
}
@Test
fun `toggleBookSelectionForAdding toggles individual books`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.toggleBookSelectionForAdding("loose")
val selectedState = viewModel.uiState.first { it.booksSelectedForAdding == setOf("loose") }
assertEquals(setOf("loose"), selectedState.booksSelectedForAdding)
viewModel.toggleBookSelectionForAdding("loose")
val clearedState = viewModel.uiState.first { it.booksSelectedForAdding.isEmpty() }
assertTrue(clearedState.booksSelectedForAdding.isEmpty())
}
@Test
fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val loose = recentFile("loose")
recentFilesFlow.value = listOf(loose)
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
viewModel.navigateToShelf("manual")
viewModel.showAddBooksToShelf()
viewModel.toggleBookSelectionForAdding("loose")
viewModel.addBooksToShelf("manual")
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().addBooksToShelf("manual", listOf("loose")) }
val state = viewModel.uiState.first {
!it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty()
}
assertFalse(state.isAddingBooksToShelf)
assertTrue(state.booksSelectedForAdding.isEmpty())
}
@Test
fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.toggleBookSelectionForAdding("loose")
viewModel.addBooksToShelf("unshelved")
val state = viewModel.uiState.first {
!it.isAddingBooksToShelf && it.booksSelectedForAdding.isEmpty()
}
assertFalse(state.isAddingBooksToShelf)
assertTrue(state.booksSelectedForAdding.isEmpty())
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().addBooksToShelf("unshelved", any()) }
}
@Test
fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val book = recentFile("book")
recentFilesFlow.value = listOf(book)
shelvesFlow.value = listOf(shelfEntity("manual", "Manual"))
shelfRefsFlow.value = listOf(BookShelfCrossRef(bookId = "book", shelfId = "manual", addedAt = 1L))
viewModel.uiState.first { it.shelves.any { shelf -> shelf.id == "manual" } }
viewModel.navigateToShelf("manual")
viewModel.onRecentItemLongPress(book)
viewModel.uiState.first { it.contextualActionItems.bookIds() == setOf("book") }
viewModel.removeContextualItemsFromShelf()
advanceUntilIdle()
coVerify { anyConstructed<RecentFilesRepository>().removeBooksFromShelf("manual", listOf("book")) }
val clearedState = viewModel.uiState.first { it.contextualActionItems.isEmpty() }
assertTrue(clearedState.contextualActionItems.isEmpty())
}
@Test
fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val color = Color(0xFF006C4C)
val theme = CustomAppTheme(id = "forest", name = "Forest", seedColor = color)
viewModel.setAppContrastOption(AppContrastOption.HIGH)
viewModel.setAppTextDimFactorLight(0.75f)
viewModel.setAppTextDimFactorDark(0.65f)
viewModel.addCustomAppTheme(theme)
val themedState = viewModel.uiState.first {
it.appContrastOption == AppContrastOption.HIGH &&
it.appTextDimFactorLight == 0.75f &&
it.appTextDimFactorDark == 0.65f &&
it.customAppThemes == listOf(theme) &&
it.appSeedColor == color
}
assertEquals(AppContrastOption.HIGH, themedState.appContrastOption)
assertEquals(listOf(theme), themedState.customAppThemes)
verify { mockEditor.putString("app_contrast_option", AppContrastOption.HIGH.name) }
verify { mockEditor.putFloat("app_text_dim_factor_light", 0.75f) }
verify { mockEditor.putFloat("app_text_dim_factor_dark", 0.65f) }
verify { mockEditor.putInt("app_seed_color", color.toArgb()) }
viewModel.deleteCustomAppTheme(theme.id)
val deletedState = viewModel.uiState.first {
it.customAppThemes.isEmpty() && it.appSeedColor == null
}
assertTrue(deletedState.customAppThemes.isEmpty())
assertEquals(null, deletedState.appSeedColor)
verify { mockEditor.remove("app_seed_color") }
}
@Test
fun `setAppSeedColor can clear a selected seed color`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val color = Color(0xFF123456)
viewModel.setAppSeedColor(color)
viewModel.uiState.first { it.appSeedColor == color }
viewModel.setAppSeedColor(null)
val clearedState = viewModel.uiState.first { it.appSeedColor == null }
assertEquals(null, clearedState.appSeedColor)
verify { mockEditor.putInt("app_seed_color", color.toArgb()) }
verify { mockEditor.remove("app_seed_color") }
}
@Test
fun `addCustomAppTheme replaces existing theme with the same id`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val first = CustomAppTheme(id = "theme", name = "First", seedColor = Color(0xFF123456))
val second = CustomAppTheme(id = "theme", name = "Second", seedColor = Color(0xFF654321))
viewModel.addCustomAppTheme(first)
viewModel.uiState.first { it.customAppThemes == listOf(first) }
viewModel.addCustomAppTheme(second)
val state = viewModel.uiState.first { it.customAppThemes == listOf(second) }
assertEquals(listOf(second), state.customAppThemes)
assertEquals(second.seedColor, state.appSeedColor)
}
@Test
fun `banner message logic works correctly`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.showBanner("Test Message", isError = true)
val currentBanner = viewModel.uiState.first {
it.bannerMessage?.message == "Test Message"
}.bannerMessage
assertEquals("Test Message", currentBanner?.message)
assertTrue(currentBanner?.isError == true)
viewModel.bannerMessageShown()
val clearedState = viewModel.uiState.first { it.bannerMessage == null }
assertEquals(null, clearedState.bannerMessage)
}
private fun recentFile(
id: String,
type: FileType = FileType.EPUB,
isRecent: Boolean = true,
title: String? = null,
timestamp: Long = 1L
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = type,
displayName = "$id.${type.name.lowercase()}",
timestamp = timestamp,
isRecent = isRecent,
title = title
)
private fun mockUri(uriString: String): Uri {
return mockk<Uri>().also { uri ->
every { uri.toString() } returns uriString
every { uri.scheme } returns uriString.substringBefore(":", "")
}
}
private fun shelfEntity(id: String, name: String) = ShelfEntity(
id = id,
name = name,
createdAt = 1L,
updatedAt = 1L
)
private fun Iterable<RecentFileItem>.bookIds(): Set<String> = mapTo(mutableSetOf()) { it.bookId }
}