Windows (#291)
* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
This commit is contained in:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
54
app/src/test/java/com/aryan/reader/FileHasherTest.kt
Normal file
54
app/src/test/java/com/aryan/reader/FileHasherTest.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
514
app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt
Normal file
514
app/src/test/java/com/aryan/reader/LibraryStateProjectorTest.kt
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
147
app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt
Normal file
147
app/src/test/java/com/aryan/reader/NonReaderScreenModelsTest.kt
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}]"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
452
app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt
Normal file
452
app/src/test/java/com/aryan/reader/epub/EpubParserUnitTest.kt
Normal 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 = ""
|
||||
)
|
||||
}
|
||||
119
app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt
Normal file
119
app/src/test/java/com/aryan/reader/epub/ImportedFileCacheTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <line>"))
|
||||
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 & B"))
|
||||
assertTrue(html.contains("<tag>"))
|
||||
}
|
||||
|
||||
private fun contextWithCache(cacheDir: File): Context {
|
||||
val context = mockk<Context>()
|
||||
every { context.cacheDir } returns cacheDir
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
|
@ -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" })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
208
app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt
Normal file
208
app/src/test/java/com/aryan/reader/opds/OpdsParserTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
134
app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt
Normal file
134
app/src/test/java/com/aryan/reader/opds/OpdsRepositoryTest.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
229
app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt
Normal file
229
app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
240
app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt
Normal file
240
app/src/test/java/com/aryan/reader/pdf/PdfReaderRichTextTest.kt
Normal 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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
139
app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt
Normal file
139
app/src/test/java/com/aryan/reader/pdf/PdfTextRepositoryTest.kt
Normal 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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue