Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
118
app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
Normal file
118
app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue