phase 0: fork Episteme, rename package to org.dueattendant149.bookreader

- Cloned from Aryan-Raj3112/episteme (AGPL-3.0)
- Package: com.aryan.reader → org.dueattendant149.bookreader
- Application ID: org.dueattendant149.bookreader
- Added AGENTS.md with migration plan
- Upstream: github.com/Aryan-Raj3112/episteme
- Origin: git.dueattendant149.org/Atte149/book-reader
This commit is contained in:
Atte149 2026-06-24 00:33:58 +03:00
parent e615128a23
commit 5f64f3d722
631 changed files with 3082 additions and 3006 deletions

View file

@ -0,0 +1,117 @@
package org.dueattendant149.bookreader
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.shared.FileType
import org.dueattendant149.bookreader.shared.ReaderFeatureSurface
import org.dueattendant149.bookreader.shared.ReaderPlatform
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppNavigationTest {
@Test
fun appDestinations_useStableReaderRoutes() {
assertThat(AppDestinations.MAIN_ROUTE).isEqualTo("main")
assertThat(AppDestinations.PDF_VIEWER_ROUTE).isEqualTo("pdf_viewer")
assertThat(AppDestinations.EPUB_READER_ROUTE).isEqualTo("epub_reader")
}
@Test
fun androidReaderSurface_mapsPdfBackedTypesToPdfViewer() {
val mappedSurfaces = listOf(
FileType.PDF,
FileType.CBZ,
FileType.CBR,
FileType.CB7,
FileType.CBT,
FileType.PPTX
).associateWith { it.readerSurfaceOnAndroid() }
assertThat(mappedSurfaces).containsExactly(
FileType.PDF, ReaderFeatureSurface.PDF_VIEWER,
FileType.CBZ, ReaderFeatureSurface.PDF_VIEWER,
FileType.CBR, ReaderFeatureSurface.PDF_VIEWER,
FileType.CB7, ReaderFeatureSurface.PDF_VIEWER,
FileType.CBT, ReaderFeatureSurface.PDF_VIEWER,
FileType.PPTX, ReaderFeatureSurface.PDF_VIEWER
)
}
@Test
fun androidReaderSurface_mapsTextBackedTypesToEpubReader() {
val mappedSurfaces = listOf(
FileType.EPUB,
FileType.MOBI,
FileType.MD,
FileType.TXT,
FileType.HTML,
FileType.FB2,
FileType.DOCX,
FileType.ODT,
FileType.FODT
).associateWith { it.readerSurfaceOnAndroid() }
assertThat(mappedSurfaces).containsExactly(
FileType.EPUB, ReaderFeatureSurface.EPUB_READER,
FileType.MOBI, ReaderFeatureSurface.EPUB_READER,
FileType.MD, ReaderFeatureSurface.EPUB_READER,
FileType.TXT, ReaderFeatureSurface.EPUB_READER,
FileType.HTML, ReaderFeatureSurface.EPUB_READER,
FileType.FB2, ReaderFeatureSurface.EPUB_READER,
FileType.DOCX, ReaderFeatureSurface.EPUB_READER,
FileType.ODT, ReaderFeatureSurface.EPUB_READER,
FileType.FODT, ReaderFeatureSurface.EPUB_READER
)
}
@Test
fun androidReaderSurface_returnsNullForUnknownFileType() {
assertThat(FileType.UNKNOWN.readerSurfaceOnAndroid()).isNull()
}
@Test
fun appNavBackInterceptor_onlyHandlesResumedNonReaderBackStackEntries() {
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
hasPreviousBackStackEntry = true,
isCurrentEntryResumed = true
)
).isTrue()
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.MAIN_ROUTE,
hasPreviousBackStackEntry = true,
isCurrentEntryResumed = true
)
).isFalse()
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.PDF_VIEWER_ROUTE,
hasPreviousBackStackEntry = true,
isCurrentEntryResumed = true
)
).isFalse()
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
hasPreviousBackStackEntry = false,
isCurrentEntryResumed = true
)
).isFalse()
assertThat(
shouldInterceptAppNavBack(
currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
hasPreviousBackStackEntry = true,
isCurrentEntryResumed = false
)
).isFalse()
}
private fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
}
}

View file

@ -0,0 +1,126 @@
package org.dueattendant149.bookreader
import android.content.Context
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.data.RecentFileItem
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HomeRecentFileCardTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun recentFileCardShowsProgressAndUnavailableState() {
val item = recentBook(
bookId = "home_unavailable_epub",
title = "Unavailable Field Guide",
author = "Casey Example",
progress = 42f,
isAvailable = false
)
setRecentFileCard(item = item)
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_unavailable_epub").assertIsDisplayed()
composeTestRule.onNodeWithText("Unavailable Field Guide").assertIsDisplayed()
composeTestRule.onNodeWithText("Casey Example").assertIsDisplayed()
composeTestRule.onNodeWithText("42%").assertIsDisplayed()
composeTestRule.onAllNodesWithContentDescription(text(R.string.not_available_locally))[0]
.assertIsDisplayed()
}
@Test
fun recentFileCardClickLongClickAndSelectedOverlayWork() {
val item = recentBook(
bookId = "home_selected_pdf",
title = "Selected Position Notes",
author = "Morgan Example",
progress = 7f
)
var clicked = false
var longClicked = false
setRecentFileCard(
item = item,
isSelected = true,
onClick = { clicked = true },
onLongClick = { longClicked = true }
)
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_selected))[0]
.assertIsDisplayed()
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performClick()
composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performTouchInput {
down(center)
advanceEventTime(600)
up()
}
assertThat(clicked).isTrue()
assertThat(longClicked).isTrue()
}
private fun setRecentFileCard(
item: RecentFileItem,
isSelected: Boolean = false,
isPinned: Boolean = false,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
composeTestRule.setContent {
MaterialTheme {
RecentFileCard(
item = item,
isSelected = isSelected,
isPinned = isPinned,
onClick = onClick,
onLongClick = onLongClick,
isDownloading = false,
usePdfFileNameAsDisplayName = false
)
}
}
}
private fun recentBook(
bookId: String,
title: String,
author: String,
progress: Float,
isAvailable: Boolean = true
): RecentFileItem {
return RecentFileItem(
bookId = bookId,
uriString = "content://home-test/$bookId",
type = FileType.EPUB,
displayName = "$bookId.epub",
timestamp = 1_000L,
title = title,
author = author,
progressPercentage = progress,
isRecent = true,
isAvailable = isAvailable
)
}
private fun text(resId: Int): String {
return context.getString(resId)
}
}

View file

@ -0,0 +1,400 @@
package org.dueattendant149.bookreader
import android.content.Context
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
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.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.TagEntity
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(ExperimentalFoundationApi::class)
@RunWith(AndroidJUnit4::class)
class LibraryScreenContentTest {
@get:Rule
val composeTestRule = createComposeRule()
private val context: Context = ApplicationProvider.getApplicationContext()
private val focusTag = TagEntity(id = "tag_focus", name = "Focus", color = null, createdAt = 1L)
private val libraryBooks = listOf(
libraryBook(
bookId = "pdf_beta",
type = FileType.PDF,
displayName = "beta.pdf",
title = "Beta Manual",
author = "Mira Example",
timestamp = 3_000L,
progress = 84f
),
libraryBook(
bookId = "epub_gamma",
type = FileType.EPUB,
displayName = "gamma.epub",
title = "Gamma Field Notes",
author = "Nora Example",
timestamp = 2_000L,
progress = 47f,
tags = listOf(focusTag)
),
libraryBook(
bookId = "epub_alpha",
type = FileType.EPUB,
displayName = "alpha.epub",
title = "Alpha Orchard",
author = "Zara Example",
timestamp = 1_000L,
progress = 12f,
tags = listOf(focusTag)
)
)
@Test
fun searchFiltersAndClearRestoresLibraryList() {
setLibraryContent()
composeTestRule.onNodeWithText("Beta Manual").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Gamma")
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
}
assertNoText("Alpha Orchard")
assertNoText("Beta Manual")
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_search)).performClick()
composeTestRule.onNodeWithText(text(R.string.library_title)).assertIsDisplayed()
}
@Test
fun searchMatchesAuthorAndTagNames() {
setLibraryContent()
composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Zara")
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty()
}
assertNoText("Gamma Field Notes")
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Focus")
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
}
assertNoText("Beta Manual")
}
@Test
fun activeFileTypeFilterChipCanBeCleared() {
setLibraryContent(initialFilters = LibraryFilters(fileTypes = setOf(FileType.EPUB)))
composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
assertNoText("Beta Manual")
composeTestRule.onNodeWithText(text(R.string.filter_types, FileType.EPUB.name)).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun tagAndReadStatusFiltersUseSharedLibraryRules() {
setLibraryContent(
initialFilters = LibraryFilters(
tagIds = setOf(focusTag.id),
readStatus = ReadStatusFilter.IN_PROGRESS
)
)
composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
assertNoText("Beta Manual")
composeTestRule.onNodeWithText(text(R.string.filter_tags, focusTag.name)).assertIsDisplayed()
composeTestRule.onNodeWithText(
text(R.string.filter_status, text(ReadStatusFilter.IN_PROGRESS.labelRes))
).assertIsDisplayed()
}
@Test
fun clearSelectionReturnsToNormalToolbar() {
setLibraryContent()
composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
down(center)
advanceEventTime(600)
up()
}
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithContentDescription(text(R.string.clear_selection)).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text(R.string.library_title)).fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun sortMenuSelectionReordersLibraryItems() {
setLibraryContent()
assertBookAbove("pdf_beta", "epub_alpha")
composeTestRule.onNodeWithTag("LibrarySortButton").performClick()
composeTestRule.onNodeWithText(text(SortOrder.TITLE_ASC.labelRes)).performClick()
composeTestRule.waitUntil(5_000) {
runCatching {
bookTop("epub_alpha") < bookTop("pdf_beta")
}.getOrDefault(false)
}
assertBookAbove("epub_alpha", "pdf_beta")
}
@Test
fun longPressBookShowsContextualToolbarActions() {
var tagClicked = false
var pinClicked = false
var infoClicked = false
var selectAllClicked = false
var deleteClicked = false
setLibraryContent(
onTagClick = { tagClicked = true },
onPinClick = { pinClicked = true },
onInfoClick = { infoClicked = true },
onSelectAllClick = { selectAllClicked = true },
onDeleteClick = { deleteClicked = true }
)
composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
down(center)
advanceEventTime(600)
up()
}
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_tag)).performClick()
composeTestRule.onNodeWithContentDescription(text(R.string.pin_unpin)).performClick()
composeTestRule.onNodeWithContentDescription(text(R.string.info)).performClick()
composeTestRule.onNodeWithContentDescription(text(R.string.select_all)).performClick()
composeTestRule.onNodeWithContentDescription(text(R.string.action_delete)).performClick()
assertThat(tagClicked).isTrue()
assertThat(pinClicked).isTrue()
assertThat(infoClicked).isTrue()
assertThat(selectAllClicked).isTrue()
assertThat(deleteClicked).isTrue()
}
@Test
fun shelvesTabShowsShelfRowsAndNewShelfAction() {
val shelf = Shelf(
id = "manual_favorites",
name = "Manual Favorites",
type = ShelfType.MANUAL,
books = listOf(libraryBooks[0], libraryBooks[1])
)
var clickedShelfId: String? = null
var longClickedShelfId: String? = null
var newShelfClicked = false
setLibraryContent(
initialPage = 1,
shelves = listOf(shelf),
onShelfClick = { clickedShelfId = it.id },
onShelfLongClick = { longClickedShelfId = it.id },
onNewShelfClick = { newShelfClicked = true }
)
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").assertIsDisplayed()
composeTestRule.onNodeWithText("Manual Favorites").assertIsDisplayed()
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performClick()
composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performTouchInput {
down(center)
advanceEventTime(600)
up()
}
composeTestRule.onNodeWithTag("LibraryNewShelfFab").performClick()
assertThat(clickedShelfId).isEqualTo("manual_favorites")
assertThat(longClickedShelfId).isEqualTo("manual_favorites")
assertThat(newShelfClicked).isTrue()
}
private fun setLibraryContent(
initialFilters: LibraryFilters = LibraryFilters(),
initialPage: Int = 0,
shelves: List<Shelf> = emptyList(),
onTagClick: () -> Unit = {},
onPinClick: () -> Unit = {},
onInfoClick: () -> Unit = {},
onSelectAllClick: () -> Unit = {},
onDeleteClick: () -> Unit = {},
onShelfClick: (Shelf) -> Unit = {},
onShelfLongClick: (Shelf) -> Unit = {},
onNewShelfClick: () -> Unit = {}
) {
val searchQuery = mutableStateOf("")
val isSearchActive = mutableStateOf(false)
val filters = mutableStateOf(initialFilters)
val sortOrder = mutableStateOf(SortOrder.RECENT)
val selectedItems = mutableStateOf(emptySet<RecentFileItem>())
composeTestRule.setContent {
val pagerState = rememberPagerState(
initialPage = initialPage,
pageCount = { 3 }
)
val visibleBooks = sortFiles(
applyLibraryFilters(
filterBySearch(libraryBooks, searchQuery.value),
filters.value
),
sortOrder.value
)
MaterialTheme {
LibraryScreenContent(
tabTitles = listOf(
text(R.string.tab_all_books),
text(R.string.tab_shelves),
text(R.string.tab_folders)
),
recentFiles = visibleBooks,
rawLibraryFiles = libraryBooks,
shelves = shelves,
selectedItems = selectedItems.value,
selectedShelves = emptySet(),
sortOrder = sortOrder.value,
libraryFilters = filters.value,
allTags = listOf(focusTag),
pinnedLibraryBookIds = emptySet(),
pagerState = pagerState,
scope = rememberCoroutineScope(),
searchQuery = searchQuery.value,
isSearchActive = isSearchActive.value,
onSearchQueryChange = { searchQuery.value = it },
onSearchActiveChange = { isSearchActive.value = it },
onSortOrderChange = { sortOrder.value = it },
onFilterClick = {},
onClearFilters = { filters.value = LibraryFilters() },
onRemoveFilter = { filters.value = it },
onTagClick = onTagClick,
onPinClick = onPinClick,
onClearSelection = { selectedItems.value = emptySet() },
onItemClick = {},
onItemLongClick = { item -> selectedItems.value = setOf(item) },
onInfoClick = onInfoClick,
onSaveClick = null,
onShareClick = null,
onDeleteClick = onDeleteClick,
onSelectAllClick = onSelectAllClick,
onShelfClick = onShelfClick,
onShelfLongClick = onShelfLongClick,
onClearShelfSelection = {},
onDeleteShelves = {},
onNewShelfClick = onNewShelfClick,
onSelectFileClick = {},
onScanNowClick = {},
onSyncMetadataClick = {},
onSelectSyncFolderClick = {},
onEditFolderFiltersClick = { _, _ -> },
onDisconnectSyncFolderClick = {},
downloadingBookIds = emptySet(),
lastFolderScanTime = null,
isLoading = false,
isRefreshing = false,
syncedFolders = emptyList(),
onRemoveFolderClick = {},
onFolderLocalSyncChange = { _, _, _ -> },
onOpdsBookDownloaded = { _, _ -> },
onStreamOpdsBook = { _, _ -> },
onDeleteCatalogStreams = {},
onSettingsClick = {},
usePdfFileNameAsDisplayName = false
)
}
}
}
private fun libraryBook(
bookId: String,
type: FileType,
displayName: String,
title: String,
author: String,
timestamp: Long,
progress: Float,
tags: List<TagEntity> = emptyList()
): RecentFileItem {
return RecentFileItem(
bookId = bookId,
uriString = "content://library-test/$bookId",
type = type,
displayName = displayName,
timestamp = timestamp,
title = title,
author = author,
progressPercentage = progress,
isRecent = true,
isAvailable = true,
fileSize = timestamp * 10,
tags = tags
)
}
private fun assertBookAbove(upperBookId: String, lowerBookId: String) {
assertThat(bookTop(upperBookId)).isLessThan(bookTop(lowerBookId))
}
private fun assertNoText(value: String) {
assertThat(composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes()).isEmpty()
}
private fun bookTop(bookId: String): Float {
return composeTestRule
.onNodeWithTag("LibraryBookItem_$bookId")
.fetchSemanticsNode()
.boundsInRoot
.top
}
private fun text(resId: Int, vararg args: Any): String {
return context.getString(resId, *args)
}
}

View file

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

View file

@ -0,0 +1,187 @@
package org.dueattendant149.bookreader.epubreader
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
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
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@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 = {},
onScrollFinishedCallback = {}
)
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 = {},
onScrollFinishedCallback = {}
)
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 = {},
onScrollFinishedCallback = {}
)
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() {
val latch = CountDownLatch(1)
var receivedJson: String? = null
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
try {
val bridge = TtsJsBridge(
scope = bridgeScope,
ttsStructuredTextHandler = { json ->
receivedJson = json
latch.countDown()
}
)
val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
bridge.onStructuredTextExtracted(jsonPayload)
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
assertThat(receivedJson).isEqualTo(jsonPayload)
} finally {
bridgeScope.cancel()
}
}
@Test
fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() {
val latch = CountDownLatch(1)
var receivedJson: String? = null
val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
try {
val bridge = TtsJsBridge(
scope = bridgeScope,
ttsStructuredTextHandler = { json ->
receivedJson = json
latch.countDown()
}
)
val jsonPayload = ""
bridge.onStructuredTextExtracted(jsonPayload)
assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
assertThat(receivedJson).isEqualTo("[]")
} finally {
bridgeScope.cancel()
}
}
@Test
fun snippetJsBridge_onSnippetExtracted_callsCallbackWithCfiAndSnippet() {
var receivedCfi = ""
var receivedSnippet = ""
val bridge = SnippetJsBridge(
onSnippetReady = { cfi, snippet ->
receivedCfi = cfi
receivedSnippet = snippet
}
)
val cfi = "/4/8:5"
val snippet = "This is the bookmark snippet."
bridge.onSnippetExtracted(cfi, snippet)
assertThat(receivedCfi).isEqualTo(cfi)
assertThat(receivedSnippet).isEqualTo(snippet)
}
@Test
fun progressJsBridge_onTopChunkUpdated_invokesCallback() {
var updatedChunk = -1
val bridge = ProgressJsBridge(onTopChunkUpdated = { index -> updatedChunk = index })
bridge.updateTopChunk(5)
assertThat(updatedChunk).isEqualTo(5)
bridge.updateTopChunk(10)
assertThat(updatedChunk).isEqualTo(10)
}
@Test
fun progressJsBridge_onTopChunkUpdated_doesNotCallForSameIndex() {
var callCount = 0
val bridge = ProgressJsBridge(onTopChunkUpdated = { callCount++ })
bridge.updateTopChunk(3)
assertThat(callCount).isEqualTo(1)
// Reporting the same index should not trigger the callback again
bridge.updateTopChunk(3)
assertThat(callCount).isEqualTo(1)
bridge.updateTopChunk(4)
assertThat(callCount).isEqualTo(2)
}
@Test
fun aiJsBridge_onContentExtracted_invokesCallback() = runTest {
var receivedContent: String? = null
val bridge = AiJsBridge(
scope = this,
onContentReady = { content -> receivedContent = content }
)
val content = "This is the chapter content for summarization."
bridge.onContentExtractedForSummarization(content)
advanceUntilIdle()
assertThat(receivedContent).isEqualTo(content)
}
}

View file

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

View file

@ -0,0 +1,108 @@
package org.dueattendant149.bookreader.epubreader
import android.content.Context
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.dueattendant149.bookreader.epub.EpubBook
import org.dueattendant149.bookreader.epub.EpubChapter
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class EpubReaderLogicTest {
private lateinit var context: Context
private lateinit var testDir: File
private lateinit var testBook: EpubBook
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
testDir = File(context.cacheDir, "test_epub_search").apply {
deleteRecursively()
mkdirs()
}
val chapter1File = File(testDir, "chapter1.html").apply {
writeText("<html><body><p>A simple Test case.</p></body></html>")
}
val chapter2File = File(testDir, "chapter2.html").apply {
writeText("<html><body><p>Another test case here.</p><p>The word Test appears twice.</p></body></html>")
}
testBook = 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()
}
@Test
fun search_findsCorrectResults() = runTest {
val results = createEpubSearcher(testBook)("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() = runTest {
val results = createEpubSearcher(testBook)("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() = runTest {
val results = createEpubSearcher(testBook)("nonexistent")
assertThat(results).isEmpty()
}
@Test
fun search_createsCorrectSnippetHighlight() = runTest {
val result = createEpubSearcher(testBook)("Test").first()
val boldRanges = result.snippet.spanStyles.filter { it.item == SpanStyle(fontWeight = FontWeight.Bold) }
assertThat(boldRanges).hasSize(1)
val highlight = boldRanges.first()
assertThat(result.snippet.text.substring(highlight.start, highlight.end)).isEqualTo("Test")
}
}

View file

@ -0,0 +1,818 @@
package org.dueattendant149.bookreader.epubreader
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.click
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
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.performTextClearance
import androidx.compose.ui.test.performTextInput
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 androidx.test.platform.app.InstrumentationRegistry
import org.dueattendant149.bookreader.FileType
import org.dueattendant149.bookreader.FileHasher
import org.dueattendant149.bookreader.MainActivity
import org.dueattendant149.bookreader.R
import org.dueattendant149.bookreader.RenderMode
import org.dueattendant149.bookreader.data.AppDatabase
import org.dueattendant149.bookreader.data.RecentFileEntity
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
import org.dueattendant149.bookreader.shared.ReaderLocator
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
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 EpubReaderScreenTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()
private val fixtureAssetName = "epub/reader_test_book.epub"
private val fixtureBookTitle = "Reader Android UI Test Book"
private val sanitizedFixtureBookTitle = "ReaderAndroidUITestBook"
private val targetContext: Context = ApplicationProvider.getApplicationContext()
private val instrumentationContext: Context = InstrumentationRegistry.getInstrumentation().context
private var currentEpubFile: File? = null
private var scenario: ActivityScenario<MainActivity>? = null
private lateinit var fixtureBookId: String
@Before
fun setup() {
clearReaderPrefs()
fixtureBookId = requireNotNull(
runBlocking {
FileHasher.calculateSha256 {
instrumentationContext.assets.open(fixtureAssetName)
}
}
)
runBlocking {
AppDatabase.getDatabase(targetContext)
.recentFileDao()
.deleteFilePermanently(listOf(fixtureBookId))
}
}
@After
fun tearDown() {
scenario?.close()
currentEpubFile?.let {
if (it.exists()) it.delete()
}
}
@Test
fun fixtureEpub_opensReaderAndShowsBookTitle() {
launchFixtureReader()
waitForReader()
showReaderChrome()
waitForText(fixtureBookTitle)
composeTestRule.onNodeWithText(fixtureBookTitle).assertIsDisplayed()
assertThat(hasContentDescription(text(R.string.tooltip_search))).isTrue()
assertThat(hasContentDescription(text(R.string.content_desc_chapters_menu))).isTrue()
}
@Test
fun fixtureEpub_recordsInitialReadingPosition() {
launchFixtureReader()
waitForReader()
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
recentFile?.lastChapterIndex != null &&
recentFile.locatorBlockIndex != null &&
recentFile.locatorCharOffset != null &&
recentFile.progressPercentage != null
}
val recentFile = readFixtureRecentFile()
assertThat(recentFile?.lastChapterIndex).isAtLeast(0)
assertThat(recentFile?.locatorBlockIndex).isAtLeast(0)
assertThat(recentFile?.locatorCharOffset).isAtLeast(0)
assertThat(recentFile?.progressPercentage).isAtLeast(0f)
}
@Test
fun fixtureEpub_restoresSeededReadingPositionWithoutResettingToStart() {
launchFixtureReader { fixtureUri ->
seedFixtureRecentFile(
uriString = fixtureUri.toString(),
chapterIndex = 1,
blockIndex = 1,
charOffset = 0,
progress = 45f
)
}
waitForReader()
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
recentFile?.lastChapterIndex == 1 &&
recentFile.locatorBlockIndex == 1 &&
recentFile.locatorCharOffset == 0 &&
(recentFile.progressPercentage ?: 0f) >= 45f
}
}
@Test
fun fixtureEpub_drawerShowsFixtureChapters() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
waitForText(text(R.string.tab_chapters))
waitForTextContaining("Chapter One")
waitForTextContaining("Chapter Two")
waitForTextContaining("Chapter Three")
}
@Test
fun fixtureEpub_drawerShowsFixtureImageCatalog() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_images))
waitForText("Fixture diagram")
waitForTextContaining("Chapter Three")
assertThat(hasContentDescription(text(R.string.content_desc_download_image))).isTrue()
}
@Test
fun fixtureEpub_searchFindsUniqueFixtureMarker() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.tooltip_search))
waitForTag("SearchTextField")
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("SEARCH_TARGET_DELTA")
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("SEARCH_TARGET_DELTA")
waitForTag("SearchResultItem_1", timeoutMillis = 20_000)
composeTestRule.onNodeWithTag("SearchResultItem_1").assertIsDisplayed()
}
@Test
fun fixtureEpub_searchNavigationPositionSurvivesReadingModeSwitches() {
launchFixtureReader()
waitForReader()
navigateToFixtureSearchResult("SEARCH_TARGET_DELTA", expectedChapterIndex = 1)
clickContentDescription(text(R.string.tooltip_close_search))
waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
recentFile?.lastChapterIndex == 1 &&
recentFile.locatorBlockIndex != null &&
recentFile.locatorCharOffset != null &&
recentFile.progressPercentage != null
}
openOverflowMenu()
clickText(text(R.string.menu_change_reading_mode))
clickText(text(R.string.menu_reading_mode_paginated))
waitForRenderMode(RenderMode.PAGINATED)
waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
recentFile?.lastChapterIndex == 1 &&
(recentFile.progressPercentage ?: 0f) > 0f
}
openOverflowMenu()
clickText(text(R.string.menu_change_reading_mode))
clickText(text(R.string.menu_reading_mode_vertical_webview))
waitForRenderMode(RenderMode.VERTICAL_SCROLL)
waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
recentFile?.lastChapterIndex == 1 &&
(recentFile.progressPercentage ?: 0f) > 0f
}
}
@Test
fun fixtureEpub_addsCurrentPageBookmarkPersistsAndDeletes() {
launchFixtureReader()
waitForReader()
openOverflowMenu()
clickText(text(R.string.menu_bookmark_this_page))
waitForFixtureBookmarks(timeoutMillis = 20_000) { bookmarksJson ->
parseBookmarksJson(bookmarksJson).isNotEmpty()
}
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_bookmarks))
waitForTextContaining("Chapter One")
assertThat(hasContentDescription(text(R.string.content_desc_more_options_bookmark))).isTrue()
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
clickText(text(R.string.action_delete))
waitForText(text(R.string.dialog_delete_bookmark))
clickText(text(R.string.action_delete))
waitForText(text(R.string.no_bookmarks_yet))
waitForFixtureBookmarks(timeoutMillis = 10_000) { bookmarksJson ->
parseBookmarksJson(bookmarksJson).isEmpty()
}
}
@Test
fun fixtureEpub_drawerShowsSeededBookmarkAndSupportsRenameDelete() {
seedFixtureBookmark()
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_bookmarks))
waitForText("BOOKMARK_TARGET_ECHO")
waitForText("Chapter Two")
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
clickText(text(R.string.action_rename))
waitForText(text(R.string.dialog_rename_bookmark))
composeTestRule.onNode(hasSetTextAction()).performTextInput("Renamed fixture bookmark")
clickText(text(R.string.action_save))
waitForText("Renamed fixture bookmark")
clickContentDescription(text(R.string.content_desc_more_options_bookmark))
clickText(text(R.string.action_delete))
waitForText(text(R.string.dialog_delete_bookmark))
clickText(text(R.string.action_delete))
waitForText(text(R.string.no_bookmarks_yet))
}
@Test
fun fixtureEpub_drawerShowsSeededAnnotationWithNoteAndFilter() {
seedFixtureHighlight()
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_annotations))
waitForText("ANNOTATION_TARGET_GOLF")
waitForText("Fixture note survives startup")
waitForTextContaining("Chapter Three")
clickText(text(R.string.filter_with_notes))
waitForText("ANNOTATION_TARGET_GOLF")
waitForText("Fixture note survives startup")
}
@Test
fun fixtureEpub_seededAnnotationSupportsColorNoteAndDeletePersistence() {
seedFixtureHighlight()
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_annotations))
waitForText("ANNOTATION_TARGET_GOLF")
clickContentDescription(text(R.string.content_desc_options))
composeTestRule.onNodeWithTag("HighlightColor_blue").performClick()
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
parseHighlightsJson(highlightsJson).singleOrNull()?.color == HighlightColor.BLUE
}
clickContentDescription(text(R.string.content_desc_options))
clickText(text(R.string.menu_edit_note))
waitForText(text(R.string.action_save_note))
composeTestRule.onNode(hasSetTextAction())
.performTextClearance()
composeTestRule.onNode(hasSetTextAction())
.performTextInput("Updated fixture note")
clickText(text(R.string.action_save_note))
waitForText("Updated fixture note")
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
parseHighlightsJson(highlightsJson).singleOrNull()?.note == "Updated fixture note"
}
clickContentDescription(text(R.string.content_desc_options))
clickText(text(R.string.action_delete))
waitForText(text(R.string.dialog_delete_highlight))
clickText(text(R.string.action_delete))
waitForText(text(R.string.no_highlights_yet))
waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
parseHighlightsJson(highlightsJson).isEmpty()
}
}
@Test
fun fixtureEpub_overflowSwitchesReadingModeAndTogglesPageOptions() {
launchFixtureReader()
waitForReader()
openOverflowMenu()
clickText(text(R.string.menu_change_reading_mode))
clickText(text(R.string.menu_reading_mode_paginated))
waitForRenderMode(RenderMode.PAGINATED)
composeTestRule.waitForIdle()
openOverflowMenu()
clickText(text(R.string.menu_tap_to_turn_pages))
assertReaderSettingEventually(
prefsName = "epub_reader_settings",
key = "tap_to_navigate_enabled",
expected = true
)
openOverflowMenu()
clickText(text(R.string.menu_realistic_page_turns))
assertReaderSettingEventually(
prefsName = "reader_prefs",
key = "page_turn_animation_enabled",
expected = true
)
openOverflowMenu()
clickText(text(R.string.menu_keep_screen_on))
assertReaderSettingEventually(
prefsName = "reader_prefs",
key = "keep_screen_on_enabled",
expected = true
)
}
@Test
fun fixtureEpub_visualOptionsSheetPersistsProgressPosition() {
launchFixtureReader()
waitForReader()
openOverflowMenu()
clickText(text(R.string.menu_visual_options))
waitForText(text(R.string.visual_options_system_ui))
waitForText(text(R.string.visual_options_progress_bar))
waitForText(text(R.string.visual_options_progress_bar_position))
clickText(text(R.string.label_top))
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.getInt("reader_page_info_position", 0) == 1
}
}
@Test
fun fixtureEpub_formatPanelShowsControlsAndPersistsLocalFontSize() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_text_formatting))
waitForText(text(R.string.section_font_alignment))
waitForText(text(R.string.section_layout_spacing))
waitForText(text(R.string.label_font_size))
waitForText(text(R.string.label_line_height))
waitForText(text(R.string.label_paragraph_gap))
waitForText(text(R.string.label_image_size))
waitForText(text(R.string.label_horizontal_margin))
waitForText(text(R.string.label_vertical_margin))
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_select_mode))[0]
.performClick()
clickText(text(R.string.format_local))
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.getBoolean("format_is_local_$fixtureBookId", false)
}
composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_increase))[0]
.performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.getFloat("local_font_size_$fixtureBookId", 1.0f) > 1.0f
}
}
@Test
fun fixtureEpub_fontSelectionSheetPersistsFontFamily() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_text_formatting))
waitForText(text(R.string.section_font_alignment))
clickContentDescription(text(R.string.content_desc_select_font_family))
waitForText(text(R.string.select_font))
waitForText(text(R.string.tab_presets))
waitForText(text(R.string.tab_imported))
clickText("Lato")
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.getString("reader_font_family", "original") == "lato"
}
}
@Test
fun fixtureEpub_themePanelShowsThemesAndPersistsSelection() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.tooltip_theme_desc))
waitForText(text(R.string.reading_themes))
waitForText(text(R.string.theme_solid_colors))
waitForText("Light")
waitForText("Dark")
clickText("Sepia")
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
.getString(PREF_READER_THEME, "system") == "sepia"
}
}
@Test
fun fixtureEpub_drawerShowsEmptyBookmarkAndAnnotationStates() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_chapters_menu))
clickText(text(R.string.tab_bookmarks))
waitForText(text(R.string.no_bookmarks_yet))
clickText(text(R.string.tab_annotations))
waitForText(text(R.string.no_highlights_yet))
}
@Test
fun fixtureEpub_searchCanClearAndClose() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.tooltip_search))
waitForTag("SearchTextField")
composeTestRule.onNodeWithTag("SearchTextField")
.performTextInput("SEARCH_TARGET_DELTA")
waitForTextContaining("SEARCH_TARGET_DELTA")
clickContentDescription(text(R.string.tooltip_clear_search))
waitForNoContentDescription(text(R.string.tooltip_clear_search))
clickContentDescription(text(R.string.tooltip_close_search))
composeTestRule.waitUntil(timeoutMillis = 5_000) {
hasContentDescription(text(R.string.tooltip_search))
}
}
@Test
fun fixtureEpub_dictionarySettingsShowsLookupSections() {
launchFixtureReader()
waitForReader()
clickReaderControl(text(R.string.content_desc_dictionary_settings))
waitForText(text(R.string.dict_lookup_settings))
waitForText(text(R.string.tooltip_dictionary))
waitForText(text(R.string.dict_translate))
waitForText(text(R.string.tooltip_search))
}
@Test
fun fixtureEpub_ttsReplacementSheetShowsGlobalAndBookScopes() {
launchFixtureReader()
waitForReader()
openOverflowMenu()
clickText(text(R.string.menu_tts_settings))
clickText(text(R.string.menu_tts_word_replacements))
waitForText(text(R.string.menu_tts_word_replacements))
waitForText(fixtureBookTitle)
waitForText(text(R.string.tts_replacements_tab_global))
waitForText(text(R.string.tts_replacements_tab_this_book))
waitForText(text(R.string.tts_replacements_enable))
}
private fun launchFixtureReader(beforeLaunch: (Uri) -> Unit = {}) {
val fixtureUri = copyAndroidTestAssetToCache(fixtureAssetName)
beforeLaunch(fixtureUri)
scenario = ActivityScenario.launch<MainActivity>(createEpubViewIntent(fixtureUri))
}
private fun clearReaderPrefs() {
listOf(
"epub_reader_settings",
"epub_reader_bookmarks",
"reader_prefs"
).forEach { prefsName ->
targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
}
targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
.edit()
.putString("render_mode", "VERTICAL_SCROLL")
.commit()
}
private fun text(resId: Int): String = targetContext.getString(resId)
private fun seedFixtureBookmark() {
val bookmark = org.json.JSONObject().apply {
put("cfi", "android-locator:1:1:0")
put("chapterTitle", "Chapter Two")
put("label", org.json.JSONObject.NULL)
put("snippet", "BOOKMARK_TARGET_ECHO")
put("pageInChapter", 1)
put("totalPagesInChapter", 3)
put("chapterIndex", 1)
put(
"locator",
org.json.JSONObject().apply {
put("chapterIndex", 1)
put("chapterId", "chapter-02")
put("pageIndex", 0)
put("blockIndex", 1)
put("charOffset", 0)
put("textQuote", "BOOKMARK_TARGET_ECHO")
put("cfi", "android-locator:1:1:0")
}
)
}
targetContext.getSharedPreferences("epub_reader_bookmarks", Context.MODE_PRIVATE)
.edit()
.putStringSet("bookmarks_cfi_$sanitizedFixtureBookTitle", setOf(bookmark.toString()))
.commit()
}
private fun seedFixtureHighlight() {
val highlight = UserHighlight(
id = "fixture_annotation_golf",
cfi = "android-locator:2:1:0",
text = "ANNOTATION_TARGET_GOLF",
color = HighlightColor.GREEN,
chapterIndex = 2,
note = "Fixture note survives startup",
locator = ReaderLocator(
chapterIndex = 2,
chapterId = "chapter-03",
blockIndex = 1,
charOffset = 0,
textQuote = "ANNOTATION_TARGET_GOLF",
cfi = "android-locator:2:1:0"
)
)
targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit()
.putString("highlights_data_$sanitizedFixtureBookTitle", highlightsToJson(listOf(highlight)))
.commit()
}
private fun seedFixtureRecentFile(
uriString: String,
chapterIndex: Int,
blockIndex: Int,
charOffset: Int,
progress: Float
) {
val now = System.currentTimeMillis()
runBlocking {
AppDatabase.getDatabase(targetContext)
.recentFileDao()
.insertOrUpdateFile(
RecentFileEntity(
bookId = fixtureBookId,
uriString = uriString,
type = FileType.EPUB,
displayName = fixtureBookTitle,
timestamp = now,
coverImagePath = null,
title = fixtureBookTitle,
author = "Fixture Author",
lastChapterIndex = chapterIndex,
lastPage = null,
lastPositionCfi = "android-locator:$chapterIndex:$blockIndex:$charOffset",
progressPercentage = progress,
isRecent = true,
isAvailable = true,
lastModifiedTimestamp = now,
isDeleted = false,
locatorBlockIndex = blockIndex,
locatorCharOffset = charOffset,
bookmarks = null,
sourceFolderUri = null,
isReflowPreferred = false,
customName = null,
highlights = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
seriesName = null,
seriesIndex = null,
description = null,
folderTextMetadataParsed = false,
folderCoverMetadataParsed = false,
originalTitle = fixtureBookTitle,
originalAuthor = "Fixture Author",
originalSeriesName = null,
originalSeriesIndex = null,
originalDescription = null
)
)
}
}
private fun createEpubViewIntent(uri: Uri): Intent {
return Intent(targetContext, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
setDataAndType(uri, "application/epub+zip")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
private fun copyAndroidTestAssetToCache(assetName: String): Uri {
val file = File(targetContext.cacheDir, "${UUID.randomUUID()}_reader_test_book.epub")
currentEpubFile = file
instrumentationContext.assets.open(assetName).use { inputStream ->
file.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
return FileProvider.getUriForFile(
targetContext,
"${targetContext.packageName}.provider",
file
)
}
private fun navigateToFixtureSearchResult(query: String, expectedChapterIndex: Int) {
clickReaderControl(text(R.string.tooltip_search))
waitForTag("SearchTextField")
composeTestRule.onNodeWithTag("SearchTextField").performTextInput(query)
waitForTag("SearchResultItem_$expectedChapterIndex", timeoutMillis = 20_000)
composeTestRule.onNodeWithTag("SearchResultItem_$expectedChapterIndex").performClick()
}
private fun waitForReader() {
waitForTag("ReaderContainer", timeoutMillis = 30_000)
}
private fun showReaderChrome() {
if (hasAnyReaderControl()) return
composeTestRule.onRoot().performTouchInput { click(center) }
composeTestRule.waitUntil(timeoutMillis = 5_000) {
hasAnyReaderControl()
}
}
private fun clickReaderControl(contentDescription: String) {
showReaderChrome()
composeTestRule.waitUntil(timeoutMillis = 5_000) {
hasContentDescription(contentDescription)
}
composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
}
private fun clickContentDescription(contentDescription: String) {
composeTestRule.waitUntil(timeoutMillis = 5_000) {
hasContentDescription(contentDescription)
}
composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
}
private fun clickText(value: String) {
composeTestRule.waitUntil(timeoutMillis = 10_000) {
composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onAllNodesWithText(value)[0].performClick()
}
private fun openOverflowMenu() {
clickReaderControl(text(R.string.content_desc_more_options))
}
private fun hasAnyReaderControl(): Boolean {
return hasContentDescription(text(R.string.tooltip_search)) ||
hasContentDescription(text(R.string.content_desc_chapters_menu)) ||
hasContentDescription(text(R.string.content_desc_more_options))
}
private fun hasContentDescription(contentDescription: String): Boolean {
return composeTestRule
.onAllNodesWithContentDescription(contentDescription)
.fetchSemanticsNodes()
.isNotEmpty()
}
private fun waitForTag(tag: String, timeoutMillis: Long = 10_000) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
private fun waitForText(value: String, timeoutMillis: Long = 10_000) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
}
}
private fun waitForTextContaining(value: String, timeoutMillis: Long = 10_000) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
composeTestRule.onAllNodesWithText(value, substring = true).fetchSemanticsNodes().isNotEmpty()
}
}
private fun waitForNoContentDescription(contentDescription: String, timeoutMillis: Long = 5_000) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
composeTestRule
.onAllNodesWithContentDescription(contentDescription)
.fetchSemanticsNodes()
.isEmpty()
}
}
private fun waitForRenderMode(expected: RenderMode) {
composeTestRule.waitUntil(timeoutMillis = 20_000) {
targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
.getString("render_mode", RenderMode.VERTICAL_SCROLL.name) == expected.name
}
}
private fun assertReaderSettingEventually(
prefsName: String,
key: String,
expected: Boolean
) {
composeTestRule.waitUntil(timeoutMillis = 5_000) {
targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
.getBoolean(key, !expected) == expected
}
}
private fun readFixtureRecentFile(): RecentFileEntity? {
return runBlocking {
AppDatabase.getDatabase(targetContext)
.recentFileDao()
.getFileByBookId(fixtureBookId)
}
}
private fun waitForRecentFile(
timeoutMillis: Long = 10_000,
predicate: (RecentFileEntity?) -> Boolean
) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
predicate(readFixtureRecentFile())
}
}
private fun waitForFixtureBookmarks(
timeoutMillis: Long = 10_000,
predicate: (String?) -> Boolean
) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
predicate(readFixtureRecentFile()?.bookmarks)
}
}
private fun waitForFixtureHighlights(
timeoutMillis: Long = 10_000,
predicate: (String?) -> Boolean
) {
composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
predicate(readFixtureRecentFile()?.highlights)
}
}
private fun parseBookmarksJson(rawJson: String?): Set<Bookmark> {
return EpubAnnotationSerializer.parseBookmarksJson(rawJson)
}
}

View file

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

View file

@ -0,0 +1,415 @@
// CssParserTest.kt
package org.dueattendant149.bookreader.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 {
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
private val baseFontSize = 16f
private val density = 1f
private fun parseTextColor(value: String): Color? {
val result = CssParser.parse(
cssContent = "p { color: $value; }",
cssPath = null,
baseFontSizeSp = baseFontSize,
density = density,
constraints = dummyConstraints,
isDarkTheme = false
)
return result.rules.byTag["p"]
?.firstOrNull()
?.style
?.spanStyle
?.color
?.takeIf { it.isSpecified }
}
@Test
fun parseColor_handlesNamedColorsCorrectly() {
assertThat(parseTextColor("red")).isEqualTo(Color.Red)
assertThat(parseTextColor("black")).isEqualTo(Color.Black)
assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent)
}
@Test
fun parseColor_handles3DigitHexCodes() {
assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles6DigitHexCodes() {
assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles8DigitHexCodes() {
assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204))
}
@Test
fun parseColor_handlesRgbFunction() {
assertThat(parseTextColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
}
@Test
fun parseColor_handlesRgbaFunction() {
assertThat(parseTextColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
}
@Test
fun parseColor_returnsNullForInvalidInput() {
assertThat(parseTextColor("not a color")).isNull()
assertThat(parseTextColor("#12345")).isNull()
assertThat(parseTextColor("rgb(1,2)")).isNull()
}
@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, "OEBPS/styles/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("OEBPS/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
val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid")
assertThat(style?.borderTop).isEqualTo(expectedBorder)
assertThat(style?.borderRight).isEqualTo(expectedBorder)
assertThat(style?.borderBottom).isEqualTo(expectedBorder)
assertThat(style?.borderLeft).isEqualTo(expectedBorder)
}
@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, "OEBPS/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
assertThat(result.fontFaces.first().src).isEqualTo("OEBPS/css/font.otf")
}
@Test
fun parse_handlesNestedMediaAndCalcVariables() {
val css = """
:root { --gap: 12px; }
@media screen and (min-width: 300px) {
p { margin-left: calc(var(--gap) + 8px); color: hsl(120 100% 25%); }
}
""".trimIndent()
val result = CssParser.parse(
css,
null,
baseFontSize,
density,
androidx.compose.ui.unit.Constraints(maxWidth = 500),
isDarkTheme = false
)
val style = result.rules.byTag["p"]!!.first().style
assertThat(style.blockStyle.margin.left).isEqualTo(20.dp)
assertThat(style.spanStyle.color).isEqualTo(Color(0, 128, 0))
}
@Test
fun parse_preservesBeforeAfterPseudoElementRules() {
val css = "p::before { content: 'Note: '; color: red; } p { color: blue; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val pseudoRule = result.rules.otherComplex.single { it.pseudoElement == "before" }
assertThat(pseudoRule.selector.selector).isEqualTo("p")
assertThat(pseudoRule.style.content).isEqualTo("'Note: '")
assertThat(pseudoRule.style.spanStyle.color).isEqualTo(Color.Red)
assertThat(result.rules.byTag["p"]!!.single().style.spanStyle.color).isEqualTo(Color.Blue)
}
@Test
fun parse_handlesModernRgbSlashAlphaAndCssHexAlpha() {
assertThat(parseTextColor("rgb(255 0 204 / 50%)")).isEqualTo(Color(255, 0, 204, 128))
assertThat(parseTextColor("#ff00cc80")).isEqualTo(Color(255, 0, 204, 128))
}
@Test
fun parse_backgroundShorthandExtractsColorAndImage() {
val css = "section { background: #ffeecc url('../images/paper.png') repeat; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["section"]!!.first().style.blockStyle
assertThat(style.backgroundColor).isEqualTo(Color(255, 238, 204))
assertThat(style.backgroundImage).isEqualTo("../images/paper.png")
}
@Test
fun parse_listStyleShorthandExtractsMarkerTypeAndImage() {
val css = "ul { list-style: square url('../images/bullet.png') outside; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["ul"]!!.first().style.blockStyle
assertThat(style.listStyleType).isEqualTo("square")
assertThat(style.listStyleImage).isEqualTo("../images/bullet.png")
}
@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_lineHeightPreservesUnitlessMultiplier() {
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(1.1.em)
}
}

View file

@ -0,0 +1,449 @@
// HtmlParserTest.kt
package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
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 androidHtmlToSemanticBlocks(
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(0, 128, 0))
}
@Test
fun htmlToSemanticBlocks_contextSensitiveSelectors_areNotReusedAcrossSameClass() {
val css = """
.warning p.note { color: red; }
.safe p.note { color: blue; }
""".trimIndent()
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse(
"""
<div class="warning"><p class="note">Danger</p></div>
<div class="safe"><p class="note">Okay</p></div>
""".trimIndent(),
cssRules = cssRules
)
val first = blocks[0] as SemanticParagraph
val second = blocks[1] as SemanticParagraph
assertThat(first.style.spanStyle.color).isEqualTo(Color.Red)
assertThat(second.style.spanStyle.color).isEqualTo(Color.Blue)
}
@Test
fun htmlToSemanticBlocks_generatedBeforeContent_isMaterializedIntoText() {
val css = "p.note::before { content: 'Note: '; color: red; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p class=\"note\">Remember this</p>", cssRules = cssRules)
val paragraph = blocks.single() as SemanticParagraph
assertThat(paragraph.text).isEqualTo("Note: Remember this")
val generatedSpan = paragraph.spans.first { it.tag == "::before" }
assertThat(generatedSpan.start).isEqualTo(0)
assertThat(generatedSpan.end).isEqualTo("Note: ".length)
assertThat(generatedSpan.style.spanStyle.color).isEqualTo(Color.Red)
}
@Test
fun htmlToSemanticBlocks_backgroundImageUrl_isResolvedIntoBlockStyle() {
val imageRelativeSrc = "images/paper.png"
val chapterParentDir = File(defaultChapterPath).parent ?: ""
val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
imageFile.parentFile?.mkdirs()
imageFile.createNewFile()
imageFile.deleteOnExit()
val css = "p.paper { background-image: url('$imageRelativeSrc'); }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p class=\"paper\">Text over paper</p>", cssRules = cssRules)
val paragraph = blocks.single() as SemanticParagraph
assertThat(paragraph.style.blockStyle.backgroundImage).isEqualTo(imageFile.absolutePath)
}
@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_complexInlineText_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.")
}
@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_beforePseudoElementContent_isIncludedInParagraphText() {
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)
assertThat(blocks).hasSize(1)
val pBlock = blocks[0] as SemanticParagraph
assertThat(pBlock.text).isEqualTo("Note: 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_veryLongInlineParagraph_splitsIntoBoundedParagraphs() {
val longText = "a".repeat(40_000)
val blocks = parse("<p>$longText</p>")
val paragraphs = blocks.filterIsInstance<SemanticParagraph>()
assertThat(paragraphs.size).isAtLeast(2)
assertThat(paragraphs.sumOf { it.text.length }).isEqualTo(longText.length)
assertThat(paragraphs.all { it.text.length <= 32_000 }).isTrue()
assertThat(
paragraphs.zipWithNext().all { (previous, next) ->
next.startCharOffsetInSource > previous.startCharOffsetInSource
}
).isTrue()
}
@Test
fun htmlToSemanticBlocks_deepInlineWrapperWithBlockDescendant_parsesWithoutSelectorRecursion() {
val mathId = "deep-math"
val mathPlaceholder = """<math-placeholder id="$mathId" alttext="Deep math"></math-placeholder>"""
val nestedHtml = (1..600).fold(mathPlaceholder) { content, _ ->
"<span>$content</span>"
}
val blocks = parse(
html = nestedHtml,
mathSvgCache = mapOf(mathId to "<svg><text>x</text></svg>")
)
assertThat(blocks).hasSize(1)
assertThat(blocks.first()).isInstanceOf(SemanticMath::class.java)
}
@Test
fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() {
// SETUP
val imageRootRelativeSrc = "images/test.jpg"
val imageFile = File(defaultExtractionPath, imageRootRelativeSrc).canonicalFile
imageFile.parentFile?.mkdirs()
imageFile.createNewFile()
imageFile.deleteOnExit()
// ACTION
val blocks = parse("<img src=\"$imageRootRelativeSrc\" alt=\"A test image\" />")
// ASSERT
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticImage::class.java)
val imageBlock = block as SemanticImage
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
}
}

View file

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

View file

@ -0,0 +1,123 @@
// PaginatedReaderDataTest.kt
package org.dueattendant149.bookreader.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,
borderTop = 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.borderTop).isNotNull()
assertThat(merged.borderTop?.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.borderTop).isNull()
assertThat(merged.borderRight).isNull()
assertThat(merged.borderBottom).isNull()
assertThat(merged.borderLeft).isNull()
}
}

View file

@ -0,0 +1,180 @@
package org.dueattendant149.bookreader.paginatedreader
import android.content.Context
import android.os.Build
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.graphics.Color
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 androidx.test.filters.SdkSuppress
import org.dueattendant149.bookreader.SearchResult
import org.dueattendant149.bookreader.epub.EpubBook
import com.google.common.truth.Truth.assertThat
import io.mockk.mockk
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.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: (pageIndex: Int) -> Unit
) = Unit
override fun findPageForAnchor(
chapterIndex: Int,
anchor: String?,
onResult: (pageIndex: Int) -> Unit
) = Unit
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) = Unit
override fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int? = null
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
override fun getCfiForPage(pageIndex: Int): String? = null
override fun onUserScrolledTo(pageIndex: Int) = Unit
override fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String? = null
}
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
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)
}
@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_whenPaginatorAlreadySet_keepsExistingPaginator() = runTest {
val context = ApplicationProvider.getApplicationContext<Context>()
val existingPaginator = viewModel.paginator
viewModel.initialize(
book = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Test Author",
language = "en",
coverImage = null
),
textMeasurer = mockk<TextMeasurer>(relaxed = true),
textConstraints = Constraints(maxWidth = 1080, maxHeight = 1920),
textStyle = TextStyle.Default,
density = Density(1f),
isDarkTheme = false,
themeBackgroundColor = Color.White,
themeTextColor = Color.Black,
context = context,
initialChapterToPaginate = 0,
mathMLRenderer = mockk<MathMLRenderer>(relaxed = true),
paragraphGapMultiplier = 1.0f
)
advanceUntilIdle()
assertThat(viewModel.paginator).isSameInstanceAs(existingPaginator)
}
}

View file

@ -0,0 +1,362 @@
// PaginatorTest.kt
package org.dueattendant149.bookreader.paginatedreader
import android.os.Build
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
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
}
override suspend fun split(block: TableBlock, availableHeight: Int): Pair<TableBlock, TableBlock>? = null
override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair<FlexContainerBlock, FlexContainerBlock>? = null
}
@RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatorTest {
private val testDensity = Density(density = 1f, fontScale = 1f)
private val pageHeight = 1000
private fun List<ContentBlock>.withoutMeasuredHeights(): List<ContentBlock> {
return map { it.withoutMeasuredHeight() }
}
private fun ContentBlock.withoutMeasuredHeight(): ContentBlock {
return when (this) {
is ParagraphBlock -> copy(expectedHeight = 0)
is ImageBlock -> copy(expectedHeight = 0)
is HeaderBlock -> copy(expectedHeight = 0)
is SpacerBlock -> copy(expectedHeight = 0)
is QuoteBlock -> copy(expectedHeight = 0)
is ListItemBlock -> copy(expectedHeight = 0)
is TableBlock -> copy(
rows = rows.map { row ->
row.map { cell ->
cell.copy(content = cell.content.withoutMeasuredHeights())
}
},
expectedHeight = 0
)
is MathBlock -> copy(expectedHeight = 0)
is WrappingContentBlock -> copy(
floatedImage = floatedImage.copy(expectedHeight = 0),
paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) },
expectedHeight = 0
)
is FlexContainerBlock -> copy(
children = children.withoutMeasuredHeights(),
expectedHeight = 0
)
}
}
@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.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
}
@Test
fun paginate_honorsBreakBeforePage() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
val block2 = ParagraphBlock(
content = AnnotatedString("After"),
style = BlockStyle(breakBefore = "page"),
blockIndex = 1
)
val pages = paginate(
listOf(block1, block2),
pageHeight,
FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
testDensity
)
assertThat(pages).hasSize(2)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
}
@Test
fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
val block1 = ParagraphBlock(
content = AnnotatedString("Keep together"),
style = BlockStyle(breakInside = "avoid"),
blockIndex = 0
)
val part1 = block1.copy(content = AnnotatedString("Keep"))
val part2 = block1.copy(content = AnnotatedString("together"))
val pages = paginate(
listOf(block1),
pageHeight = 400,
measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
splittableParagraphs = mapOf(block1 to (part1 to part2))
),
density = testDensity
)
assertThat(pages).hasSize(1)
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
}
@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.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content.withoutMeasuredHeights()).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.withoutMeasuredHeights()).containsExactly(splitWrapper)
assertThat(pages[1].content.withoutMeasuredHeights()).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.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).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.withoutMeasuredHeights()).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.withoutMeasuredHeights()).containsExactly(block1)
assertThat(pages[1].content.withoutMeasuredHeights()).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)
// Empty split heads are skipped so pagination keeps only the remaining content.
assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
}
}

View file

@ -0,0 +1,103 @@
package org.dueattendant149.bookreader.paginatedreader
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.sp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReaderLinkHitTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun urlAnnotationAtPositionIgnoresSameLineSpaceAfterLink() {
lateinit var text: AnnotatedString
var layoutResult: TextLayoutResult? = null
composeTestRule.setContent {
val textMeasurer = rememberTextMeasurer()
text = linkText("Open")
layoutResult = textMeasurer.measure(
text = text,
style = TextStyle(fontSize = 24.sp),
constraints = Constraints.fixedWidth(500)
)
}
composeTestRule.waitForIdle()
val layout = checkNotNull(layoutResult)
val firstBox = layout.getBoundingBox(0)
val lastBox = layout.getBoundingBox(text.length - 1)
val y = (firstBox.top + firstBox.bottom) / 2f
assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(firstBox.left + 1f, y)))
.isEqualTo(HREF)
assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(lastBox.right + 60f, y)))
.isNull()
}
@Test
fun urlAnnotationAtPositionAppliesTextStartOffsetForWrappedLineLayouts() {
lateinit var fullText: AnnotatedString
var lineLayoutResult: TextLayoutResult? = null
val prefix = "Before "
val label = "Open"
composeTestRule.setContent {
val textMeasurer = rememberTextMeasurer()
fullText = buildAnnotatedString {
append(prefix)
append(label)
addStringAnnotation("URL", HREF, prefix.length, prefix.length + label.length)
}
lineLayoutResult = textMeasurer.measure(
text = fullText.subSequence(prefix.length, fullText.length),
style = TextStyle(fontSize = 24.sp),
constraints = Constraints.fixedWidth(500)
)
}
composeTestRule.waitForIdle()
val layout = checkNotNull(lineLayoutResult)
val firstBox = layout.getBoundingBox(0)
val lastBox = layout.getBoundingBox(label.length - 1)
val y = (firstBox.top + firstBox.bottom) / 2f
assertThat(
fullText.readerUrlAnnotationAtPosition(
layout = layout,
position = Offset(firstBox.left + 1f, y),
textStartOffset = prefix.length
)
).isEqualTo(HREF)
assertThat(
fullText.readerUrlAnnotationAtPosition(
layout = layout,
position = Offset(lastBox.right + 60f, y),
textStartOffset = prefix.length
)
).isNull()
}
private fun linkText(label: String) = buildAnnotatedString {
append(label)
addStringAnnotation("URL", HREF, 0, label.length)
}
private companion object {
const val HREF = "chapter.xhtml#target"
}
}

