Windows (#291)
* 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:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue