* 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

@ -0,0 +1,54 @@
package com.aryan.reader
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
class FileHasherTest {
@Test
fun `calculateSha256 returns known SHA-256 for stream content`() = runTest {
val hash = FileHasher.calculateSha256 {
ByteArrayInputStream("hello world".toByteArray())
}
assertEquals(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
hash
)
}
@Test
fun `calculateSha256 supports large multi-buffer streams`() = runTest {
val bytes = ByteArray(20_000) { index -> (index % 127).toByte() }
val first = FileHasher.calculateSha256 { ByteArrayInputStream(bytes) }
val second = FileHasher.calculateSha256 {
object : InputStream() {
private var index = 0
override fun read(): Int {
if (index >= bytes.size) return -1
return bytes[index++].toInt() and 0xff
}
}
}
assertEquals(first, second)
}
@Test
fun `calculateSha256 returns null when provider is null or stream throws`() = runTest {
assertNull(FileHasher.calculateSha256 { null })
assertNull(
FileHasher.calculateSha256 {
object : InputStream() {
override fun read(): Int = throw IOException("boom")
}
}
)
}
}

View file

@ -1,6 +1,8 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FileTypeResolverTest {
@ -13,6 +15,27 @@ class FileTypeResolverTest {
assertEquals(FileType.EPUB, resolveFileTypeFromName("book.epub.txt"))
}
@Test
fun `code and data files resolve for manual viewing`() {
assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv"))
assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt"))
assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt"))
}
@Test
fun `manual only reader files are excluded from folder sync eligibility`() {
assertTrue(isManualOnlyReaderFileName("table.csv"))
assertTrue(isManualOnlyReaderFileName("script.kt.txt"))
assertFalse(isManualOnlyReaderFileName("chapter.html"))
assertFalse(isManualOnlyReaderFileName("notes.txt"))
assertFalse(isManualOnlyReaderFileName("book.fodt"))
assertFalse(isLocalFolderSyncEligibleFile("table.csv", "text/csv"))
assertFalse(isLocalFolderSyncEligibleFile("payload", "application/json"))
assertTrue(isLocalFolderSyncEligibleFile("chapter.html", "text/html"))
assertTrue(isLocalFolderSyncEligibleFile("book.fodt", "text/xml"))
}
@Test
fun `plain txt remains txt when inner extension is unsupported`() {
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))

View file

@ -0,0 +1,514 @@
package com.aryan.reader
import com.aryan.reader.data.BookShelfCrossRef
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.ShelfEntity
import com.aryan.reader.data.TagEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LibraryStateProjectorTest {
@Test
fun `filterBySearch matches display name title author and tags`() {
val sciFi = tag("tag_scifi", "Sci-Fi")
val fantasy = tag("tag_fantasy", "Fantasy")
val files = listOf(
recentFile("display", displayName = "Android Patterns.pdf"),
recentFile("title", title = "Clean Architecture"),
recentFile("author", author = "Octavia Butler"),
recentFile("tagged", tags = listOf(sciFi)),
recentFile("miss", tags = listOf(fantasy))
)
assertEquals(listOf("display"), filterBySearch(files, "android").ids())
assertEquals(listOf("title"), filterBySearch(files, "architecture").ids())
assertEquals(listOf("author"), filterBySearch(files, "butler").ids())
assertEquals(listOf("tagged"), filterBySearch(files, "sci").ids())
assertEquals(files.ids(), filterBySearch(files, " ").ids())
}
@Test
fun `applyLibraryFilters requires all active filters to match`() {
val activeTag = tag("active", "Active")
val files = listOf(
recentFile(
id = "match",
type = FileType.PDF,
sourceFolderUri = "content://sync",
progressPercentage = 50f,
tags = listOf(activeTag)
),
recentFile(
id = "wrong_type",
type = FileType.EPUB,
sourceFolderUri = "content://sync",
progressPercentage = 50f,
tags = listOf(activeTag)
),
recentFile(
id = "wrong_source",
type = FileType.PDF,
sourceFolderUri = null,
progressPercentage = 50f,
tags = listOf(activeTag)
),
recentFile(
id = "completed",
type = FileType.PDF,
sourceFolderUri = "content://sync",
progressPercentage = 100f,
tags = listOf(activeTag)
)
)
val filters = LibraryFilters(
fileTypes = setOf(FileType.PDF),
sourceFolders = setOf("content://sync"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf(activeTag.id)
)
assertEquals(listOf("match"), applyLibraryFilters(files, filters).ids())
assertTrue(filters.isActive)
}
@Test
fun `applyLibraryFilters supports in-app storage source`() {
val localBook = recentFile("local", uriString = "content://local", sourceFolderUri = null)
val streamedBook = recentFile("streamed", uriString = "opds-pse://book", sourceFolderUri = null)
val syncedBook = recentFile("synced", sourceFolderUri = "content://sync")
val result = applyLibraryFilters(
listOf(localBook, streamedBook, syncedBook),
LibraryFilters(sourceFolders = setOf("IN_APP_STORAGE"))
)
assertEquals(listOf("local"), result.ids())
}
@Test
fun `applyLibraryFilters treats opds streams separately from in-app storage`() {
val localBook = recentFile("local", uriString = "file:///local/book.epub", sourceFolderUri = null)
val streamedBook = recentFile("streamed", uriString = "opds-pse://book", sourceFolderUri = null)
assertEquals(
listOf("local"),
applyLibraryFilters(
listOf(localBook, streamedBook),
LibraryFilters(sourceFolders = setOf("IN_APP_STORAGE"))
).ids()
)
}
@Test
fun `applyLibraryFilters separates unread in progress and completed books`() {
val unread = recentFile("unread", progressPercentage = null)
val started = recentFile("started", progressPercentage = 1f)
val middle = recentFile("middle", progressPercentage = 45f)
val done = recentFile("done", progressPercentage = 100f)
val files = listOf(unread, started, middle, done)
assertEquals(
listOf("unread"),
applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.UNREAD)).ids()
)
assertEquals(
listOf("started", "middle"),
applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.IN_PROGRESS)).ids()
)
assertEquals(
listOf("done"),
applyLibraryFilters(files, LibraryFilters(readStatus = ReadStatusFilter.COMPLETED)).ids()
)
}
@Test
fun `sortFiles orders by title author progress size and recency`() {
val files = listOf(
recentFile("charlie", title = "Charlie", author = null, timestamp = 3L, progressPercentage = 50f, fileSize = 300L),
recentFile("alpha", title = "Alpha", author = "Zimmer", timestamp = 1L, progressPercentage = 10f, fileSize = 100L),
recentFile("bravo", title = "Bravo", author = "Asimov", timestamp = 2L, progressPercentage = 90f, fileSize = 200L)
)
assertEquals(listOf("charlie", "bravo", "alpha"), sortFiles(files, SortOrder.RECENT).ids())
assertEquals(listOf("alpha", "bravo", "charlie"), sortFiles(files, SortOrder.TITLE_ASC).ids())
assertEquals(listOf("bravo", "alpha", "charlie"), sortFiles(files, SortOrder.AUTHOR_ASC).ids())
assertEquals(listOf("alpha", "charlie", "bravo"), sortFiles(files, SortOrder.PERCENT_ASC).ids())
assertEquals(listOf("bravo", "charlie", "alpha"), sortFiles(files, SortOrder.PERCENT_DESC).ids())
assertEquals(listOf("alpha", "bravo", "charlie"), sortFiles(files, SortOrder.SIZE_ASC).ids())
assertEquals(listOf("charlie", "bravo", "alpha"), sortFiles(files, SortOrder.SIZE_DESC).ids())
}
@Test
fun `sortFiles falls back to display names and keeps unknown authors last`() {
val files = listOf(
recentFile("unknown", displayName = "Zulu.epub", title = null, author = null),
recentFile("known", displayName = "Beta.epub", title = null, author = "Ada"),
recentFile("title", displayName = "Alpha.epub", title = "Omega", author = "Grace")
)
assertEquals(listOf("known", "title", "unknown"), sortFiles(files, SortOrder.AUTHOR_ASC).ids())
assertEquals(listOf("known", "title", "unknown"), sortFiles(files, SortOrder.TITLE_ASC).ids())
}
@Test
fun `project builds non-reader library state from repository data`() {
val tag = tag("tag_favorite", "Favorite")
val alpha = recentFile(
id = "alpha",
type = FileType.PDF,
title = "Zebra",
timestamp = 30L,
progressPercentage = 100f
)
val beta = recentFile(
id = "beta",
type = FileType.EPUB,
title = "Alpha",
timestamp = 20L,
sourceFolderUri = "content://sync",
progressPercentage = 40f
)
val gamma = recentFile(
id = "gamma",
type = FileType.MD,
title = "Notes",
timestamp = 10L,
isRecent = false
)
val reflowCopy = recentFile(id = "beta_reflow", title = "Alpha Reflow")
val manualShelf = shelfEntity("manual", "Manual")
val state = ReaderScreenState(
sortOrder = SortOrder.TITLE_ASC,
recentFilesLimit = 1,
openTabIds = listOf("beta", "missing"),
contextualActionItems = setOf(recentFile("beta"), recentFile("missing")),
viewingShelfId = "manual",
contextualActionShelfIds = setOf("manual", "missing")
)
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = state,
recentFilesFromDb = listOf(alpha, beta, gamma, reflowCopy),
dbShelves = listOf(manualShelf),
shelfRefs = listOf(BookShelfCrossRef(bookId = "alpha", shelfId = "manual", addedAt = 1L)),
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = "beta", tagId = tag.id))
)
)
assertEquals(listOf("beta", "gamma", "alpha"), result.allRecentFiles.ids())
assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids())
assertEquals(listOf("beta"), result.recentFiles.ids())
assertEquals(listOf("beta"), result.openTabs.ids())
assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
assertEquals(listOf(tag), result.contextualActionItems.first().tags)
assertEquals("manual", result.viewingShelfId)
assertEquals(setOf("manual"), result.contextualActionShelfIds)
assertEquals(listOf(tag), result.allTags)
assertFalse(result.rawLibraryFiles.any { it.bookId.endsWith("_reflow") })
}
@Test
fun `project applies search filters and sort only to library results`() {
val tag = tag("work", "Work")
val match = recentFile(
id = "match",
title = "Android Work",
type = FileType.PDF,
progressPercentage = 80f,
sourceFolderUri = "content://sync"
)
val searchMiss = recentFile(
id = "search_miss",
title = "Poetry",
type = FileType.PDF,
progressPercentage = 80f,
sourceFolderUri = "content://sync"
)
val filterMiss = recentFile(
id = "filter_miss",
title = "Android Notes",
type = FileType.EPUB,
progressPercentage = 80f,
sourceFolderUri = "content://sync"
)
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
searchQuery = "android",
sortOrder = SortOrder.TITLE_ASC,
libraryFilters = LibraryFilters(
fileTypes = setOf(FileType.PDF),
sourceFolders = setOf("content://sync"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf(tag.id)
)
),
recentFilesFromDb = listOf(searchMiss, filterMiss, match),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = "match", tagId = tag.id))
)
)
assertEquals(listOf("match"), result.allRecentFiles.ids())
assertEquals(listOf("search_miss", "filter_miss", "match"), result.rawLibraryFiles.ids())
assertEquals(listOf(tag), result.allTags)
}
@Test
fun `project builds manual tag series and unshelved shelves`() {
val favorite = tag("favorite", "Favorite")
val manualShelf = shelfEntity("manual", "Manual")
val manualBook = recentFile("manual", title = "Manual")
val taggedBook = recentFile("tagged", title = "Tagged")
val seriesOne = recentFile("series_1", title = "Series One", seriesName = "Saga", seriesIndex = 1.0)
val seriesTwo = recentFile("series_2", title = "Series Two", seriesName = "Saga", seriesIndex = 2.0)
val loose = recentFile("loose", title = "Loose")
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(sortOrder = SortOrder.TITLE_ASC),
recentFilesFromDb = listOf(manualBook, taggedBook, seriesTwo, loose, seriesOne),
dbShelves = listOf(manualShelf),
shelfRefs = listOf(BookShelfCrossRef(bookId = "manual", shelfId = "manual", addedAt = 1L)),
dbTags = listOf(favorite),
tagRefs = listOf(BookTagCrossRef(bookId = "tagged", tagId = favorite.id))
)
)
val manual = result.shelves.first { it.id == "manual" }
val tagShelf = result.shelves.first { it.id == "tag_favorite" }
val series = result.shelves.first { it.id == "series_Saga" }
val unshelved = result.shelves.first { it.id == "unshelved" }
assertEquals(ShelfType.MANUAL, manual.type)
assertEquals(listOf("manual"), manual.books.ids())
assertEquals(ShelfType.TAG, tagShelf.type)
assertEquals(listOf("tagged"), tagShelf.books.ids())
assertEquals(ShelfType.SERIES, series.type)
assertEquals(listOf("series_1", "series_2"), series.books.ids())
assertEquals(listOf("loose", "tagged"), unshelved.books.ids())
}
@Test
fun `project does not create series shelf for a single series book`() {
val single = recentFile("single", title = "Only Volume", seriesName = "Solo", seriesIndex = 1.0)
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(),
recentFilesFromDb = listOf(single),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
assertTrue(result.shelves.none { it.type == ShelfType.SERIES })
assertEquals(listOf("single"), result.shelves.first { it.id == "unshelved" }.books.ids())
}
@Test
fun `project exposes all books for adding except books already in current shelf`() {
val shelf = shelfEntity("manual", "Manual")
val shelved = recentFile("shelved", title = "Shelved")
val loose = recentFile("loose", title = "Loose")
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
viewingShelfId = "manual",
isAddingBooksToShelf = true,
addBooksSource = AddBooksSource.ALL_BOOKS,
sortOrder = SortOrder.TITLE_ASC
),
recentFilesFromDb = listOf(shelved, loose),
dbShelves = listOf(shelf),
shelfRefs = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L)),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
assertEquals(listOf("loose"), result.booksAvailableForAdding.ids())
}
@Test
fun `project exposes only unshelved books for default add books source`() {
val shelf = shelfEntity("manual", "Manual")
val shelved = recentFile("shelved", title = "Shelved")
val loose = recentFile("loose", title = "Loose")
val tagged = recentFile("tagged", title = "Tagged")
val tag = tag("tagged", "Tagged")
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
viewingShelfId = "manual",
isAddingBooksToShelf = true,
addBooksSource = AddBooksSource.UNSHELVED,
sortOrder = SortOrder.TITLE_ASC
),
recentFilesFromDb = listOf(shelved, loose, tagged),
dbShelves = listOf(shelf),
shelfRefs = listOf(BookShelfCrossRef(bookId = "shelved", shelfId = "manual", addedAt = 1L)),
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = "tagged", tagId = tag.id))
)
)
assertEquals(listOf("loose", "tagged"), result.booksAvailableForAdding.ids())
}
@Test
fun `project clears stale shelf mode when selected shelf disappears`() {
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
viewingShelfId = "deleted",
isAddingBooksToShelf = true,
contextualActionShelfIds = setOf("deleted")
),
recentFilesFromDb = listOf(recentFile("book")),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
assertNull(result.viewingShelfId)
assertFalse(result.isAddingBooksToShelf)
assertTrue(result.contextualActionShelfIds.isEmpty())
}
@Test
fun `project creates root and nested shelves for synced folders`() {
val rootBook = recentFile("root", sourceFolderUri = "content://library", timestamp = 2L)
val nestedBook = recentFile("nested", sourceFolderUri = "content://library", timestamp = 1L)
val projector = LibraryStateProjector(
FolderPathResolver { item ->
when (item.bookId) {
"nested" -> listOf("Series", "Volume 1")
else -> emptyList()
}
}
)
val result = projector.project(
LibraryProjectionInput(
state = ReaderScreenState(
syncedFolders = listOf(
SyncedFolder(
uriString = "content://library",
name = "Library",
lastScanTime = 1L
)
)
),
recentFilesFromDb = listOf(rootBook, nestedBook),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
val rootShelf = result.shelves.first { it.id == "folder_content://library" }
val seriesShelf = result.shelves.first { it.id == "folder_content://library::Series" }
val volumeShelf = result.shelves.first { it.id == "folder_content://library::Series/Volume 1" }
assertEquals("Library", rootShelf.name)
assertEquals(listOf("root", "nested"), rootShelf.books.ids())
assertEquals(listOf("root"), rootShelf.directBooks.ids())
assertEquals(listOf(seriesShelf.id), rootShelf.childShelfIds)
assertEquals(rootShelf.id, seriesShelf.parentShelfId)
assertEquals(listOf("nested"), seriesShelf.books.ids())
assertEquals(listOf(volumeShelf.id), seriesShelf.childShelfIds)
assertEquals(seriesShelf.id, volumeShelf.parentShelfId)
assertEquals(listOf("nested"), volumeShelf.directBooks.ids())
assertEquals(2, volumeShelf.depth)
}
@Test
fun `project names folder shelf local folder when synced folder metadata is missing`() {
val book = recentFile("folder_book", sourceFolderUri = "content://external")
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(),
recentFilesFromDb = listOf(book),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
val folderShelf = result.shelves.first { it.id == "folder_content://external" }
assertEquals("Local Folder", folderShelf.name)
assertEquals(listOf("folder_book"), folderShelf.books.ids())
assertEquals(listOf("folder_book"), folderShelf.directBooks.ids())
}
private fun recentFile(
id: String,
uriString: String? = "content://$id",
type: FileType = FileType.EPUB,
displayName: String = "$id.${type.name.lowercase()}",
title: String? = null,
author: String? = null,
timestamp: Long = 1L,
isRecent: Boolean = true,
sourceFolderUri: String? = null,
progressPercentage: Float? = null,
tags: List<TagEntity> = emptyList(),
fileSize: Long = 0L,
seriesName: String? = null,
seriesIndex: Double? = null
) = RecentFileItem(
bookId = id,
uriString = uriString,
type = type,
displayName = displayName,
title = title,
author = author,
timestamp = timestamp,
isRecent = isRecent,
sourceFolderUri = sourceFolderUri,
progressPercentage = progressPercentage,
tags = tags,
fileSize = fileSize,
seriesName = seriesName,
seriesIndex = seriesIndex
)
private fun tag(id: String, name: String) = TagEntity(
id = id,
name = name,
createdAt = 1L
)
private fun shelfEntity(id: String, name: String) = ShelfEntity(
id = id,
name = name,
createdAt = 1L,
updatedAt = 1L
)
private fun List<RecentFileItem>.ids() = map { it.bookId }
}

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 }
}

View file

@ -0,0 +1,147 @@
package com.aryan.reader
import com.aryan.reader.data.RecentFileItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NonReaderScreenModelsTest {
@Test
fun `home model treats open tabs as non-empty content`() {
val tab = recentFile("tab")
val model = ReaderScreenState(
isTabsEnabled = true,
openTabs = listOf(tab),
rawLibraryFiles = listOf(tab)
).toHomeScreenModel()
assertFalse(model.isEmpty)
assertTrue(model.isLibraryEmpty)
assertEquals(listOf(tab), model.openTabs)
}
@Test
fun `home model reports empty when there are no recents or open tabs`() {
val archivedBook = recentFile("archived", isRecent = false)
val model = ReaderScreenState(
recentFiles = emptyList(),
rawLibraryFiles = listOf(archivedBook)
).toHomeScreenModel()
assertTrue(model.isEmpty)
assertTrue(model.isLibraryEmpty)
}
@Test
fun `home model ignores open tabs for empty state when tabs are disabled`() {
val tab = recentFile("tab")
val model = ReaderScreenState(
isTabsEnabled = false,
openTabs = listOf(tab),
recentFiles = emptyList()
).toHomeScreenModel()
assertTrue(model.isEmpty)
assertEquals(listOf(tab), model.openTabs)
}
@Test
fun `home model exposes contextual selection and device limit state`() {
val selected = recentFile("selected")
val deviceState = DeviceLimitReachedState(isLimitReached = true)
val model = ReaderScreenState(
recentFiles = listOf(selected),
contextualActionItems = setOf(selected),
deviceLimitState = deviceState
).toHomeScreenModel()
assertTrue(model.isContextualModeActive)
assertEquals(setOf(selected), model.selectedItems)
assertEquals(deviceState, model.deviceLimitState)
assertFalse(model.isEmpty)
assertFalse(model.isLibraryEmpty)
}
@Test
fun `library model exposes contextual and shelf selection state`() {
val folderBook = recentFile("folder", sourceFolderUri = "content://folder")
val shelf = Shelf(
id = "manual",
name = "Manual",
type = ShelfType.MANUAL,
books = listOf(folderBook)
)
val model = ReaderScreenState(
contextualActionItems = setOf(folderBook),
contextualActionShelfIds = setOf(shelf.id),
sortOrder = SortOrder.TITLE_ASC,
shelves = listOf(shelf),
rawLibraryFiles = listOf(folderBook),
searchQuery = "folder",
isSearchActive = true
).toLibraryScreenModel()
assertTrue(model.isContextualModeActive)
assertTrue(model.isShelfContextualModeActive)
assertTrue(model.containsFolderItemsInSelection)
assertEquals(setOf(folderBook), model.selectedItems)
assertEquals(setOf(shelf.id), model.selectedShelves)
assertEquals(SortOrder.TITLE_ASC, model.sortOrder)
assertEquals("folder", model.searchQuery)
assertTrue(model.isSearchActive)
}
@Test
fun `library model reports inactive contextual states for normal browsing`() {
val book = recentFile("book")
val model = ReaderScreenState(
allRecentFiles = listOf(book),
rawLibraryFiles = listOf(book),
sortOrder = SortOrder.RECENT
).toLibraryScreenModel()
assertFalse(model.isContextualModeActive)
assertFalse(model.isShelfContextualModeActive)
assertFalse(model.containsFolderItemsInSelection)
assertTrue(model.selectedItems.isEmpty())
assertTrue(model.selectedShelves.isEmpty())
assertEquals(listOf(book), model.rawLibraryFiles)
assertEquals(SortOrder.RECENT, model.sortOrder)
}
@Test
fun `library model distinguishes folder and non-folder selections`() {
val localBook = recentFile("local")
val model = ReaderScreenState(
contextualActionItems = setOf(localBook),
rawLibraryFiles = listOf(localBook)
).toLibraryScreenModel()
assertTrue(model.isContextualModeActive)
assertFalse(model.containsFolderItemsInSelection)
assertEquals(setOf(localBook), model.selectedItems)
}
private fun recentFile(
id: String,
isRecent: Boolean = true,
sourceFolderUri: String? = null
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = FileType.EPUB,
displayName = "$id.epub",
timestamp = 1L,
isRecent = isRecent,
sourceFolderUri = sourceFolderUri
)
}

View file

@ -0,0 +1,46 @@
package com.aryan.reader
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementRule
import org.junit.Assert.assertEquals
import org.junit.Test
class TtsReplacementChunkTest {
@Test
fun `tts chunk spoken text falls back to original text`() {
val chunk = TtsChunk(
text = "Dr. Smith",
sourceCfi = "epubcfi(/6/2)",
startOffsetInSource = 12
)
assertEquals("Dr. Smith", chunk.spokenText)
}
@Test
fun `chunk preparation keeps original text and writes spoken text`() {
val preferences = ReaderTtsReplacementPreferences(
globalRules = listOf(
ReaderTtsReplacementRule(
id = "dr",
from = "Dr.",
to = "Doctor",
wholeWord = false
)
)
)
val chunk = TtsChunk(
text = "Dr. Smith",
sourceCfi = "epubcfi(/6/2)",
startOffsetInSource = 12
)
val prepared = listOf(chunk).withTtsReplacements(preferences, "book").single()
assertEquals("Dr. Smith", prepared.text)
assertEquals("Doctor Smith", prepared.spokenText)
assertEquals("epubcfi(/6/2)", prepared.sourceCfi)
assertEquals(12, prepared.startOffsetInSource)
}
}

View file

@ -0,0 +1,90 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FolderBookMetadataTest {
@Test
fun `metadata JSON round trips nullable reader progress fields`() {
val metadata = FolderBookMetadata(
bookId = "book-1",
title = "Title",
author = null,
displayName = "Title.epub",
type = "EPUB",
lastChapterIndex = 4,
lastPage = null,
lastPositionCfi = "/4/2:10",
progressPercentage = 42.5f,
isRecent = false,
lastModifiedTimestamp = 1234L,
bookmarksJson = """[{"chapter":4}]""",
locatorBlockIndex = 99,
locatorCharOffset = null,
customName = "Custom",
highlightsJson = """[{"id":"h1"}]"""
)
val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString())
assertEquals(metadata.copy(author = null, lastPage = null, locatorCharOffset = null), decoded)
}
@Test
fun `fromJsonString applies legacy defaults for missing optional fields`() {
val decoded = FolderBookMetadata.fromJsonString("""{"bookId":"legacy"}""")
assertEquals("legacy", decoded.bookId)
assertEquals("Unknown", decoded.displayName)
assertEquals("PDF", decoded.type)
assertEquals(0f, decoded.progressPercentage)
assertTrue(decoded.isRecent)
assertEquals(0L, decoded.lastModifiedTimestamp)
assertNull(decoded.title)
assertNull(decoded.lastChapterIndex)
assertNull(decoded.locatorBlockIndex)
}
@Test
fun `toRecentFileItem maps metadata and falls back to EPUB for unknown type`() {
val metadata = FolderBookMetadata(
bookId = "book-2",
title = "Remote Title",
author = "Author",
displayName = "Remote.bin",
type = "NOT_A_TYPE",
lastChapterIndex = 2,
lastPage = 12,
lastPositionCfi = "/6",
progressPercentage = 75f,
isRecent = true,
lastModifiedTimestamp = 500L,
bookmarksJson = "bookmarks",
locatorBlockIndex = 7,
locatorCharOffset = 8,
customName = "Shelf Name",
highlightsJson = "highlights"
)
val item = metadata.toRecentFileItem(
uriString = "content://book",
coverPath = "/covers/book.png",
sourceFolderUri = "content://folder"
)
assertEquals("book-2", item.bookId)
assertEquals(FileType.EPUB, item.type)
assertEquals("Remote Title", item.title)
assertEquals("Author", item.author)
assertEquals(12, item.lastPage)
assertEquals(7, item.locatorBlockIndex)
assertEquals(8, item.locatorCharOffset)
assertEquals("content://folder", item.sourceFolderUri)
assertEquals("Shelf Name", item.customName)
assertEquals("highlights", item.highlightsJson)
}
}

View file

@ -0,0 +1,138 @@
package com.aryan.reader.data
import androidx.room.Room
import com.aryan.reader.FileType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class RecentFileDaoReadingPositionTest {
private lateinit var db: AppDatabase
private lateinit var dao: RecentFileDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
AppDatabase::class.java
).allowMainThreadQueries().build()
dao = db.recentFileDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun `updateEpubReadingPosition persists cfi locator progress and timestamps`() = runTest {
dao.insertOrUpdateFile(recentFileEntity())
dao.updateEpubReadingPosition(
bookId = "book-1",
cfi = "/4/2/6:33",
chapterIndex = 7,
blockIndex = 42,
charOffset = 33,
progress = 58.5f,
timestamp = 9_000L
)
val saved = dao.getFileByUri("content://books/one")!!
assertEquals("/4/2/6:33", saved.lastPositionCfi)
assertEquals(7, saved.lastChapterIndex)
assertEquals(42, saved.locatorBlockIndex)
assertEquals(33, saved.locatorCharOffset)
assertEquals(58.5f, saved.progressPercentage)
assertEquals(9_000L, saved.timestamp)
assertEquals(9_000L, saved.lastModifiedTimestamp)
}
@Test
fun `updateEpubReadingPosition can persist locator when webview cfi is unavailable`() = runTest {
dao.insertOrUpdateFile(recentFileEntity(lastPositionCfi = "/old:1"))
dao.updateEpubReadingPosition(
bookId = "book-1",
cfi = null,
chapterIndex = 2,
blockIndex = 9,
charOffset = 0,
progress = 12f,
timestamp = 2_000L
)
val saved = dao.getFileByBookId("book-1")!!
assertNull(saved.lastPositionCfi)
assertEquals(2, saved.lastChapterIndex)
assertEquals(9, saved.locatorBlockIndex)
assertEquals(0, saved.locatorCharOffset)
assertEquals(12f, saved.progressPercentage)
}
@Test
fun `recent file summary exposes persisted cfi and locator fields for reader restore`() = runTest {
dao.insertOrUpdateFile(recentFileEntity())
dao.updateEpubReadingPosition(
bookId = "book-1",
cfi = "/6/4:12",
chapterIndex = 3,
blockIndex = 21,
charOffset = 12,
progress = 44f,
timestamp = 3_000L
)
val item = dao.getRecentFiles().first().single().toRecentFileItem()
assertEquals("/6/4:12", item.lastPositionCfi)
assertEquals(3, item.lastChapterIndex)
assertEquals(21, item.locatorBlockIndex)
assertEquals(12, item.locatorCharOffset)
assertEquals(44f, item.progressPercentage)
assertTrue(item.isRecent)
}
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
return RecentFileEntity(
bookId = "book-1",
uriString = "content://books/one",
type = FileType.EPUB,
displayName = "One.epub",
timestamp = 1_000L,
coverImagePath = null,
title = "One",
author = "Author",
lastChapterIndex = null,
lastPage = null,
lastPositionCfi = lastPositionCfi,
progressPercentage = null,
isRecent = true,
isAvailable = true,
lastModifiedTimestamp = 1_000L,
isDeleted = false,
locatorBlockIndex = null,
locatorCharOffset = null,
bookmarks = null,
sourceFolderUri = null,
isReflowPreferred = false,
customName = null,
highlights = null,
fileSize = 123L,
seriesName = null,
seriesIndex = null,
description = null,
folderTextMetadataParsed = false
)
}
}

View file

@ -0,0 +1,54 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.junit.Assert.assertEquals
import org.junit.Test
class RecentFileItemReadingPositionMappingTest {
@Test
fun `recent file entity mapping preserves epub cfi locator and progress fields`() {
val item = recentFileItem()
val roundTripped = item.toRecentFileEntity().toRecentFileItem()
assertEquals(item.lastPositionCfi, roundTripped.lastPositionCfi)
assertEquals(item.lastChapterIndex, roundTripped.lastChapterIndex)
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
}
@Test
fun `cloud metadata mapping preserves epub cfi locator and progress fields`() {
val item = recentFileItem()
val roundTripped = item.toBookMetadata().toRecentFileItem()
assertEquals(item.lastPositionCfi, roundTripped.lastPositionCfi)
assertEquals(item.lastChapterIndex, roundTripped.lastChapterIndex)
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
}
private fun recentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = "book-1",
uriString = "content://books/one",
type = FileType.EPUB,
displayName = "One.epub",
timestamp = 1_000L,
title = "One",
author = "Author",
lastChapterIndex = 4,
lastPositionCfi = "/4/2/6:88",
locatorBlockIndex = 30,
locatorCharOffset = 88,
progressPercentage = 61.5f,
lastModifiedTimestamp = 2_000L,
bookmarksJson = """[{"cfi":"/4/2"}]""",
highlightsJson = """[{"cfi":"/4/2/6:88"}]"""
)
}
}

View file

@ -0,0 +1,148 @@
package com.aryan.reader.data
import android.content.Context
import com.aryan.reader.FileType
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import java.io.File
class RecentFilesRepositoryReadingPositionMergeTest {
private lateinit var context: Context
private lateinit var recentFileDao: RecentFileDao
private lateinit var repository: RecentFilesRepository
@Before
fun setUp() {
val testRoot = File("build/test-tmp/RecentFilesRepositoryReadingPositionMergeTest/${System.nanoTime()}")
val filesDir = File(testRoot, "files").apply { mkdirs() }
val cacheDir = File(testRoot, "cache").apply { mkdirs() }
context = mockk(relaxed = true)
every { context.applicationContext } returns context
every { context.filesDir } returns filesDir
every { context.cacheDir } returns cacheDir
recentFileDao = mockk()
val shelfDao = mockk<ShelfDao>()
val tagDao = mockk<TagDao>()
val db = mockk<AppDatabase>()
every { db.recentFileDao() } returns recentFileDao
every { db.shelfDao() } returns shelfDao
every { db.tagDao() } returns tagDao
every { shelfDao.getAllActiveShelves() } returns flowOf(emptyList())
every { shelfDao.getAllBookShelfCrossRefs() } returns flowOf(emptyList())
every { tagDao.getAllTags() } returns flowOf(emptyList())
every { tagDao.getAllBookTagCrossRefs() } returns flowOf(emptyList())
mockkObject(AppDatabase.Companion)
every { AppDatabase.getDatabase(any()) } returns db
repository = RecentFilesRepository(context)
}
@After
fun tearDown() {
unmockkObject(AppDatabase.Companion)
}
@Test
fun `addRecentFile preserves existing reading position when incoming metadata omits it`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity()
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 2_000L,
isRecent = true
)
)
assertEquals("/4/2/6:44", inserted.captured.lastPositionCfi)
assertEquals(6, inserted.captured.lastChapterIndex)
assertEquals(24, inserted.captured.locatorBlockIndex)
assertEquals(44, inserted.captured.locatorCharOffset)
assertEquals(71.5f, inserted.captured.progressPercentage)
coVerify { recentFileDao.insertOrUpdateFile(any()) }
}
@Test
fun `addRecentFile uses incoming reading position when newer metadata includes it`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity()
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 2_000L,
lastChapterIndex = 8,
lastPositionCfi = "/6/4:12",
locatorBlockIndex = 31,
locatorCharOffset = 12,
progressPercentage = 82f,
isRecent = true
)
)
assertEquals("/6/4:12", inserted.captured.lastPositionCfi)
assertEquals(8, inserted.captured.lastChapterIndex)
assertEquals(31, inserted.captured.locatorBlockIndex)
assertEquals(12, inserted.captured.locatorCharOffset)
assertEquals(82f, inserted.captured.progressPercentage)
}
private fun existingEntity(): RecentFileEntity {
return RecentFileEntity(
bookId = "book-1",
uriString = "content://old",
type = FileType.EPUB,
displayName = "Old.epub",
timestamp = 1_000L,
coverImagePath = "/covers/old.png",
title = "Old",
author = "Author",
lastChapterIndex = 6,
lastPage = null,
lastPositionCfi = "/4/2/6:44",
progressPercentage = 71.5f,
isRecent = true,
isAvailable = true,
lastModifiedTimestamp = 1_500L,
isDeleted = false,
locatorBlockIndex = 24,
locatorCharOffset = 44,
bookmarks = "bookmarks",
sourceFolderUri = "content://folder",
isReflowPreferred = false,
customName = "Custom",
highlights = "highlights",
fileSize = 123L,
seriesName = "Series",
seriesIndex = 1.0,
description = "Description",
folderTextMetadataParsed = true
)
}
}

View file

@ -0,0 +1,143 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SmartCollectionEngineTest {
@Test
fun `definition JSON round trips and ignores unknown fields`() {
val definition = SmartCollectionDefinition(
matchAll = false,
rules = listOf(
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"),
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "50")
)
)
val encoded = SmartCollectionEngine.toJson(definition)
val decoded = SmartCollectionEngine.fromJson(
encoded.replaceFirst("{", """{"unknown":"kept-for-forward-compat",""")
)
assertEquals(definition, decoded)
}
@Test
fun `fromJson returns null for blank malformed and incompatible payloads`() {
assertNull(SmartCollectionEngine.fromJson(null))
assertNull(SmartCollectionEngine.fromJson(" "))
assertNull(SmartCollectionEngine.fromJson("{not json"))
assertNull(SmartCollectionEngine.fromJson("""{"matchAll":true,"rules":[{"field":"NOPE"}]}"""))
}
@Test
fun `matchAll requires every rule while matchAny accepts a single matching rule`() {
val book = book(
title = "Dune Messiah",
author = "Frank Herbert",
progressPercentage = 41f,
type = FileType.EPUB
)
val titleAndHighProgress = SmartCollectionDefinition(
matchAll = true,
rules = listOf(
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"),
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "80")
)
)
val titleOrHighProgress = titleAndHighProgress.copy(matchAll = false)
assertFalse(SmartCollectionEngine.evaluate(book, titleAndHighProgress))
assertTrue(SmartCollectionEngine.evaluate(book, titleOrHighProgress))
}
@Test
fun `string folder file type and tag rules are case insensitive`() {
val book = book(
displayName = "fallback-name.pdf",
title = null,
author = "Ursula K. Le Guin",
sourceFolderUri = "content://library/Sci-Fi",
type = FileType.PDF,
tags = listOf(
TagEntity(id = "t1", name = "Classic Science Fiction", createdAt = 1L),
TagEntity(id = "t2", name = "Queued", createdAt = 2L)
)
)
assertTrue(
SmartCollectionEngine.evaluate(
book,
SmartCollectionDefinition(
rules = listOf(
SmartRule(SmartField.TITLE, SmartOperator.EQUALS, "fallback-name.pdf"),
SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, "le guin"),
SmartRule(SmartField.FOLDER, SmartOperator.CONTAINS, "SCI-FI"),
SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "pdf"),
SmartRule(SmartField.TAG, SmartOperator.CONTAINS, "science")
)
)
)
)
}
@Test
fun `numeric rules handle equals greater less missing progress and invalid values`() {
val startedBook = book(progressPercentage = 33.5f)
val missingProgressBook = book(progressPercentage = null)
assertTrue(matchesProgress(startedBook, SmartOperator.EQUALS, "33.5"))
assertTrue(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "33"))
assertTrue(matchesProgress(startedBook, SmartOperator.LESS_THAN, "34"))
assertFalse(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "not-a-number"))
assertTrue(matchesProgress(missingProgressBook, SmartOperator.EQUALS, "0"))
}
@Test
fun `empty definitions never match`() {
assertFalse(SmartCollectionEngine.evaluate(book(), SmartCollectionDefinition()))
}
private fun matchesProgress(
book: RecentFileItem,
operator: SmartOperator,
value: String
): Boolean {
return SmartCollectionEngine.evaluate(
book,
SmartCollectionDefinition(
rules = listOf(SmartRule(SmartField.PROGRESS, operator, value))
)
)
}
private fun book(
bookId: String = "book-id",
displayName: String = "display.epub",
title: String? = "Display",
author: String? = null,
progressPercentage: Float? = null,
sourceFolderUri: String? = null,
type: FileType = FileType.EPUB,
tags: List<TagEntity> = emptyList()
): RecentFileItem {
return RecentFileItem(
bookId = bookId,
uriString = "content://book/$bookId",
type = type,
displayName = displayName,
timestamp = 1L,
title = title,
author = author,
progressPercentage = progressPercentage,
sourceFolderUri = sourceFolderUri,
tags = tags
)
}
}

View file

@ -0,0 +1,452 @@
package com.aryan.reader.epub
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class EpubParserUnitTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `createEpubBook parses metadata spine ncx toc page list css images and extracted files`() = runTest {
val cacheDir = temp.newFolder("cache")
val extractionDir = temp.newFolder("extract")
val parser = EpubParser(contextWithCache(cacheDir))
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "book-id",
shouldUseToc = true,
originalBookNameHint = "fallback.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
assertEquals("Sample/Book".asFileName(), book.fileName)
assertEquals("Sample/Book", book.title)
assertEquals("Jane Writer", book.author)
assertEquals("en", book.language)
assertEquals("Series Name", book.seriesName)
assertEquals(2.5, book.seriesIndex)
assertEquals("Long description", book.description)
assertEquals(extractionDir.absolutePath, book.extractionBasePath)
assertTrue(File(extractionDir, "OEBPS/chapters/chapter 2.xhtml").isFile)
assertEquals(2, book.chapters.size)
assertEquals("NCX Chapter One", book.chapters[0].title)
assertEquals("OEBPS/chapters/chapter1.xhtml", book.chapters[0].htmlFilePath)
assertEquals(0, book.chapters[0].depth)
assertTrue(book.chapters[0].isInToc)
assertEquals("Nested Two", book.chapters[1].title)
assertEquals("OEBPS/chapters/chapter 2.xhtml", book.chapters[1].htmlFilePath)
assertEquals(1, book.chapters[1].depth)
assertTrue(book.chapters[1].plainTextContent.contains("Chapter Two"))
assertEquals(
listOf(
EpubTocEntry("NCX Chapter One", "OEBPS/chapters/chapter1.xhtml", "start", 0),
EpubTocEntry("Nested Two", "OEBPS/chapters/chapter 2.xhtml", "top", 1)
),
book.tableOfContents
)
assertEquals(1, book.pageList.size)
assertEquals("7", book.pageList.single().value)
assertEquals("OEBPS/chapters/chapter 2.xhtml#page7", book.pageList.single().contentSrc)
assertEquals(
mapOf(
"OEBPS/styles/main.css" to "body { color: black; }",
"OEBPS/styles/extra.css" to "p { margin: 0; }"
),
book.css
)
assertEquals(
setOf("OEBPS/images/picture.jpg", "OEBPS/images/unlisted.png"),
book.images.map { it.absPath }.toSet()
)
}
@Test
fun `createEpubBook can parse metadata only without chapters css or images`() = runTest {
val cacheDir = temp.newFolder("cache-metadata")
val extractionDir = temp.newFolder("extract-metadata")
val parser = EpubParser(contextWithCache(cacheDir))
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "book-id",
shouldUseToc = true,
originalBookNameHint = "fallback.epub",
parseContent = false,
extractionDirOverride = extractionDir
)
assertEquals("Sample/Book", book.title)
assertEquals(emptyList<EpubChapter>(), book.chapters)
assertEquals(emptyList<EpubImage>(), book.images)
assertEquals(emptyMap<String, String>(), book.css)
assertEquals(emptyList<EpubTocEntry>(), book.tableOfContents)
assertTrue(extractionDir.list().isNullOrEmpty())
}
@Test
fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest {
val cacheDir = temp.newFolder("cache-warm-open")
val parser = EpubParser(contextWithCache(cacheDir))
val first = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "warm-book",
shouldUseToc = true,
originalBookNameHint = "warm.epub"
)
val activeDir = ImportedFileCache.activeBookDir(contextWithCache(cacheDir), "warm-book")
File(activeDir, "sentinel.txt").writeText("still here")
val second = parser.createEpubBook(
inputStream = ByteArrayInputStream(minimalEpubBytesWithoutOptionalMetadata()),
bookId = "warm-book",
shouldUseToc = true,
originalBookNameHint = "warm.epub"
)
assertEquals(first.title, second.title)
assertEquals(first.chapters.size, second.chapters.size)
assertTrue(File(activeDir, "sentinel.txt").isFile)
}
@Test
fun `metadata only parse does not clear active extracted content`() = runTest {
val cacheDir = temp.newFolder("cache-metadata-preserve")
val context = contextWithCache(cacheDir)
val parser = EpubParser(context)
val activeDir = ImportedFileCache.ensureActiveBookDir(context, "metadata-book")
File(activeDir, "sentinel.txt").writeText("active")
parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "metadata-book",
parseContent = false,
originalBookNameHint = "metadata.epub"
)
assertTrue(File(activeDir, "sentinel.txt").isFile)
}
@Test
fun `createEpubBook falls back to file hint author language and chapter titles when metadata and ncx are absent`() = runTest {
val parser = EpubParser(contextWithCache(temp.newFolder("cache-fallback")))
val extractionDir = temp.newFolder("extract-fallback")
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(minimalEpubBytesWithoutOptionalMetadata()),
bookId = "book-id",
shouldUseToc = false,
originalBookNameHint = "Original Name.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
assertEquals("Original Name", book.title)
assertEquals("Unknown Author", book.author)
assertEquals("en", book.language)
assertEquals("HTML Heading", book.chapters.single().title)
assertEquals(0, book.chapters.single().depth)
assertTrue(book.chapters.single().isInToc)
assertEquals(emptyList<EpubTocEntry>(), book.tableOfContents)
}
@Test
fun `createEpubBook throws parser exception for missing container rootfile or opf`() = runTest {
val parser = EpubParser(contextWithCache(temp.newFolder("cache-errors")))
val missingContainer = runCatching {
parser.createEpubBook(ByteArrayInputStream(zipBytes("OEBPS/content.opf" to "<package/>")), "id")
}.exceptionOrNull()
val missingOpf = runCatching {
parser.createEpubBook(
ByteArrayInputStream(
zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/missing.opf"/></rootfiles></container>
""".trimIndent()
)
),
"id"
)
}.exceptionOrNull()
assertTrue(missingContainer is EpubParserException)
assertTrue(missingContainer!!.message!!.contains("container.xml"))
assertTrue(missingOpf is EpubParserException)
assertTrue(missingOpf!!.message!!.contains(".opf file missing"))
}
@Test
fun `EpubXMLFileParser extracts first heading and preserves optional fragment`() {
val parser = EpubXMLFileParser(
fileRelativePath = "chapters/one.xhtml",
data = "<html><body><h2> Chapter Title </h2><h1>Ignored</h1></body></html>".toByteArray(),
fragmentId = "anchor"
)
val output = parser.parseForTitleAndPath()
assertEquals("Chapter Title", output.title)
assertEquals("chapters/one.xhtml#anchor", output.effectiveHtmlPath)
}
@Test
fun `xml helpers select tags attributes children and filename conversions`() {
val document = parseXMLFile(
"""
<root>
<item id="one"><child>A</child><child>B</child></item>
<item id="two" />
</root>
""".trimIndent().toByteArray()
)!!
val firstItem = document.selectFirstTag("item")!!
assertEquals("one", firstItem.getAttributeValue("id"))
assertEquals("A", firstItem.selectFirstChildTag("child")!!.textContent)
assertEquals(listOf("A", "B"), firstItem.selectChildTag("child").map { it.textContent }.toList())
assertEquals("OPS_chapter_one.xhtml", "OPS/chapter/one.xhtml".asFileName())
assertNull(document.selectFirstTag("missing"))
}
@Test
fun `EpubXMLFileParser returns null title and unfragmented path when heading and fragment are absent`() {
val parser = EpubXMLFileParser(
fileRelativePath = "chapters/plain.xhtml",
data = "<html><body><p>No heading here.</p></body></html>".toByteArray()
)
val output = parser.parseForTitleAndPath()
assertNull(output.title)
assertEquals("chapters/plain.xhtml", output.effectiveHtmlPath)
}
@Test
fun `createEpubBook normalizes leading slash opf path from container`() = runTest {
val parser = EpubParser(contextWithCache(temp.newFolder("cache-leading-slash")))
val extractionDir = temp.newFolder("extract-leading-slash")
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(
zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="/OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Slash Book</dc:title></metadata>
<manifest><item id="chap1" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="chap1"/></spine>
</package>
""".trimIndent(),
"OEBPS/chapter.xhtml" to "<html><body><p>Text</p></body></html>"
)
),
bookId = "book-id",
originalBookNameHint = "fallback.epub",
extractionDirOverride = extractionDir
)
assertEquals("Slash Book", book.title)
assertEquals("OEBPS/chapter.xhtml", book.chapters.single().htmlFilePath)
}
@Test
fun `createEpubBook creates synthetic readable chapter for image spine items`() = runTest {
val parser = EpubParser(contextWithCache(temp.newFolder("cache-image-spine")))
val extractionDir = temp.newFolder("extract-image-spine")
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(imageSpineEpubBytes()),
bookId = "book-id",
shouldUseToc = false,
originalBookNameHint = "image-book.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
val chapter = book.chapters.single()
assertEquals("Image", chapter.title)
assertEquals("OEBPS/images/page1.jpg", chapter.htmlFilePath)
assertEquals("[Image]", chapter.plainTextContent)
assertTrue(chapter.htmlContent.contains("<img src=\"OEBPS/images/page1.jpg\""))
assertEquals(listOf(EpubImage("OEBPS/images/page1.jpg")), book.images)
}
@Test
fun `hasReadableExtractedContent validates blank dirs empty dirs and chapter files`() {
assertFalse(epubBook(extractionBasePath = "").hasReadableExtractedContent())
assertFalse(epubBook(extractionBasePath = File(temp.root, "missing").absolutePath).hasReadableExtractedContent())
val emptyDir = temp.newFolder("empty-readable")
assertFalse(epubBook(extractionBasePath = emptyDir.absolutePath).hasReadableExtractedContent())
val nonChapterDir = temp.newFolder("non-chapter")
File(nonChapterDir, "asset.css").writeText("body{}")
assertTrue(epubBook(extractionBasePath = nonChapterDir.absolutePath).hasReadableExtractedContent())
val chapterDir = temp.newFolder("chapters-readable")
File(chapterDir, "one.xhtml").writeText("<p>One</p>")
val readable = epubBook(
extractionBasePath = chapterDir.absolutePath,
chapters = listOf(chapter("one.xhtml"))
)
val missing = readable.copy(chapters = listOf(chapter("one.xhtml"), chapter("two.xhtml")))
assertTrue(readable.hasReadableExtractedContent())
assertFalse(missing.hasReadableExtractedContent())
}
private fun contextWithCache(cacheDir: File): Context {
val context = mockk<Context>()
every { context.cacheDir } returns cacheDir
return context
}
private fun sampleEpubBytes(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container version="1.0">
<rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles>
</container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Sample/Book</dc:title>
<dc:creator>Jane Writer</dc:creator>
<dc:language>en</dc:language>
<dc:description>Long description</dc:description>
<meta name="calibre:series" content="Series Name"/>
<meta name="calibre:series_index" content="2.5"/>
</metadata>
<manifest>
<item id="chap1" href="chapters/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="chap2" href="chapters/chapter%202.xhtml" media-type="application/xhtml+xml"/>
<item id="toc" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="style" href="styles/main.css" media-type="text/css"/>
<item id="pic" href="images/picture.jpg" media-type="image/jpeg"/>
</manifest>
<spine toc="toc">
<itemref idref="chap1"/>
<itemref idref="chap2"/>
</spine>
</package>
""".trimIndent(),
"OEBPS/toc.ncx" to """
<ncx>
<navMap>
<navPoint id="nav1">
<navLabel><text>NCX Chapter One</text></navLabel>
<content src="chapters/chapter1.xhtml#start"/>
<navPoint id="nav2">
<navLabel><text>Nested Two</text></navLabel>
<content src="chapters/chapter%202.xhtml#top"/>
</navPoint>
</navPoint>
</navMap>
<pageList>
<pageTarget id="p7" value="7">
<navLabel><text>7</text></navLabel>
<content src="chapters/chapter%202.xhtml#page7"/>
</pageTarget>
</pageList>
</ncx>
""".trimIndent(),
"OEBPS/chapters/chapter1.xhtml" to "<html><body><h1>Ignored HTML Title</h1><p>One</p></body></html>",
"OEBPS/chapters/chapter 2.xhtml" to "<html><body><h2>Chapter Two</h2><p>Two text</p></body></html>",
"OEBPS/styles/main.css" to "body { color: black; }",
"OEBPS/styles/extra.css" to "p { margin: 0; }",
"OEBPS/images/picture.jpg" to "not-real-image",
"OEBPS/images/unlisted.png" to "not-real-image"
)
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package>
<metadata />
<manifest>
<item id="chap1" href="chapter.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine><itemref idref="chap1"/></spine>
</package>
""".trimIndent(),
"OEBPS/chapter.xhtml" to "<html><body><h1>HTML Heading</h1><p>Text</p></body></html>"
)
private fun imageSpineEpubBytes(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Image Book</dc:title></metadata>
<manifest>
<item id="page1" href="images/page1.jpg" media-type="image/jpeg"/>
</manifest>
<spine><itemref idref="page1"/></spine>
</package>
""".trimIndent(),
"OEBPS/images/page1.jpg" to "not-real-image"
)
private fun zipBytes(vararg entries: Pair<String, String>): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
entries.forEach { (name, content) ->
zip.putNextEntry(ZipEntry(name))
zip.write(content.toByteArray(Charsets.UTF_8))
zip.closeEntry()
}
}
return out.toByteArray()
}
private fun epubBook(
extractionBasePath: String,
chapters: List<EpubChapter> = emptyList()
): EpubBook =
EpubBook(
fileName = "book.epub",
title = "Book",
author = "Author",
language = "en",
coverImage = null,
chapters = chapters,
extractionBasePath = extractionBasePath
)
private fun chapter(path: String): EpubChapter =
EpubChapter(
chapterId = path,
absPath = path,
title = path,
htmlFilePath = path,
plainTextContent = "",
htmlContent = ""
)
}

View file

@ -0,0 +1,119 @@
package com.aryan.reader.epub
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class ImportedFileCacheTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `active directory names are sanitized stable and marked active`() {
val first = ImportedFileCache.activeBookDirName("Book:/One?*")
val second = ImportedFileCache.activeBookDirName("Book:/One?*")
assertTrue(first.startsWith("imported_file_"))
assertFalse(first.contains(":"))
assertFalse(first.contains("/"))
assertFalse(first.contains("?"))
assertFalse(first.contains("*"))
assertTrue(ImportedFileCache.isActiveBookDir(first))
assertFalse(ImportedFileCache.isTemporaryBookDir(first))
assertTrue(first == second)
}
@Test
fun `prepareDirectory clears stale contents before reusing directory`() {
val dir = temp.newFolder("active")
File(dir, "old.xhtml").writeText("stale")
val prepared = ImportedFileCache.prepareDirectory(dir)
assertTrue(prepared.isDirectory)
assertTrue(prepared.listFiles().isNullOrEmpty())
}
@Test
fun `ensureActiveBookDir preserves active contents and resetActiveBookDir clears them`() {
val context = contextWithCache(temp.newFolder("ensure-active-cache"))
val active = ImportedFileCache.ensureActiveBookDir(context, "Book")
File(active, "book_metadata.json").writeText("cached")
val ensuredAgain = ImportedFileCache.ensureActiveBookDir(context, "Book")
assertEquals("cached", File(ensuredAgain, "book_metadata.json").readText())
val reset = ImportedFileCache.resetActiveBookDir(context, "Book")
assertTrue(reset.isDirectory)
assertTrue(reset.listFiles().isNullOrEmpty())
}
@Test
fun `temporary directory creation and targeted cleanup only remove matching book marker`() {
val context = contextWithCache(temp.newFolder("cache"))
val firstBookTemp = ImportedFileCache.createTemporaryBookDir(context, "Book One", "preview/import")
val secondBookTemp = ImportedFileCache.createTemporaryBookDir(context, "Book Two", "preview/import")
File(firstBookTemp, "file.txt").writeText("one")
File(secondBookTemp, "file.txt").writeText("two")
ImportedFileCache.clearTemporaryBookDirs(context, "Book One")
assertFalse(firstBookTemp.exists())
assertTrue(secondBookTemp.exists())
assertTrue(ImportedFileCache.isTemporaryBookDir(secondBookTemp.name))
assertFalse(ImportedFileCache.isActiveBookDir(secondBookTemp.name))
}
@Test
fun `deleteStaleTemporaryBookDirs removes old temporary dirs and keeps fresh and active dirs`() {
val cacheDir = temp.newFolder("stale-cache")
val context = contextWithCache(cacheDir)
val staleTemp = ImportedFileCache.createTemporaryBookDir(context, "Book", "stale")
val freshTemp = ImportedFileCache.createTemporaryBookDir(context, "Book", "fresh")
val activeDir = ImportedFileCache.prepareActiveBookDir(context, "Book")
val now = 10_000L
staleTemp.setLastModified(1_000L)
freshTemp.setLastModified(9_500L)
activeDir.setLastModified(1_000L)
ImportedFileCache.deleteStaleTemporaryBookDirs(context, olderThanMillis = 5_000L, nowMillis = now)
assertFalse(staleTemp.exists())
assertTrue(freshTemp.exists())
assertTrue(activeDir.exists())
}
@Test
fun `clearBookCache removes active legacy and temporary cache directories`() {
val cacheDir = temp.newFolder("clear-book-cache")
val context = contextWithCache(cacheDir)
val active = ImportedFileCache.prepareActiveBookDir(context, "Book")
val legacy = File(cacheDir, "imported_file_Book").apply { mkdirs() }
val temporary = ImportedFileCache.createTemporaryBookDir(context, "Book", "tmp")
File(active, "active.txt").writeText("active")
File(legacy, "legacy.txt").writeText("legacy")
File(temporary, "temporary.txt").writeText("temporary")
ImportedFileCache.clearBookCache(context, "Book")
assertFalse(active.exists())
assertFalse(legacy.exists())
assertFalse(temporary.exists())
}
private fun contextWithCache(cacheDir: File): Context {
val context = mockk<Context>()
every { context.cacheDir } returns cacheDir
return context
}
}

View file

@ -0,0 +1,146 @@
package com.aryan.reader.epub
import android.content.Context
import com.aryan.reader.FileType
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.ByteArrayInputStream
import java.io.File
class SingleFileImporterTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `metadata-only import returns lightweight book for supported text formats`() = runTest {
val importer = SingleFileImporter(contextWithCache(temp.newFolder("metadata-cache")))
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream("ignored".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Notes.txt",
bookId = "notes",
parseContent = false
)
assertEquals("Notes.txt", book.fileName)
assertEquals("Notes", book.title)
assertEquals("Unknown", book.author)
assertEquals("en", book.language)
assertEquals(emptyList<EpubChapter>(), book.chapters)
assertEquals("", book.extractionBasePath)
}
@Test
fun `plain text import escapes html groups paragraphs and writes cached metadata`() = runTest {
val cache = temp.newFolder("txt-cache")
val importer = SingleFileImporter(contextWithCache(cache))
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream("First <line>\ncontinues\n\nSecond & final".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Plain.txt",
bookId = "plain-book"
)
assertEquals("Plain", book.title)
assertEquals(1, book.chapters.size)
assertEquals("Part 1", book.chapters.single().title)
assertTrue(book.chapters.single().plainTextContent.contains("First <line> continues"))
assertTrue(File(book.extractionBasePath, "part_1.html").readText().contains("First &lt;line&gt;"))
assertTrue(File(book.extractionBasePath, "book_metadata.json").isFile)
}
@Test
fun `plain text import reuses cached metadata before reading the stream`() = runTest {
val cache = temp.newFolder("txt-cache-reuse")
val importer = SingleFileImporter(contextWithCache(cache))
val first = importer.importSingleFile(
inputStream = ByteArrayInputStream("Cached content".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Cached.txt",
bookId = "cached-book"
)
val second = importer.importSingleFile(
inputStream = ByteArrayInputStream("Different content that should not be parsed".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Cached.txt",
bookId = "cached-book"
)
assertEquals(first.title, second.title)
assertEquals(first.chapters.single().plainTextContent, second.chapters.single().plainTextContent)
assertTrue(second.chapters.single().plainTextContent.contains("Cached content"))
}
@Test
fun `html import extracts title author style skips scripts and splits page breaks`() = runTest {
val importer = SingleFileImporter(contextWithCache(temp.newFolder("html-cache")))
val html = """
<html>
<head>
<title>HTML Title</title>
<meta name="author" content="HTML Author">
<style>p { color: red; }</style>
</head>
<body>
<p>First page</p>
<script>bad()</script>
<page-break></page-break>
<p>Second page</p>
</body>
</html>
""".trimIndent()
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream(html.toByteArray()),
type = FileType.HTML,
originalBookNameHint = "fallback.html",
bookId = "html-book"
)
assertEquals("HTML Title", book.title)
assertEquals("HTML Author", book.author)
assertEquals(2, book.chapters.size)
assertEquals("HTML Title", book.chapters[0].title)
assertEquals("Page 2", book.chapters[1].title)
assertTrue(book.chapters[0].plainTextContent.contains("First page"))
assertTrue(book.chapters[1].plainTextContent.contains("Second page"))
assertFalse(File(book.extractionBasePath, "page_1.html").readText().contains("bad()"))
assertTrue(File(book.extractionBasePath, "page_1.html").readText().contains("p { color: red; }"))
}
@Test
fun `csv txt wrapper imports as html table`() = runTest {
val importer = SingleFileImporter(contextWithCache(temp.newFolder("csv-cache")))
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream("Name,Value\nA & B,<tag>".toByteArray()),
type = FileType.HTML,
originalBookNameHint = "data.csv.txt",
bookId = "csv-book"
)
val html = File(book.extractionBasePath, "page_1.html").readText()
assertEquals("data.csv", book.title)
assertTrue(html.contains("<table>"))
assertTrue(html.contains("A &amp; B"))
assertTrue(html.contains("&lt;tag&gt;"))
}
private fun contextWithCache(cacheDir: File): Context {
val context = mockk<Context>()
every { context.cacheDir } returns cacheDir
return context
}
}

View file

@ -0,0 +1,200 @@
package com.aryan.reader.epubreader
import android.webkit.WebView
import com.aryan.reader.RenderMode
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class EpubReaderBridgeAndControlsTest {
@Test
fun `sanitizePlaceholders keeps one header per toolbar section and inserts empty placeholders`() {
val input = listOf(
FlatToolItem("old_header", FlatItemType.SECTION_HEADER, section = ToolbarSection.BOTTOM),
FlatToolItem("format", FlatItemType.TOOL, tool = ReaderTool.FORMAT, section = ToolbarSection.BOTTOM),
FlatToolItem("more_header", FlatItemType.MORE_HEADER, title = "More"),
FlatToolItem("reading_mode", FlatItemType.MORE_TOOL, tool = ReaderTool.READING_MODE)
)
val sanitized = sanitizePlaceholders(input)
assertEquals(
listOf(
FlatItemType.SECTION_HEADER,
FlatItemType.EMPTY_PLACEHOLDER,
FlatItemType.SECTION_HEADER,
FlatItemType.TOOL,
FlatItemType.SECTION_HEADER,
FlatItemType.EMPTY_PLACEHOLDER,
FlatItemType.MORE_HEADER,
FlatItemType.MORE_TOOL
),
sanitized.map { it.type }
)
assertEquals(listOf(ToolbarSection.TOP, ToolbarSection.BOTTOM, ToolbarSection.HIDDEN), sanitized.filter { it.type == FlatItemType.SECTION_HEADER }.map { it.section })
assertEquals(ReaderTool.FORMAT, sanitized.single { it.type == FlatItemType.TOOL }.tool)
}
@Test
fun `auto scroll bridge invokes chapter end callback`() {
var calls = 0
AutoScrollJsBridge { calls++ }.onChapterEnd()
assertEquals(1, calls)
}
@Test
fun `tts bridge relays nonblank structured text and normalizes blank payloads`() = runTest {
val received = CompletableDeferred<String>()
val bridge = TtsJsBridge(scope = this, ttsStructuredTextHandler = { received.complete(it) })
bridge.onStructuredTextExtracted("[{\"text\":\"Hello\"}]")
assertEquals("[{\"text\":\"Hello\"}]", received.await())
val blankReceived = CompletableDeferred<String>()
TtsJsBridge(scope = this, ttsStructuredTextHandler = { blankReceived.complete(it) }).onStructuredTextExtracted(" ")
assertEquals("[]", blankReceived.await())
}
@Test
fun `highlight bridge forwards create and click events`() {
var created: Triple<String, String, String>? = null
var clicked: List<Any>? = null
val bridge = HighlightJsBridge(
onCreateCallback = { cfi, text, color -> created = Triple(cfi, text, color) },
onClickCallback = { cfi, text, left, top, right, bottom ->
clicked = listOf(cfi, text, left, top, right, bottom)
}
)
bridge.onHighlightCreated("/4", "Text", "yellow")
bridge.onHighlightClicked("/4", "Text", 1, 2, 3, 4)
assertEquals(Triple("/4", "Text", "yellow"), created)
assertEquals(listOf("/4", "Text", 1, 2, 3, 4), clicked)
}
@Test
fun `content snippet progress footnote and ai bridges forward callbacks`() = runTest {
var requestedChunk = -1
var snippet = "" to ""
var progressCalls = 0
var lastChunk = -1
var footnote = ""
val aiContent = CompletableDeferred<String>()
ContentBridge { requestedChunk = it }.requestChunk(7)
SnippetJsBridge { cfi, text -> snippet = cfi to text }.onSnippetExtracted("/6", "Snippet")
val progress = ProgressJsBridge {
progressCalls++
lastChunk = it
}
progress.updateTopChunk(2)
progress.updateTopChunk(2)
progress.updateTopChunk(3)
FootnoteJsBridge { footnote = it }.onFootnoteRequested("<p>Note</p>")
AiJsBridge(scope = this, onContentReady = { aiContent.complete(it) }).onContentExtractedForSummarization("Chapter text")
assertEquals(7, requestedChunk)
assertEquals("/6" to "Snippet", snippet)
assertEquals(2, progressCalls)
assertEquals(3, lastChunk)
assertEquals("<p>Note</p>", footnote)
assertEquals("Chapter text", aiContent.await())
}
@Test
fun `ai bridge ignores blank content`() = runTest {
var called = false
AiJsBridge(scope = this, onContentReady = { called = true }).onContentExtractedForSummarization(" ")
assertFalse(called)
}
@Test
fun `cfi bridge parses save bookmark and scroll callbacks with fallback for invalid save json`() {
val saved = mutableListOf<String>()
val bookmark = mutableListOf<String>()
val scrollResults = mutableListOf<Boolean>()
val bridge = CfiJsBridge(
onCfiReady = { saved.add(it) },
onCfiForBookmarkReady = { bookmark.add(it) },
onScrollFinishedCallback = { scrollResults.add(it) }
)
bridge.onCfiExtracted(JSONObject().put("cfi", "/4/2:8").put("log", JSONArray()).toString())
bridge.onCfiExtracted(JSONObject().put("cfi", "").toString())
bridge.onCfiExtracted("broken")
bridge.onCfiForBookmarkExtracted(JSONObject().put("cfi", "/6/4:1").toString())
bridge.onCfiForBookmarkExtracted("broken")
bridge.onScrollFinished(true)
bridge.onScrollFinished(false)
assertEquals(listOf("/4/2:8", "/4"), saved)
assertEquals(listOf("/6/4:1"), bookmark)
assertEquals(listOf(true, false), scrollResults)
}
@Test
fun `cfi bridge preserves full reading position cfi payloads for save and bookmark callbacks`() {
val saved = mutableListOf<String>()
val bookmark = mutableListOf<String>()
val bridge = CfiJsBridge(
onCfiReady = { saved.add(it) },
onCfiForBookmarkReady = { bookmark.add(it) },
onScrollFinishedCallback = {}
)
val cfi = "/6/4[chapter]!/4/2/8:137"
bridge.onCfiExtracted(JSONObject().put("cfi", cfi).put("log", JSONArray().put("exact")).toString())
bridge.onCfiForBookmarkExtracted(JSONObject().put("cfi", cfi).put("log", JSONArray()).toString())
assertEquals(listOf(cfi), saved)
assertEquals(listOf(cfi), bookmark)
}
@Test
fun `updateAutoScrollJs emits start and stop commands`() {
val webView = mockk<WebView>(relaxed = true)
updateAutoScrollJs(webView, playing = true, speed = 1.25f)
updateAutoScrollJs(webView, playing = false, speed = 9f)
verify { webView.evaluateJavascript("javascript:window.autoScroll.start(1.25);", null) }
verify { webView.evaluateJavascript("javascript:window.autoScroll.stop();", null) }
}
@Test
fun `initiateTtsPlayback chooses web extraction for vertical mode and callback for paginated mode`() {
val webView = mockk<WebView>(relaxed = true)
var paginatedStarts = 0
initiateTtsPlayback(RenderMode.VERTICAL_SCROLL, webView) { paginatedStarts++ }
initiateTtsPlayback(RenderMode.PAGINATED, webView) { paginatedStarts++ }
verify { webView.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null) }
assertEquals(1, paginatedStarts)
}
@Test
fun `reader tool metadata has stable unique names and categories`() {
assertEquals(ReaderTool.entries.size, ReaderTool.entries.map { it.name }.toSet().size)
assertTrue(ReaderTool.entries.any { it.category == "Top Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
}
}

View file

@ -0,0 +1,161 @@
package com.aryan.reader.epubreader
import android.content.Context
import com.aryan.reader.R
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.LocatorConverter
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class EpubReaderContentTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `loadChapterContent removes scripts keeps head and chunks body nodes by twenty`() = runTest {
val root = temp.newFolder("content")
val body = (1..21).joinToString("") { index ->
if (index == 3) "<script>bad()</script><p>Paragraph $index</p>" else "<p>Paragraph $index</p>"
}
writeChapter(root, "chapter.xhtml", "<html><head><style>.x{}</style></head><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val result = loadChapterContent(
context = contextWithStrings(),
epubBook = book,
chapterIndex = 0,
chunkTargetOverride = null,
isInitialCfiLoad = false,
cfiToLoad = null,
locatorConverter = mockk()
)
assertTrue(result.isSuccess)
assertEquals("<style>.x{}</style>", result.head.trim())
assertEquals(2, result.chunks.size)
assertFalse(result.chunks.joinToString().contains("<script>"))
assertTrue(result.chunks[0].contains("Paragraph 20"))
assertTrue(result.chunks[1].contains("Paragraph 21"))
assertEquals(0, result.startChunkIndex)
}
@Test
fun `loadChapterContent clamps explicit chunk override into available chunk range`() = runTest {
val root = temp.newFolder("override")
val body = (1..5).joinToString("") { "<p>Only $it</p>" }
writeChapter(root, "chapter.xhtml", "<html><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val high = loadChapterContent(contextWithStrings(), book, 0, 99, false, null, mockk())
val low = loadChapterContent(contextWithStrings(), book, 0, -5, false, null, mockk())
assertEquals(0, high.startChunkIndex)
assertEquals(0, low.startChunkIndex)
}
@Test
fun `loadChapterContent calculates initial chunk from cfi locator block index`() = runTest {
val root = temp.newFolder("cfi")
val body = (1..60).joinToString("") { "<p>Paragraph $it</p>" }
writeChapter(root, "chapter.xhtml", "<html><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val locatorConverter = mockk<LocatorConverter>()
coEvery { locatorConverter.getLocatorFromCfi(book, 0, "/4/2:10") } returns Locator(0, blockIndex = 45, charOffset = 0)
val result = loadChapterContent(
context = contextWithStrings(),
epubBook = book,
chapterIndex = 0,
chunkTargetOverride = null,
isInitialCfiLoad = true,
cfiToLoad = "/4/2:10",
locatorConverter = locatorConverter
)
assertEquals(2, result.startChunkIndex)
}
@Test
fun `loadChapterContent falls back to last chunk when cfi cannot be resolved`() = runTest {
val root = temp.newFolder("cfi-missing")
val body = (1..45).joinToString("") { "<p>Paragraph $it</p>" }
writeChapter(root, "chapter.xhtml", "<html><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val locatorConverter = mockk<LocatorConverter>()
coEvery { locatorConverter.getLocatorFromCfi(book, 0, "/missing") } returns null
val result = loadChapterContent(contextWithStrings(), book, 0, null, true, "/missing", locatorConverter)
assertEquals(2, result.startChunkIndex)
}
@Test
fun `loadChapterContent returns localized empty and missing chapter placeholders`() = runTest {
val root = temp.newFolder("placeholders")
writeChapter(root, "empty.xhtml", "<html><body></body></html>")
val book = epubBook(root, listOf(chapter("empty.xhtml"), chapter("missing.xhtml")))
val empty = loadChapterContent(contextWithStrings(), book, 0, null, false, null, mockk())
val missing = loadChapterContent(contextWithStrings(), book, 1, null, false, null, mockk())
assertEquals(listOf("<body><p>Empty chapter</p></body>"), empty.chunks)
assertEquals(listOf("<h1>Chapter not found</h1>"), missing.chunks)
assertTrue(missing.isSuccess)
}
@Test
fun `loadChapterContent reports out of bounds chapter index`() = runTest {
val root = temp.newFolder("bounds")
val result = loadChapterContent(contextWithStrings(), epubBook(root, emptyList()), 0, null, false, null, mockk())
assertFalse(result.isSuccess)
assertEquals("Chapter index out of bounds", result.errorMessage)
assertEquals(emptyList<String>(), result.chunks)
}
private fun writeChapter(root: java.io.File, relativePath: String, html: String) {
val file = java.io.File(root, relativePath)
file.parentFile?.mkdirs()
file.writeText(html)
}
private fun epubBook(root: java.io.File, chapters: List<EpubChapter>): EpubBook =
EpubBook(
fileName = "book.epub",
title = "Book",
author = "Author",
language = "en",
coverImage = null,
chapters = chapters,
extractionBasePath = root.absolutePath
)
private fun chapter(path: String): EpubChapter =
EpubChapter(
chapterId = path,
absPath = path,
title = path,
htmlFilePath = path,
plainTextContent = "",
htmlContent = ""
)
private fun contextWithStrings(): Context {
val context = mockk<Context>()
every { context.getString(R.string.chapter_empty) } returns "Empty chapter"
every { context.getString(R.string.chapter_not_found) } returns "Chapter not found"
every { context.getString(R.string.error_loading_chapter) } returns "Error loading chapter"
return context
}
}

View file

@ -0,0 +1,352 @@
package com.aryan.reader.epubreader
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.epub.EpubChapter
import io.mockk.every
import io.mockk.mockk
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class EpubReaderPreferencesAndAnnotationsTest {
@Test
fun `reader settings defaults and invalid persisted enum values fall back safely`() {
val prefs = TestSharedPreferences(
"reader_system_ui_mode" to Int.MIN_VALUE,
"reader_page_info_mode" to Int.MAX_VALUE,
"reader_page_info_position" to -20,
"reader_font_family" to "missing",
"reader_text_align" to "diagonal"
)
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
val format = loadFormatSettings(context, bookId = "book", isLocal = false)
assertEquals(SystemUiMode.DEFAULT, loadSystemUiMode(context))
assertEquals(PageInfoMode.DEFAULT, loadPageInfoMode(context))
assertEquals(PageInfoPosition.BOTTOM, loadPageInfoPosition(context))
assertEquals(DEFAULT_FONT_SIZE_VAL, format.fontSize, 0.0001f)
assertEquals(DEFAULT_LINE_HEIGHT_VAL, format.lineHeight, 0.0001f)
assertEquals(DEFAULT_PARAGRAPH_GAP_VAL, format.paragraphGap, 0.0001f)
assertEquals(DEFAULT_IMAGE_SIZE_VAL, format.imageSize, 0.0001f)
assertEquals(ReaderFont.ORIGINAL, format.font)
assertEquals(ReaderTextAlign.DEFAULT, format.textAlign)
assertNull(format.customPath)
}
@Test
fun `global and local format settings round trip including custom fonts`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
saveReaderSettings(
context = context,
fontSize = 1.4f,
lineHeight = 1.6f,
paragraphGap = 0.7f,
imageSize = 1.2f,
horizontalMargin = 1.8f,
verticalMargin = 0.4f,
fontFamily = ReaderFont.LORA,
customFontPath = null,
textAlign = ReaderTextAlign.JUSTIFY
)
saveLocalReaderSettings(
context = context,
bookId = "book",
fontSize = 0.9f,
lineHeight = 1.1f,
paragraphGap = 1.3f,
imageSize = 1.5f,
horizontalMargin = 0.2f,
verticalMargin = 2.2f,
fontFamily = ReaderFont.MERRIWEATHER,
customFontPath = "/fonts/custom.ttf",
textAlign = ReaderTextAlign.LEFT
)
val global = loadFormatSettings(context, bookId = "book", isLocal = false)
val local = loadFormatSettings(context, bookId = "book", isLocal = true)
assertEquals(1.4f, global.fontSize, 0.0001f)
assertEquals(ReaderFont.LORA, global.font)
assertEquals(ReaderTextAlign.JUSTIFY, global.textAlign)
assertNull(global.customPath)
assertEquals(0.9f, local.fontSize, 0.0001f)
assertEquals(1.1f, local.lineHeight, 0.0001f)
assertEquals(1.3f, local.paragraphGap, 0.0001f)
assertEquals(1.5f, local.imageSize, 0.0001f)
assertEquals(0.2f, local.horizontalMargin, 0.0001f)
assertEquals(2.2f, local.verticalMargin, 0.0001f)
assertEquals(ReaderFont.ORIGINAL, local.font)
assertEquals("/fonts/custom.ttf", local.customPath)
assertEquals(ReaderTextAlign.LEFT, local.textAlign)
}
@Test
fun `local format settings fall back to global values per missing local field`() {
val prefs = TestSharedPreferences(
"reader_font_size" to 1.8f,
"reader_line_height" to 1.7f,
"reader_paragraph_gap" to 1.6f,
"reader_image_size" to 1.5f,
"reader_horizontal_margin" to 1.4f,
"reader_vertical_margin" to 1.3f,
"reader_font_family" to ReaderFont.LEXEND.id,
"reader_text_align" to ReaderTextAlign.JUSTIFY.id,
"local_font_size_book" to 0.8f,
"local_font_family_book" to ReaderFont.LATO.id
)
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
val local = loadFormatSettings(context, bookId = "book", isLocal = true)
assertEquals(0.8f, local.fontSize, 0.0001f)
assertEquals(1.7f, local.lineHeight, 0.0001f)
assertEquals(1.6f, local.paragraphGap, 0.0001f)
assertEquals(1.5f, local.imageSize, 0.0001f)
assertEquals(1.4f, local.horizontalMargin, 0.0001f)
assertEquals(1.3f, local.verticalMargin, 0.0001f)
assertEquals(ReaderFont.LATO, local.font)
assertEquals(ReaderTextAlign.JUSTIFY, local.textAlign)
}
@Test
fun `simple reader preference toggles and numeric settings round trip`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
saveTtsSpeechRate(context, 1.35f)
saveTtsPitch(context, 0.85f)
saveSystemUiMode(context, SystemUiMode.HIDDEN)
savePageInfoMode(context, PageInfoMode.SYNC)
savePageInfoPosition(context, PageInfoPosition.TOP)
savePullToTurn(context, false)
savePullToTurnMultiplier(context, 1.75f)
saveAutoScrollSpeed(context, 2.5f)
saveTapToNavigateSetting(context, true)
saveVolumeScrollSetting(context, true)
saveRemoveEdgePadding(context, true)
saveFormatIsLocal(context, "book", true)
assertEquals(1.35f, loadTtsSpeechRate(context), 0.0001f)
assertEquals(0.85f, loadTtsPitch(context), 0.0001f)
assertEquals(SystemUiMode.HIDDEN, loadSystemUiMode(context))
assertEquals(PageInfoMode.SYNC, loadPageInfoMode(context))
assertEquals(PageInfoPosition.TOP, loadPageInfoPosition(context))
assertFalse(loadPullToTurn(context))
assertEquals(1.75f, loadPullToTurnMultiplier(context), 0.0001f)
assertEquals(2.5f, loadAutoScrollSpeed(context), 0.0001f)
assertTrue(loadTapToNavigateSetting(context))
assertTrue(loadVolumeScrollSetting(context))
assertTrue(loadRemoveEdgePadding(context))
assertTrue(loadFormatIsLocal(context, "book"))
assertEquals(0f, loadHorizontalMargin(context), 0.0001f)
}
@Test
fun `explicit horizontal margin wins over remove edge padding migration fallback`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
saveRemoveEdgePadding(context, true)
saveReaderSettings(
context = context,
fontSize = 1f,
lineHeight = 1f,
paragraphGap = 1f,
imageSize = 1f,
horizontalMargin = 2.4f,
verticalMargin = 1f,
fontFamily = ReaderFont.ORIGINAL,
customFontPath = null,
textAlign = ReaderTextAlign.DEFAULT
)
assertEquals(2.4f, loadHorizontalMargin(context), 0.0001f)
}
@Test
fun `highlight palette saves exactly four known colors and falls back otherwise`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
saveHighlightPalette(context, listOf(HighlightColor.CYAN, HighlightColor.MAGENTA, HighlightColor.LIME, HighlightColor.PINK))
assertEquals(
listOf(HighlightColor.CYAN, HighlightColor.MAGENTA, HighlightColor.LIME, HighlightColor.PINK),
loadHighlightPalette(context)
)
val invalidContext = contextWithPrefs(
SETTINGS_PREFS_NAME to TestSharedPreferences("highlight_palette_ids" to "yellow,unknown,blue")
)
assertEquals(
listOf(HighlightColor.YELLOW, HighlightColor.GREEN, HighlightColor.BLUE, HighlightColor.RED),
loadHighlightPalette(invalidContext)
)
}
@Test
fun `highlight JSON round trips notes escapes unknown colors and invalid JSON`() {
val highlights = listOf(
UserHighlight(id = "h1", cfi = "/4/2:1", text = "Quote", color = HighlightColor.BLUE, chapterIndex = 2, note = "Remember"),
UserHighlight(id = "h2", cfi = "/4/4:3", text = "Plain", color = HighlightColor.RED, chapterIndex = 3, note = null)
)
val parsed = parseHighlightsJson(highlightsToJson(highlights))
assertEquals(highlights, parsed)
assertNull(parsed[1].note)
val unknownColorJson = JSONArray().put(
JSONObject()
.put("id", "h3")
.put("cfi", "/4")
.put("text", "Text")
.put("colorId", "infrared")
.put("chapterIndex", 1)
).toString()
assertEquals(HighlightColor.YELLOW, parseHighlightsJson(unknownColorJson).single().color)
assertEquals(emptyList<UserHighlight>(), parseHighlightsJson("{broken"))
assertEquals(emptyList<UserHighlight>(), parseHighlightsJson(null))
}
@Test
fun `highlight preference storage uses sanitized title and can be cleared`() {
val prefs = TestSharedPreferences()
val context = contextWithPrefs(SETTINGS_PREFS_NAME to prefs)
val highlight = UserHighlight(id = "h1", cfi = "/4", text = "Text", color = HighlightColor.GREEN, chapterIndex = 0, note = "Note")
saveHighlightsToPrefs(context, "Book: One!", listOf(highlight))
assertEquals(listOf(highlight), loadHighlightsFromPrefs(context, "Book One"))
clearHighlightsFromPrefs(context, "Book One")
assertEquals(emptyList<UserHighlight>(), loadHighlightsFromPrefs(context, "Book One"))
}
@Test
fun `processAndAddHighlight updates exact cfi match and appends overlaps independently`() {
val highlights = mutableListOf(
UserHighlight(id = "existing", cfi = "/4/2:10", text = "Old", color = HighlightColor.YELLOW, chapterIndex = 1, note = "keep")
)
val updatedCfi = processAndAddHighlight("/4/2:10", "New", HighlightColor.PURPLE, chapterIndex = 1, currentList = highlights)
val addedCfi = processAndAddHighlight("/4/2:11", "Overlap", HighlightColor.CYAN, chapterIndex = 1, currentList = highlights)
assertEquals("/4/2:10", updatedCfi)
assertEquals("/4/2:11", addedCfi)
assertEquals(2, highlights.size)
assertEquals("existing", highlights[0].id)
assertEquals("New", highlights[0].text)
assertEquals(HighlightColor.PURPLE, highlights[0].color)
assertEquals("keep", highlights[0].note)
assertEquals("Overlap", highlights[1].text)
}
@Test
fun `bookmarks parse current and legacy payloads with optional label and pages`() {
val chapters = listOf(
chapter("Chapter 1"),
chapter("Chapter 2")
)
val current = JSONObject()
.put("cfi", "/4")
.put("chapterTitle", "Chapter 1")
.put("label", "Named mark")
.put("snippet", "Snippet")
.put("pageInChapter", 2)
.put("totalPagesInChapter", 9)
.put("chapterIndex", 0)
val legacy = JSONObject()
.put("cfi", "/6")
.put("chapterTitle", "Chapter 2")
.put("snippet", "Legacy")
val context = contextWithPrefs()
val bookmarks = loadBookmarks(context, "Book", chapters, JSONArray(listOf(current.toString(), legacy.toString())).toString())
assertEquals(
setOf(
Bookmark("/4", "Chapter 1", "Named mark", "Snippet", 2, 9, 0),
Bookmark("/6", "Chapter 2", null, "Legacy", null, null, 1)
),
bookmarks
)
}
@Test
fun `bookmarks fall back to shared preferences using sanitized book title`() {
val bookmarkPrefs = TestSharedPreferences(
"bookmarks_cfi_BookOne" to setOf(
JSONObject()
.put("cfi", "/4")
.put("chapterTitle", "Chapter")
.put("snippet", "Saved")
.put("chapterIndex", 0)
.toString()
)
)
val context = contextWithPrefs("epub_reader_bookmarks" to bookmarkPrefs)
val bookmarks = loadBookmarks(context, "Book: One!", listOf(chapter("Chapter")), bookmarksJson = null)
assertEquals(setOf(Bookmark("/4", "Chapter", null, "Saved", null, null, 0)), bookmarks)
}
@Test
fun `bookmarks ignore malformed entries while keeping valid ones from view model json`() {
val valid = JSONObject()
.put("cfi", "/8")
.put("chapterTitle", "Chapter")
.put("snippet", "Valid")
.put("chapterIndex", 0)
.toString()
val malformed = "{\"cfi\":\"/broken\""
val context = contextWithPrefs()
val bookmarks = loadBookmarks(context, "Book", listOf(chapter("Chapter")), JSONArray(listOf(valid, malformed)).toString())
assertEquals(setOf(Bookmark("/8", "Chapter", null, "Valid", null, null, 0)), bookmarks)
}
@Test
fun `escapeJsString escapes all characters that break JavaScript string literals`() {
val raw = "\\ ' \" \n \r \t \u2028 \u2029"
assertEquals("\\\\ \\' \\\" \\n \\r \\t \\u2028 \\u2029", escapeJsString(raw))
}
@Test
fun `highlight color metadata stays unique and maps to concrete argb colors`() {
assertEquals(HighlightColor.entries.size, HighlightColor.entries.map { it.id }.toSet().size)
assertEquals(HighlightColor.entries.size, HighlightColor.entries.map { it.cssClass }.toSet().size)
assertEquals(Color(0xFFFBC02D).toArgb(), HighlightColor.YELLOW.color.toArgb())
}
private fun chapter(title: String): EpubChapter =
EpubChapter(
chapterId = title,
absPath = "$title.xhtml",
title = title,
htmlFilePath = "$title.xhtml",
plainTextContent = "",
htmlContent = ""
)
private fun contextWithPrefs(vararg prefsByName: Pair<String, SharedPreferences>): Context {
val context = mockk<Context>()
val prefsMap = prefsByName.toMap()
every { context.getSharedPreferences(any<String>(), Context.MODE_PRIVATE) } answers {
prefsMap[firstArg<String>()] ?: TestSharedPreferences()
}
return context
}
}

View file

@ -0,0 +1,271 @@
package com.aryan.reader.epubreader
import androidx.compose.ui.text.buildAnnotatedString
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchState
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.paginatedreader.Page
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
class EpubReaderSearchTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `search scans existing chapter files case-insensitively and skips missing chapters`() = runTest {
val root = temp.newFolder("book")
writeChapter(root, "chapter1.xhtml", "<html><body><p>Alpha needle.</p></body></html>")
writeChapter(root, "chapter2.xhtml", "<html><body><p>Needle one.</p><p>needle two.</p></body></html>")
val book = epubBook(
root = root,
chapters = listOf(
chapter("ch1", "One", "chapter1.xhtml"),
chapter("missing", "Missing", "missing.xhtml"),
chapter("ch2", "Two", "chapter2.xhtml")
)
)
val results = createEpubSearcher(book)("NEEDLE")
assertEquals(listOf("One", "Two", "Two"), results.map { it.locationTitle })
assertEquals(listOf(0, 2, 2), results.map { it.locationInSource })
assertEquals(listOf(0, 0, 1), results.map { it.occurrenceIndexInLocation })
assertTrue(results.all { it.query == "NEEDLE" })
}
@Test
fun `search records chunk index after body children are chunked in groups of twenty`() = runTest {
val root = temp.newFolder("chunked")
val paragraphs = (1..25).joinToString("") { index ->
if (index == 22) "<p>late target appears here</p>" else "<p>filler $index</p>"
}
writeChapter(root, "chapter.xhtml", "<html><body>$paragraphs</body></html>")
val book = epubBook(root, listOf(chapter("ch1", "Chunky", "chapter.xhtml")))
val result = createEpubSearcher(book)("target").single()
assertEquals(1, result.chunkIndex)
assertEquals(0, result.occurrenceIndexInLocation)
assertEquals("Chunky", result.locationTitle)
}
@Test
fun `search currently requires only a word start and highlights the matched substring`() = runTest {
val root = temp.newFolder("word-start")
writeChapter(root, "chapter.xhtml", "<html><body><p>cart art artist</p></body></html>")
val book = epubBook(root, listOf(chapter("ch1", "Words", "chapter.xhtml")))
val results = createEpubSearcher(book)("art")
assertEquals(2, results.size)
assertEquals(listOf("art", "art"), results.map { result ->
val style = result.snippet.spanStyles.single()
result.snippet.substring(style.start, style.end)
})
}
@Test
fun `vertical navigation changes chapter when needed and scrolls in place when chunk is loaded`() {
val result = searchResult(chapter = 1, chunk = 2, occurrence = 3)
val searchState = SearchState(CoroutineScope(UnconfinedTestDispatcher())) { emptyList() }.apply {
searchResults = listOf(result)
}
val webView = mockk<android.webkit.WebView>(relaxed = true)
val chapterChanges = mutableListOf<Triple<Int, Int, SearchResult>>()
val inPlaceScrolls = mutableListOf<SearchResult>()
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.VERTICAL_SCROLL,
currentChapterIndex = 0,
loadedChunkCount = 10,
webView = webView,
paginator = null,
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
onVerticalChapterChange = { chapterIndex, chunkIndex, navResult ->
chapterChanges.add(Triple(chapterIndex, chunkIndex, navResult))
},
onVerticalScrollToResult = { inPlaceScrolls.add(it) },
onPaginatedScrollToPage = {}
)
assertEquals(listOf(Triple(1, 2, result)), chapterChanges)
assertTrue(inPlaceScrolls.isEmpty())
assertEquals(0, searchState.currentSearchResultIndex)
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.VERTICAL_SCROLL,
currentChapterIndex = 1,
loadedChunkCount = 3,
webView = webView,
paginator = null,
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
onVerticalChapterChange = { chapterIndex, chunkIndex, navResult ->
chapterChanges.add(Triple(chapterIndex, chunkIndex, navResult))
},
onVerticalScrollToResult = { inPlaceScrolls.add(it) },
onPaginatedScrollToPage = {}
)
assertEquals(listOf(result), inPlaceScrolls)
verify { webView.evaluateJavascript("javascript:window.scrollToOccurrence(3);", null) }
}
@Test
fun `vertical navigation reloads same chapter when target chunk has not been loaded`() {
val result = searchResult(chapter = 0, chunk = 5, occurrence = 0)
val searchState = SearchState(CoroutineScope(UnconfinedTestDispatcher())) { emptyList() }.apply {
searchResults = listOf(result)
}
val chapterChanges = mutableListOf<Pair<Int, Int>>()
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.VERTICAL_SCROLL,
currentChapterIndex = 0,
loadedChunkCount = 5,
webView = null,
paginator = null,
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
onVerticalChapterChange = { chapterIndex, chunkIndex, _ -> chapterChanges.add(chapterIndex to chunkIndex) },
onVerticalScrollToResult = {},
onPaginatedScrollToPage = {}
)
assertEquals(listOf(0 to 5), chapterChanges)
}
@Test
fun `paginated navigation asks paginator for target page and invokes suspend scroll callback`() = runTest {
val result = searchResult(chapter = 0, chunk = 0, occurrence = 0)
val searchState = SearchState(this) { emptyList() }.apply { searchResults = listOf(result) }
val paginator = FakePaginator(pageForResult = 42)
val pages = mutableListOf<Int>()
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.PAGINATED,
currentChapterIndex = 0,
loadedChunkCount = 0,
webView = null,
paginator = paginator,
coroutineScope = this,
onVerticalChapterChange = { _, _, _ -> error("Unexpected vertical navigation") },
onVerticalScrollToResult = { error("Unexpected vertical scroll") },
onPaginatedScrollToPage = { pages.add(it) }
)
advanceUntilIdle()
assertEquals(result, paginator.lastSearchResult)
assertEquals(listOf(42), pages)
}
@Test
fun `navigation ignores out of bounds search result index`() {
val searchState = SearchState(CoroutineScope(UnconfinedTestDispatcher())) { emptyList() }
var called = false
performSearchResultNavigation(
index = 0,
searchState = searchState,
renderMode = RenderMode.VERTICAL_SCROLL,
currentChapterIndex = 0,
loadedChunkCount = 0,
webView = null,
paginator = null,
coroutineScope = CoroutineScope(UnconfinedTestDispatcher()),
onVerticalChapterChange = { _, _, _ -> called = true },
onVerticalScrollToResult = { called = true },
onPaginatedScrollToPage = { called = true }
)
assertTrue(!called)
assertEquals(-1, searchState.currentSearchResultIndex)
}
private fun writeChapter(root: java.io.File, relativePath: String, html: String) {
val file = java.io.File(root, relativePath)
file.parentFile?.mkdirs()
file.writeText(html)
}
private fun epubBook(root: java.io.File, chapters: List<EpubChapter>): EpubBook =
EpubBook(
fileName = "test.epub",
title = "Test",
author = "Author",
language = "en",
coverImage = null,
chapters = chapters,
extractionBasePath = root.absolutePath
)
private fun chapter(id: String, title: String, path: String): EpubChapter =
EpubChapter(
chapterId = id,
absPath = path,
title = title,
htmlFilePath = path,
plainTextContent = "",
htmlContent = ""
)
private fun searchResult(chapter: Int, chunk: Int, occurrence: Int): SearchResult =
SearchResult(
locationInSource = chapter,
locationTitle = "Chapter $chapter",
snippet = buildAnnotatedString { append("snippet") },
query = "needle",
occurrenceIndexInLocation = occurrence,
chunkIndex = chunk
)
private class FakePaginator(private val pageForResult: Int) : IPaginator {
var lastSearchResult: SearchResult? = null
override val totalPageCount: Int = 0
override val isLoading: Boolean = false
override val generation: Int = 0
override val pageShiftRequest: Flow<Int> = emptyFlow()
override fun getPageContent(pageIndex: Int): Page? = null
override fun getChapterPathForPage(pageIndex: Int): String? = null
override fun getPlainTextForChapter(chapterIndex: Int): String? = null
override fun navigateToHref(currentChapterAbsPath: String, href: String, onNavigationComplete: (pageIndex: Int) -> Unit) = Unit
override fun findPageForSearchResult(result: SearchResult, onResult: (pageIndex: Int) -> Unit) {
lastSearchResult = result
onResult(pageForResult)
}
override fun findPageForAnchor(chapterIndex: Int, anchor: String?, onResult: (pageIndex: Int) -> Unit) = Unit
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) = Unit
override fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int? = null
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
override fun getCfiForPage(pageIndex: Int): String? = null
override fun onUserScrolledTo(pageIndex: Int) = Unit
override fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String? = null
}
}

View file

@ -0,0 +1,57 @@
package com.aryan.reader.epubreader
import android.content.SharedPreferences
internal class TestSharedPreferences(vararg initial: Pair<String, Any?>) : SharedPreferences {
private val values = initial.toMap().toMutableMap()
override fun getAll(): MutableMap<String, *> = values
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
val value = values[key] as? Set<*> ?: return defValues
return value.filterIsInstance<String>().toMutableSet()
}
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
override fun contains(key: String?): Boolean = values.containsKey(key)
override fun edit(): SharedPreferences.Editor = Editor()
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
private inner class Editor : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearRequested = false
override fun putString(key: String?, value: String?): SharedPreferences.Editor = applyPut(key, value)
override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor =
applyPut(key, values?.toSet())
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = applyPut(key, value)
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = applyPut(key, value)
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = applyPut(key, value)
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = applyPut(key, value)
override fun remove(key: String?): SharedPreferences.Editor = applyPut(key, null)
override fun clear(): SharedPreferences.Editor {
clearRequested = true
return this
}
override fun commit(): Boolean {
flush()
return true
}
override fun apply() = flush()
private fun applyPut(key: String?, value: Any?): SharedPreferences.Editor {
if (key != null) pending[key] = value
return this
}
private fun flush() {
if (clearRequested) values.clear()
pending.forEach { (key, value) ->
if (value == null) values.remove(key) else values[key] = value
}
}
}
}

View file

@ -0,0 +1,208 @@
package com.aryan.reader.opds
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class OpdsParserTest {
@Test
fun `parse OPDS 2 feed resolves links facets navigation publications and metadata`() {
val feed = OpdsParser().parse(
bodyString = """
{
"metadata": {"title": "Catalog"},
"links": [
{"rel": "next", "href": "page/2"},
{"rel": ["search"], "href": "search{?query}"}
],
"facets": [
{
"metadata": {"title": "Format"},
"links": [
{"title": "EPUB", "href": "?format=epub", "properties": {"active": true}}
]
}
],
"navigation": [
{"title": "Authors", "href": "../authors", "description": "Browse authors"}
],
"publications": [
{
"metadata": {
"identifier": "pub-1",
"title": "Example Book",
"description": "Long summary",
"author": [{"name": "Ada Writer", "links": [{"href": "/authors/ada"}]}],
"language": "en",
"publisher": "Example Press",
"published": "2026-01-02",
"subject": [{"name": "Fiction"}],
"belongsTo": {"series": {"name": "Series", "position": 2}}
},
"images": [
{"href": "images/thumb.jpg"},
{"rel": "cover", "href": "images/cover.jpg"}
],
"links": [
{
"rel": "http://opds-spec.org/acquisition",
"href": "downloads/book.epub",
"type": "application/epub+zip"
},
{
"rel": ["http://vaemendis.net/opds-pse/stream"],
"href": "stream/{page}",
"properties": {"numberOfItems": 12}
}
]
}
]
}
""".trimIndent(),
baseUrl = "https://example.org/opds/catalog/index.json"
)
assertEquals("Catalog", feed.title)
assertEquals("https://example.org/opds/catalog/page/2", feed.nextUrl)
assertEquals("https://example.org/opds/catalog/search{?query}", feed.searchUrl)
assertEquals(OpdsFacet("EPUB", "Format", "https://example.org/opds/catalog/?format=epub", true), feed.facets.single())
val navigation = feed.entries.first { it.isNavigation }
assertEquals("Authors", navigation.title)
assertEquals("https://example.org/opds/authors", navigation.navigationUrl)
val publication = feed.entries.first { it.isAcquisition }
assertEquals("pub-1", publication.id)
assertEquals("Example Book", publication.title)
assertEquals("Ada Writer", publication.author)
assertEquals("https://example.org/authors/ada", publication.authors.single().url)
assertEquals("Long summary", publication.summary)
assertEquals("https://example.org/opds/catalog/images/cover.jpg", publication.coverUrl)
assertEquals("Example Press", publication.publisher)
assertEquals("2026-01-02", publication.published)
assertEquals("en", publication.language)
assertEquals("Series", publication.series)
assertEquals("2", publication.seriesIndex)
assertEquals(listOf("Fiction"), publication.categories)
assertEquals("https://example.org/opds/catalog/downloads/book.epub", publication.bestAcquisition?.url)
assertEquals("EPUB", publication.bestAcquisition?.formatName)
assertEquals(12, publication.pseCount)
assertEquals("https://example.org/opds/catalog/stream/{page}", publication.pseUrlTemplate)
assertTrue(publication.isStreamable)
}
@Test
fun `parse OPDS 1 feed extracts catalog links entry metadata acquisitions and stream info`() {
val feed = OpdsParser().parse(
bodyString = """
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:opds="http://opds-spec.org/2010/catalog"
xmlns:pse="http://vaemendis.net/opds-pse/ns">
<title>XML Catalog</title>
<link rel="next" href="next.xml" />
<link rel="search" href="/search.xml" />
<link rel="facet" title="English" href="?lang=en" opds:facetGroup="Language" opds:activeFacet="true" />
<entry>
<id>xml-1</id>
<title>XML Book</title>
<summary>Summary text</summary>
<author>
<name>XML Author</name>
<uri>/people/xml-author</uri>
</author>
<publisher>XML Press</publisher>
<language>en</language>
<published>2025-12-31</published>
<category term="fiction" label="Fiction" />
<meta property="calibre:series">XML Series</meta>
<meta property="calibre:series_index">3</meta>
<link rel="http://opds-spec.org/image/thumbnail" href="thumb.jpg" />
<link rel="http://opds-spec.org/image" href="cover.jpg" />
<link rel="http://opds-spec.org/acquisition" type="application/pdf" href="book.pdf" />
<link rel="http://vaemendis.net/opds-pse/stream" href="stream/{page}" pse:count="8" />
</entry>
</feed>
""".trimIndent(),
baseUrl = "https://example.org/root/feed.xml"
)
assertEquals("XML Catalog", feed.title)
assertEquals("https://example.org/root/next.xml", feed.nextUrl)
assertEquals("https://example.org/search.xml", feed.searchUrl)
assertEquals(OpdsFacet("English", "Language", "https://example.org/root/?lang=en", true), feed.facets.single())
val entry = feed.entries.single()
assertEquals("xml-1", entry.id)
assertEquals("XML Book", entry.title)
assertEquals("Summary text", entry.summary)
assertEquals(OpdsAuthor("XML Author", "https://example.org/people/xml-author"), entry.authors.single())
assertEquals("https://example.org/root/thumb.jpg", entry.coverUrl)
assertEquals("XML Press", entry.publisher)
assertEquals("2025-12-31", entry.published)
assertEquals("en", entry.language)
assertEquals("XML Series", entry.series)
assertEquals("3", entry.seriesIndex)
assertEquals(listOf("Fiction"), entry.categories)
assertEquals(OpdsAcquisition("https://example.org/root/book.pdf", "application/pdf"), entry.acquisitions.single())
assertEquals(8, entry.pseCount)
assertEquals("https://example.org/root/stream/{page}", entry.pseUrlTemplate)
}
@Test
fun `parse OPDS 2 groups and fallback metadata produce navigation entries`() {
val feed = OpdsParser().parse(
bodyString = """
{
"groups": [
{
"metadata": {"title": "Group Title"},
"links": [{"href": "group-feed"}],
"navigation": [{"title": "Nested Nav", "href": "nested"}],
"publications": [{"links": [], "metadata": {"title": "No Identifier"}}]
}
]
}
""".trimIndent(),
baseUrl = "https://example.org/catalog/"
)
assertEquals("OPDS 2.0 Feed", feed.title)
assertEquals("Nested Nav", feed.entries[0].title)
assertEquals("https://example.org/catalog/nested", feed.entries[0].navigationUrl)
assertEquals("Group Title", feed.entries[2].title)
assertEquals("https://example.org/catalog/group-feed", feed.entries[2].navigationUrl)
assertEquals("No Identifier", feed.entries[1].title)
assertFalse(feed.entries[1].isAcquisition)
assertNull(feed.entries[1].bestAcquisition)
}
@Test
fun `acquisition format names and priority prefer richer reader formats`() {
val acquisitions = listOf(
OpdsAcquisition("txt", "text/plain"),
OpdsAcquisition("pdf", "application/pdf"),
OpdsAcquisition("epub", "application/epub+zip"),
OpdsAcquisition("unknown", "application/octet-stream")
)
val entry = OpdsEntry(
id = "id",
title = "Book",
summary = null,
coverUrl = null,
acquisitions = acquisitions,
navigationUrl = null
)
assertEquals("EPUB", acquisitions[2].formatName)
assertEquals("TXT", acquisitions[0].formatName)
assertEquals("OCTET-STREAM", acquisitions[3].formatName)
assertEquals(acquisitions[2], entry.bestAcquisition)
}
}

View file

@ -0,0 +1,134 @@
package com.aryan.reader.opds
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class OpdsRepositoryTest {
@Test
fun `getCatalogs seeds default catalogs only once`() {
val repository = repositoryWithFreshPrefs()
val first = repository.getCatalogs()
val second = repository.getCatalogs()
assertEquals(2, first.size)
assertEquals(first, second)
assertTrue(first.all { it.isDefault })
assertTrue(first.any { it.title == "Project Gutenberg" })
assertTrue(first.any { it.title == "Standard Ebooks" })
}
@Test
fun `add update and remove catalog preserve defaults and trim editable credentials`() {
val repository = repositoryWithFreshPrefs()
repository.getCatalogs()
repository.addCatalog(" Custom ", " https://example.org/opds ", " user ", " pass ")
val added = repository.getCatalogs().single { !it.isDefault }
repository.updateCatalog(
id = added.id,
title = " Updated ",
url = " https://example.org/new ",
username = " ",
password = " token "
)
val updated = repository.getCatalogs().single { !it.isDefault }
assertEquals("Updated", updated.title)
assertEquals("https://example.org/new", updated.url)
assertNull(updated.username)
assertEquals("token", updated.password)
val defaultId = repository.getCatalogs().first { it.isDefault }.id
repository.removeCatalog(defaultId)
assertEquals(3, repository.getCatalogs().size)
repository.removeCatalog(updated.id)
assertTrue(repository.getCatalogs().all { it.isDefault })
}
@Test
fun `basic authenticator adds authorization once and ignores missing credentials`() {
val request = Request.Builder().url("https://example.org/feed").build()
val response = responseFor(request, "Basic realm=\"Catalog\"")
val authenticated = OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, response)
val missingCredentials = OpdsRepository.OpdsAuthenticator("", "pass")
.authenticate(null, response)
val alreadyAuthorized = OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, responseFor(request.newBuilder().header("Authorization", "old").build(), "Basic"))
assertEquals("Basic dXNlcjpwYXNz", authenticated?.header("Authorization"))
assertNull(missingCredentials)
assertNull(alreadyAuthorized)
}
@Test
fun `digest authenticator builds digest header with qop opaque and request uri`() {
val request = Request.Builder()
.url("https://example.org/catalog/feed?x=1")
.build()
val response = responseFor(
request,
"Digest realm=\"realm\", nonce=\"abc\", qop=\"auth\", opaque=\"opaque-token\""
)
val authenticated = OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, response)
val header = authenticated?.header("Authorization").orEmpty()
assertTrue(header.startsWith("Digest "))
assertTrue(header.contains("""username="user""""))
assertTrue(header.contains("""realm="realm""""))
assertTrue(header.contains("""nonce="abc""""))
assertTrue(header.contains("""uri="/catalog/feed?x=1""""))
assertTrue(header.contains("qop=auth"))
assertTrue(header.contains("nc=00000001"))
assertTrue(header.contains("""cnonce=""""))
assertTrue(header.contains("""opaque="opaque-token""""))
assertNotNull(Regex("""response="[a-f0-9]{32}"""").find(header))
}
@Test
fun `authenticator ignores unsupported challenge`() {
val request = Request.Builder().url("https://example.org/feed").build()
assertNull(
OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, responseFor(request, "Bearer realm=\"x\""))
)
}
private fun repositoryWithFreshPrefs(): OpdsRepository {
val context = RuntimeEnvironment.getApplication()
context.getSharedPreferences("reader_opds_prefs", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
return OpdsRepository(context)
}
private fun responseFor(request: Request, challenge: String): Response {
return Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(401)
.message("Unauthorized")
.header("WWW-Authenticate", challenge)
.body("".toResponseBody(null))
.build()
}
}

View file

@ -0,0 +1,30 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class CfiUtilsTest {
@Test
fun `getPath strips offsets while preserving full cfi path`() {
assertEquals("/4/2/6", CfiUtils.getPath("/4/2/6:13"))
assertEquals("/4/2/6", CfiUtils.getPath("/4/2/6"))
}
@Test
fun `getOffset parses valid offsets and defaults invalid or missing offsets to zero`() {
assertEquals(13, CfiUtils.getOffset("/4/2/6:13"))
assertEquals(0, CfiUtils.getOffset("/4/2/6:0"))
assertEquals(0, CfiUtils.getOffset("/4/2/6"))
assertEquals(0, CfiUtils.getOffset("/4/2/6:bad"))
}
@Test
fun `compare sorts numeric cfi paths before character offsets`() {
assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0)
assertTrue(CfiUtils.compare("/4/2/6", "/4/2/6/2") < 0)
assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0)
assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6"))
}
}

View file

@ -0,0 +1,201 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ContentStylerTest {
@Test
fun `paragraph styling applies user text alignment and preserves cfi metadata`() {
val styler = styler(userTextAlign = TextAlign.Justify)
val block = styler.style(
listOf(
SemanticParagraph(
text = "Aligned text",
spans = emptyList(),
style = CssStyle(),
elementId = "p1",
cfi = "/4/2",
startCharOffsetInSource = 7,
blockIndex = 10
)
)
).single() as ParagraphBlock
assertEquals(TextAlign.Justify, block.textAlign)
assertEquals("p1", block.elementId)
assertEquals("/4/2", block.cfi)
assertEquals(7, block.startCharOffsetInSource)
assertEquals(10, block.blockIndex)
assertEquals("Aligned text", block.content.text)
}
@Test
fun `floating image is grouped with following paragraphs until clear`() {
val blocks = styler().style(
listOf(
SemanticImage(
path = "image.png",
altText = "Cover",
intrinsicWidth = 120f,
intrinsicHeight = 200f,
style = CssStyle(blockStyle = BlockStyle(float = "left")),
elementId = "img",
cfi = "/4/4",
blockIndex = 1
),
paragraph("Wrapped one", blockIndex = 2),
paragraph("Wrapped two", blockIndex = 3),
paragraph(
"After clear",
blockIndex = 4,
style = CssStyle(blockStyle = BlockStyle(clear = "left"))
)
)
)
val wrapping = blocks[0] as WrappingContentBlock
assertEquals("image.png", wrapping.floatedImage.path)
assertEquals(listOf("Wrapped one", "Wrapped two"), wrapping.paragraphsToWrap.map { it.content.text })
assertEquals("After clear", (blocks[1] as ParagraphBlock).content.text)
}
@Test
fun `ordered list items receive decimal markers and nested text styles`() {
val list = SemanticList(
items = listOf(
SemanticListItem(
text = "First",
spans = listOf(
SemanticSpan(
start = 0,
end = 5,
style = CssStyle(spanStyle = SpanStyle(color = Color.Red)),
tag = "span",
linkHref = "https://example.org",
elementId = "link"
)
),
style = CssStyle(),
elementId = "li1",
cfi = "/4/2/2",
startCharOffsetInSource = 0,
itemMarkerImage = null,
blockIndex = 11
),
SemanticListItem(
text = "Second",
spans = emptyList(),
style = CssStyle(),
elementId = "li2",
cfi = "/4/2/4",
startCharOffsetInSource = 6,
itemMarkerImage = null,
blockIndex = 12
)
),
isOrdered = true,
style = CssStyle(blockStyle = BlockStyle(listStyleType = "decimal-leading-zero")),
elementId = "list",
cfi = "/4/2",
blockIndex = 10
)
val flex = styler().style(listOf(list)).single() as FlexContainerBlock
val first = flex.children[0] as ListItemBlock
val second = flex.children[1] as ListItemBlock
assertEquals("01. ", first.itemMarker)
assertEquals("02. ", second.itemMarker)
assertEquals("li1", first.elementId)
assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item)
assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item)
}
@Test
fun `math svg is themed and external images are embedded when resolvable`() {
val root = kotlin.io.path.createTempDirectory("content-styler-svg").toFile()
val chapterDir = java.io.File(root, "chapters").apply { mkdirs() }
val image = java.io.File(chapterDir, "pixel.png")
image.writeBytes(
java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
)
val styler = styler(
extractionBasePath = root.absolutePath,
chapterAbsPath = "chapters/chapter.xhtml",
baseTextStyle = TextStyle(fontSize = 16.sp, color = Color.Black)
)
val math = styler.style(
listOf(
SemanticMath(
svgContent = """<svg><text fill="#fff">x</text><image href="pixel.png"/></svg>""",
altText = "x",
svgWidth = null,
svgHeight = null,
svgViewBox = null,
isFromMathJax = false,
style = CssStyle(),
elementId = null,
cfi = "/math",
blockIndex = 1
)
)
).single() as MathBlock
val svgContent = math.svgContent!!
assertTrue(svgContent.contains("fill:#000000"))
assertTrue(svgContent.contains("data:image/png;base64,"))
}
private fun paragraph(
text: String,
blockIndex: Int,
style: CssStyle = CssStyle()
): SemanticParagraph {
return SemanticParagraph(
text = text,
spans = emptyList(),
style = style,
elementId = null,
cfi = null,
startCharOffsetInSource = 0,
blockIndex = blockIndex
)
}
private fun styler(
userTextAlign: TextAlign? = null,
extractionBasePath: String = "",
chapterAbsPath: String = "chapter.xhtml",
baseTextStyle: TextStyle = TextStyle(fontSize = 16.sp, color = Color.Black)
): ContentStyler {
return ContentStyler(
baseTextStyle = baseTextStyle,
fontFamilyMap = emptyMap(),
density = Density(1f),
isDarkTheme = false,
themeBackgroundColor = Color.White,
themeTextColor = Color.Black,
chapterAbsPath = chapterAbsPath,
extractionBasePath = extractionBasePath,
userTextAlign = userTextAlign,
paragraphGapMultiplier = 1f
)
}
}

View file

@ -0,0 +1,51 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Constraints
import org.junit.Assert.assertEquals
import org.junit.Test
class CssParserThemeModeTest {
@Test
fun parseCanPreserveRawPaintColorsForLayoutCaches() {
val result = CssParser.parse(
cssContent = "p { color: #000000; background-color: #ffffff; border-top-width: 1px; border-top-style: solid; border-top-color: #000000; }",
cssPath = null,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 400, maxHeight = 800),
isDarkTheme = true,
themeBackgroundColor = Color.Black,
themeTextColor = Color.White,
adaptThemeColors = false
)
val style = result.rules.byTag.getValue("p").single().style
assertEquals(Color.Black, style.spanStyle.color)
assertEquals(Color.White, style.blockStyle.backgroundColor)
assertEquals(Color.Black, style.blockStyle.borderTop?.color)
}
@Test
fun parseStillAdaptsPaintColorsWhenThemeModeIsEnabled() {
val result = CssParser.parse(
cssContent = "p { color: #000000; background-color: #ffffff; border-top-width: 1px; border-top-style: solid; border-top-color: #000000; }",
cssPath = null,
baseFontSizeSp = 16f,
density = 1f,
constraints = Constraints(maxWidth = 400, maxHeight = 800),
isDarkTheme = true,
themeBackgroundColor = Color.Black,
themeTextColor = Color.White,
adaptThemeColors = true
)
val style = result.rules.byTag.getValue("p").single().style
assertEquals(Color.White, style.spanStyle.color)
assertEquals(Color.Transparent, style.blockStyle.backgroundColor)
assertEquals(Color.White, style.blockStyle.borderTop?.color)
}
}

View file

@ -0,0 +1,277 @@
package com.aryan.reader.paginatedreader
import android.content.Context
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.data.AnchorIndexEntry
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.ConfigurationCache
import com.aryan.reader.paginatedreader.data.PageCacheChunk
import com.aryan.reader.paginatedreader.data.PageCacheMetadata
import com.aryan.reader.paginatedreader.data.PageIndexEntry
import com.aryan.reader.paginatedreader.data.ProcessedBook
import com.aryan.reader.paginatedreader.data.ProcessedChapter
import com.aryan.reader.paginatedreader.data.ProcessedChapterChunk
import com.aryan.reader.paginatedreader.data.ProcessedChapterMetadata
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalSerializationApi::class)
class LocatorConverterTest {
private val proto = ProtoBuf {
serializersModule = semanticBlockModule
}
@Test
fun `getLocatorFromCfi resolves best cached semantic block and preserves character offset`() = runTest {
val blocks = semanticBlocks()
val converter = converterFor(blocks)
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6:13")
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 13), locator)
}
@Test
fun `zero estimate semantic cache remains usable`() = runTest {
val converter = converterFor(semanticBlocks(), estimatedPageCount = 0)
val locator = converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2/6:7")
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 7), locator)
}
@Test
fun `stable book id is used for locator cache lookups`() = runTest {
val chapter = ProcessedChapter(
bookId = "stable-book-id",
chapterIndex = 0,
contentBlocksProto = proto.encodeToByteArray(semanticBlocks()),
estimatedPageCount = 1
)
val dao = FakeBookCacheDao(chapter)
val converter = LocatorConverter(dao, proto, mockk<Context>(relaxed = true), stableBookId = "stable-book-id")
converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2")
assertEquals("stable-book-id", dao.requestedBookIds.single())
}
@Test
fun `cfi locator cfi round trip preserves exact block and offset across reader modes`() = runTest {
val converter = converterFor(
listOf(
paragraph("Outer text", blockIndex = 10, cfi = "/4/2"),
paragraph("Nested candidate", blockIndex = 11, cfi = "/4/2/6"),
paragraph("Deep exact candidate", blockIndex = 12, cfi = "/4/2/6/10")
)
)
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6/10:19")
val cfi = locator?.let { converter.getCfiFromLocator(book, it) }
assertEquals(Locator(chapterIndex = 0, blockIndex = 12, charOffset = 19), locator)
assertEquals("/4/2/6/10:19", cfi)
}
@Test
fun `zero offset cfi round trip canonicalizes to base path without losing locator`() = runTest {
val converter = converterFor(semanticBlocks())
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6:0")
val cfi = locator?.let { converter.getCfiFromLocator(book, it) }
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 0), locator)
assertEquals("/4/2/6", cfi)
}
@Test
fun `malformed cfi offset is treated as block start and remains restorable`() = runTest {
val converter = converterFor(semanticBlocks())
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2:not-a-number")
val cfi = locator?.let { converter.getCfiFromLocator(book, it) }
assertEquals(Locator(chapterIndex = 0, blockIndex = 1, charOffset = 0), locator)
assertEquals("/4/2", cfi)
}
@Test
fun `getCfiFromLocator finds nested block and appends positive offset`() = runTest {
val converter = converterFor(semanticBlocks())
assertEquals(
"/6/4:8",
converter.getCfiFromLocator(book(), Locator(chapterIndex = 0, blockIndex = 3, charOffset = 8))
)
assertEquals(
"/4/2",
converter.getCfiFromLocator(book(), Locator(chapterIndex = 0, blockIndex = 1, charOffset = 0))
)
assertNull(converter.getCfiFromLocator(book(), Locator(chapterIndex = 0, blockIndex = 404, charOffset = 0)))
}
@Test
fun `getTextOffset sums preceding text blocks including nested containers`() = runTest {
val converter = converterFor(semanticBlocks())
val offset = converter.getTextOffset(book(), Locator(chapterIndex = 0, blockIndex = 3, charOffset = 4))
assertEquals("First paragraph".length + 1 + "Second paragraph".length + 1 + 4, offset)
}
@Test
fun `getTtsChunksForChapter traverses cached semantic text blocks with source cfi`() = runTest {
val converter = converterFor(
listOf(
paragraph("First sentence. Second sentence.", blockIndex = 1, cfi = "/4/2", offset = 5),
SemanticFlexContainer(
children = listOf(paragraph("Nested text.", blockIndex = 2, cfi = "/6/2", offset = 40)),
style = CssStyle(),
elementId = null,
cfi = null,
blockIndex = 10
)
)
)
val chunks = converter.getTtsChunksForChapter(book(), chapterIndex = 0)!!
assertTrue(chunks.isNotEmpty())
assertEquals("/4/2", chunks.first().sourceCfi)
assertEquals(5, chunks.first().startOffsetInSource)
assertTrue(chunks.any { it.text.contains("Nested text") && it.sourceCfi == "/6/2" })
}
@Test
fun `invalid cached proto returns null instead of processing when chapter has no html`() = runTest {
val dao = FakeBookCacheDao(
ProcessedChapter(
bookId = "Book",
chapterIndex = 0,
contentBlocksProto = byteArrayOf(1, 2, 3),
estimatedPageCount = 1
)
)
val converter = LocatorConverter(dao, proto, mockk<Context>(relaxed = true))
assertNull(converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2"))
}
private fun converterFor(blocks: List<SemanticBlock>, estimatedPageCount: Int = 1): LocatorConverter {
val chapter = ProcessedChapter(
bookId = "Book",
chapterIndex = 0,
contentBlocksProto = proto.encodeToByteArray(blocks),
estimatedPageCount = estimatedPageCount
)
return LocatorConverter(FakeBookCacheDao(chapter), proto, mockk<Context>(relaxed = true))
}
private fun semanticBlocks(): List<SemanticBlock> {
return listOf(
paragraph("First paragraph", blockIndex = 1, cfi = "/4/2"),
paragraph("Second paragraph", blockIndex = 2, cfi = "/4/2/6"),
SemanticFlexContainer(
children = listOf(paragraph("Nested paragraph", blockIndex = 3, cfi = "/6/4")),
style = CssStyle(),
elementId = null,
cfi = null,
blockIndex = 20
)
)
}
private fun paragraph(
text: String,
blockIndex: Int,
cfi: String,
offset: Int = 0
): SemanticParagraph {
return SemanticParagraph(
text = text,
spans = emptyList(),
style = CssStyle(),
elementId = null,
cfi = cfi,
startCharOffsetInSource = offset,
blockIndex = blockIndex
)
}
private fun book(): EpubBook {
return EpubBook(
fileName = "book.epub",
title = "Book",
author = "Author",
language = "en",
coverImage = null,
chapters = listOf(
EpubChapter(
chapterId = "c1",
absPath = "c1.xhtml",
title = "Chapter",
htmlFilePath = "c1.xhtml",
plainTextContent = "",
htmlContent = ""
)
),
extractionBasePath = ""
)
}
private class FakeBookCacheDao(
private val chapter: ProcessedChapter?
) : BookCacheDao() {
val requestedBookIds = mutableListOf<String>()
override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? {
requestedBookIds += bookId
return chapter
}
override suspend fun insertProcessedChapters(chapters: List<ProcessedChapter>) = Unit
override suspend fun getProcessedBook(bookId: String): ProcessedBook? = null
override suspend fun insertProcessedBook(book: ProcessedBook) = Unit
override suspend fun deleteBook(bookId: String) = Unit
override suspend fun clearProcessedBooks() = Unit
override suspend fun insertAnchorIndices(anchors: List<AnchorIndexEntry>) = Unit
override suspend fun getAnchorIndex(bookId: String, anchorId: String): AnchorIndexEntry? = null
override suspend fun deleteAnchorsForBook(bookId: String) = Unit
override suspend fun deleteConfigurationCacheForBook(bookId: String) = Unit
override suspend fun clearAnchors() = Unit
override suspend fun clearConfigurationCache() = Unit
override suspend fun getConfigurationCache(bookId: String, configHash: Int): ConfigurationCache? = null
override suspend fun insertConfigurationCache(cache: ConfigurationCache) = Unit
override suspend fun cleanupOldConfigurations(bookId: String) = Unit
override suspend fun insertPageIndexEntries(entries: List<PageIndexEntry>) = Unit
override suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List<PageIndexEntry> = emptyList()
override suspend fun cleanupOldPageCaches(bookId: String) = Unit
protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null
protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List<ByteArray> = emptyList()
protected override suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata) = Unit
protected override suspend fun insertChapterChunks(chunks: List<ProcessedChapterChunk>) = Unit
protected override suspend fun deleteChapterMetadataForBook(bookId: String) = Unit
protected override suspend fun deleteAllChapterMetadata() = Unit
protected override suspend fun deletePageCacheMetadataForBook(bookId: String) = Unit
protected override suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int) = Unit
protected override suspend fun clearPageCacheMetadata() = Unit
protected override suspend fun getPageCacheMetadata(bookId: String, configHash: Int, chapterIndex: Int): PageCacheMetadata? = null
protected override suspend fun getPageCacheChunks(bookId: String, configHash: Int, chapterIndex: Int): List<ByteArray> = emptyList()
protected override suspend fun insertPageCacheMetadata(metadata: PageCacheMetadata) = Unit
protected override suspend fun insertPageCacheChunks(chunks: List<PageCacheChunk>) = Unit
}
}

View file

@ -0,0 +1,81 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.aryan.reader.epub.EpubChapter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PageCountEstimatorTest {
@Test
fun `estimateChapterPageCount always returns at least one page`() {
assertEquals(
1,
PageCountEstimator.estimateChapterPageCount(
chapter = chapter(html = ""),
constraints = Constraints(maxWidth = 0, maxHeight = 0),
textStyle = TextStyle(fontSize = 16.sp),
density = Density(1f)
)
)
}
@Test
fun `estimateChapterPageCount increases as visible content grows`() {
val constraints = Constraints(maxWidth = 400, maxHeight = 600)
val style = TextStyle(fontSize = 16.sp)
val density = Density(1f)
val short = PageCountEstimator.estimateChapterPageCount(
chapter = chapter(html = "a".repeat(400)),
constraints = constraints,
textStyle = style,
density = density
)
val long = PageCountEstimator.estimateChapterPageCount(
chapter = chapter(html = "a".repeat(40_000)),
constraints = constraints,
textStyle = style,
density = density
)
assertTrue(long > short)
}
@Test
fun `larger font and line height estimate more pages`() {
val constraints = Constraints(maxWidth = 500, maxHeight = 700)
val density = Density(1f)
val content = chapter(html = "reader ".repeat(5000))
val compact = PageCountEstimator.estimateChapterPageCount(
chapter = content,
constraints = constraints,
textStyle = TextStyle(fontSize = 12.sp, lineHeight = 14.sp),
density = density
)
val large = PageCountEstimator.estimateChapterPageCount(
chapter = content,
constraints = constraints,
textStyle = TextStyle(fontSize = 24.sp, lineHeight = 32.sp),
density = density
)
assertTrue(large > compact)
}
private fun chapter(html: String): EpubChapter {
return EpubChapter(
chapterId = "chapter",
absPath = "chapter.xhtml",
title = "Chapter",
htmlFilePath = "chapter.xhtml",
plainTextContent = "",
htmlContent = html
)
}
}

View file

@ -0,0 +1,43 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PaginatedReconfigurationTest {
@Test
fun `visible page locator is preferred for reconfiguration restore`() {
val visiblePageLocator = Locator(chapterIndex = 2, blockIndex = 40, charOffset = 12)
val fallbackLocator = Locator(chapterIndex = 1, blockIndex = 10, charOffset = 3)
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = visiblePageLocator,
fallbackLocator = fallbackLocator
)
assertEquals(visiblePageLocator, anchor)
}
@Test
fun `last known locator is used when current page is temporarily unavailable`() {
val fallbackLocator = Locator(chapterIndex = 3, blockIndex = 90, charOffset = 24)
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = null,
fallbackLocator = fallbackLocator
)
assertEquals(fallbackLocator, anchor)
}
@Test
fun `missing page and fallback locators leave restore unset`() {
val anchor = resolvePaginatedReconfigurationAnchor(
currentPageLocator = null,
fallbackLocator = null
)
assertNull(anchor)
}
}

View file

@ -0,0 +1,75 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Test
class RenderThemeApplierTest {
@Test
fun displayThemeRecolorsCachedPageWithoutMutatingSource() {
val source = Page(
content = listOf(
ParagraphBlock(
content = buildAnnotatedString {
withStyle(SpanStyle(color = Color.Black)) {
append("Hello")
}
},
style = BlockStyle(
backgroundColor = Color.White,
borderTop = BorderStyle(width = 1.dp, color = Color.Black)
),
blockIndex = 1
)
)
)
val themed = source.applyReaderThemeForDisplay(
isDarkTheme = true,
themeBackgroundColor = Color.Black,
themeTextColor = Color.White
)
val sourceParagraph = source.content.single() as ParagraphBlock
val themedParagraph = themed.content.single() as ParagraphBlock
assertEquals(Color.Black, sourceParagraph.content.spanStyles.single().item.color)
assertEquals(Color.White, themedParagraph.content.spanStyles.single().item.color)
assertEquals(Color.White, sourceParagraph.style.backgroundColor)
assertEquals(Color.Transparent, themedParagraph.style.backgroundColor)
assertEquals(Color.Black, sourceParagraph.style.borderTop?.color)
assertEquals(Color.White, themedParagraph.style.borderTop?.color)
}
@Test
fun displayThemeRecolorsCustomUnderlineAnnotations() {
val underlineColor = Color.Black.value.toString()
val source = Page(
content = listOf(
ParagraphBlock(
content = buildAnnotatedString {
append("Hello")
addStringAnnotation("CustomUnderline", "solid|$underlineColor|0", 0, 5)
},
blockIndex = 1
)
)
)
val themed = source.applyReaderThemeForDisplay(
isDarkTheme = true,
themeBackgroundColor = Color.Black,
themeTextColor = Color.White
)
val themedParagraph = themed.content.single() as ParagraphBlock
val annotation = themedParagraph.content.getStringAnnotations("CustomUnderline", 0, 5).single()
assertEquals("solid|${Color.White.value}|0", annotation.item)
}
}

View file

@ -0,0 +1,167 @@
package com.aryan.reader.paginatedreader.data
import androidx.room.Room
import com.aryan.reader.paginatedreader.Page
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class BookCacheDaoTest {
private lateinit var db: BookCacheDatabase
private lateinit var dao: BookCacheDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
BookCacheDatabase::class.java
).allowMainThreadQueries().build()
dao = db.bookCacheDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun `processed chapters round trip empty and chunked proto payloads`() = runTest {
val largePayload = ByteArray(950 * 1024) { index -> (index % 251).toByte() }
val chapters = listOf(
ProcessedChapter(
bookId = "book",
chapterIndex = 0,
contentBlocksProto = ByteArray(0),
estimatedPageCount = 1
),
ProcessedChapter(
bookId = "book",
chapterIndex = 1,
contentBlocksProto = largePayload,
estimatedPageCount = 12
)
)
dao.insertProcessedChapters(chapters)
val empty = dao.getProcessedChapter("book", 0)!!
val large = dao.getProcessedChapter("book", 1)!!
assertEquals(1, empty.estimatedPageCount)
assertEquals(0, empty.contentBlocksProto.size)
assertEquals(12, large.estimatedPageCount)
assertArrayEquals(largePayload, large.contentBlocksProto)
}
@Test
fun `delete and clear operations remove book chapters anchors and configuration cache`() = runTest {
dao.insertProcessedBook(ProcessedBook("book", LATEST_PROCESSING_VERSION, 10))
dao.insertProcessedChapters(
listOf(ProcessedChapter("book", 0, byteArrayOf(1, 2, 3), estimatedPageCount = 2))
)
dao.insertAnchorIndices(listOf(AnchorIndexEntry("book", "anchor", 0, 99)))
dao.insertConfigurationCache(ConfigurationCache("book", configHash = 123, chapterPageCounts = "0:2"))
dao.insertPageCache(
PageCacheEntry(
bookId = "book",
configHash = 123,
chapterIndex = 0,
processingVersion = LATEST_PROCESSING_VERSION,
pageCacheVersion = LATEST_PAGE_CACHE_VERSION,
contentVersion = 456,
pageCount = 1,
pagesProto = byteArrayOf(9, 8, 7)
),
pageIndexEntries = listOf(
PageIndexEntry(
bookId = "book",
configHash = 123,
chapterIndex = 0,
pageInChapter = 0,
firstBlockIndex = 1,
lastBlockIndex = 2,
firstTextBlockIndex = 1,
firstTextCharOffset = 0,
firstTextEndOffset = 10,
firstCfi = "/4/2",
anchors = "anchor"
)
)
)
dao.deleteEntireBookCache("book")
assertNull(dao.getProcessedBook("book"))
assertNull(dao.getProcessedChapter("book", 0))
assertNull(dao.getAnchorIndex("book", "anchor"))
assertNull(dao.getConfigurationCache("book", 123))
assertNull(dao.getPageCache("book", 123, 0))
}
@Test
fun `configuration cleanup keeps only the three most recent hashes for a book`() = runTest {
(1..5).forEach { hash ->
dao.insertConfigurationCache(ConfigurationCache("book", hash, "0:$hash"))
}
dao.cleanupOldConfigurations("book")
assertNull(dao.getConfigurationCache("book", 1))
assertNull(dao.getConfigurationCache("book", 2))
assertEquals("0:3", dao.getConfigurationCache("book", 3)?.chapterPageCounts)
assertEquals("0:4", dao.getConfigurationCache("book", 4)?.chapterPageCounts)
assertEquals("0:5", dao.getConfigurationCache("book", 5)?.chapterPageCounts)
}
@OptIn(ExperimentalSerializationApi::class)
@Test
fun `page cache round trips chunked measured pages and page index entries`() = runTest {
val proto = ProtoBuf
val pagesProto = proto.encodeToByteArray(listOf(Page(content = emptyList())))
val largePayload = pagesProto + ByteArray(950 * 1024) { index -> (index % 127).toByte() }
val entry = PageCacheEntry(
bookId = "book",
configHash = 321,
chapterIndex = 2,
processingVersion = LATEST_PROCESSING_VERSION,
pageCacheVersion = LATEST_PAGE_CACHE_VERSION,
contentVersion = 654,
pageCount = 1,
pagesProto = largePayload
)
val indexEntry = PageIndexEntry(
bookId = "book",
configHash = 321,
chapterIndex = 2,
pageInChapter = 0,
firstBlockIndex = 4,
lastBlockIndex = 9,
firstTextBlockIndex = 4,
firstTextCharOffset = 12,
firstTextEndOffset = 80,
firstCfi = "/4/2",
anchors = "chapter-start"
)
dao.insertPageCache(entry, listOf(indexEntry))
val cached = dao.getPageCache("book", 321, 2)!!
val cachedIndex = dao.getPageIndexEntries("book", 321, 2)
assertEquals(LATEST_PAGE_CACHE_VERSION, cached.pageCacheVersion)
assertEquals(654, cached.contentVersion)
assertArrayEquals(largePayload, cached.pagesProto)
assertEquals(listOf(indexEntry), cachedIndex)
}
}

View file

@ -0,0 +1,229 @@
package com.aryan.reader.pdf
import android.graphics.RectF
import android.graphics.Rect
import com.aryan.reader.pdf.ocr.OcrBlock
import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrLine
import com.aryan.reader.pdf.ocr.OcrResult
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PdfReaderCoreLogicTest {
@Test
fun `generateShortId creates a four digit sync suffix`() {
repeat(100) {
val id = generateShortId()
assertTrue(id, id.matches(Regex("\\d{4}")))
assertTrue(id, id.toInt() in 1000..9998)
}
}
@Test
fun `resolveEraserStrokeWidth uses eraser size only for stylus override`() {
assertEquals(
0.08f,
resolveEraserStrokeWidth(
isEraserOverride = true,
activeToolThickness = 0.005f,
eraserToolThickness = 0.08f
),
0.0001f
)
assertEquals(
0.005f,
resolveEraserStrokeWidth(
isEraserOverride = false,
activeToolThickness = 0.005f,
eraserToolThickness = 0.08f
),
0.0001f
)
}
@Test
fun `getSuggestedFilename sanitizes truncates and marks annotated copies`() {
val filename = getSuggestedFilename(
originalName = "A very long odd @name with spaces and symbols that should be truncated eventually.pdf",
isAnnotated = true
)
assertTrue(filename, filename.matches(Regex("A_very_long_odd__name_with_spaces_and_symbols_that_annotated_\\d{4}\\.pdf")))
assertTrue(filename.length <= "A_very_long_odd__name_with_spaces_and_symbols_that_annotated_0000.pdf".length)
}
@Test
fun `getSuggestedFilename uses Document when original name is missing`() {
val filename = getSuggestedFilename(originalName = null, isAnnotated = false)
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
}
@Test
fun `preprocessTextForTts returns empty processed text for blank input`() {
val processed = preprocessTextForTts(" \n\t ")
assertEquals("", processed.cleanText)
assertEquals(emptyList<Int>(), processed.indexMap)
}
@Test
fun `preprocessTextForTts turns layout newlines inside sentences into spaces`() {
val processed = preprocessTextForTts("Hello\nworld\r\nagain")
assertEquals("Hello world again", processed.cleanText)
assertEquals(processed.cleanText.length, processed.indexMap.size)
assertEquals(5, processed.indexMap[5])
assertEquals(12, processed.indexMap[11])
}
@Test
fun `preprocessTextForTts keeps current punctuation newline behavior`() {
val processed = preprocessTextForTts("End.\nNext?\nLast!")
assertEquals("End.Next?Last!", processed.cleanText)
}
@Test
fun `mergeRectsIntoLines combines rectangles with vertical overlap and keeps separate lines sorted`() {
val rects = listOf(
Rect(40, 50, 60, 70),
Rect(10, 10, 20, 30),
Rect(22, 15, 35, 31),
Rect(8, 55, 30, 75)
)
val merged = mergeRectsIntoLines(rects)
assertEquals(
listOf(
Rect(10, 10, 35, 31),
Rect(8, 50, 60, 75)
),
merged
)
}
@Test
fun `mergeRectsIntoLines treats touching vertical edges as separate lines`() {
val merged = mergeRectsIntoLines(
listOf(
Rect(0, 0, 10, 10),
Rect(0, 10, 10, 20)
)
)
assertEquals(listOf(Rect(0, 0, 10, 10), Rect(0, 10, 10, 20)), merged)
}
@Test
fun `mergePdfRectsIntoLines normalizes inverted pdf rects and merges slight line overlap`() {
val merged = mergePdfRectsIntoLines(
listOf(
RectF(0f, 100f, 20f, 90f),
RectF(22f, 99f, 40f, 91f),
RectF(0f, 70f, 10f, 60f)
)
)
assertEquals(2, merged.size)
assertRectFEquals(RectF(0f, 100f, 40f, 90f), merged[0])
assertRectFEquals(RectF(0f, 70f, 10f, 60f), merged[1])
}
@Test
fun `findRectsForTextChunkInOcrVisual matches words case-insensitively across elements`() {
val result = ocrResult(
OcrElement("The", Rect(0, 0, 10, 10), emptyList()),
OcrElement("Quick", Rect(12, 0, 30, 10), emptyList()),
OcrElement("Brown.", Rect(32, 0, 55, 10), emptyList()),
OcrElement("Fox", Rect(57, 0, 70, 10), emptyList())
)
val rects = findRectsForTextChunkInOcrVisual(result, "quick brown")
assertEquals(listOf(Rect(12, 0, 30, 10), Rect(32, 0, 55, 10)), rects)
}
@Test
fun `findRectsForTextChunkInOcrVisual returns empty for blank text missing words and missing OCR elements`() {
val result = ocrResult(OcrElement("Only", Rect(0, 0, 10, 10), emptyList()))
assertEquals(emptyList<Rect>(), findRectsForTextChunkInOcrVisual(result, ""))
assertEquals(emptyList<Rect>(), findRectsForTextChunkInOcrVisual(result, "missing"))
assertEquals(emptyList<Rect>(), findRectsForTextChunkInOcrVisual(OcrResult("", emptyList()), "Only"))
}
@Test
fun `findWordBoundaries expands from middle of word across letters and digits`() = runTest {
val textPage = FakeReaderTextPage("Start A1b2 end")
val bounds = findWordBoundaries(textPage, initialCharIndex = 8, pageCharCount = textPage.source.length)
assertEquals(6 to 10, bounds)
}
@Test
fun `findWordBoundaries stops at punctuation boundaries`() = runTest {
val textPage = FakeReaderTextPage("can't stop")
val leftSide = findWordBoundaries(textPage, initialCharIndex = 2, pageCharCount = textPage.source.length)
val rightSide = findWordBoundaries(textPage, initialCharIndex = 4, pageCharCount = textPage.source.length)
assertEquals(0 to 3, leftSide)
assertEquals(4 to 5, rightSide)
}
@Test
fun `findWordBoundaries returns null for punctuation and out of bounds selection`() = runTest {
val textPage = FakeReaderTextPage("word.")
assertNull(findWordBoundaries(textPage, initialCharIndex = 4, pageCharCount = textPage.source.length))
assertNull(findWordBoundaries(textPage, initialCharIndex = -1, pageCharCount = textPage.source.length))
assertNull(findWordBoundaries(textPage, initialCharIndex = 5, pageCharCount = textPage.source.length))
}
private class FakeReaderTextPage(val source: String) : ReaderTextPage {
override suspend fun textPageCountChars(): Int = source.length
override suspend fun textPageGetText(startIndex: Int, count: Int): String? =
source.substring(startIndex, (startIndex + count).coerceAtMost(source.length))
override suspend fun textPageGetRectsForRanges(ranges: IntArray): List<ReaderTextRect>? = null
override suspend fun textPageGetCharIndexAtPos(
x: Double,
y: Double,
xTolerance: Double,
yTolerance: Double
): Int = -1
override suspend fun textPageGetCharBox(index: Int): RectF? = null
override suspend fun textPageGetUnicode(index: Int): Int = source[index].code
override suspend fun loadWebLink(): ReaderWebLinks? = null
override fun close() = Unit
}
private fun ocrResult(vararg elements: OcrElement): OcrResult {
val line = OcrLine(
text = elements.joinToString(" ") { it.text },
boundingBox = null,
elements = elements.toList()
)
val block = OcrBlock(text = line.text, boundingBox = null, lines = listOf(line))
return OcrResult(text = line.text, textBlocks = listOf(block))
}
private fun assertRectFEquals(expected: RectF, actual: RectF) {
assertEquals(expected.left, actual.left, 0.0001f)
assertEquals(expected.top, actual.top, 0.0001f)
assertEquals(expected.right, actual.right, 0.0001f)
assertEquals(expected.bottom, actual.bottom, 0.0001f)
}
}

View file

@ -0,0 +1,219 @@
package com.aryan.reader.pdf
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.epubreader.SystemUiMode
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PdfReaderPreferencesTest {
@Test
fun `tool preferences load defaults and preserve saved order with unknowns removed`() {
val prefs = InMemorySharedPreferences(
PDF_TOOL_ORDER_KEY to "SEARCH,NO_SUCH_TOOL,TOC,SEARCH",
PDF_BOTTOM_TOOLS_KEY to setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.TOC.name),
PDF_HIDDEN_TOOLS_KEY to setOf(PdfReaderTool.PRINT.name)
)
val context = contextWithPrefs(prefs)
val order = loadPdfToolOrder(context)
assertEquals(listOf(PdfReaderTool.SEARCH, PdfReaderTool.TOC), order.take(2))
assertEquals(PdfReaderTool.entries.size, order.size)
assertEquals(PdfReaderTool.entries.toSet(), order.toSet())
assertEquals(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.TOC.name), loadPdfBottomTools(context))
assertEquals(setOf(PdfReaderTool.PRINT.name), loadPdfHiddenTools(context))
}
@Test
fun `tool preferences save hidden bottom and explicit order`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
savePdfHiddenTools(context, setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name))
savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name))
savePdfToolOrder(context, listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH))
assertEquals(setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name), loadPdfHiddenTools(context))
assertEquals(setOf(PdfReaderTool.SEARCH.name), loadPdfBottomTools(context))
assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2))
}
@Test
fun `reader mode and enum preferences default safely when saved values are invalid`() {
val prefs = InMemorySharedPreferences(
DISPLAY_MODE_KEY to "BROKEN",
DOCK_LOCATION_KEY to "MISSING",
DOCK_OFFSET_X_KEY to 12.5f,
DOCK_OFFSET_Y_KEY to -7.25f,
OCR_LANGUAGE_KEY to "UNKNOWN",
PDF_SYSTEM_UI_MODE_KEY to Int.MIN_VALUE
)
val context = contextWithPrefs(prefs)
assertEquals(DisplayMode.VERTICAL_SCROLL, loadDisplayMode(context))
assertEquals(DockLocation.BOTTOM to Offset(12.5f, -7.25f), loadDockState(context))
assertEquals(OcrLanguage.LATIN, loadOcrLanguage(context))
assertEquals(SystemUiMode.SYNC, loadPdfSystemUiMode(context))
assertFalse(hasUserSelectedOcrLanguage(context))
}
@Test
fun `reader mode and enum preferences save and load selected values`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
saveDisplayMode(context, DisplayMode.PAGINATION)
saveDockState(context, DockLocation.FLOATING, Offset(3f, 4f))
saveOcrLanguage(context, OcrLanguage.JAPANESE)
savePdfSystemUiMode(context, SystemUiMode.HIDDEN)
assertEquals(DisplayMode.PAGINATION, loadDisplayMode(context))
assertEquals(DockLocation.FLOATING to Offset(3f, 4f), loadDockState(context))
assertEquals(OcrLanguage.JAPANESE, loadOcrLanguage(context))
assertTrue(hasUserSelectedOcrLanguage(context))
assertEquals(SystemUiMode.HIDDEN, loadPdfSystemUiMode(context))
}
@Test
fun `theme dictionary and simple boolean preferences round trip`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
savePdfThemeId(context, "sepia")
saveKeepScreenOn(context, true)
saveUseOnlineDict(context, false)
saveExternalDictPackage(context, "com.example.dict")
saveExternalTranslatePackage(context, "com.example.translate")
saveExternalSearchPackage(context, "com.example.search")
savePdfMusicianMode(context, true)
savePdfScrollLocked(context, "book/one", true)
savePdfLockedState(context, "book/one", scale = 2.25f, offsetX = -10f, offsetY = 42f)
saveStylusOnlyMode(context, true)
savePdfDarkMode(context, true)
assertEquals("sepia", loadPdfThemeId(context))
assertTrue(loadKeepScreenOn(context))
assertFalse(loadUseOnlineDict(context))
assertEquals("com.example.dict", loadExternalDictPackage(context))
assertEquals("com.example.translate", loadExternalTranslatePackage(context))
assertEquals("com.example.search", loadExternalSearchPackage(context))
assertTrue(loadPdfMusicianMode(context))
assertTrue(loadPdfScrollLocked(context, "book/one"))
assertEquals(Triple(2.25f, -10f, 42f), loadPdfLockedState(context, "book/one"))
assertNull(loadPdfLockedState(context, "missing"))
assertTrue(loadStylusOnlyMode(context))
assertTrue(loadPdfDarkMode(context))
}
@Test
fun `auto scroll global and per book preferences round trip with null local settings until speed exists`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
assertNull(loadPdfAutoScrollLocalSettings(context, "book"))
savePdfAutoScrollSpeed(context, 4.5f)
savePdfAutoScrollMinSpeed(context, 0.25f)
savePdfAutoScrollMaxSpeed(context, 8.75f)
savePdfAutoScrollUseSlider(context, true)
savePdfAutoScrollLocalMode(context, "book", true)
savePdfAutoScrollLocalSettings(context, "book", speed = 5.5f, min = 0.5f, max = 9f)
assertEquals(4.5f, loadPdfAutoScrollSpeed(context), 0.0001f)
assertEquals(0.25f, loadPdfAutoScrollMinSpeed(context), 0.0001f)
assertEquals(8.75f, loadPdfAutoScrollMaxSpeed(context), 0.0001f)
assertTrue(loadPdfAutoScrollUseSlider(context))
assertTrue(loadPdfAutoScrollLocalMode(context, "book"))
assertEquals(Triple(5.5f, 0.5f, 9f), loadPdfAutoScrollLocalSettings(context, "book"))
}
@Test
fun `custom highlight colors round trip while missing colors fall back to defaults`() {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
saveCustomHighlightColors(
context,
mapOf(
PdfHighlightColor.YELLOW to Color(0xFF010203),
PdfHighlightColor.RED to Color(0xFF0A0B0C)
)
)
val colors = loadCustomHighlightColors(context)
assertEquals(Color(0xFF010203).toArgb(), colors.getValue(PdfHighlightColor.YELLOW).toArgb())
assertEquals(Color(0xFF0A0B0C).toArgb(), colors.getValue(PdfHighlightColor.RED).toArgb())
assertEquals(PdfHighlightColor.GREEN.color.toArgb(), colors.getValue(PdfHighlightColor.GREEN).toArgb())
}
private fun contextWithPrefs(prefs: SharedPreferences): Context {
val context = mockk<Context>()
every { context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) } returns prefs
return context
}
private class InMemorySharedPreferences(vararg initial: Pair<String, Any?>) : SharedPreferences {
private val values = initial.toMap().toMutableMap()
override fun getAll(): MutableMap<String, *> = values
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
val value = values[key] as? Set<*> ?: return defValues
return value.filterIsInstance<String>().toMutableSet()
}
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
override fun contains(key: String?): Boolean = values.containsKey(key)
override fun edit(): SharedPreferences.Editor = Editor()
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
private inner class Editor : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearRequested = false
override fun putString(key: String?, value: String?): SharedPreferences.Editor = applyPut(key, value)
override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor =
applyPut(key, values?.toSet())
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = applyPut(key, value)
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = applyPut(key, value)
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = applyPut(key, value)
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = applyPut(key, value)
override fun remove(key: String?): SharedPreferences.Editor = applyPut(key, null)
override fun clear(): SharedPreferences.Editor {
clearRequested = true
return this
}
override fun commit(): Boolean {
flush()
return true
}
override fun apply() = flush()
private fun applyPut(key: String?, value: Any?): SharedPreferences.Editor {
if (key != null) pending[key] = value
return this
}
private fun flush() {
if (clearRequested) values.clear()
pending.forEach { (key, value) ->
if (value == null) values.remove(key) else values[key] = value
}
}
}
}
}

View file

@ -0,0 +1,165 @@
package com.aryan.reader.pdf
import android.content.Context
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.PdfHighlightRepository
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import com.aryan.reader.pdf.data.VirtualPage
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class PdfReaderRepositoryTest {
@Test
fun `PdfAnnotationRepository saves loads and exposes non empty sync file`() = runTest {
val context = contextWithFilesDir(tempRoot("annotation"))
val repository = PdfAnnotationRepository(context)
val annotations = mapOf(
1 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = 1,
points = listOf(PdfPoint(0.1f, 0.2f, 123L)),
color = Color.Red,
strokeWidth = 0.01f
)
)
)
repository.saveAnnotations("folder/book.pdf", annotations)
val loaded = repository.loadAnnotations("folder/book.pdf")
assertEquals(1, loaded.getValue(1).single().pageIndex)
assertEquals(InkType.PEN, loaded.getValue(1).single().inkType)
assertNotNull(repository.getAnnotationFileForSync("folder/book.pdf"))
assertTrue(File(context.filesDir, "annotations/annotation_folder_book.pdf.json").exists())
}
@Test
fun `PdfAnnotationRepository keeps empty save as no syncable file`() = runTest {
val context = contextWithFilesDir(tempRoot("annotation-empty"))
val repository = PdfAnnotationRepository(context)
repository.saveAnnotations("book", emptyMap())
assertEquals(emptyMap<Int, List<PdfAnnotation>>(), repository.loadAnnotations("book"))
assertNull(repository.getAnnotationFileForSync("book"))
}
@Test
fun `PdfHighlightRepository saves loads deletes empty highlights and clears all`() = runTest {
val context = contextWithFilesDir(tempRoot("highlights"))
val repository = PdfHighlightRepository(context)
val highlight = PdfUserHighlight(
id = "h1",
pageIndex = 2,
bounds = emptyList(),
color = PdfHighlightColor.GREEN,
text = "quote",
range = 5 to 10
)
repository.saveHighlights("book/one", listOf(highlight))
assertEquals(listOf(highlight), repository.loadHighlights("book/one"))
assertTrue(repository.getFileForSync("book/one").exists())
repository.saveHighlights("book/one", emptyList())
assertEquals(emptyList<PdfUserHighlight>(), repository.loadHighlights("book/one"))
assertFalse(repository.getFileForSync("book/one").exists())
repository.saveHighlights("book/two", listOf(highlight.copy(id = "h2")))
repository.clearAll()
assertFalse(File(context.filesDir, "pdf_highlights").exists())
}
@Test
fun `PdfTextBoxRepository saves loads deletes and clears files`() = runTest {
val context = contextWithFilesDir(tempRoot("textboxes"))
val repository = PdfTextBoxRepository(context)
val box = PdfTextBox(
id = "box",
pageIndex = 0,
relativeBounds = Rect(0.1f, 0.2f, 0.3f, 0.4f),
text = "Text box",
color = Color.Black,
backgroundColor = Color.White,
fontSize = 16f
)
repository.saveTextBoxes("book/one", listOf(box))
assertEquals(listOf(box), repository.loadTextBoxes("book/one"))
assertTrue(repository.getFileForSync("book/one").exists())
repository.deleteForBook("book/one")
assertEquals(emptyList<PdfTextBox>(), repository.loadTextBoxes("book/one"))
repository.saveTextBoxes("book/two", listOf(box.copy(id = "box2")))
repository.clearAll()
assertTrue(File(context.filesDir, "textboxes").listFiles().orEmpty().isEmpty())
}
@Test
fun `PageLayoutRepository returns default pdf pages when no layout exists`() = runTest {
val repository = PageLayoutRepository(contextWithFilesDir(tempRoot("layout-default")))
assertEquals(
listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1), VirtualPage.PdfPage(2)),
repository.loadLayout("missing", totalPdfPages = 3)
)
assertNull(repository.getLayoutOrNull("missing"))
}
@Test
fun `PageLayoutRepository round trips pdf and blank virtual pages`() = runTest {
val context = contextWithFilesDir(tempRoot("layout"))
val repository = PageLayoutRepository(context)
val pages = listOf(
VirtualPage.PdfPage(0),
VirtualPage.BlankPage(id = "blank-1", width = 612, height = 792, wasManuallyAdded = true),
VirtualPage.PdfPage(3)
)
repository.saveLayout("folder/book.pdf", pages)
assertEquals(pages, repository.loadLayout("folder/book.pdf", totalPdfPages = 10))
assertNotNull(repository.getLayoutOrNull("folder/book.pdf"))
assertTrue(File(context.filesDir, "page_layouts/layout_folder_book.pdf.json").exists())
}
@Test
fun `PageLayoutRepository falls back for corrupt loadLayout but returns null for corrupt optional lookup`() = runTest {
val context = contextWithFilesDir(tempRoot("layout-corrupt"))
val repository = PageLayoutRepository(context)
repository.getLayoutFile("book").writeText("not json")
assertEquals(
listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1)),
repository.loadLayout("book", totalPdfPages = 2)
)
assertNull(repository.getLayoutOrNull("book"))
}
private fun contextWithFilesDir(filesDir: File): Context {
val context = mockk<Context>()
every { context.filesDir } returns filesDir
return context
}
private fun tempRoot(name: String): File {
return File("build/test-tmp/pdf-reader/$name-${System.nanoTime()}").apply { mkdirs() }
}
}

View file

@ -0,0 +1,240 @@
package com.aryan.reader.pdf
import android.content.Context
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class PdfReaderRichTextTest {
@Test
fun `RichTextMapper toAnnotatedString clips global spans into requested local range`() {
val document = GlobalRichDocument(
text = "0123456789",
spans = listOf(
GlobalRichSpan(
start = 2,
end = 6,
color = Color.Red.toArgb(),
backgroundColor = Color.Yellow.toArgb(),
fontSizeNorm = 0.02f,
isBold = true,
isItalic = true,
isUnderline = true,
isStrikethrough = true
)
)
)
val annotated = RichTextMapper.toAnnotatedString(
document = document,
pageHeightPx = 1_000f,
rangeStart = 4,
rangeEnd = 8
)
assertEquals("4567", annotated.text)
val range = annotated.spanStyles.single()
assertEquals(0, range.start)
assertEquals(2, range.end)
assertEquals(Color.Red, range.item.color)
assertEquals(Color.Yellow, range.item.background)
assertEquals(20.sp, range.item.fontSize)
assertEquals(FontWeight.Bold, range.item.fontWeight)
assertEquals(FontStyle.Italic, range.item.fontStyle)
assertTrue(range.item.textDecoration!!.contains(TextDecoration.Underline))
assertTrue(range.item.textDecoration!!.contains(TextDecoration.LineThrough))
}
@Test
fun `RichTextMapper toAnnotatedString clamps invalid ranges and falls back to 16sp when page height is invalid`() {
val document = GlobalRichDocument(
text = "abcdef",
spans = listOf(
GlobalRichSpan(
start = 0,
end = 6,
color = Color.Blue.toArgb(),
backgroundColor = Color.Transparent.toArgb(),
fontSizeNorm = 0.5f,
isBold = false,
isItalic = false,
isUnderline = false,
isStrikethrough = false
)
)
)
val empty = RichTextMapper.toAnnotatedString(document, pageHeightPx = 500f, rangeStart = 10, rangeEnd = 1)
val full = RichTextMapper.toAnnotatedString(document, pageHeightPx = 0f, rangeStart = -10, rangeEnd = 99)
assertEquals("", empty.text)
assertEquals("abcdef", full.text)
assertEquals(16.sp, full.spanStyles.single().item.fontSize)
}
@Test
fun `RichTextMapper fromAnnotatedString splits overlapping styles and preserves page breaks`() {
val text = "Hello${PAGE_BREAK_CHAR}World"
val annotated = buildAnnotatedString {
append(text)
addStyle(
SpanStyle(
color = Color.Black,
background = Color.Transparent,
fontSize = 20.sp
),
start = 0,
end = text.length
)
addStyle(
SpanStyle(
color = Color.Magenta,
background = Color.Cyan,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
textDecoration = TextDecoration.combine(
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
)
),
start = 0,
end = 5
)
}
val document = RichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 1_000f)
assertEquals(text, document.text)
assertEquals(2, document.spans.size)
val first = document.spans[0]
assertEquals(0, first.start)
assertEquals(5, first.end)
assertEquals(Color.Magenta.toArgb(), first.color)
assertEquals(Color.Cyan.toArgb(), first.backgroundColor)
assertEquals(0.024f, first.fontSizeNorm, 0.0001f)
assertTrue(first.isBold)
assertTrue(first.isItalic)
assertTrue(first.isUnderline)
assertTrue(first.isStrikethrough)
val second = document.spans[1]
assertEquals(5, second.start)
assertEquals(text.length, second.end)
assertEquals(Color.Black.toArgb(), second.color)
assertFalse(second.isBold)
}
@Test
fun `RichTextMapper fromAnnotatedString merges adjacent identical effective styles`() {
val annotated = buildAnnotatedString {
append("abcd")
val style = SpanStyle(
color = Color.Green,
background = Color.Transparent,
fontSize = 18.sp
)
addStyle(style, start = 0, end = 2)
addStyle(style, start = 2, end = 4)
}
val document = RichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 900f)
assertEquals(1, document.spans.size)
val span = document.spans.single()
assertEquals(0, span.start)
assertEquals(4, span.end)
assertEquals(Color.Green.toArgb(), span.color)
assertEquals(Color.Transparent.toArgb(), span.backgroundColor)
assertEquals(0.02f, span.fontSizeNorm, 0.0001f)
assertFalse(span.isBold)
assertFalse(span.isItalic)
assertFalse(span.isUnderline)
assertFalse(span.isStrikethrough)
}
@Test
fun `RichTextMapper fromAnnotatedString returns empty rich document for empty text`() {
assertEquals(
GlobalRichDocument("", emptyList()),
RichTextMapper.fromAnnotatedString(AnnotatedString(""), pageHeightPx = 1_000f)
)
}
@Test
fun `hasRenderableRichText ignores whitespace and explicit page breaks`() {
assertFalse(" \n\t${PAGE_BREAK_CHAR}".hasRenderableRichText())
assertTrue("${PAGE_BREAK_CHAR}\nVisible".hasRenderableRichText())
}
@Test
fun `PdfRichTextRepository saves and loads rich document with sanitized book id`() = runTest {
val context = contextWithFilesDir(tempRoot("rich-save-load"))
val repository = PdfRichTextRepository(context)
val document = GlobalRichDocument(
text = "Saved rich text",
spans = listOf(
GlobalRichSpan(
start = 0,
end = 5,
color = Color.Red.toArgb(),
backgroundColor = Color.Transparent.toArgb(),
fontSizeNorm = 0.018f,
isBold = true,
isItalic = false,
isUnderline = true,
isStrikethrough = false,
fontPath = "asset:fonts/lora.ttf"
)
)
)
repository.save("folder/book:name?.pdf", document)
val file = repository.getFileForSync("folder/book:name?.pdf")
assertTrue(file.name.matches(Regex("rich_doc_folder_book_name_\\.pdf\\.json")))
assertTrue(file.exists())
assertEquals(document, repository.document.value)
val reloaded = PdfRichTextRepository(context)
reloaded.load("folder/book:name?.pdf")
assertEquals(document, reloaded.document.value)
}
@Test
fun `PdfRichTextRepository load returns empty document for missing and corrupt files`() = runTest {
val context = contextWithFilesDir(tempRoot("rich-corrupt"))
val repository = PdfRichTextRepository(context)
repository.load("missing")
assertEquals(GlobalRichDocument("", emptyList()), repository.document.value)
repository.getFileForSync("corrupt").writeText("{not json")
repository.load("corrupt")
assertEquals(GlobalRichDocument("", emptyList()), repository.document.value)
}
private fun contextWithFilesDir(filesDir: File): Context {
val context = mockk<Context>()
every { context.filesDir } returns filesDir
return context
}
private fun tempRoot(name: String): File {
return File("build/test-tmp/pdf-reader/$name-${System.nanoTime()}").apply { mkdirs() }
}
}

View file

@ -0,0 +1,218 @@
package com.aryan.reader.pdf
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.AnnotationSerializer
import com.aryan.reader.pdf.data.HighlightSerializer
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.TextBoxSerializer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PdfReaderSerializerTest {
@Test
fun `AnnotationSerializer round trips multi page annotations with precision and style`() {
val annotations = mapOf(
0 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.FOUNTAIN_PEN,
pageIndex = 0,
points = listOf(
PdfPoint(0.123456f, 0.987654f, 10L),
PdfPoint(0.2f, 0.3f, 11L)
),
color = Color(0xFF336699),
strokeWidth = 0.0125f
)
),
2 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.HIGHLIGHTER_ROUND,
pageIndex = 2,
points = emptyList(),
color = Color(0x8CFFEB3B),
strokeWidth = 0.035f
)
)
)
val decoded = AnnotationSerializer.fromJson(AnnotationSerializer.toJson(annotations))
assertEquals(setOf(0, 2), decoded.keys)
val first = decoded.getValue(0).single()
assertEquals(AnnotationType.INK, first.type)
assertEquals(InkType.FOUNTAIN_PEN, first.inkType)
assertEquals(Color(0xFF336699).toArgb(), first.color.toArgb())
assertEquals(0.0125f, first.strokeWidth, 0.00001f)
assertEquals(0.12346f, first.points[0].x, 0.00001f)
assertEquals(0.98765f, first.points[0].y, 0.00001f)
assertEquals(10L, first.points[0].timestamp)
assertEquals(InkType.HIGHLIGHTER_ROUND, decoded.getValue(2).single().inkType)
}
@Test
fun `AnnotationSerializer supports legacy type field and malformed input fallback`() {
val legacyJson = """
[
{
"pageIndex": 4,
"annotationType": "BROKEN",
"type": "PENCIL",
"color": -65536,
"strokeWidth": 0.5,
"points": [{ "x": 0.1, "y": 0.2 }]
}
]
""".trimIndent()
val decoded = AnnotationSerializer.fromJson(legacyJson).getValue(4).single()
assertEquals(AnnotationType.INK, decoded.type)
assertEquals(InkType.PENCIL, decoded.inkType)
assertEquals(0L, decoded.points.single().timestamp)
assertTrue(AnnotationSerializer.fromJson("not json").isEmpty())
assertTrue(AnnotationSerializer.fromJson("").isEmpty())
}
@Test
fun `TextBoxSerializer round trips bounds text styling and optional font data`() {
val boxes = listOf(
PdfTextBox(
id = "box-1",
pageIndex = 3,
relativeBounds = Rect(0.1f, 0.2f, 0.8f, 0.4f),
text = "Hello annotations",
color = Color(0xFF112233),
backgroundColor = Color(0x66123456),
fontSize = 18f,
isBold = true,
isItalic = true,
isUnderline = true,
isStrikeThrough = true,
fontPath = "/fonts/test.ttf",
fontName = "Test Font"
)
)
val decoded = TextBoxSerializer.fromJson(TextBoxSerializer.toJson(boxes)).single()
assertEquals("box-1", decoded.id)
assertEquals(3, decoded.pageIndex)
assertEquals(Rect(0.1f, 0.2f, 0.8f, 0.4f), decoded.relativeBounds)
assertEquals("Hello annotations", decoded.text)
assertEquals(Color(0xFF112233).toArgb(), decoded.color.toArgb())
assertEquals(Color(0x66123456).toArgb(), decoded.backgroundColor.toArgb())
assertEquals(18f, decoded.fontSize, 0.0001f)
assertTrue(decoded.isBold)
assertTrue(decoded.isItalic)
assertTrue(decoded.isUnderline)
assertTrue(decoded.isStrikeThrough)
assertEquals("/fonts/test.ttf", decoded.fontPath)
assertEquals("Test Font", decoded.fontName)
}
@Test
fun `TextBoxSerializer defaults missing optional style fields and rejects malformed json`() {
val legacyJson = """
[
{
"id": "legacy-box",
"pageIndex": 1,
"text": "Legacy",
"color": -16777216,
"backgroundColor": 0,
"fontSize": 14.0,
"bounds": { "left": 0.0, "top": 0.1, "right": 0.8, "bottom": 0.2 }
}
]
""".trimIndent()
val decoded = TextBoxSerializer.fromJson(legacyJson).single()
assertEquals("legacy-box", decoded.id)
assertEquals("Legacy", decoded.text)
assertEquals(Rect(0f, 0.1f, 0.8f, 0.2f), decoded.relativeBounds)
assertFalse(decoded.isBold)
assertFalse(decoded.isItalic)
assertFalse(decoded.isUnderline)
assertFalse(decoded.isStrikeThrough)
assertNull(decoded.fontPath)
assertNull(decoded.fontName)
assertTrue(TextBoxSerializer.fromJson("broken").isEmpty())
}
@Test
fun `HighlightSerializer round trips highlights and falls back on invalid color`() {
val highlights = listOf(
PdfUserHighlight(
id = "highlight-1",
pageIndex = 5,
bounds = listOf(RectF(0f, 0f, 1f, 1f)),
color = PdfHighlightColor.BLUE,
text = "Selected text",
range = 7 to 20,
note = "Important"
)
)
val decoded = HighlightSerializer.fromJson(HighlightSerializer.toJson(highlights)).single()
assertEquals("highlight-1", decoded.id)
assertEquals(5, decoded.pageIndex)
assertEquals(PdfHighlightColor.BLUE, decoded.color)
assertEquals("Selected text", decoded.text)
assertEquals(7 to 20, decoded.range)
assertEquals("Important", decoded.note)
assertEquals(1, decoded.bounds.size)
assertRectFEquals(RectF(0f, 0f, 1f, 1f), decoded.bounds.single())
val invalidColor = """[{"pageIndex":0,"bounds":[],"color":"NOPE","text":"x"}]"""
assertEquals(PdfHighlightColor.YELLOW, HighlightSerializer.fromJson(invalidColor).single().color)
assertTrue(HighlightSerializer.fromJson("bad").isEmpty())
}
@Test
fun `HighlightSerializer omits blank notes and defaults missing legacy values`() {
val json = HighlightSerializer.toJson(
listOf(
PdfUserHighlight(
id = "blank-note",
pageIndex = 0,
bounds = emptyList(),
color = PdfHighlightColor.RED,
text = "Text",
range = 2 to 4,
note = " "
)
)
)
val decodedBlankNote = HighlightSerializer.fromJson(json).single()
assertNull(decodedBlankNote.note)
val legacyJson = """[{"pageIndex":2,"bounds":[],"color":"GREEN"}]"""
val decodedLegacy = HighlightSerializer.fromJson(legacyJson).single()
assertTrue(decodedLegacy.id.isNotBlank())
assertEquals("", decodedLegacy.text)
assertEquals(0 to 0, decodedLegacy.range)
}
private fun assertRectFEquals(expected: RectF, actual: RectF) {
assertEquals(expected.left, actual.left, 0.0001f)
assertEquals(expected.top, actual.top, 0.0001f)
assertEquals(expected.right, actual.right, 0.0001f)
assertEquals(expected.bottom, actual.bottom, 0.0001f)
}
}

View file

@ -0,0 +1,147 @@
package com.aryan.reader.pdf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.AnnotationToolSettings
import com.aryan.reader.pdf.data.TextStyleConfig
import com.aryan.reader.pdf.data.ToolConfig
import com.aryan.reader.shared.pdf.PdfAnnotationKind
import com.aryan.reader.shared.pdf.PdfInkTool
import com.aryan.reader.shared.pdf.PdfPageBounds
import com.aryan.reader.shared.pdf.PdfPagePoint
import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PdfReaderSettingsAndSharedModelsTest {
@Test
fun `AnnotationToolSettings falls back safely for invalid selected and last tools`() {
val settings = AnnotationToolSettings(
selectedToolName = "BROKEN",
lastActivePenType = "MISSING",
lastActiveHighlighterType = "NOPE"
)
assertEquals(InkType.PEN, settings.getActiveTool())
assertEquals(InkType.PEN, settings.getLastPenTool())
assertEquals(InkType.HIGHLIGHTER, settings.getLastHighlighterTool())
}
@Test
fun `AnnotationToolSettings returns custom configs and palettes`() {
val settings = AnnotationToolSettings(
selectedToolName = InkType.TEXT.name,
toolConfigs = mapOf(InkType.PEN.name to ToolConfig(Color.Cyan.toArgb(), 0.25f)),
penPaletteArgb = listOf(Color.Red.toArgb(), Color.Green.toArgb()),
highlighterPaletteArgb = listOf(Color.Yellow.toArgb()),
textStyle = TextStyleConfig(colorArgb = Color.Magenta.toArgb(), fontSize = 22f),
isHighlighterSnapEnabled = true
)
assertEquals(InkType.TEXT, settings.getActiveTool())
assertEquals(Color.Cyan.toArgb(), settings.getToolColor(InkType.PEN).toArgb())
assertEquals(0.25f, settings.getToolThickness(InkType.PEN), 0.0001f)
assertEquals(Color.Red.toArgb(), settings.getPenPalette().first().toArgb())
assertEquals(Color.Yellow.toArgb(), settings.getHighlighterPalette().single().toArgb())
assertTrue(settings.isHighlighterSnapEnabled)
assertEquals(22f, settings.textStyle.fontSize, 0.0001f)
}
@Test
fun `AnnotationSettingsRepository default configs cover every ink type`() {
InkType.entries.forEach { type ->
val config = AnnotationSettingsRepository.getDefaultConfig(type)
assertTrue("Expected positive thickness for $type", config.thickness > 0f)
}
}
@Test
fun `SharedPdfAnnotationSerializer round trips current store shape`() {
val annotation = SharedPdfAnnotation(
id = "ann-1",
pageIndex = 7,
kind = PdfAnnotationKind.TEXT,
tool = PdfInkTool.TEXT,
points = listOf(PdfPagePoint(0.1f, 0.2f, 100L)),
bounds = PdfPageBounds(0.1f, 0.2f, 0.3f, 0.4f),
text = "Margin note",
colorArgb = 0xFF112233.toInt(),
backgroundArgb = 0x66112233,
strokeWidth = 1.5f,
fontSize = 19f,
isBold = true,
isItalic = true,
createdAt = 1234L
)
val decoded = SharedPdfAnnotationSerializer.decode(
SharedPdfAnnotationSerializer.encode(listOf(annotation))
)
assertEquals(listOf(annotation), decoded)
assertEquals(emptyList<SharedPdfAnnotation>(), SharedPdfAnnotationSerializer.decode(""))
assertEquals(emptyList<SharedPdfAnnotation>(), SharedPdfAnnotationSerializer.decode("bad json"))
}
@Test
fun `SharedPdfAnnotationSerializer decodes legacy bare annotation array`() {
val legacyJson = """
[
{
"id": "legacy",
"pageIndex": 1,
"kind": "INK",
"tool": "PEN",
"points": [{"x":0.2,"y":0.3,"timestamp":9}],
"colorArgb": -1
}
]
""".trimIndent()
val decoded = SharedPdfAnnotationSerializer.decode(legacyJson).single()
assertEquals("legacy", decoded.id)
assertEquals(1, decoded.pageIndex)
assertEquals(PdfAnnotationKind.INK, decoded.kind)
assertEquals(PdfInkTool.PEN, decoded.tool)
assertEquals(PdfPagePoint(0.2f, 0.3f, 9L), decoded.points.single())
}
@Test
fun `SharedPdfAnnotationDefaults supplies expected tool defaults and palettes`() {
assertEquals(5, SharedPdfAnnotationDefaults.penPalette.size)
assertEquals(5, SharedPdfAnnotationDefaults.highlighterPalette.size)
val pen = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN)
val eraser = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER)
val highlighter = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER)
assertTrue(pen.strokeWidth > 0f)
assertEquals(0x00000000, eraser.colorArgb)
assertTrue(highlighter.strokeWidth > pen.strokeWidth)
}
@Test
fun `PdfZoomSpec clamps scale and keeps render size under pixel budget`() {
val spec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1f, maxRenderPixels = 1_000_000)
assertEquals(0.5f, spec.clamp(0.1f), 0.0001f)
assertEquals(4f, spec.clamp(10f), 0.0001f)
val safeScale = spec.safeRenderScale(pageWidth = 2_000f, pageHeight = 2_000f, requestedScale = 4f)
assertTrue(safeScale < 1f)
assertTrue(safeScale >= 0.1f)
val (width, height) = spec.renderSize(pageWidth = 2_000f, pageHeight = 2_000f, requestedScale = 4f)
assertTrue(width * height <= 1_000_000)
assertTrue(width >= 1)
assertTrue(height >= 1)
}
}

View file

@ -0,0 +1,139 @@
package com.aryan.reader.pdf
import android.content.Context
import com.aryan.reader.SearchResult
import com.aryan.reader.pdf.data.PdfMetaDao
import com.aryan.reader.pdf.data.PdfMetadata
import com.aryan.reader.pdf.data.PdfSearchMatch
import com.aryan.reader.pdf.data.PdfTextDao
import com.aryan.reader.pdf.data.PdfTextDatabase
import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.SmartSearchResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkObject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class PdfTextRepositoryTest {
private lateinit var dao: PdfTextDao
private lateinit var metaDao: PdfMetaDao
private lateinit var repository: PdfTextRepository
@Before
fun setUp() {
dao = mockk(relaxed = true)
metaDao = mockk(relaxed = true)
val db = mockk<PdfTextDatabase>()
every { db.pdfTextDao() } returns dao
every { db.pdfMetaDao() } returns metaDao
mockkObject(PdfTextDatabase.Companion)
every { PdfTextDatabase.getDatabase(any()) } returns db
repository = PdfTextRepository(mockk<Context>(relaxed = true))
}
@After
fun tearDown() {
unmockkObject(PdfTextDatabase.Companion)
}
@Test
fun `page ratios load parse failures and save preserves existing OCR language`() = runTest {
coEvery { metaDao.getMetadata("missing") } returns null
coEvery { metaDao.getMetadata("bad") } returns PdfMetadata("bad", 1, "not json", "LATIN")
coEvery { metaDao.getMetadata("book") } returnsMany listOf(
PdfMetadata("book", 2, "[1.25,0.75]", "DEVANAGARI"),
PdfMetadata("book", 2, "[1.25,0.75]", "DEVANAGARI")
)
val inserted = slot<PdfMetadata>()
coEvery { metaDao.insertMetadata(capture(inserted)) } returns Unit
assertNull(repository.getPageRatios("missing"))
assertNull(repository.getPageRatios("bad"))
assertEquals(listOf(1.25f, 0.75f), repository.getPageRatios("book"))
repository.savePageRatios("book", listOf(2f, 3.5f))
assertEquals("book", inserted.captured.bookId)
assertEquals(2, inserted.captured.totalPages)
assertEquals("[2,3.5]", inserted.captured.ratiosJson)
assertEquals("DEVANAGARI", inserted.captured.ocrLanguage)
}
@Test
fun `searchBookFlow sanitizes FTS query and filters exact phrase punctuation`() = runTest {
every { dao.searchBookFlow("book", "content:hello* content:world*") } returns flowOf(
listOf(
PdfSearchMatch(0, "", "hello, world appears here"),
PdfSearchMatch(1, "", "hello world without comma")
)
)
val results = repository.searchBookFlow("book", "hello, world").first()
assertEquals(listOf(0), results.map { it.pageIndex })
}
@Test
fun `smart search emits exact results with occurrence indexes and highlighted snippets`() = runTest {
coEvery { dao.countMatches("book", "content:needle*") } returns 2
coEvery { dao.getAllMatches("book", "content:needle*") } returns listOf(
PdfSearchMatch(4, "", "needle one and needle two")
)
val result = repository.searchBookSmart("book", "needle").first()
assertTrue(result is SmartSearchResult.Exact)
val matches = (result as SmartSearchResult.Exact).matches
assertEquals(2, matches.size)
assertEquals(4, matches[0].locationInSource)
assertEquals("Page 5", matches[0].locationTitle)
assertEquals(0, matches[0].occurrenceIndexInLocation)
assertEquals(1, matches[1].occurrenceIndexInLocation)
assertEquals("needle", matches[0].query)
}
@Test
fun `smart search emits paged result when page match count is large`() = runTest {
coEvery { dao.countMatches("book", "content:common*") } returns 51
every { dao.searchBookPagingSource("book", "content:common*") } returns mockk(relaxed = true)
val result = repository.searchBookSmart("book", "common").first()
assertTrue(result is SmartSearchResult.Paged)
assertEquals(51, (result as SmartSearchResult.Paged).totalPageCount)
}
@Test
fun `next and previous search result navigate within current page before querying adjacent pages`() = runTest {
val current = SearchResult(
locationInSource = 0,
locationTitle = "Page 1",
snippet = androidx.compose.ui.text.AnnotatedString("first"),
query = "needle",
occurrenceIndexInLocation = 0,
chunkIndex = 0
)
coEvery { dao.getPageText("book", 0) } returns "needle then needle again"
val next = repository.getNextResult("book", "needle", current)
val prev = repository.getPrevResult("book", "needle", current.copy(occurrenceIndexInLocation = 1))
assertEquals(1, next?.occurrenceIndexInLocation)
assertEquals(0, prev?.occurrenceIndexInLocation)
coVerify(exactly = 0) { dao.getNextPageWithMatch(any(), any(), any()) }
coVerify(exactly = 0) { dao.getPrevPageWithMatch(any(), any(), any()) }
}
}

View file

@ -0,0 +1,171 @@
package com.aryan.reader.pdf
import android.graphics.RectF
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PdfiumAnnotationExporterTest {
@Test
fun `buildPayload flattens ink annotations and skips unsupported ink tools`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = mapOf(
2 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = 99,
points = listOf(PdfPoint(0.1f, 0.2f), PdfPoint(0.3f, 0.4f)),
color = Color(0xFF336699),
strokeWidth = 0.0125f
),
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.ERASER,
pageIndex = 2,
points = listOf(PdfPoint(0.5f, 0.6f), PdfPoint(0.7f, 0.8f)),
color = Color.Black,
strokeWidth = 0.1f
)
)
),
textBoxes = emptyList(),
highlights = emptyList()
)
assertArrayEquals(intArrayOf(2), payload.inkPageIndices)
assertArrayEquals(intArrayOf(InkType.PEN.ordinal), payload.inkTypes)
assertArrayEquals(intArrayOf(Color(0xFF336699).toArgb()), payload.inkColors)
assertArrayEquals(floatArrayOf(0.0125f), payload.inkStrokeWidths, 0.0001f)
assertArrayEquals(intArrayOf(0), payload.inkPointOffsets)
assertArrayEquals(intArrayOf(2), payload.inkPointCounts)
assertArrayEquals(floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f), payload.inkPoints, 0.0001f)
}
@Test
fun `buildPayload preserves highlight pdf rects and content notes`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = emptyMap(),
textBoxes = emptyList(),
highlights = listOf(
PdfUserHighlight(
id = "highlight-1",
pageIndex = 1,
bounds = listOf(RectF(10f, 90f, 40f, 80f), RectF(50f, 70f, 60f, 65f)),
color = PdfHighlightColor.BLUE,
text = "Selected text",
range = 0 to 13,
note = "Important"
)
)
)
assertArrayEquals(intArrayOf(1), payload.highlightPageIndices)
assertArrayEquals(intArrayOf(PdfHighlightColor.BLUE.color.toArgb()), payload.highlightColors)
assertArrayEquals(intArrayOf(0), payload.highlightRectOffsets)
assertArrayEquals(intArrayOf(2), payload.highlightRectCounts)
assertArrayEquals(
floatArrayOf(10f, 90f, 40f, 80f, 50f, 70f, 60f, 65f),
payload.highlightRects,
0.0001f
)
assertEquals("Important", payload.highlightContents.single())
}
@Test
fun `buildPayload flattens raster text overlays and leaves native text empty`() {
val pixels = intArrayOf(
0x00000000,
0xFF112233.toInt(),
0x80123456.toInt(),
0x00000000
)
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = emptyMap(),
textBoxes = emptyList(),
highlights = emptyList(),
rasterOverlays = listOf(
PdfiumRasterOverlay(
pageIndex = 3,
left = 0.1f,
top = 0.2f,
right = 0.8f,
bottom = 0.4f,
width = 2,
height = 2,
pixels = pixels
)
)
)
assertTrue(payload.textPageIndices.isEmpty())
assertTrue(payload.textValues.isEmpty())
assertArrayEquals(intArrayOf(3), payload.rasterPageIndices)
assertArrayEquals(floatArrayOf(0.1f, 0.2f, 0.8f, 0.4f), payload.rasterBounds, 0.0001f)
assertArrayEquals(intArrayOf(2), payload.rasterWidths)
assertArrayEquals(intArrayOf(2), payload.rasterHeights)
assertArrayEquals(intArrayOf(0), payload.rasterPixelOffsets)
assertArrayEquals(pixels, payload.rasterPixels)
assertTrue(payload.hasAnnotations())
}
@Test
fun `buildPayload omits blank text boxes and empty highlight bounds`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = emptyMap(),
textBoxes = listOf(
PdfTextBox(
id = "blank-box",
pageIndex = 0,
relativeBounds = Rect(0.1f, 0.2f, 0.3f, 0.4f),
text = " ",
color = Color.Black,
backgroundColor = Color.Transparent,
fontSize = 12f
)
),
highlights = listOf(
PdfUserHighlight(
pageIndex = 0,
bounds = emptyList(),
color = PdfHighlightColor.YELLOW,
text = "Selected",
range = 0 to 8
)
)
)
assertFalse(payload.hasAnnotations())
assertTrue(payload.textValues.isEmpty())
assertTrue(payload.rasterPixels.isEmpty())
assertTrue(payload.highlightContents.isEmpty())
}
@Test
fun `supportsOriginalPageOrder rejects reordered or blank virtual layouts`() {
assertTrue(PdfiumAnnotationExporter.supportsOriginalPageOrder(null))
assertTrue(
PdfiumAnnotationExporter.supportsOriginalPageOrder(
listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1))
)
)
assertFalse(PdfiumAnnotationExporter.supportsOriginalPageOrder(listOf(VirtualPage.PdfPage(1))))
assertFalse(
PdfiumAnnotationExporter.supportsOriginalPageOrder(
listOf(VirtualPage.PdfPage(0), VirtualPage.BlankPage("blank", 300, 400))
)
)
}
}