View file

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

View file

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

View file

@ -0,0 +1,306 @@
// PdfAnnotationTest.kt
package org.dueattendant149.bookreader.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.onAllNodesWithTag
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 org.dueattendant149.bookreader.MainActivity
import org.dueattendant149.bookreader.R
import com.google.common.truth.Truth.assertThat
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 text(resId: Int): String = context.getString(resId)
private fun dockTag(resId: Int): String = "DockItem_${text(resId)}"
private fun assertNoNodeWithTag(tag: String) {
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
}
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<MainActivity>(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(text(R.string.content_desc_toggle_editing_mode))
.assertIsDisplayed()
.performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_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(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsDisplayed()
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)).assertIsDisplayed()
}
// --- TOOL LOGIC TESTS ---
@Test
fun testToolPersistence() {
enterEditMode()
// 1. Select Highlighter
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).performClick()
composeTestRule.waitForIdle()
// 2. Verify selection state
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsNotSelected()
// 3. Exit Edit Mode
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
composeTestRule.waitForIdle()
// 4. Re-enter Edit Mode
enterEditMode()
// 5. Verify Highlighter is STILL selected (Persistence)
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
}
@Test
fun testEraserSettingsPopupOpensWhenAlreadySelected() {
enterEditMode()
// Select Eraser
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsSelected()
// Click Eraser again to open its settings popup.
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed()
}
// --- 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(dockTag(R.string.content_desc_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()
assertNoNodeWithTag("ToolSettingsPopup")
}
@Test
fun testColorPaletteAndThickness() {
enterEditMode()
// Open Settings for Pen (Default selected, so one click opens settings)
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_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(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
}
// --- UNDO/REDO TESTS ---
@Test
fun testDrawingEnablesUndo() {
enterEditMode()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_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(text(R.string.content_desc_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(text(R.string.content_desc_undo))
val redoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_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(dockTag(R.string.content_desc_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(text(R.string.content_desc_toggle_visibility)).performClick()
composeTestRule.waitForIdle()
// 3. Verify Dock items are hidden
assertNoNodeWithTag(dockTag(R.string.content_desc_pen))
// 4. Verify "Show Dock" floating button is visible
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).assertIsDisplayed()
// 5. Restore
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).performClick()
composeTestRule.waitForIdle()
// 6. Verify Dock items return
composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
}
}

View file

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

View file

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

View file

@ -0,0 +1,258 @@
package org.dueattendant149.bookreader.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.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
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.performClick
import androidx.compose.ui.test.performTextInput
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 androidx.test.rule.GrantPermissionRule
import org.dueattendant149.bookreader.MainActivity
import org.dueattendant149.bookreader.R
import com.google.common.truth.Truth.assertThat
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 PdfViewerScreenTest {
@get:Rule
val composeTestRule = createEmptyComposeRule()
@get:Rule
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
private val context: Context = ApplicationProvider.getApplicationContext()
private var currentPdfFile: File? = null
private var scenario: ActivityScenario<MainActivity>? = null
@Before
fun setup() {
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
val samplePdfUri = copyAssetToCache(context, "sample.pdf")
scenario = ActivityScenario.launch<MainActivity>(createPdfViewIntent(context, samplePdfUri))
}
@After
fun tearDown() {
scenario?.close()
currentPdfFile?.let {
if (it.exists()) it.delete()
}
}
private fun text(resId: Int, vararg args: Any): String = context.getString(resId, *args)
private fun assertNoNodeWithTag(tag: String) {
assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
}
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 fun waitForDocumentLoad(pageText: String = text(R.string.page_of_pages, 1, 4)) {
composeTestRule.waitUntil(timeoutMillis = 15_000) {
composeTestRule
.onAllNodesWithText(pageText)
.fetchSemanticsNodes().isNotEmpty()
}
}
private fun openMoreOptions() {
composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_more_options)).performClick()
}
private fun openNavigationDrawer() {
composeTestRule.onNodeWithTag("TocButton").performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text(R.string.tab_chapters)).fetchSemanticsNodes().isNotEmpty() &&
composeTestRule.onAllNodesWithTag("BookmarksTab").fetchSemanticsNodes().isNotEmpty()
}
}
private fun selectChaptersTabIfTabsPaneIsFirst() {
if (composeTestRule.onAllNodesWithTag("TabsTab").fetchSemanticsNodes().isNotEmpty()) {
composeTestRule.onNodeWithText(text(R.string.tab_chapters)).performClick()
composeTestRule.waitForIdle()
}
}
private fun selectBookmarksTab() {
composeTestRule.onNodeWithTag("BookmarksTab").performClick()
composeTestRule.waitForIdle()
}
private fun selectReadingMode(modeText: String) {
openMoreOptions()
composeTestRule.onNodeWithText(text(R.string.menu_change_reading_mode)).performClick()
composeTestRule.onNodeWithText(modeText).performClick()
composeTestRule.waitForIdle()
}
private fun ensurePaginationMode() {
selectReadingMode(text(R.string.menu_reading_mode_paginated))
}
@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 documentLoadsAndDisplaysCorrectPageCount() {
waitForDocumentLoad()
composeTestRule.onNodeWithTag("PageNumberIndicator")
.assertIsDisplayed()
}
@Test
fun tableOfContentsButton_handlesTabsPaneAndOpensChaptersTab() {
waitForDocumentLoad()
openNavigationDrawer()
selectChaptersTabIfTabsPaneIsFirst()
composeTestRule.onNodeWithText(text(R.string.tab_chapters)).assertIsDisplayed()
composeTestRule.onNodeWithTag("BookmarksTab").assertIsDisplayed()
}
@Test
fun bookmarkFunctionality_addAndDeleteCurrentPage() {
waitForDocumentLoad()
openMoreOptions()
composeTestRule.onNodeWithText(text(R.string.menu_bookmark_this_page)).performClick()
composeTestRule.waitForIdle()
openNavigationDrawer()
selectBookmarksTab()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithTag("BookmarkItem_0").assertIsDisplayed()
.assert(hasText(text(R.string.pdf_page_short, 1), substring = true))
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_more_options_bookmark)).performClick()
composeTestRule.onNodeWithText(text(R.string.action_delete)).performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(text(R.string.action_delete), useUnmergedTree = true).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isEmpty()
}
assertNoNodeWithTag("BookmarkItem_0")
composeTestRule.onNodeWithText(text(R.string.no_bookmarks_yet)).assertIsDisplayed()
}
@Test
fun sliderNavigation_opensAndDisplaysCorrectly() {
waitForDocumentLoad()
composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_navigate_slider)).performClick()
composeTestRule.onNodeWithText(text(R.string.page_format, 1, 4)).assertIsDisplayed()
}
@Test
fun displayMode_switchesToVerticalScroll() {
waitForDocumentLoad()
ensurePaginationMode()
assertNoNodeWithTag("PdfVerticalScroll")
selectReadingMode(text(R.string.menu_reading_mode_vertical))
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
ensurePaginationMode()
assertNoNodeWithTag("PdfVerticalScroll")
}
@Test
fun displayModeSelectionPersistsReaderPreference() {
waitForDocumentLoad()
selectReadingMode(text(R.string.menu_reading_mode_paginated))
waitForDisplayModePreference(DisplayMode.PAGINATION)
selectReadingMode(text(R.string.menu_reading_mode_vertical))
waitForDisplayModePreference(DisplayMode.VERTICAL_SCROLL)
}
@Test
fun search_uiOpensAndAcceptsQuery() {
waitForDocumentLoad()
composeTestRule.onNodeWithTag("SearchButton").performClick()
composeTestRule.waitForIdle()
val ocrLanguageText = text(R.string.ocr_language_latin)
if (composeTestRule.onAllNodesWithText(ocrLanguageText).fetchSemanticsNodes().isNotEmpty()) {
composeTestRule.onNodeWithText(ocrLanguageText).performClick()
composeTestRule.waitForIdle()
}
composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_close_search)).performClick()
assertNoNodeWithTag("SearchTextField")
}
private fun waitForDisplayModePreference(expected: DisplayMode) {
composeTestRule.waitUntil(timeoutMillis = 5_000) {
context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
.getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name) == expected.name
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,71 @@
// TtsUtilsTest.kt
package org.dueattendant149.bookreader.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()
}
}