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", "
Alpha needle.
")
writeChapter(root, "chapter2.xhtml", "Needle one.
needle two.
")
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) "late target appears here
" else "filler $index
"
}
writeChapter(root, "chapter.xhtml", "$paragraphs")
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 scans oversized text nodes in bounded windows`() = runTest {
val root = temp.newFolder("bounded-window")
val filler = "alpha ".repeat(7_000)
writeChapter(
root,
"chapter.xhtml",
"${filler}Needle ${filler}pineedle ${filler}Needle
"
)
val book = epubBook(root, listOf(chapter("ch1", "Large", "chapter.xhtml")))
val results = createEpubSearcher(book)("needle")
assertEquals(2, results.size)
assertEquals(listOf(0, 1), results.map { it.occurrenceIndexInLocation })
assertTrue(results.all { it.snippet.text.contains("Needle", ignoreCase = true) })
}
@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", "cart art artist
")
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(relaxed = true)
val chapterChanges = mutableListOf>()
val inPlaceScrolls = mutableListOf()
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>()
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()
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): 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 = 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? = null
}
}