* Centralize library management logic and introduce support for plain text and HTML formats

* Centralize library management logic and introduce support for plain text and HTML formats

* Expand unit test coverage for library state management, UI models, and MainViewModel features.

* Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence

* Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges.

* Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin

* Add comprehensive unit tests

* Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic.

* Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version

* Folder import support for desktop app

* Introduce Smart Shelves with rule-based filtering in desktop version

* Implement shared EPUB annotation serialization and highlight rendering

* Centralize file type capabilities and platform-specific support logic

* Refactor reader state management to use a central reducer

* Implement customizable reader toolbar and advanced formatting settings in shared

* Implement locator-based navigation and customizable highlight palette for desktop app

* Enhance reader customization and expand search functionality in desktop app

* Redesign reader settings and tools into a tabbed control panel in desktop app

* Enhance reader navigation and highlight precision in desktop app

* Implement bidirectional position synchronization and dynamic highlights in the desktop reader

* Implement shared state management and enhanced search for the PDF reader in desktop app

* Add vertical scroll support to the desktop PDF reader

* Implement ink, text, and eraser annotation support in desktop PDF viewer

* Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app

* Implement link handling and navigation for PDF and EPUB readers in desktop app

* Implement PDF jump history for navigation in desktop app

* Enhance PDF ink rendering and annotation capabilities in desktop app

* Implement advanced PDF text annotations with inline editing and rich styling in desktop app

* Add move handle and movement logic for PDF text annotations in desktop app

* Implement local folder synchronization and metadata sidecar support in desktop app

* Implement book metadata extraction and drag-and-drop import for Desktop

* Implement dynamic and custom app theme management for desktop

* Introduce canonical PDF annotation codec and support for multi-segment highlights

* Implement rich text editing and pagination support for the PDF reader in desktop app

* Improve PDF rich text pagination, synchronization, and observability in desktop

* Hide trailing structural page breaks in rich text editor

* Implement a unified JVM book loader and expand supported formats on Desktop

* Add comic archive support for Desktop and enhance MOBI parsing

* Implement shared OPDS catalog support and UI for Android and Desktop

* Improve native WebView lifecycle and surface transition management on Desktop

* Enable Compose Swing interop blending and simplify Desktop WebView management

* Integrate BYOK AI features and Cloud TTS for desktop

* Enhance Desktop TTS with streaming audio and improved secure storage for AI key

* Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app

* Implement custom font management and utility screens in desktop app

* Implement PDFium-based PDF annotation export

* Remove PdfBox dependency and standardize PDF export via Pdfium

* Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app

* Implement reader themes and custom texture support in desktop app

* Redesign non-reader UI with responsive navigation and enhanced library management in desktop app

* Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app

* Exclude manual-only files from automated sync and import

* Implement customizable Text-to-Speech (TTS) word replacements

* Optimize reader performance with persistent layout caching and decoupled theme rendering

* Improve position restoration during reader reconfiguration in epub pagination

* Use independent thickness for eraser tool and stylus override
This commit is contained in:
Aryan 2026-05-10 10:07:37 +05:30 committed by GitHub
parent 88c7fa7b5c
commit 8366d76dcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
214 changed files with 53372 additions and 4702 deletions

View file

@ -3,13 +3,26 @@ 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.tom_roush.pdfbox.android.PDFBoxResourceLoader
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
@ -20,6 +33,7 @@ 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 {
@ -33,9 +47,24 @@ class MainViewModelTest {
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
@ -49,10 +78,18 @@ class MainViewModelTest {
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? }
@ -60,15 +97,39 @@ class MainViewModelTest {
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
mockkStatic(AppDatabase::class)
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
mockkStatic(WorkManager::class)
every { WorkManager.getInstance(any()) } returns mockk(relaxed = true)
mockkStatic(PDFBoxResourceLoader::class)
every { PDFBoxResourceLoader.init(any()) } just Runs
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)
@ -76,18 +137,28 @@ class MainViewModelTest {
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<RecentFilesRepository>().getRecentFilesFlow() } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().activeShelvesFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().shelfCrossRefsFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().tagsFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().tagCrossRefsFlow } returns flowOf(emptyList())
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
@ -109,8 +180,11 @@ class MainViewModelTest {
viewModel.setSearchActive(true)
viewModel.onSearchQueryChange("Moby Dick")
assertEquals("Moby Dick", viewModel.uiState.value.searchQuery)
assertTrue(viewModel.uiState.value.isSearchActive)
val state = viewModel.uiState.first {
it.searchQuery == "Moby Dick" && it.isSearchActive
}
assertEquals("Moby Dick", state.searchQuery)
assertTrue(state.isSearchActive)
}
@Test
@ -127,6 +201,18 @@ class MainViewModelTest {
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)) {
@ -135,7 +221,8 @@ class MainViewModelTest {
viewModel.setAppThemeMode(AppThemeMode.DARK)
assertEquals(AppThemeMode.DARK, viewModel.uiState.value.appThemeMode)
val state = viewModel.uiState.first { it.appThemeMode == AppThemeMode.DARK }
assertEquals(AppThemeMode.DARK, state.appThemeMode)
verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) }
}
@ -147,10 +234,688 @@ class MainViewModelTest {
viewModel.setTabsEnabled(true)
assertTrue(viewModel.uiState.value.isTabsEnabled)
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 `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)) {
@ -159,11 +924,46 @@ class MainViewModelTest {
viewModel.showBanner("Test Message", isError = true)
val currentBanner = viewModel.uiState.value.bannerMessage
val currentBanner = viewModel.uiState.first {
it.bannerMessage?.message == "Test Message"
}.bannerMessage
assertEquals("Test Message", currentBanner?.message)
assertTrue(currentBanner?.isError == true)
viewModel.bannerMessageShown()
assertEquals(null, viewModel.uiState.value.bannerMessage)
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 }
}