Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

View file

@ -0,0 +1,118 @@
// AppNavigationTest.kt
package com.aryan.reader
import android.net.Uri
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.epub.EpubBook
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppNavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController
private val fakeUiState = MutableStateFlow(ReaderScreenState())
// Mock ViewModel that uses the fake state
private val fakeViewModel: MainViewModel = object : MainViewModel(
ApplicationProvider.getApplicationContext()
) {
override val uiState = fakeUiState
override fun clearSelectedFile() {
fakeUiState.value = fakeUiState.value.copy(
selectedFileType = null,
selectedPdfUri = null,
selectedEpubBook = null
)
}
}
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Before
fun setup() {
composeTestRule.setContent {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
AppNavigation(
navController = navController,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(400.dp, 800.dp)),
viewModel = fakeViewModel
)
}
}
@Test
fun appNavigation_defaultStartDestination_isMainRoute() {
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
}
@Test
fun appNavigation_whenPdfSelected_navigatesToPdfViewer() {
// Trigger state change
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.PDF,
selectedPdfUri = Uri.parse("content://dummy.pdf")
)
// Let compose recompose and run LaunchedEffect
composeTestRule.waitForIdle()
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
}
@Test
fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
// Trigger state change
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.EPUB,
selectedEpubBook = EpubBook(
fileName = "dummy.epub",
title = "Dummy Book",
author = "Author",
language = "en",
coverImage = null
)
)
composeTestRule.waitForIdle()
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.EPUB_READER_ROUTE, currentRoute)
}
@Test
fun appNavigation_whenFileCleared_navigatesBackToMain() {
// First, navigate to PDF viewer
fakeUiState.value = ReaderScreenState(
selectedFileType = FileType.PDF,
selectedPdfUri = Uri.parse("content://dummy.pdf")
)
composeTestRule.waitForIdle()
assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
// Then, trigger the clear action (simulating onNavigateBack)
fakeViewModel.clearSelectedFile()
composeTestRule.waitForIdle()
val currentRoute = navController.currentBackStackEntry?.destination?.route
assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
}
}

View file

@ -0,0 +1,27 @@
package com.aryan.reader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* A JUnit TestRule that sets the Main dispatcher to a TestDispatcher for the duration of a test.
* This allows tests to execute coroutines on the Main dispatcher without needing a real Android environment.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

View file

@ -0,0 +1,156 @@
package com.aryan.reader.epubreader
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChapterWebViewBridgeTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun cfiJsBridge_onCfiExtracted_callsCallbackWithCorrectCfi() {
var receivedCfi = ""
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
onCfiForBookmarkReady = {}
)
val cfi = "/4/2[chapter1]/6:10"
val jsonResponse = JSONObject().apply {
put("cfi", cfi)
put("log", JSONArray(listOf("log message 1", "log message 2")))
}.toString()
bridge.onCfiExtracted(jsonResponse)
assertThat(receivedCfi).isEqualTo(cfi)
}
@Test
fun cfiJsBridge_onCfiExtracted_withInvalidJson_callsCallbackWithFallbackCfi() {
var receivedCfi = ""
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
onCfiForBookmarkReady = {}
)
val invalidJson = "this is not json"
bridge.onCfiExtracted(invalidJson)
assertThat(receivedCfi).isEqualTo("/4")
}
@Test
fun cfiJsBridge_onCfiExtracted_withEmptyCfi_callsCallbackWithCfi() {
var receivedCfi: String? = null
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
onCfiForBookmarkReady = {}
)
val jsonResponse = JSONObject().apply {
put("cfi", "")
put("log", JSONArray())
}.toString()
bridge.onCfiExtracted(jsonResponse)
// The handler is only called if the CFI is not blank, so it should remain null
assertThat(receivedCfi).isNull()
}
@Test
fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() = runTest {
var receivedJson: String? = null
val bridge = TtsJsBridge(
scope = this,
ttsStructuredTextHandler = { json -> receivedJson = json }
)
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
bridge.onStructuredTextExtracted(jsonPayload)
advanceUntilIdle()
assertThat(receivedJson).isEqualTo(jsonPayload)
}
@Test
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() = runTest {
var receivedJson: String? = null
val bridge = TtsJsBridge(
scope = this,
ttsStructuredTextHandler = { json -> receivedJson = json }
)
val jsonPayload = ""
bridge.onStructuredTextExtracted(jsonPayload)
advanceUntilIdle()
assertThat(receivedJson).isEqualTo("[]")
}
@Test
fun snippetJsBridge_onSnippetExtracted_callsCallbackWithCfiAndSnippet() {
var receivedCfi = ""
var receivedSnippet = ""
val bridge = SnippetJsBridge(
onSnippetReady = { cfi, snippet ->
receivedCfi = cfi
receivedSnippet = snippet
}
)
val cfi = "/4/8:5"
val snippet = "This is the bookmark snippet."
bridge.onSnippetExtracted(cfi, snippet)
assertThat(receivedCfi).isEqualTo(cfi)
assertThat(receivedSnippet).isEqualTo(snippet)
}
@Test
fun progressJsBridge_onTopChunkUpdated_invokesCallback() {
var updatedChunk = -1
val bridge = ProgressJsBridge(onTopChunkUpdated = { index -> updatedChunk = index })
bridge.updateTopChunk(5)
assertThat(updatedChunk).isEqualTo(5)
bridge.updateTopChunk(10)
assertThat(updatedChunk).isEqualTo(10)
}
@Test
fun progressJsBridge_onTopChunkUpdated_doesNotCallForSameIndex() {
var callCount = 0
val bridge = ProgressJsBridge(onTopChunkUpdated = { callCount++ })
bridge.updateTopChunk(3)
assertThat(callCount).isEqualTo(1)
// Reporting the same index should not trigger the callback again
bridge.updateTopChunk(3)
assertThat(callCount).isEqualTo(1)
bridge.updateTopChunk(4)
assertThat(callCount).isEqualTo(2)
}
@Test
fun aiJsBridge_onContentExtracted_invokesCallback() = runTest {
var receivedContent: String? = null
val bridge = AiJsBridge(
scope = this,
onContentReady = { content -> receivedContent = content }
)
val content = "This is the chapter content for summarization."
bridge.onContentExtractedForSummarization(content)
advanceUntilIdle()
assertThat(receivedContent).isEqualTo(content)
}
}

View file

@ -0,0 +1,93 @@
package com.aryan.reader.epubreader
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.epub.EpubChapter
import com.google.common.truth.Truth.assertThat
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EpubReaderBookmarkTest {
private lateinit var context: Context
private val testBookTitle = "My Test Book"
private val chapters = listOf(
EpubChapter(chapterId = "ch1", title = "Chapter 1", htmlFilePath = "", absPath = "", htmlContent = "", plainTextContent = ""),
EpubChapter(chapterId = "ch2", title = "Chapter 2", htmlFilePath = "", absPath = "", htmlContent = "", plainTextContent = "")
)
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
// Clear any old prefs to ensure a clean slate for each test
val prefs = context.getSharedPreferences("epub_reader_bookmarks", Context.MODE_PRIVATE)
prefs.edit().clear().apply()
}
@Test
fun loadBookmarks_withValidJson_parsesCorrectly() {
val bookmark1 = JSONObject().apply {
put("cfi", "/4/2:10")
put("chapterTitle", "Chapter 1")
put("snippet", "A snippet of text")
put("chapterIndex", 0)
}
val bookmark2 = JSONObject().apply {
put("cfi", "/6/4:22")
put("chapterTitle", "Chapter 2")
put("snippet", "Another snippet")
put("chapterIndex", 1)
}
val bookmarksJson = JSONArray(listOf(bookmark1.toString(), bookmark2.toString())).toString()
val bookmarks = loadBookmarks(context, testBookTitle, chapters, bookmarksJson)
assertThat(bookmarks).hasSize(2)
assertThat(bookmarks).contains(
Bookmark(
cfi = "/4/2:10",
chapterTitle = "Chapter 1",
snippet = "A snippet of text",
pageInChapter = null,
totalPagesInChapter = null,
chapterIndex = 0
)
)
}
@Test
fun loadBookmarks_withInvalidJson_returnsEmptySet() {
val invalidJson = "[{\"cfi\": \"/4/2:10\", snippet: \"invalid json\"}]" // snippet value not in quotes
val bookmarks = loadBookmarks(context, testBookTitle, chapters, invalidJson)
assertThat(bookmarks).isEmpty()
}
@Test
fun loadBookmarks_withMissingChapterIndex_calculatesItFromTitle() {
val bookmark1 = JSONObject().apply {
put("cfi", "/6/4:22")
put("chapterTitle", "Chapter 2") // This should map to index 1
put("snippet", "Another snippet")
}
val bookmarksJson = JSONArray(listOf(bookmark1.toString())).toString()
val bookmarks = loadBookmarks(context, testBookTitle, chapters, bookmarksJson)
assertThat(bookmarks).hasSize(1)
val loadedBookmark = bookmarks.first()
assertThat(loadedBookmark.chapterIndex).isEqualTo(1)
assertThat(loadedBookmark.chapterTitle).isEqualTo("Chapter 2")
}
@Test
fun loadBookmarks_withNullJson_fallsBackToSharedPreferences() {
// This test doesn't write to shared prefs, so it should return an empty set.
val bookmarks = loadBookmarks(context, testBookTitle, chapters, null)
assertThat(bookmarks).isEmpty()
}
}

View file

@ -0,0 +1,204 @@
// app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
package com.aryan.reader.epubreader
import android.content.Context
import timber.log.Timber
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.SearchResult
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import kotlin.math.max
import kotlin.math.min
@RunWith(AndroidJUnit4::class)
class EpubReaderLogicTest {
private lateinit var context: Context
private lateinit var testDir: File
private lateinit var mockEpubBook: EpubBook
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
testDir = File(context.cacheDir, "test_epub").apply { mkdirs() }
// Create dummy chapter files
val chapter1File = File(testDir, "chapter1.html")
chapter1File.writeText("<html><body><p>A simple Test case.</p></body></html>")
val chapter2File = File(testDir, "chapter2.html")
chapter2File.writeText("<html><body><p>Another test case here.</p><p>The word Test appears twice.</p></body></html>")
mockEpubBook = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Tester",
language = "en",
coverImage = null,
extractionBasePath = testDir.absolutePath,
chapters = listOf(
EpubChapter(
chapterId = "ch1",
absPath = chapter1File.absolutePath,
title = "Chapter 1",
htmlFilePath = "chapter1.html",
plainTextContent = "",
htmlContent = ""
),
EpubChapter(
chapterId = "ch2",
absPath = chapter2File.absolutePath,
title = "Chapter 2",
htmlFilePath = "chapter2.html",
plainTextContent = "",
htmlContent = ""
)
)
)
}
@After
fun tearDown() {
testDir.deleteRecursively()
}
private suspend fun searchEpub(book: EpubBook, query: String): List<SearchResult> {
val TAG = "EpubReaderLogicTest"
Timber.d("Starting search for query: '$query'")
return withContext(Dispatchers.IO) {
val results = mutableListOf<SearchResult>()
book.chapters.forEachIndexed { chapterIndex, chapter ->
try {
val fullPath = "${book.extractionBasePath}/${chapter.htmlFilePath}"
Timber.d("Chapter ${chapterIndex + 1}: Checking path '$fullPath'")
val htmlFile = File(fullPath)
if (!htmlFile.exists()) {
Timber.e("File does not exist: $fullPath")
return@forEachIndexed
}
val doc = Jsoup.parse(htmlFile, "UTF-8")
val bodyChildren = doc.body().children().toList()
val chunks = bodyChildren.chunked(20)
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
val content = Jsoup.parse(chunkHtml).text()
var lastIndex = -1
while (true) {
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
Timber.d("Found potential match for '$query' at index $lastIndex.")
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
Timber.d("Is it a word start? -> $isWordStart")
if (isWordStart) {
Timber.d("Match is a word start. Adding to results.")
val snippetStart = max(0, lastIndex - 35)
val snippetEnd = min(content.length, lastIndex + query.length + 35)
val rawSnippet = content.substring(snippetStart, snippetEnd)
val annotatedSnippet = buildAnnotatedString {
append(rawSnippet)
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
val highlightEnd = highlightStart + query.length
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightEnd
)
}
results.add(
SearchResult(
locationInSource = chapterIndex,
locationTitle = chapter.title,
snippet = annotatedSnippet,
query = query,
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
chunkIndex = chunkIndex
)
)
}
}
}
} catch (e: Exception) {
Timber.e("Error during search in chapter ${chapter.title}", e)
throw e
}
}
Timber.d("Search finished. Total results found: ${results.size}")
results
}
}
@Test
fun search_findsCorrectResults() = runBlocking {
val results = searchEpub(mockEpubBook, "case")
assertThat(results).hasSize(2)
assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1)
assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1)
}
@Test
fun search_isCaseInsensitive() = runBlocking {
val results = searchEpub(mockEpubBook, "test")
assertThat(results).hasSize(3)
assertThat(results[0].locationTitle).isEqualTo("Chapter 1")
assertThat(results[1].locationTitle).isEqualTo("Chapter 2")
assertThat(results[2].locationTitle).isEqualTo("Chapter 2")
}
@Test
fun search_noResultsFound() = runBlocking {
val results = searchEpub(mockEpubBook, "nonexistent")
assertThat(results).isEmpty()
}
@Test
fun search_createsCorrectSnippetHighlight() = runBlocking {
val query = "Test"
mockEpubBook.chapters.first()
val content = "A simple Test case."
val annotatedString = buildAnnotatedStringWithHighlight(content, query)
val spanStyles = annotatedString.spanStyles
assertThat(spanStyles).hasSize(1)
val style = spanStyles.first().item
assertThat(style.fontWeight).isEqualTo(FontWeight.Bold)
val start = spanStyles.first().start
val end = spanStyles.first().end
assertThat(annotatedString.substring(start, end)).isEqualTo(query)
}
@Suppress("SameParameterValue")
private fun buildAnnotatedStringWithHighlight(content: String, query: String): AnnotatedString {
return buildAnnotatedString {
append(content)
val highlightStart = content.indexOf(query, ignoreCase = true)
if (highlightStart != -1) {
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightStart + query.length
)
}
}
}
}

View file

@ -0,0 +1,27 @@
package com.aryan.reader.epubreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* A JUnit TestRule that sets the Main dispatcher to a TestDispatcher for the duration of a test.
* This allows tests to execute coroutines on the Main dispatcher without needing a real Android environment.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

View file

@ -0,0 +1,336 @@
// CssParserTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CssParserTest {
@Test
fun parseColor_handlesNamedColorsCorrectly() {
assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red)
assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black)
assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent)
}
@Test
fun parseColor_handles3DigitHexCodes() {
assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles6DigitHexCodes() {
assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles8DigitHexCodes() {
assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC))
}
@Test
fun parseColor_handlesRgbFunction() {
assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
}
@Test
fun parseColor_handlesRgbaFunction() {
assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
}
@Test
fun parseColor_returnsNullForInvalidInput() {
assertThat(CssParser.parseColor("not a color")).isNull()
assertThat(CssParser.parseColor("#12345")).isNull()
assertThat(CssParser.parseColor("rgb(1,2)")).isNull()
}
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
private val baseFontSize = 16f
private val density = 1f
@Test
fun parse_handlesSimpleRule() {
val css = "p { color: red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val rules = result.rules.byTag["p"]
assertThat(rules).hasSize(1)
assertThat(rules?.first()?.style?.spanStyle?.color).isEqualTo(Color.Red)
}
@Test
fun parse_handlesMultipleSelectors() {
val css = "h1, h2, h3 { font-weight: bold; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag["h1"]).hasSize(1)
assertThat(result.rules.byTag["h2"]).hasSize(1)
assertThat(result.rules.byTag["h3"]).hasSize(1)
assertThat(result.rules.byTag["h1"]?.first()?.style?.spanStyle?.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(result.rules.byTag["h2"]?.first()?.style?.spanStyle?.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(result.rules.byTag["h3"]?.first()?.style?.spanStyle?.fontWeight).isEqualTo(FontWeight.Bold)
}
@Test
fun parse_handlesImportantRules() {
val css = "p { color: red !important; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val importantRule = result.rules.byTag["p"]?.find { it.selector.specificity >= 10000 }
assertThat(importantRule).isNotNull()
assertThat(importantRule!!.style.spanStyle.color).isEqualTo(Color.Red)
val normalRule = result.rules.byTag["p"]?.find { it.selector.specificity < 10000 }
assertThat(normalRule).isNull()
}
@Test
fun parse_createsBothNormalAndImportantRulesWhenMixed() {
val css = "p { color: blue; background-color: white !important; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val rules = result.rules.byTag["p"]
assertThat(rules).hasSize(2)
val importantRule = rules?.find { it.selector.specificity >= 10000 }
assertThat(importantRule).isNotNull()
assertThat(importantRule!!.style.blockStyle.backgroundColor).isEqualTo(Color.White)
assertThat(importantRule.style.spanStyle.color.isSpecified).isFalse()
val normalRule = rules.find { it.selector.specificity < 10000 }
assertThat(normalRule).isNotNull()
assertThat(normalRule!!.style.spanStyle.color).isEqualTo(Color.Blue)
assertThat(normalRule.style.blockStyle.backgroundColor.isSpecified).isFalse()
}
@Test
fun parse_extractsFontFaceRulesAndResolvesPath() {
val css = """
@font-face {
font-family: "MyCustomFont";
src: url("../fonts/myfont.ttf");
font-weight: bold;
}
p { color: black; }
""".trimIndent()
val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag).containsKey("p")
assertThat(result.fontFaces).hasSize(1)
val fontFace = result.fontFaces.first()
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf")
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
}
@Test
fun parse_handlesFontFaceWithDataUri() {
val dataUri = "data:font/truetype;base64,AAEAAA..."
val css = """
@font-face {
font-family: 'MyDataFont';
src: url('$dataUri');
}
""".trimIndent()
val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
assertThat(result.fontFaces.first().src).isEqualTo(dataUri)
}
@Test
fun parse_sanitizesPseudoClassesFromSelectors() {
val css = "a:hover, p::first-line, button:focus { color: red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag.keys).containsExactly("a", "p", "button")
}
@Test
fun parse_calculatesSpecificityCorrectly() {
val css = """
#myId { color: red; } /* 100 */
p.myClass { color: green; } /* 11 */
p { color: blue; } /* 1 */
div p { color: yellow; } /* 2 */
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val idRule = result.rules.byId["myId"]?.first()
val classRule = result.rules.otherComplex.find { it.selector.selector == "p.myClass" }
val elementRule = result.rules.byTag["p"]?.first()
val descendantRule = result.rules.otherComplex.find { it.selector.selector == "div p" }
assertThat(idRule?.selector?.specificity).isEqualTo(100)
assertThat(classRule?.selector?.specificity).isEqualTo(11)
assertThat(elementRule?.selector?.specificity).isEqualTo(1)
assertThat(descendantRule?.selector?.specificity).isEqualTo(2)
}
@Test
fun parse_ignoresComments() {
val css = """
/* This is a comment */
p {
color: /* another comment */ blue; /* block comment */
}
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val rules = result.rules.byTag["p"]
assertThat(rules).hasSize(1)
assertThat(rules?.first()?.style?.spanStyle?.color).isEqualTo(Color.Blue)
}
@Test
fun parse_handlesBorderShorthand() {
val css = "div { border: 2px solid red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
assertThat(style?.border).isNotNull()
assertThat(style?.border?.width).isEqualTo(2.dp)
assertThat(style?.border?.style).isEqualTo("solid")
assertThat(style?.border?.color).isEqualTo(Color.Red)
}
@Test
fun parse_handlesMarginAndPaddingShorthand() {
val css = "p { margin: 10px 20px; padding: 1em 2em 3em 4em; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style?.blockStyle
assertThat(style?.margin?.top).isEqualTo(10.dp)
assertThat(style?.margin?.right).isEqualTo(20.dp)
assertThat(style?.margin?.bottom).isEqualTo(10.dp)
assertThat(style?.margin?.left).isEqualTo(20.dp)
assertThat(style?.padding?.top).isEqualTo(16.dp) // 1em
assertThat(style?.padding?.right).isEqualTo(32.dp) // 2em
assertThat(style?.padding?.bottom).isEqualTo(48.dp) // 3em
assertThat(style?.padding?.left).isEqualTo(64.dp) // 4em
}
@Test
fun parse_handlesFontSizeWithEmUnits() {
val css = "p { font-size: 1.2em; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.fontSize?.isEm).isTrue()
assertThat(style?.fontSize?.value).isEqualTo(1.2f)
}
@Test
fun parse_optimizationCategorizesRulesCorrectly() {
val css = """
p { color: blue; }
.myClass { color: green; }
#myId { color: red; }
div > p { color: yellow; }
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag).containsKey("p")
assertThat(result.rules.byClass).containsKey("myClass")
assertThat(result.rules.byId).containsKey("myId")
assertThat(result.rules.otherComplex).hasSize(1)
assertThat(result.rules.otherComplex.first().selector.selector).isEqualTo("div > p")
}
@Test
fun parse_mediaQueryAppliesDarkThemeRules() {
val css = """
p { color: black; }
@media (prefers-color-scheme: dark) {
p { color: white; }
}
""".trimIndent()
val lightResult = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(lightResult.rules.byTag["p"]?.first()?.style?.spanStyle?.color).isEqualTo(Color.Black)
val darkResult = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = true)
assertThat(darkResult.rules.byTag["p"]?.last()?.style?.spanStyle?.color).isEqualTo(Color.White)
}
@Test
fun parse_fontFaceSelectsPreferredSourceFormat() {
val css = """
@font-face {
font-family: "MyFont";
src: url("font.woff2") format("woff2"),
url("font.otf") format("opentype"),
url("font.ttf") format("truetype");
}
""".trimIndent()
val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf")
}
@Test
fun parse_propertiesHandlesVariousUnitsAndValues() {
val css = """
p {
font-size: 150%;
text-transform: uppercase;
text-decoration: underline;
text-align: center;
page-break-inside: avoid;
margin: 0 auto;
}
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.fontSize).isEqualTo(1.5.em)
assertThat(style?.textTransform).isEqualTo("uppercase")
assertThat(style?.spanStyle?.textDecoration).isEqualTo(TextDecoration.Underline)
assertThat(style?.paragraphStyle?.textAlign).isEqualTo(TextAlign.Center)
assertThat(style?.blockStyle?.pageBreakInsideAvoid).isTrue()
assertThat(style?.blockStyle?.horizontalAlign).isEqualTo("center")
}
@Test
fun parse_themeAdaptationAdaptsColorsCorrectlyForDarkTheme() {
val css = "p { color: #111; background-color: #EEE; }" // very dark text, very light bg
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = true)
val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.spanStyle?.color).isEqualTo(Color.White.copy(alpha = 0.87f))
assertThat(style?.blockStyle?.backgroundColor).isEqualTo(Color.Transparent)
}
@Test
fun parse_dataUriWithSemicolonParsesCorrectly() {
val dataUri = "data:font/opentype;base64,d09GMgABAAAAAAPs...;something=else"
val css = """
@font-face {
font-family: 'MyDataFont';
src: url('$dataUri');
}
p { color: red; }
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
assertThat(result.fontFaces.first().src).isEqualTo(dataUri)
assertThat(result.rules.byTag).containsKey("p")
}
@Test
fun parse_textEmphasisParsesCorrectly() {
val css = "p { -epub-text-emphasis-style: filled dot; -epub-text-emphasis-color: red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val emphasis = result.rules.byTag["p"]?.first()?.style?.textEmphasis
assertThat(emphasis).isNotNull()
assertThat(emphasis?.style).isEqualTo("dot")
assertThat(emphasis?.fill).isEqualTo("filled")
assertThat(emphasis?.color).isEqualTo(Color.Red)
}
@Test
fun parse_lineHeightClampsSmallEmValues() {
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em)
}
}

View file

@ -0,0 +1,375 @@
// HtmlParserTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class HtmlParserTest {
// region Test Setup
private val defaultTextStyle = TextStyle.Default.copy(fontSize = 16.sp, color = Color.Black)
private val defaultDensity = Density(density = 1f, fontScale = 1f)
private val defaultConstraints = Constraints(maxWidth = 1000)
private val defaultChapterPath = "OEBPS/chapter1.xhtml"
private val defaultExtractionPath = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir.absolutePath + "/epub_test/"
private fun parse(
html: String,
cssRules: OptimizedCssRules? = null,
mathSvgCache: Map<String, String> = emptyMap()
): List<SemanticBlock> {
val userAgentRules = CssParser.parse(
cssContent = UserAgentStylesheet.default,
cssPath = null,
baseFontSizeSp = defaultTextStyle.fontSize.value,
density = defaultDensity.density,
constraints = defaultConstraints,
isDarkTheme = false // This is for CSS parsing, not the semantic parser
).rules
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
return htmlToSemanticBlocks(
html = "<body>$html</body>", // Wrap in body to match real usage
cssRules = allRules, // Use the combined list of rules
textStyle = defaultTextStyle,
chapterAbsPath = defaultChapterPath,
extractionBasePath = defaultExtractionPath,
density = defaultDensity,
fontFamilyMap = emptyMap(),
constraints = defaultConstraints,
mathSvgCache = mathSvgCache
)
}
@Test
fun htmlToSemanticBlocks_simpleParagraphTag_createsSemanticParagraph() {
val blocks = parse("<p>Hello World</p>")
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticParagraph::class.java)
val pBlock = block as SemanticParagraph
assertThat(pBlock.text).isEqualTo("Hello World")
}
@Test
fun htmlToSemanticBlocks_headerTag_createsSemanticHeaderWithCorrectLevel() {
val blocks = parse("<h2>Chapter 2</h2>")
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticHeader::class.java)
val hBlock = block as SemanticHeader
assertThat(hBlock.text).isEqualTo("Chapter 2")
assertThat(hBlock.level).isEqualTo(2)
}
@Test
fun htmlToSemanticBlocks_nestedTag_inheritsStyleFromParent() {
val blocks = parse("<div style=\"color: #FF0000;\"><p>This text should be red.</p></div>")
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This text should be red.")
assertThat(pBlock.style.spanStyle.color).isEqualTo(Color.Red)
}
@Test
fun htmlToSemanticBlocks_inlineStyle_overridesCssRule() {
val css = "p { color: red; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p style=\"color: green;\">I am green.</p>", cssRules = cssRules)
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
val blockStyle = pBlock.style.spanStyle
assertThat(blockStyle.color).isEqualTo(Color.Green)
}
@Test
fun htmlToSemanticBlocks_elementWithDisplayNone_isNotIncludedInOutput() {
val blocks = parse("<p>Visible</p><p style=\"display: none;\">Invisible</p>")
assertThat(blocks).hasSize(1)
assertThat((blocks.first() as SemanticParagraph).text).isEqualTo("Visible")
}
@Test
fun htmlToSemanticBlocks_imageWithNonExistentPath_producesNoBlock() {
// This tests the negative path where resolveImagePath returns null
val blocks = parse("<img src=\"non/existent/path.jpg\" />")
assertThat(blocks).isEmpty()
}
@Test
fun htmlToSemanticBlocks_unorderedList_createsSemanticList() {
val blocks = parse("<ul><li>Item 1</li><li>Item 2</li></ul>")
assertThat(blocks).hasSize(1)
val listBlock = blocks.first() as SemanticList
assertThat(listBlock.isOrdered).isFalse()
assertThat(listBlock.items).hasSize(2)
val item1 = listBlock.items[0]
val item2 = listBlock.items[1]
assertThat(item1.text).isEqualTo("Item 1")
assertThat(item2.text).isEqualTo("Item 2")
}
@Test
fun htmlToSemanticBlocks_orderedListWithCssType_createsCorrectSemanticList() {
val css = "ol { list-style-type: lower-roman; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<ol><li>Item 1</li><li>Item 2</li></ol>", cssRules = cssRules)
assertThat(blocks).hasSize(1)
val listBlock = blocks.first() as SemanticList
assertThat(listBlock.isOrdered).isTrue()
assertThat(listBlock.style.blockStyle.listStyleType).isEqualTo("lower-roman")
assertThat(listBlock.items).hasSize(2)
}
@Test
fun htmlToSemanticBlocks_table_createsSemanticTableWithCorrectStructure() {
val html = """
<table>
<tr>
<th>Header 1</th>
<th style="text-align: right;">Header 2</th>
</tr>
<tr>
<td>Data A</td>
<td>Data B</td>
</tr>
</table>
""".trimIndent()
val blocks = parse(html)
assertThat(blocks).hasSize(1)
val tableBlock = blocks.first() as SemanticTable
assertThat(tableBlock.rows).hasSize(2)
// Verify Header Row
val headerRow = tableBlock.rows[0]
assertThat(headerRow).hasSize(2)
assertThat(headerRow[0].isHeader).isTrue()
assertThat((headerRow[0].content.first() as SemanticParagraph).text).isEqualTo("Header 1")
assertThat(headerRow[1].isHeader).isTrue()
assertThat((headerRow[1].content.first() as SemanticParagraph).text).isEqualTo("Header 2")
assertThat(headerRow[1].style.paragraphStyle.textAlign).isEqualTo(TextAlign.End)
// Verify Data Row
val dataRow = tableBlock.rows[1]
assertThat(dataRow).hasSize(2)
assertThat(dataRow[0].isHeader).isFalse()
assertThat((dataRow[0].content.first() as SemanticParagraph).text).isEqualTo("Data A")
assertThat(dataRow[1].isHeader).isFalse()
assertThat((dataRow[1].content.first() as SemanticParagraph).text).isEqualTo("Data B")
}
@Test
fun htmlToSemanticBlocks_textTransformations_areAppliedCorrectly() {
val blocks = parse("<p style=\"text-transform: uppercase;\">hello world</p>")
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
// The transformation is applied during text building
assertThat(pBlock.text).isEqualTo("HELLO WORLD")
}
@Test
fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() {
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
val blocks = parse(html)
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
// Find the range for "bold" and check its style
val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
assertThat(boldRange).isNotNull()
assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
// Find the range for "italic" and check its style
val italicRange =
pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
assertThat(italicRange).isNotNull()
assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
}
@Test
fun htmlToSemanticBlocks_imageWithExistingPath_createsSemanticImageWithCorrectPath() {
// SETUP
val imageRelativeSrc = "../images/test.jpg"
val chapterParentDir = File(defaultChapterPath).parent ?: ""
val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
imageFile.parentFile?.mkdirs()
imageFile.createNewFile()
imageFile.deleteOnExit()
// ACTION
val blocks = parse("<img src=\"$imageRelativeSrc\" alt=\"A test image\" />")
// ASSERT
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticImage::class.java)
val imageBlock = block as SemanticImage
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
assertThat(imageBlock.altText).isEqualTo("A test image")
}
@Test
fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() {
val css = "p::before { content: \"Note: \"; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
// The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
assertThat(blocks).hasSize(1)
val pBlock = blocks[0] as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is a test.")
}
@Test
fun htmlToSemanticBlocks_hrWithPseudoElement_ignoresPseudoElement() {
val css = "hr.fancy::after { content: ''; display: block; border-bottom: 2px solid blue; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<hr class=\"fancy\" />", cssRules = cssRules)
// The pseudo-element is ignored, so only the spacer from <hr> is created.
assertThat(blocks).hasSize(1)
assertThat(blocks[0]).isInstanceOf(SemanticSpacer::class.java)
}
@Test
fun htmlToSemanticBlocks_inlineSvg_createsSemanticMathWithCorrectContent() {
val svg = """
<svg width="100" height="100">
<title>My SVG</title>
<circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
<text x="50" y="50" fill="red">Hello</text>
</svg>
""".trimIndent()
val blocks = parse(svg)
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticMath::class.java)
val mathBlock = block as SemanticMath
assertThat(mathBlock.altText).isEqualTo("My SVG")
// The parser now passes the SVG content through as-is.
assertThat(mathBlock.svgContent).contains("""<text x="50" y="50" fill="red">Hello</text>""")
}
@Test
fun htmlToSemanticBlocks_imgTagWithSvgSource_createsSemanticMath() {
// SETUP
val svgContent = """<svg width="10" height="10"><rect width="10" height="10" /></svg>"""
val svgRelativeSrc = "images/test.svg"
val chapterParentDir = File(defaultChapterPath).parent ?: ""
val svgFile = File(File(defaultExtractionPath, chapterParentDir), svgRelativeSrc).canonicalFile
svgFile.parentFile?.mkdirs()
svgFile.writeText(svgContent)
svgFile.deleteOnExit()
// ACTION
val blocks = parse("<img src=\"$svgRelativeSrc\" />")
// ASSERT
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticMath::class.java)
val mathBlock = block as SemanticMath
assertThat(mathBlock.svgContent).contains("<rect")
}
@Test
fun htmlToSemanticBlocks_mathPlaceholder_createsSemanticMathFromCache() {
val svgContent = "<svg><text>E=mc^2</text></svg>"
val cache = mapOf("math-123" to svgContent)
val blocks = parse(
html = """<math-placeholder id="math-123" alttext="An equation"></math-placeholder>""",
mathSvgCache = cache
)
assertThat(blocks).hasSize(1)
val block = blocks.first() as SemanticMath
assertThat(block.svgContent).isEqualTo(svgContent)
assertThat(block.altText).isEqualTo("An equation")
assertThat(block.isFromMathJax).isTrue()
}
@Test
fun htmlToSemanticBlocks_displayFlex_createsSemanticFlexContainer() {
val html = """
<div style="display: flex;">
<p>One</p>
<p>Two</p>
</div>
""".trimIndent()
val blocks = parse(html)
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticFlexContainer::class.java)
val flexBlock = block as SemanticFlexContainer
assertThat(flexBlock.children).hasSize(2)
assertThat(flexBlock.children[0]).isInstanceOf(SemanticParagraph::class.java)
assertThat((flexBlock.children[0] as SemanticParagraph).text).isEqualTo("One")
}
@Test
fun htmlToSemanticBlocks_brTagInParagraph_createsNewlineCharacter() {
val blocks = parse("<p>Line one.<br>Line two.</p>")
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("Line one.\nLine two.")
}
@Test
fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() {
// SETUP
val imageRootRelativeSrc = "images/test.jpg"
val imageFile = File(defaultExtractionPath, imageRootRelativeSrc).canonicalFile
imageFile.parentFile?.mkdirs()
imageFile.createNewFile()
imageFile.deleteOnExit()
// ACTION
val blocks = parse("<img src=\"$imageRootRelativeSrc\" alt=\"A test image\" />")
// ASSERT
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticImage::class.java)
val imageBlock = block as SemanticImage
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
}
}

View file

@ -0,0 +1,35 @@
// MainDispatcherRule.kt
package com.aryan.reader.paginatedreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* A JUnit TestRule that sets the Main dispatcher to a TestDispatcher for the duration of a test.
* This allows tests to execute coroutines on the Main dispatcher without needing a real Android environment.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
Dispatchers.setMain(testDispatcher)
try {
base.evaluate()
} finally {
Dispatchers.resetMain()
}
}
}
}
}

View file

@ -0,0 +1,120 @@
// PaginatedReaderDataTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PaginatedReaderDataTest {
@Test
fun cssStyle_mergeCorrectlyCombinesStyles() {
val baseStyle = CssStyle(
spanStyle = SpanStyle(color = Color.Black, fontWeight = FontWeight.Normal, fontSize = 16.sp),
paragraphStyle = ParagraphStyle(textAlign = TextAlign.Start),
fontFamilies = listOf("serif"),
display = "block"
)
val overrideStyle = CssStyle(
spanStyle = SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic),
paragraphStyle = ParagraphStyle(textAlign = TextAlign.Center),
fontFamilies = listOf("sans-serif"),
textTransform = "uppercase"
)
val merged = baseStyle.merge(overrideStyle)
// Overridden properties
assertThat(merged.spanStyle.color).isEqualTo(Color.Red)
assertThat(merged.spanStyle.fontStyle).isEqualTo(FontStyle.Italic)
assertThat(merged.paragraphStyle.textAlign).isEqualTo(TextAlign.Center)
assertThat(merged.fontFamilies).containsExactly("sans-serif")
assertThat(merged.textTransform).isEqualTo("uppercase")
// Inherited properties
assertThat(merged.spanStyle.fontWeight).isEqualTo(FontWeight.Normal)
assertThat(merged.spanStyle.fontSize).isEqualTo(16.sp)
assertThat(merged.display).isEqualTo("block")
}
@Test
fun cssStyle_mergeWithEmptyOverrideDoesNotChangeBase() {
val baseStyle = CssStyle(
spanStyle = SpanStyle(color = Color.Black, fontWeight = FontWeight.Normal),
fontFamilies = listOf("serif")
)
val overrideStyle = CssStyle()
val merged = baseStyle.merge(overrideStyle)
assertThat(merged).isEqualTo(baseStyle)
}
@Test
fun blockStyle_mergeUsesOverrideProperties() {
val baseStyle = BlockStyle(
padding = BoxBorders(top = 10.dp, left = 10.dp),
margin = BoxBorders(top = 5.dp, bottom = 5.dp),
width = 100.dp,
backgroundColor = Color.White
)
val overrideStyle = BlockStyle(
padding = BoxBorders(top = 5.dp, right = 5.dp),
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
width = 200.dp,
backgroundColor = Color.Black,
border = BorderStyle(width = 1.dp, color = Color.Red)
)
val merged = baseStyle.merge(overrideStyle)
// Padding should be from override, not additive
assertThat(merged.padding.top).isEqualTo(5.dp)
assertThat(merged.padding.left).isEqualTo(10.dp) // from base
assertThat(merged.padding.right).isEqualTo(5.dp)
assertThat(merged.padding.bottom).isEqualTo(0.dp) // from base
// Margin should be from override
assertThat(merged.margin.top).isEqualTo(5.dp) // from base
assertThat(merged.margin.bottom).isEqualTo(10.dp)
assertThat(merged.margin.left).isEqualTo(10.dp)
assertThat(merged.margin.right).isEqualTo(0.dp) // from base
// Other properties
assertThat(merged.width).isEqualTo(200.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
assertThat(merged.border).isNotNull()
assertThat(merged.border?.width).isEqualTo(1.dp)
}
@Test
fun blockStyle_mergeWithEmptyOverrideDoesNotChangeBase() {
val baseStyle = BlockStyle(
padding = BoxBorders(10.dp, 10.dp, 10.dp, 10.dp),
margin = BoxBorders(5.dp, 5.dp, 5.dp, 5.dp),
width = 100.dp,
backgroundColor = Color.White
)
val overrideStyle = BlockStyle()
val merged = baseStyle.merge(overrideStyle)
assertThat(merged.padding.top).isEqualTo(10.dp)
assertThat(merged.margin.top).isEqualTo(5.dp)
assertThat(merged.width).isEqualTo(100.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.White)
assertThat(merged.border).isNull()
}
}

View file

@ -0,0 +1,304 @@
// PaginatedReaderViewModelTest.kt
package com.aryan.reader.paginatedreader
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.SearchResult
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
private class FakePaginator(
initiallyLoading: Boolean,
initialPageCount: Int,
initialGeneration: Int
) : IPaginator {
override var isLoading by mutableStateOf(initiallyLoading)
override var totalPageCount by mutableIntStateOf(initialPageCount)
override var generation by mutableIntStateOf(initialGeneration)
override val pageShiftRequest: Flow<Int> = emptyFlow()
var lastNavigatedHref: String? = null
var lastNavigatedChapter: String? = null
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
) {
lastNavigatedChapter = currentChapterAbsPath
lastNavigatedHref = href
}
override fun findPageForSearchResult(
result: SearchResult,
onResult: (Int) -> Unit
) = Unit
// Add stubs for the other missing interface members
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (Int) -> Unit) = Unit
override fun findPageForCfiAndOffset(
chapterIndex: Int,
cfi: String,
charOffset: Int
): Int? {
return null
}
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
override fun getCfiForPage(pageIndex: Int): String? = null
override fun onUserScrolledTo(pageIndex: Int) = Unit
}
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class PaginatedReaderViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: PaginatedReaderViewModel
private lateinit var fakePaginator: FakePaginator
@Before
fun setUp() {
viewModel = PaginatedReaderViewModel()
fakePaginator = FakePaginator(
initiallyLoading = true,
initialPageCount = 0,
initialGeneration = 0
)
viewModel.setPaginatorForTest(fakePaginator)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun uiState_reflectsPaginatorInitialState() = runTest {
val initialState = viewModel.uiState.value
assertThat(initialState.isLoading).isTrue()
assertThat(initialState.totalPageCount).isEqualTo(0)
assertThat(initialState.generation).isEqualTo(0)
}
@Test
fun uiState_updatesWhenPaginatorIsLoadingChanges() = runTest {
assertThat(viewModel.uiState.value.isLoading).isTrue()
fakePaginator.isLoading = false
Snapshot.sendApplyNotifications()
advanceUntilIdle()
assertThat(viewModel.uiState.value.isLoading).isFalse()
}
@Test
fun uiState_updatesWhenPaginatorTotalPageCountChanges() = runTest {
assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(0)
fakePaginator.totalPageCount = 123
Snapshot.sendApplyNotifications()
advanceUntilIdle()
assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(123)
}
@Test
fun uiState_updatesWhenPaginatorGenerationChanges() = runTest {
assertThat(viewModel.uiState.value.generation).isEqualTo(0)
fakePaginator.generation = 5
Snapshot.sendApplyNotifications()
advanceUntilIdle()
assertThat(viewModel.uiState.value.generation).isEqualTo(5)
}
@Test
fun onLinkClick_callsPaginatorNavigateToHrefWithCorrectArguments() {
val currentChapter = "chapter1.xhtml"
val href = "#section2"
viewModel.onLinkClick(currentChapter, href) {}
assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
}
@Test
fun initialize_createsARealPaginatorAndUpdateState() = runTest {
// Arrange
val viewModel = PaginatedReaderViewModel() // Create a fresh ViewModel
val context = ApplicationProvider.getApplicationContext<Context>()
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
val textStyle = TextStyle.Default
val density = Density(1f)
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
val testBook = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Test Author",
language = "en",
coverImage = null,
chapters = listOf(
EpubChapter(
chapterId = "ch1",
title = "Chapter 1",
htmlFilePath = "ch1.html",
absPath = "/ops/ch1.html",
htmlContent = "<p>Some content</p>",
plainTextContent = "Some content"
)
),
css = mapOf("/ops/style.css" to "p {color: red;}"),
extractionBasePath = ""
)
// Mock dependencies for BookPaginator
val mockDao = mockk<BookCacheDao>(relaxed = true)
coEvery { mockDao.getProcessedBook(any()) } returns null // Simulate cache miss
val mockDb = mockk<BookCacheDatabase>()
every { mockDb.bookCacheDao() } returns mockDao
mockkObject(BookCacheDatabase.Companion)
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
mockkObject(BookProcessingWorker.Companion)
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
// Pre-condition check
assertThat(viewModel.uiState.value.isLoading).isTrue()
assertThat(viewModel.paginator).isNull()
// Act
viewModel.initialize(
book = testBook,
textMeasurer = textMeasurer,
textConstraints = constraints,
textStyle = textStyle,
density = density,
isDarkTheme = false,
context = context,
initialChapterToPaginate = 0,
mathMLRenderer = mathMLRenderer
)
advanceUntilIdle() // Allow coroutines to complete
// Assert
assertThat(viewModel.paginator).isInstanceOf(BookPaginator::class.java)
assertThat(viewModel.uiState.value.isLoading).isFalse()
assertThat(viewModel.uiState.value.totalPageCount).isGreaterThan(0)
}
@Test
fun initialize_isIdempotent() = runTest {
// Arrange
val viewModel = PaginatedReaderViewModel()
val context = ApplicationProvider.getApplicationContext<Context>()
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
val textStyle = TextStyle.Default
val density = Density(1f)
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
val testBook = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Test Author",
language = "en",
coverImage = null,
chapters = listOf(
EpubChapter(
chapterId = "ch1",
title = "Chapter 1",
htmlFilePath = "ch1.html",
absPath = "/ops/ch1.html",
htmlContent = "<p>Some content</p>",
plainTextContent = "Some content"
)
),
css = mapOf("/ops/style.css" to "p {color: red;}"),
extractionBasePath = ""
)
// Mock dependencies
val mockDao = mockk<BookCacheDao>(relaxed = true)
coEvery { mockDao.getProcessedBook(any()) } returns null
val mockDb = mockk<BookCacheDatabase>()
every { mockDb.bookCacheDao() } returns mockDao
mockkObject(BookCacheDatabase.Companion)
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
mockkObject(BookProcessingWorker.Companion)
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
// Act
viewModel.initialize(
book = testBook,
textMeasurer = textMeasurer,
textConstraints = constraints,
textStyle = textStyle,
density = density,
isDarkTheme = false,
context = context,
initialChapterToPaginate = 0,
mathMLRenderer = mathMLRenderer
)
advanceUntilIdle()
val firstPaginator = viewModel.paginator
assertThat(firstPaginator).isNotNull()
// Act again
viewModel.initialize(
book = testBook,
textMeasurer = textMeasurer,
textConstraints = constraints,
textStyle = textStyle,
density = density,
isDarkTheme = false,
context = context,
initialChapterToPaginate = 0,
mathMLRenderer = mathMLRenderer
)
advanceUntilIdle()
// Assert
val secondPaginator = viewModel.paginator
assertThat(secondPaginator).isSameInstanceAs(firstPaginator)
}
}

View file

@ -0,0 +1,278 @@
// PaginatorTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
class FakeSplittableMeasurementProvider(
private val heights: Map<ContentBlock, Int>,
private val splittableParagraphs: Map<ParagraphBlock, Pair<ParagraphBlock, ParagraphBlock>> = emptyMap(),
private val splittableWrappers: Map<WrappingContentBlock, Pair<WrappingContentBlock, List<ContentBlock>>> = emptyMap()
) : BlockMeasurementProvider {
override suspend fun measure(block: ContentBlock): Int {
// Provide a more helpful error message if a block's height is not defined.
return heights[block] ?: error("No height specified for block: $block")
}
override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>? {
val splitPair = splittableParagraphs[block]
if (splitPair != null) {
val part1Height = heights[splitPair.first] ?: 0
// Only return the split pair if the first part actually fits in the available height.
if (part1Height <= availableHeight) {
return splitPair
}
}
return null
}
override suspend fun split(block: WrappingContentBlock, availableHeight: Int): Pair<WrappingContentBlock, List<ContentBlock>>? {
val splitPair = splittableWrappers[block]
if (splitPair != null) {
val part1Height = heights[splitPair.first] ?: 0
if (part1Height <= availableHeight) {
return splitPair
}
}
return null
}
}
@RunWith(AndroidJUnit4::class)
class PaginatorTest {
private val testDensity = Density(density = 1f, fontScale = 1f)
private val pageHeight = 1000
@Test
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
assertThat(pages).isEmpty()
}
@Test
fun paginate_givenBlocksThatFit_createsOnePage() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Block 1"), blockIndex = 0)
val block2 = ParagraphBlock(content = AnnotatedString("Block 2"), blockIndex = 1)
val blocks = listOf(block1, block2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 200, block2 to 300)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
assertThat(pages.first().content).hasSize(2)
}
@Test
fun paginate_givenBlockThatOverflows_createsTwoPages() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Block 1"), blockIndex = 0) // Height: 600
val block2 = ParagraphBlock(content = AnnotatedString("Block 2"), blockIndex = 1) // Height: 500
val blocks = listOf(block1, block2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 600, block2 to 500)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(block2)
}
@Test
fun paginate_correctlySplitsAParagraphBlock() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("First block"), blockIndex = 0)
val originalParagraph = ParagraphBlock(content = AnnotatedString("Long text to be split"), blockIndex = 1)
val part1 = ParagraphBlock(content = AnnotatedString("Long text"), blockIndex = 1)
val part2 = ParagraphBlock(content = AnnotatedString("to be split"), blockIndex = 1)
val blocks = listOf(block1, originalParagraph)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(
block1 to 500,
originalParagraph to 800,
part1 to 450, // Fits in the remaining 500
part2 to 350
),
splittableParagraphs = mapOf(originalParagraph to (part1 to part2))
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content).containsExactly(part2)
}
@Test
fun paginate_correctlySplitsAWrappingContentBlock() = runTest {
val image = ImageBlock("image.png", null, 100f, 300f, blockIndex = 0)
val para1 = ParagraphBlock(content = AnnotatedString("Para 1"), blockIndex = 1)
val para2 = ParagraphBlock(content = AnnotatedString("Para 2"), blockIndex = 2)
val originalWrapper = WrappingContentBlock(floatedImage = image, paragraphsToWrap = listOf(para1, para2), blockIndex = 3)
val splitWrapper = WrappingContentBlock(floatedImage = image, paragraphsToWrap = listOf(para1), blockIndex = 3)
val remainingBlocks = listOf(para2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(
originalWrapper to 1500,
splitWrapper to 300,
para2 to 200
),
splittableWrappers = mapOf(originalWrapper to (splitWrapper to remainingBlocks))
)
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(splitWrapper)
assertThat(pages[1].content).containsExactly(para2)
}
@Test
fun paginate_respectsPageBreakInsideAvoid() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("First block"), blockIndex = 0) // Height 800
val unsplittableBlock = ParagraphBlock(
content = AnnotatedString("Can't split me"),
style = BlockStyle(pageBreakInsideAvoid = true),
blockIndex = 1
) // Height 300
val blocks = listOf(block1, unsplittableBlock)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 800, unsplittableBlock to 300)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(unsplittableBlock)
}
@Test
fun paginate_oversizedUnsplittableBlockGetsItsOwnPage() = runTest {
val oversizedBlock = ImageBlock(path = "test.jpg", altText = null, blockIndex = 0) // Height 1200
val blocks = listOf(oversizedBlock)
val measurementProvider = FakeSplittableMeasurementProvider(heights = mapOf(oversizedBlock to 1200))
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
assertThat(pages[0].content).containsExactly(oversizedBlock)
}
@Test
fun paginate_collapsesVerticalMarginsBetweenBlocks() = runTest {
val block1 = ParagraphBlock(
content = AnnotatedString("Block 1"),
style = BlockStyle(margin = BoxBorders(bottom = 50.dp)), // 50px margin
blockIndex = 0
)
val block2 = ParagraphBlock(
content = AnnotatedString("Block 2"),
style = BlockStyle(margin = BoxBorders(top = 80.dp)), // 80px margin
blockIndex = 1
)
val blocks = listOf(block1, block2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 100, block2 to 100)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
val pageContent = pages.first().content
assertThat(pageContent).hasSize(2)
// The paginator logic sets the bottom margin of the previous block to 0
// and sets the top margin of the current block to the collapsed value.
assertThat(pageContent[0].style.margin.bottom).isEqualTo(0.dp)
assertThat(pageContent[1].style.margin.top).isEqualTo(80.dp) // max(50, 80) is 80
}
@Test
fun paginate_preservesTopMarginOfTheFirstBlockOnANewPage() = runTest {
val block1 = ParagraphBlock(
content = AnnotatedString("Block 1"),
style = BlockStyle(margin = BoxBorders(top = 30.dp)),
blockIndex = 0
)
val block2 = ParagraphBlock(content = AnnotatedString("Block 2"), blockIndex = 1)
val blocks = listOf(block1, block2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 980, block2 to 100)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
// First block on page 1 should have its top margin preserved.
val page1Block1 = pages[0].content.first()
assertThat(page1Block1.style.margin.top).isEqualTo(30.dp)
// First block on page 2 should also have its top margin preserved.
val page2Block1 = pages[1].content.first()
assertThat(page2Block1.style.margin.top).isEqualTo(0.dp) // The default is 0.dp
}
@Test
fun paginate_blockPushedToNextPageWhenNotEnoughSpaceForSplitting() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Block 1"), blockIndex = 0)
val splittableBlock = ParagraphBlock(content = AnnotatedString("Splittable"), blockIndex = 1)
val part1 = ParagraphBlock(content = AnnotatedString("Split"), blockIndex = 1)
val part2 = ParagraphBlock(content = AnnotatedString("table"), blockIndex = 1)
val blocks = listOf(block1, splittableBlock)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(
block1 to 960, // Leaves 40px remaining, which is < 50, so no split should occur
splittableBlock to 100,
part1 to 30,
part2 to 70
),
splittableParagraphs = mapOf(splittableBlock to (part1 to part2))
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
}
@Test
fun paginate_doesNotAddEmptyPart1AfterSplitting() = runTest {
val originalBlock = ParagraphBlock(content = AnnotatedString("Some text"), blockIndex = 0)
val part1 = ParagraphBlock(content = AnnotatedString(""), blockIndex = 0) // Empty part 1
val part2 = ParagraphBlock(content = AnnotatedString("Some text"), blockIndex = 0)
val blocks = listOf(originalBlock)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(
originalBlock to 200,
part1 to 0,
part2 to 200
),
splittableParagraphs = mapOf(originalBlock to (part1 to part2))
)
// Set page height so that a split is attempted.
val pages = paginate(blocks, 150, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
// The page should be empty because part1 was empty, and the original block was re-added
// to the remaining list. The next page then contains the full block.
assertThat(pages[0].content).containsExactly(part2)
}
}

View file

@ -0,0 +1,115 @@
// StyleUtilsTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class StyleUtilsTest {
private val baseFontSizeSp = 16f
private val density = 2.0f
private val containerWidthPx = 1000
@Test
fun parseCssSizeToDp_handlesPxValues() {
assertThat(parseCssSizeToDp("100px", baseFontSizeSp, density, containerWidthPx)).isEqualTo(50.dp)
}
@Test
fun parseCssSizeToDp_handlesEmValues() {
assertThat(parseCssSizeToDp("1.5em", baseFontSizeSp, density, containerWidthPx)).isEqualTo(24.dp)
}
@Test
fun parseCssSizeToDp_handlesRemValues() {
assertThat(parseCssSizeToDp("2rem", baseFontSizeSp, density, containerWidthPx)).isEqualTo(32.dp)
}
@Test
fun parseCssSizeToDp_handlesPtValues() {
assertThat(parseCssSizeToDp("12pt", baseFontSizeSp, density, containerWidthPx).value).isWithin(0.01f).of(8.0f)
}
@Test
fun parseCssSizeToDp_handlesPercentageValues() {
// 50% of 1000px = 500px. 500px / 2.0 density = 250dp
assertThat(parseCssSizeToDp("50%", baseFontSizeSp, density, containerWidthPx)).isEqualTo(250.dp)
}
@Test
fun parseCssSizeToDp_returns0ForInvalidInput() {
assertThat(parseCssSizeToDp("invalid", baseFontSizeSp, density, containerWidthPx)).isEqualTo(0.dp)
}
@Test
fun parseCssSizeToDp_handlesZeroDensity() {
assertThat(parseCssSizeToDp("100px", baseFontSizeSp, 0f, containerWidthPx)).isEqualTo(0.dp)
}
@Test
fun parseCssSizeToDp_handlesZeroContainerWidthForPercentage() {
assertThat(parseCssSizeToDp("50%", baseFontSizeSp, density, 0)).isEqualTo(0.dp)
}
@Test
fun parseCssSizeToDp_handlesValuesWithWhitespace() {
assertThat(parseCssSizeToDp(" 1.5em ", baseFontSizeSp, density, containerWidthPx)).isEqualTo(24.dp)
}
@Test
fun parseCssDimensionToTextUnit_handlesPxValues() {
val result = parseCssDimensionToTextUnit("100px", containerWidthPx, density)
assertThat(result.isSp).isTrue()
assertThat(result.value).isWithin(0.01f).of(50f)
}
@Test
fun parseCssDimensionToTextUnit_handlesEmValues() {
val result = parseCssDimensionToTextUnit("1.5em", containerWidthPx, density)
assertThat(result.isEm).isTrue()
assertThat(result.value).isEqualTo(1.5f)
}
@Test
fun parseCssDimensionToTextUnit_handlesRemValues() {
// rem is treated as em
val result = parseCssDimensionToTextUnit("2rem", containerWidthPx, density)
assertThat(result.isEm).isTrue()
assertThat(result.value).isEqualTo(2f)
}
@Test
fun parseCssDimensionToTextUnit_handlesPtValues() {
val result = parseCssDimensionToTextUnit("12pt", containerWidthPx, density)
assertThat(result.isSp).isTrue()
assertThat(result.value).isWithin(0.01f).of(8.0f)
}
@Test
fun parseCssDimensionToTextUnit_handlesPercentageValues() {
val result = parseCssDimensionToTextUnit("50%", containerWidthPx, density)
assertThat(result.isSp).isTrue()
assertThat(result.value).isWithin(0.01f).of(250f)
}
@Test
fun parseCssDimensionToTextUnit_returnsUnspecifiedForInvalidInput() {
assertThat(parseCssDimensionToTextUnit("invalid", containerWidthPx, density).isUnspecified).isTrue()
}
@Test
fun parseCssDimensionToTextUnit_handlesZeroDensity() {
assertThat(parseCssDimensionToTextUnit("100px", containerWidthPx, 0f).isUnspecified).isTrue()
}
@Test
fun parseCssDimensionToTextUnit_handlesZeroContainerWidthForPercentage() {
assertThat(parseCssDimensionToTextUnit("50%", 0, density).isUnspecified).isTrue()
}
}

View file

@ -0,0 +1,35 @@
// MainDispatcherRule.kt
package com.aryan.reader.pdf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* A JUnit TestRule that sets the Main dispatcher to a TestDispatcher for the duration of a test.
* This allows tests to execute coroutines on the Main dispatcher without needing a real Android environment.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
Dispatchers.setMain(testDispatcher)
try {
base.evaluate()
} finally {
Dispatchers.resetMain()
}
}
}
}
}

View file

@ -0,0 +1,297 @@
// PdfAnnotationTest.kt
package com.aryan.reader.pdf
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.MainActivity
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class PdfAnnotationTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
private var currentPdfFile: File? = null
private var scenario: ActivityScenario<MainActivity>? = null
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
@Before
fun setup() {
// Clear settings to ensure fresh state for every test
context.getSharedPreferences("annotation_settings_global", Context.MODE_PRIVATE)
.edit().clear().commit()
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit().clear().commit()
scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
waitForDocumentLoad()
}
@After
fun tearDown() {
scenario?.close()
currentPdfFile?.let { if (it.exists()) it.delete() }
}
private fun waitForDocumentLoad() {
composeTestRule.waitUntil(timeoutMillis = 15_000) {
runCatching {
composeTestRule.onNodeWithTag("PageNumberIndicator").assertIsDisplayed()
true
}.getOrDefault(false)
}
}
private fun enterEditMode() {
composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode")
.assertIsDisplayed()
.performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription("Close Edit Mode").assertIsDisplayed()
}
private fun tapOutsidePopup() {
// Taps the center of the PDF viewer to dismiss popups
composeTestRule.onNodeWithTag("PdfVerticalScroll").performTouchInput {
click(center)
}
composeTestRule.waitForIdle()
}
@Suppress("SameParameterValue")
private fun copyAssetToCache(context: Context, assetName: String): Uri {
val uniqueName = "${UUID.randomUUID()}_$assetName"
val file = File(context.cacheDir, uniqueName)
currentPdfFile = file
if (file.exists()) file.delete()
context.assets.open(assetName).use { inputStream ->
file.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
return FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
}
// --- BASIC UI TESTS ---
@Test
fun testEnterAndExitEditMode() {
enterEditMode()
// Verify Dock Items exist using new Tags
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsDisplayed()
composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode").assertIsDisplayed()
}
// --- TOOL LOGIC TESTS ---
@Test
fun testToolPersistence() {
enterEditMode()
// 1. Select Highlighter
composeTestRule.onNodeWithTag("DockItem_Highlighter").performClick()
composeTestRule.waitForIdle()
// 2. Verify selection state
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsNotSelected()
// 3. Exit Edit Mode
composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
composeTestRule.waitForIdle()
// 4. Re-enter Edit Mode
enterEditMode()
// 5. Verify Highlighter is STILL selected (Persistence)
composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
}
@Test
fun testEraserHasNoPopup() {
enterEditMode()
// Select Eraser
composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsSelected()
// Click Eraser AGAIN (Should NOT open popup)
composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
}
// --- SETTINGS POPUP TESTS ---
@Test
fun testSettingsPopupInteractions() {
enterEditMode()
// 1. Pen is default. Click Pen ONCE to open Settings.
// (Clicking twice would toggle it off, which caused previous failures)
composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
composeTestRule.waitForIdle()
// 2. Verify Popup Displayed
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed()
// 3. Verify Pen Types exist
composeTestRule.onNodeWithTag("SettingsItem_FOUNTAIN_PEN").assertIsDisplayed()
composeTestRule.onNodeWithTag("SettingsItem_MARKER").assertIsDisplayed()
// 4. Switch internal Pen Type
composeTestRule.onNodeWithTag("SettingsItem_PENCIL").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("SettingsItem_PENCIL").assertIsSelected()
// 5. Dismiss Settings
tapOutsidePopup()
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
}
@Test
fun testColorPaletteAndThickness() {
enterEditMode()
// Open Settings for Pen (Default selected, so one click opens settings)
composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
composeTestRule.waitForIdle()
// Test Palette Click (Index 1)
composeTestRule.onNodeWithTag("Palette_Item_1").assertIsDisplayed().performClick()
// Test Thickness Buttons
composeTestRule.onNodeWithTag("Property_Plus").performClick()
composeTestRule.onNodeWithTag("Property_Plus").performClick()
composeTestRule.onNodeWithTag("Property_Minus").performClick()
tapOutsidePopup()
// Quick verification that settings didn't crash app
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
}
// --- UNDO/REDO TESTS ---
@Test
fun testDrawingEnablesUndo() {
enterEditMode()
composeTestRule.onNodeWithContentDescription("Undo")
.assertIsDisplayed()
.assertIsNotEnabled()
// Draw a single DOT stroke to ensure exactly one action is recorded
composeTestRule.onNodeWithTag("PdfVerticalScroll").performTouchInput {
click(center)
}
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription("Undo").assertIsEnabled()
}
@Test
fun testUndoRedoLogic() {
enterEditMode()
// Draw 1 stroke (click = dot) to ensure stack size is exactly 1
composeTestRule.onNodeWithTag("PdfVerticalScroll").performTouchInput {
click(center)
}
composeTestRule.waitForIdle()
val undoNode = composeTestRule.onNodeWithContentDescription("Undo")
val redoNode = composeTestRule.onNodeWithContentDescription("Redo")
undoNode.assertIsEnabled()
redoNode.assertIsNotEnabled()
// Perform Undo
undoNode.performClick()
composeTestRule.waitForIdle()
undoNode.assertIsNotEnabled()
redoNode.assertIsEnabled()
// Perform Redo
redoNode.performClick()
composeTestRule.waitForIdle()
undoNode.assertIsEnabled()
redoNode.assertIsNotEnabled()
}
// --- DOCK INTERACTIONS TESTS ---
@Test
fun testDockMinimization() {
enterEditMode()
// 1. Drag Dock to make it floating (using Pen icon as handle)
composeTestRule.onNodeWithTag("DockItem_Pen").performTouchInput {
down(center)
advanceEventTime(600) // Long press
// Drag UP significantly
moveBy(androidx.compose.ui.geometry.Offset(0f, -600f), delayMillis = 1000)
up()
}
composeTestRule.waitForIdle()
// 2. Minimize (Eye icon)
composeTestRule.onNodeWithContentDescription("Toggle Visibility").performClick()
composeTestRule.waitForIdle()
// 3. Verify Dock items are hidden
composeTestRule.onNodeWithTag("DockItem_Pen").assertDoesNotExist()
// 4. Verify "Show Dock" floating button is visible
composeTestRule.onNodeWithContentDescription("Show Dock").assertIsDisplayed()
// 5. Restore
composeTestRule.onNodeWithContentDescription("Show Dock").performClick()
composeTestRule.waitForIdle()
// 6. Verify Dock items return
composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
}
}

View file

@ -0,0 +1,82 @@
// app/src/androidTest/java/com/aryan/reader/pdf/PdfCoverGeneratorTest.kt
package com.aryan.reader.pdf
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class PdfCoverGeneratorTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var context: Context
private lateinit var coverGenerator: PdfCoverGenerator
private var samplePdfUri: Uri? = null
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
coverGenerator = PdfCoverGenerator(context)
try {
samplePdfUri = copyAssetToCache(context, "sample.pdf")
} catch (_: IOException) {
println("Could not copy sample.pdf from assets. Skipping PdfCoverGenerator tests.")
samplePdfUri = null
}
}
@After
fun tearDown() {
val cacheFile = File(context.cacheDir, "sample.pdf")
if (cacheFile.exists()) {
cacheFile.delete()
}
}
@Test
fun generateCover_returnsBitmapForValidPdf() = runTest {
val uri = samplePdfUri ?: return@runTest
val targetHeight = 600
val cover = coverGenerator.generateCover(uri, targetHeight)
assertThat(cover).isNotNull()
assertThat(cover!!.height).isEqualTo(targetHeight)
assertThat(cover.width).isGreaterThan(0)
}
@Test
fun generateCover_returnsNullForInvalidUri() = runTest {
val invalidUri = Uri.fromFile(File("nonexistent/file.pdf"))
val cover = coverGenerator.generateCover(invalidUri)
assertThat(cover).isNull()
}
private fun copyAssetToCache(context: Context, @Suppress("SameParameterValue") assetName: String): Uri {
val file = File(context.cacheDir, assetName)
if (file.exists()) file.delete()
context.assets.open(assetName).use { inputStream ->
file.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
return FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
}
}

View file

@ -0,0 +1,85 @@
// app/src/androidTest/java/com/aryan/reader/pdf/PdfHelperTest.kt
package com.aryan.reader.pdf
import android.graphics.Rect
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PdfHelperTest {
@Test
fun mergeRectsIntoLines_mergesHorizontallyAdjacentRects() {
val rects = listOf(
Rect(0, 0, 10, 10),
Rect(11, 0, 20, 10)
)
val merged = mergeRectsIntoLines(rects)
assertThat(merged).hasSize(1)
assertThat(merged.first()).isEqualTo(Rect(0, 0, 20, 10))
}
@Test
fun mergeRectsIntoLines_doesNotMergeVerticallySeparatedRects() {
val rects = listOf(
Rect(0, 0, 10, 10),
Rect(0, 11, 10, 20)
)
val merged = mergeRectsIntoLines(rects)
assertThat(merged).hasSize(2)
}
@Test
fun mergeRectsIntoLines_handlesMultipleLines() {
val rects = listOf(
Rect(0, 0, 10, 10), // line 1
Rect(11, 0, 20, 10), // line 1
Rect(0, 15, 10, 25), // line 2
Rect(11, 15, 20, 25) // line 2
)
val merged = mergeRectsIntoLines(rects)
assertThat(merged).hasSize(2)
assertThat(merged).containsExactly(
Rect(0, 0, 20, 10),
Rect(0, 15, 20, 25)
)
}
@Test
fun mergeRectsIntoLines_handlesEmptyList() {
val merged = mergeRectsIntoLines(emptyList())
assertThat(merged).isEmpty()
}
@Test
fun preprocessTextForTts_replacesNewlineWithSpaceForSoftBreak() {
val raw = "Hello\nWorld"
val processed = preprocessTextForTts(raw)
assertThat(processed.cleanText).isEqualTo("Hello World")
}
@Test
fun preprocessTextForTts_handlesNewlineAfterPunctuation() {
// Based on the current implementation, a newline after a sentence-ending punctuation
// results in the words being concatenated without a space. This test verifies that behavior.
val raw = "Hello.\nWorld"
val processed = preprocessTextForTts(raw)
assertThat(processed.cleanText).isEqualTo("Hello.World")
}
@Test
fun preprocessTextForTts_handlesCarriageReturn() {
val raw = "Hello\r\nWorld"
val processed = preprocessTextForTts(raw)
assertThat(processed.cleanText).isEqualTo("Hello World")
}
@Test
fun preprocessTextForTts_trimsResult() {
val raw = " Hello World \n"
val processed = preprocessTextForTts(raw)
assertThat(processed.cleanText).isEqualTo("Hello World")
}
}

View file

@ -0,0 +1,313 @@
package com.aryan.reader.pdf
import android.Manifest
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipe
import androidx.core.content.FileProvider
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import com.aryan.reader.MainActivity
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class PdfViewerScreenTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()
@get:Rule
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
@org.junit.Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
private val context: Context = ApplicationProvider.getApplicationContext()
private var currentPdfFile: File? = null
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
@get:Rule
val activityRule = ActivityScenarioRule<MainActivity>(createPdfViewIntent(context, samplePdfUri))
@After
fun tearDown() {
currentPdfFile?.let {
if (it.exists()) it.delete()
}
}
private fun waitForDocumentLoad(pageText: String = "Page 1 of 4") {
composeTestRule.waitUntil(timeoutMillis = 15_000) {
composeTestRule
.onAllNodesWithText(pageText)
.fetchSemanticsNodes().size == 1
}
}
private fun ensurePaginationMode() {
composeTestRule.onNodeWithContentDescription("More Options").performClick()
composeTestRule.onNodeWithText("Reading Mode: Paginated").performClick()
composeTestRule.waitForIdle()
}
@Test
fun documentLoadsAndDisplaysCorrectPageCount() {
waitForDocumentLoad()
composeTestRule.onNodeWithTag("PageNumberIndicator")
.assertIsDisplayed()
}
@Test
fun tableOfContents_displaysEmptyState() {
waitForDocumentLoad()
composeTestRule.onNodeWithTag("TocButton").performClick()
composeTestRule.onNodeWithText("Chapters are not available for this book.").assertIsDisplayed()
}
@Suppress("SameParameterValue")
private fun copyAssetToCache(context: Context, assetName: String): Uri {
val uniqueName = "${UUID.randomUUID()}_$assetName"
val file = File(context.cacheDir, uniqueName)
currentPdfFile = file
if (file.exists()) file.delete()
context.assets.open(assetName).use { inputStream ->
file.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
return FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
}
@Test
fun bookmarkFunctionality_addNavigateAndDelete() {
waitForDocumentLoad()
ensurePaginationMode()
composeTestRule.onNodeWithText("Page 1 of 4").assertIsDisplayed()
try {
composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
composeTestRule.onRoot().performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Page 2 of 4").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("Page 2 of 4").assertIsDisplayed()
} catch (e: Exception) {
throw e
}
try {
composeTestRule.onNodeWithContentDescription("More Options").performClick()
composeTestRule.onNodeWithText("Bookmark this page").performClick()
composeTestRule.waitForIdle()
} catch (e: Exception) {
throw e
}
try {
composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
composeTestRule.onRoot().performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Page 3 of 4").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("Page 3 of 4").assertIsDisplayed()
} catch (e: Exception) {
throw e
}
try {
composeTestRule.onNodeWithTag("TocButton").performClick()
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("BookmarkItem_1").assertIsDisplayed()
.assert(hasText("Page 2", substring = true))
} catch (e: Exception) {
throw e
}
try {
composeTestRule.onNodeWithTag("BookmarkItem_1").performClick()
composeTestRule.waitForIdle()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodes(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).fetchSemanticsNodes().size == 1
}
composeTestRule.onNode(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).assertIsDisplayed()
} catch (e: Exception) {
throw e
}
try {
composeTestRule.onNodeWithTag("TocButton").performClick()
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
composeTestRule.waitForIdle()
} catch (e: Exception) {
throw e
}
try {
composeTestRule.onNodeWithContentDescription("More options for bookmark").performClick()
composeTestRule.onNodeWithText("Delete").performClick()
} catch (e: Exception) {
throw e
}
try {
composeTestRule.onNodeWithText("Delete", useUnmergedTree = true).performClick()
} catch (e: Exception) {
throw e
}
try {
composeTestRule.onNodeWithTag("BookmarkItem_1").assertDoesNotExist()
composeTestRule.onNodeWithText("You haven't added any bookmarks yet.").assertIsDisplayed()
} catch (e: Exception) {
throw e
}
}
@Test
fun sliderNavigation_opensAndDisplaysCorrectly() {
waitForDocumentLoad()
composeTestRule.onNodeWithContentDescription("Navigate with slider").performClick()
composeTestRule.onNodeWithContentDescription("Exit slider navigation").assertIsDisplayed()
composeTestRule.onNodeWithText("1 / 4").assertIsDisplayed()
}
@Test
fun displayMode_switchesToVerticalScroll() {
waitForDocumentLoad()
// Ensure we are in Pagination mode first to test the switch
ensurePaginationMode()
// Verify Vertical Scroll component is NOT displayed initially
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
// Switch to Vertical Scroll
composeTestRule.onNodeWithContentDescription("More Options").performClick()
composeTestRule.onNodeWithText("Reading Mode: Vertical scroll").performClick()
composeTestRule.waitForIdle()
// Verify Vertical Scroll component IS displayed
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
// Switch back to Pagination
ensurePaginationMode()
// Verify Vertical Scroll component is gone
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
}
@Test
fun search_uiOpensAndAcceptsQuery() {
waitForDocumentLoad()
// Click search button
composeTestRule.onNodeWithTag("SearchButton").performClick()
composeTestRule.onNodeWithText("English, Spanish, French, etc.").performClick()
// Verify text field appears
composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
// Enter text
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
// Verify text exists in the field
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
// Close search
composeTestRule.onNodeWithContentDescription("Close Search").performClick()
// Verify text field is gone
composeTestRule.onNodeWithTag("SearchTextField").assertDoesNotExist()
}
@Test
fun fullScreen_togglesVisibility() {
waitForDocumentLoad()
// Click enter full screen button
composeTestRule.onNodeWithContentDescription("Enter Full Screen").performClick()
// Verify exit full screen button appears
composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertIsDisplayed()
// Click exit full screen
composeTestRule.onNodeWithContentDescription("Exit Full Screen").performClick()
// Verify exit button is gone and enter button returns
composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertDoesNotExist()
composeTestRule.onNodeWithContentDescription("Enter Full Screen").assertIsDisplayed()
}
@Test
fun darkMode_togglesState() {
waitForDocumentLoad()
// Initial state: Light mode (default from cleared prefs), so button says "Enable Dark Mode"
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
// Toggle On
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").performClick()
// State changed: Now button says "Disable Dark Mode"
composeTestRule.onNodeWithContentDescription("Disable Dark Mode").assertIsDisplayed()
// Toggle Off
composeTestRule.onNodeWithContentDescription("Disable Dark Mode").performClick()
// State changed back
composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
}
}

View file

@ -0,0 +1,105 @@
// BaseTtsSynthesizerTest.kt
package com.aryan.reader.tts
import android.speech.tts.TextToSpeech
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BaseTtsSynthesizerTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var synthesizer: BaseTtsSynthesizer
private var ttsEngineAvailable = false
@Before
fun setUp() {
// Ensure TTS is available on the device/emulator before running tests
val tts = TextToSpeech(ApplicationProvider.getApplicationContext(), null)
if (tts.engines.isNotEmpty()) {
ttsEngineAvailable = true
synthesizer = BaseTtsSynthesizer(ApplicationProvider.getApplicationContext())
}
tts.shutdown()
}
@After
fun tearDown() {
if (this::synthesizer.isInitialized) {
synthesizer.shutdown()
}
}
@Test
fun initialize_initializesTtsEngineSuccessfully() {
if (!ttsEngineAvailable) return
runBlocking {
// This will throw if it fails
withTimeout(10000L) {
synthesizer.initialize()
}
// No assertion needed, success is not throwing an exception.
}
}
@Test
fun synthesizeToFile_withValidText_createsAudioFile() {
if (!ttsEngineAvailable) return
runBlocking {
withTimeout(15000L) {
synthesizer.initialize()
val (file, returnedText) = synthesizer.synthesizeToFile("This is a test.")
assertThat(file).isNotNull()
assertThat(file?.exists()).isTrue()
assertThat(file?.length()).isGreaterThan(0L)
assertThat(returnedText).isEqualTo("This is a test.")
file?.delete()
}
}
}
@Test
fun synthesizeToFile_withBlankText_returnsNullFile() {
if (!ttsEngineAvailable) return
runBlocking {
synthesizer.initialize()
val (file, returnedText) = synthesizer.synthesizeToFile(" ")
assertThat(file).isNull()
assertThat(returnedText).isEqualTo(" ")
}
}
@Test
fun synthesizeToFile_withoutInitializingFirst_initializesAndSucceeds() {
if (!ttsEngineAvailable) return
runBlocking {
withTimeout(15000L) {
val (file, returnedText) = synthesizer.synthesizeToFile("This should work.")
assertThat(file).isNotNull()
assertThat(file?.exists()).isTrue()
assertThat(file?.length()).isGreaterThan(0L)
assertThat(returnedText).isEqualTo("This should work.")
file?.delete()
}
}
}
}

View file

@ -0,0 +1,27 @@
package com.aryan.reader.tts
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* A JUnit TestRule that sets the Main dispatcher to a TestDispatcher for the duration of a test.
* This allows tests to execute coroutines on the Main dispatcher without needing a real Android environment.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

View file

@ -0,0 +1,71 @@
// TtsUtilsTest.kt
package com.aryan.reader.tts
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import androidx.test.ext.junit.runners.AndroidJUnit4
@RunWith(AndroidJUnit4::class)
class TtsUtilsTest {
@Test
fun splitTextIntoChunks_withShortText_returnsSingleChunk() {
val text = "This is a short sentence."
val chunks = splitTextIntoChunks(text, 100)
assertThat(chunks).containsExactly("This is a short sentence.")
}
@Test
fun splitTextIntoChunks_withMultipleSentences_splitsCorrectly() {
val text = "First sentence. Second sentence! Third sentence? And a fourth."
val chunks = splitTextIntoChunks(text, 20)
assertThat(chunks).containsExactly(
"First sentence.",
"Second sentence!",
"Third sentence?",
"And a fourth."
).inOrder()
}
@Test
fun splitTextIntoChunks_combinesShortSentences() {
val text = "First. Second. Third. Fourth."
val chunks = splitTextIntoChunks(text, maxLengthPerChunk = 20)
assertThat(chunks).containsExactly(
"First. Second.",
"Third. Fourth."
).inOrder()
}
@Test
fun splitTextIntoChunks_withLongSentence_doesNotSplitSentence() {
val text = "This is a very long sentence that exceeds the maximum chunk length but has no punctuation to split on."
val chunks = splitTextIntoChunks(text, 50)
assertThat(chunks).containsExactly(text)
}
@Test
fun splitTextIntoChunks_withEmptyText_returnsEmptyList() {
val text = ""
val chunks = splitTextIntoChunks(text, 100)
assertThat(chunks).isEmpty()
}
@Test
fun splitTextIntoChunks_withBlankText_returnsEmptyList() {
val text = " "
val chunks = splitTextIntoChunks(text, 100)
assertThat(chunks).isEmpty()
}
@Test
fun splitTextIntoChunks_handlesAbbreviations() {
val text = "Mr. Smith went to Washington. Dr. Jones followed."
val chunks = splitTextIntoChunks(text, 40)
assertThat(chunks).containsExactly(
"Mr. Smith went to Washington.",
"Dr. Jones followed."
).inOrder()
}
}