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:
parent
e615128a23
commit
5f64f3d722
631 changed files with 3082 additions and 3006 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue