Linux support (#381)
* Add desktop release CI and support for Arch Linux packaging * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Configure Gradle and update Java environment in desktop-release workflow * Update Java setup and AUR packaging in desktop release workflow * Update Java setup and AUR packaging in desktop release workflow * Add MSIX packaging support for Windows desktop distribution * Update AUR packaging metadata and validation * Use spine toc attribute for NCX resolution * crash fixes * Implement automatic discovery and injection of EPUB font face siblings * Enhance custom font support with family grouping and variable font handling * Optimize metadata loading and improve TTS highlighting * Add keyboard navigation support for EPUB reader * Refine PDF spread page sizing to respect aspect ratios * Implement responsive maximum height for reader popups and sheets * Handle TTS generation failures by skipping problematic chunks * Refactor PDF tile rendering logic and zoom indicator behavior * Prefer block and offset locators over page index in native vertical flow * Implement save and share actions for original book files * Add Estonian language support * Implement temporary viewing mode for external files * Implement direct opening for temporary external files without library persistence * fix failing tests * Import SharedFileCapabilities in DesktopLibraryUi * Improve native vertical reader progress, persistence, and image support * Center target in viewport for native vertical reader and support animated scrolling
This commit is contained in:
parent
a13d6599d1
commit
625a4d5d2e
102 changed files with 6012 additions and 687 deletions
|
|
@ -12,16 +12,28 @@ class AndroidStringFormatResourcesTest {
|
|||
|
||||
@Test
|
||||
fun `vietnamese strings cover translatable base resources`() {
|
||||
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-vi", localeName = "Vietnamese")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `estonian strings cover translatable base resources`() {
|
||||
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-et", localeName = "Estonian")
|
||||
}
|
||||
|
||||
private fun assertLocaleCoversTranslatableBaseResources(
|
||||
localeDirectory: String,
|
||||
localeName: String
|
||||
) {
|
||||
val resDirectory = findResDirectory()
|
||||
val baseNames = readResourceNames(
|
||||
stringsFile = File(resDirectory, "values/strings.xml"),
|
||||
includeNonTranslatable = false
|
||||
)
|
||||
val vietnameseNames = readResourceNames(File(resDirectory, "values-vi/strings.xml"))
|
||||
val missingNames = baseNames.filterNot { it in vietnameseNames }
|
||||
val localizedNames = readResourceNames(File(resDirectory, "$localeDirectory/strings.xml"))
|
||||
val missingNames = baseNames.filterNot { it in localizedNames }
|
||||
|
||||
assertTrue(
|
||||
"Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}",
|
||||
"Missing $localeName strings:\n${missingNames.joinToString(separator = "\n")}",
|
||||
missingNames.isEmpty()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ class AppLanguageOptionsTest {
|
|||
assertEquals(
|
||||
listOf(
|
||||
"en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl",
|
||||
"id", "vi", "ja", "ko", "hi", "zh-CN"
|
||||
"id", "vi", "ja", "ko", "hi", "zh-CN", "et"
|
||||
),
|
||||
supportedAppLanguageOptions.mapNotNull { it.tag }
|
||||
)
|
||||
assertEquals(R.string.language_chinese_simplified, supportedAppLanguageOptions.last().labelRes)
|
||||
assertEquals(R.string.language_estonian, supportedAppLanguageOptions.last().labelRes)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -51,6 +51,7 @@ class AppLanguageOptionsTest {
|
|||
val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" }
|
||||
val japanese = supportedAppLanguageOptions.first { it.tag == "ja" }
|
||||
val korean = supportedAppLanguageOptions.first { it.tag == "ko" }
|
||||
val estonian = supportedAppLanguageOptions.first { it.tag == "et" }
|
||||
|
||||
assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce"))
|
||||
assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch"))
|
||||
|
|
@ -66,6 +67,7 @@ class AppLanguageOptionsTest {
|
|||
assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet"))
|
||||
assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo"))
|
||||
assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul"))
|
||||
assertTrue(estonian.matchesLanguageSearch(label = "Eesti", query = "eesti"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
17
app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt
Normal file
17
app/src/test/java/com/aryan/reader/ClipboardUtilsTest.kt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ClipboardUtilsTest {
|
||||
@Test
|
||||
fun `set primary clip reports success`() {
|
||||
assertTrue(setPrimaryClipSafely {})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set primary clip handles security rejection`() {
|
||||
assertFalse(setPrimaryClipSafely { throw SecurityException("denied") })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ExternalFileOpenRouteDeciderTest {
|
||||
@Test
|
||||
fun `temporary behavior routes to temporary activity`() {
|
||||
assertTrue(ExternalFileOpenRouteDecider.shouldOpenTemporary("TEMPORARY"))
|
||||
assertEquals(
|
||||
TemporaryExternalFileActivity::class.java,
|
||||
ExternalFileOpenRouteDecider.targetActivityClass("TEMPORARY")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing behaviors route to main activity`() {
|
||||
listOf(null, "ASK", "KEEP", "DELETE").forEach { behavior ->
|
||||
assertFalse(ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior))
|
||||
assertEquals(
|
||||
MainActivity::class.java,
|
||||
ExternalFileOpenRouteDecider.targetActivityClass(behavior)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ContentResolver
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
|
|
@ -22,6 +23,7 @@ import com.aryan.reader.tts.TtsPlaybackManager
|
|||
import io.mockk.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
|
@ -45,6 +47,7 @@ class MainViewModelTest {
|
|||
private lateinit var mockApplication: Application
|
||||
private lateinit var mockPrefs: SharedPreferences
|
||||
private lateinit var mockEditor: SharedPreferences.Editor
|
||||
private val prefsStringSets = mutableMapOf<String, Set<String>>()
|
||||
|
||||
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
|
||||
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
|
||||
|
|
@ -64,6 +67,12 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
private class TestMainViewModel(application: Application) : MainViewModel(application) {
|
||||
val locallyCleanedBookIds = mutableListOf<String>()
|
||||
|
||||
override suspend fun cleanupBookDataLocally(bookId: String) {
|
||||
locallyCleanedBookIds += bookId
|
||||
}
|
||||
|
||||
fun clearForTest() {
|
||||
ViewModel::class.java
|
||||
.getDeclaredMethod("clear\$lifecycle_viewmodel_release")
|
||||
|
|
@ -83,6 +92,7 @@ class MainViewModelTest {
|
|||
billingStateFlow.value = ProUpgradeState()
|
||||
customFontsFlow.value = emptyList()
|
||||
ttsStateFlow.value = TtsPlaybackManager.TtsState()
|
||||
prefsStringSets.clear()
|
||||
|
||||
mockkStatic(Log::class)
|
||||
every { Log.isLoggable(any(), any()) } returns false
|
||||
|
|
@ -109,9 +119,14 @@ class MainViewModelTest {
|
|||
every { mockApplication.filesDir } returns filesDir
|
||||
every { mockApplication.cacheDir } returns cacheDir
|
||||
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
|
||||
every { mockApplication.getString(any()) } answers { "res-${firstArg<Int>()}" }
|
||||
every { mockApplication.getString(any(), *anyVararg()) } answers { "res-${firstArg<Int>()}" }
|
||||
every { mockPrefs.edit() } returns mockEditor
|
||||
|
||||
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
|
||||
every { mockPrefs.getStringSet(any(), any()) } answers {
|
||||
prefsStringSets[firstArg<String>()]?.toMutableSet() ?: secondArg<Set<String>?>()?.toMutableSet()
|
||||
}
|
||||
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
|
||||
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
|
||||
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
|
||||
|
|
@ -174,6 +189,7 @@ class MainViewModelTest {
|
|||
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().addRecentFile(any()) } just Runs
|
||||
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
|
||||
|
||||
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
|
||||
|
|
@ -417,38 +433,39 @@ class MainViewModelTest {
|
|||
viewModel.setStrictFileFilter(true)
|
||||
viewModel.setUsePdfFileNameAsDisplayName(true)
|
||||
viewModel.setExternalFileBehavior("KEEP")
|
||||
viewModel.setExternalFileBehavior("TEMPORARY")
|
||||
|
||||
val state = viewModel.uiState.first {
|
||||
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP"
|
||||
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "TEMPORARY"
|
||||
}
|
||||
assertTrue(state.useStrictFileFilter)
|
||||
assertTrue(state.usePdfFileNameAsDisplayName)
|
||||
assertEquals("KEEP", state.externalFileBehavior)
|
||||
assertEquals("TEMPORARY", state.externalFileBehavior)
|
||||
verify { mockEditor.putBoolean("use_strict_file_filter", true) }
|
||||
verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) }
|
||||
verify { mockEditor.putString("external_file_behavior", "KEEP") }
|
||||
verify { mockEditor.putString("external_file_behavior", "TEMPORARY") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) {
|
||||
val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub"
|
||||
val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}"""
|
||||
every {
|
||||
mockPrefs.getStringSet("pending_external_file_removals", any())
|
||||
} returns mutableSetOf(pendingEntry)
|
||||
prefsStringSets["pending_external_file_removals"] = setOf(pendingEntry)
|
||||
every { mockPrefs.getString("last_open_book_id", null) } returns "external-book"
|
||||
every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name
|
||||
|
||||
val restored = TestMainViewModel(mockApplication)
|
||||
try {
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify {
|
||||
coVerify(timeout = 1_000) {
|
||||
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
|
||||
}
|
||||
|
||||
coVerify {
|
||||
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
|
||||
}
|
||||
assertEquals(listOf("external-book"), restored.locallyCleanedBookIds)
|
||||
verify(atLeast = 1) { mockEditor.remove("last_open_book_id") }
|
||||
verify(atLeast = 1) { mockEditor.remove("last_open_file_type") }
|
||||
verify { mockEditor.remove("pending_external_file_removals") }
|
||||
|
|
@ -457,6 +474,60 @@ class MainViewModelTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `temporary external pdf opens directly without importing or adding to library`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
val externalUri = mockUri("content://external/temp.pdf", path = "/temp.pdf", lastPathSegment = "temp.pdf")
|
||||
val resolver = mockk<ContentResolver>()
|
||||
every { mockApplication.contentResolver } returns resolver
|
||||
every { resolver.getType(externalUri) } returns "application/pdf"
|
||||
every { resolver.query(externalUri, null, null, null, null) } returns null
|
||||
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(match { it.startsWith("temporary-") }) } returns null
|
||||
|
||||
viewModel.onFileSelected(
|
||||
externalUri,
|
||||
isFromRecent = false,
|
||||
isExternalIntent = true,
|
||||
isTemporaryExternalIntent = true
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val selected = viewModel.uiState.first { it.selectedBookId?.startsWith("temporary-") == true && it.selectedPdfUri != null }
|
||||
assertEquals(externalUri, selected.selectedPdfUri)
|
||||
assertEquals(null, selected.showExternalFileSavePromptFor)
|
||||
coVerify(exactly = 0) { anyConstructed<BookImporter>().importBook(any()) }
|
||||
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().addRecentFile(any()) }
|
||||
verify(exactly = 0) { mockEditor.putStringSet("pending_external_file_removals", any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing temporary external direct book signals activity finish without library cleanup`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
val item = recentFile("external-book", type = FileType.PDF)
|
||||
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(item.bookId) } returns item
|
||||
viewModel.trackExternalOpenForClose(
|
||||
bookId = item.bookId,
|
||||
importedCopyUriString = null,
|
||||
isTemporaryExternalIntent = true
|
||||
)
|
||||
viewModel.onRecentFileClicked(item)
|
||||
advanceUntilIdle()
|
||||
viewModel.uiState.first { it.selectedBookId == item.bookId }
|
||||
val finishEvent = backgroundScope.async { viewModel.temporaryExternalOpenFinished.first() }
|
||||
|
||||
viewModel.clearSelectedFile()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(null, viewModel.uiState.value.showExternalFileSavePromptFor)
|
||||
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf(item.bookId)) }
|
||||
coVerify(exactly = 0) { anyConstructed<BookImporter>().deleteBookByUriString(item.uriString!!) }
|
||||
assertTrue(finishEvent.isCompleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
|
|
|
|||
22
app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt
Normal file
22
app/src/test/java/com/aryan/reader/ReaderPopupSizingTest.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ReaderPopupSizingTest {
|
||||
|
||||
@Test
|
||||
fun `modal max height leaves edge margin on landscape-height screens`() {
|
||||
assertEquals(306, readerModalMaxHeightDp(screenHeightDp = 360))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modal max height uses preferred minimum when there is room`() {
|
||||
assertEquals(220, readerModalMaxHeightDp(screenHeightDp = 252))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modal max height stays within tiny screens`() {
|
||||
assertEquals(168, readerModalMaxHeightDp(screenHeightDp = 200))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.aryan.reader.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImportedFontFileNameTest {
|
||||
@Test
|
||||
fun importedFontFileNamePreservesVariableFontVariantTokens() {
|
||||
val fileName = importedFontFileName(
|
||||
displayName = "Pliant-Italic-VariableFont_wdth,wght",
|
||||
extension = "TTF"
|
||||
)
|
||||
|
||||
assertEquals("Pliant-Italic-VariableFont_wdth,wght.ttf", fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedFontFileNameRemovesPathUnsafeCharacters() {
|
||||
val fileName = importedFontFileName(
|
||||
displayName = """Pliant/Italic:VariableFont*wdth?wght""",
|
||||
extension = "t/tf"
|
||||
)
|
||||
|
||||
assertEquals("Pliant_Italic_VariableFont_wdth_wght.ttf", fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importedFontFileNameFallsBackForBlankNames() {
|
||||
assertTrue(importedFontFileName("...", "ttf").startsWith("font."))
|
||||
}
|
||||
}
|
||||
|
|
@ -105,6 +105,26 @@ class RecentFileDaoReadingPositionTest {
|
|||
assertTrue(item.isRecent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent file summary caps oversized descriptions while full lookup keeps metadata`() = runTest {
|
||||
val longDescription = "Summary ".repeat(2_000)
|
||||
val longOriginalDescription = "Original ".repeat(2_000)
|
||||
dao.insertOrUpdateFile(
|
||||
recentFileEntity().copy(
|
||||
description = longDescription,
|
||||
originalDescription = longOriginalDescription
|
||||
)
|
||||
)
|
||||
|
||||
val summary = dao.getRecentFiles().first().single()
|
||||
val full = dao.getFileByBookId("book-1")!!
|
||||
|
||||
assertEquals(4_096, summary.description?.length)
|
||||
assertEquals(4_096, summary.originalDescription?.length)
|
||||
assertEquals(longDescription, full.description)
|
||||
assertEquals(longOriginalDescription, full.originalDescription)
|
||||
}
|
||||
|
||||
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
|
||||
return RecentFileEntity(
|
||||
bookId = "book-1",
|
||||
|
|
|
|||
|
|
@ -104,6 +104,30 @@ class EpubParserUnitTest {
|
|||
assertTrue(extractionDir.list().isNullOrEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createEpubBook uses spine toc id when manifest contains volume ncx files first`() = runTest {
|
||||
val cacheDir = temp.newFolder("cache-merged-toc")
|
||||
val extractionDir = temp.newFolder("extract-merged-toc")
|
||||
val parser = EpubParser(contextWithCache(cacheDir))
|
||||
|
||||
val book = parser.createEpubBook(
|
||||
inputStream = ByteArrayInputStream(mergedVolumeTocEpubBytes()),
|
||||
bookId = "book-id",
|
||||
shouldUseToc = true,
|
||||
originalBookNameHint = "merged.epub",
|
||||
parseContent = true,
|
||||
extractionDirOverride = extractionDir
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
|
||||
book.tableOfContents.map { it.label }
|
||||
)
|
||||
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
|
||||
assertEquals("Volume 2", book.chapters[2].title)
|
||||
assertEquals("Chapter 2", book.chapters[3].title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata only extraction streams images to disk without retaining image bytes`() {
|
||||
val cacheDir = temp.newFolder("cache-metadata-stream")
|
||||
|
|
@ -449,6 +473,50 @@ class EpubParserUnitTest {
|
|||
"OEBPS/images/unlisted.png" to "not-real-image"
|
||||
)
|
||||
|
||||
private fun mergedVolumeTocEpubBytes(): ByteArray = zipBytes(
|
||||
"META-INF/container.xml" to """
|
||||
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
|
||||
""".trimIndent(),
|
||||
"OEBPS/content.opf" to """
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata><dc:title>Merged Volumes</dc:title></metadata>
|
||||
<manifest>
|
||||
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
</manifest>
|
||||
<spine toc="ncx">
|
||||
<itemref idref="v1title"/>
|
||||
<itemref idref="v1c1"/>
|
||||
<itemref idref="v2title"/>
|
||||
<itemref idref="v2c1"/>
|
||||
</spine>
|
||||
</package>
|
||||
""".trimIndent(),
|
||||
"OEBPS/1/toc.ncx" to """
|
||||
<ncx><navMap>
|
||||
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
|
||||
</navMap></ncx>
|
||||
""".trimIndent(),
|
||||
"OEBPS/toc.ncx" to """
|
||||
<ncx><navMap>
|
||||
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
|
||||
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
|
||||
</navPoint>
|
||||
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
|
||||
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
|
||||
</navPoint>
|
||||
</navMap></ncx>
|
||||
""".trimIndent(),
|
||||
"OEBPS/1/title.xhtml" to "<html><body><h1>HTML Volume 1</h1><p>Volume one.</p></body></html>",
|
||||
"OEBPS/1/chapter1.xhtml" to "<html><body><h1>HTML Chapter 1</h1><p>Chapter one.</p></body></html>",
|
||||
"OEBPS/2/title.xhtml" to "<html><body><h1>HTML Volume 2</h1><p>Volume two.</p></body></html>",
|
||||
"OEBPS/2/chapter1.xhtml" to "<html><body><h1>HTML Chapter 2</h1><p>Chapter two.</p></body></html>"
|
||||
)
|
||||
|
||||
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
|
||||
"META-INF/container.xml" to """
|
||||
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.aryan.reader.epubreader
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class EpubReaderTtsHighlightAssetTest {
|
||||
|
||||
@Test
|
||||
fun `tts highlight is constrained to one readable block and does not inherit spacing`() {
|
||||
val js = epubReaderAsset().readText()
|
||||
|
||||
assertTrue(js.contains("const TTS_HIGHLIGHT_BLOCK_SELECTOR"))
|
||||
assertTrue(js.contains("getTtsHighlightBlock(baseNode)"))
|
||||
assertTrue(js.contains("document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT"))
|
||||
assertTrue(js.contains("text-align-last: auto !important;"))
|
||||
assertTrue(js.contains("letter-spacing: normal !important;"))
|
||||
assertTrue(js.contains("word-spacing: normal !important;"))
|
||||
}
|
||||
|
||||
private fun epubReaderAsset(): File {
|
||||
val candidates = listOf(
|
||||
File("src/main/assets/epub_reader.js"),
|
||||
File("app/src/main/assets/epub_reader.js")
|
||||
)
|
||||
return candidates.firstOrNull { it.isFile }
|
||||
?: error("Unable to locate epub_reader.js from ${File(".").absolutePath}")
|
||||
}
|
||||
}
|
||||
|
|
@ -62,4 +62,45 @@ class EpubTtsChunkMatchingTest {
|
|||
|
||||
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical continuation falls back to loaded chunk boundary when resume match is unavailable`() {
|
||||
val chunks = listOf(
|
||||
TtsChunk("Loaded one", "/4/2", 0),
|
||||
TtsChunk("Loaded two", "/4/4", 0),
|
||||
TtsChunk("Remaining three", "/4/6", 0),
|
||||
TtsChunk("Remaining four", "/4/8", 0)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
resolveTtsContinuationStartIndex(
|
||||
chunks = chunks,
|
||||
loadedChunkCount = 2,
|
||||
sourceCfi = "/does/not/match",
|
||||
startOffsetInSource = 0,
|
||||
currentText = "not present"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical continuation starts after matched spoken chunk`() {
|
||||
val chunks = listOf(
|
||||
TtsChunk("Loaded one", "/4/2", 0),
|
||||
TtsChunk("Loaded two", "/4/4", 0),
|
||||
TtsChunk("Remaining three", "/4/6", 0)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
resolveTtsContinuationStartIndex(
|
||||
chunks = chunks,
|
||||
loadedChunkCount = 1,
|
||||
sourceCfi = "/4/4",
|
||||
startOffsetInSource = 0,
|
||||
currentText = "Loaded two"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import android.view.KeyEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidEpubKeyCommandsTest {
|
||||
@Test
|
||||
fun `left and right map to page changes`() {
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_LEFT)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left and right respect right to left pagination`() {
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||
androidEpubKeyCommandOrNull(
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||
androidEpubKeyCommandOrNull(
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up and down map to vertical scroll`() {
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.SCROLL_UP,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_UP)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.SCROLL_DOWN,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_DOWN)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page home and end keys map to reader navigation`() {
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.PREVIOUS_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_UP)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.NEXT_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_DOWN)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.FIRST_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_HOME)
|
||||
)
|
||||
assertEquals(
|
||||
AndroidEpubKeyCommand.LAST_PAGE,
|
||||
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_END)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ctrl shortcuts are left for reader chrome and search handling`() {
|
||||
assertNull(androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT, isCtrlPressed = true))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class EpubFontFaceSiblingsTest {
|
||||
|
||||
@Test
|
||||
fun expandFontFacesWithSiblings_addsItalicAndBoldItalicVariants() {
|
||||
val root = createTempRoot()
|
||||
val fontsDir = File(root, "OEBPS/fonts").apply { mkdirs() }
|
||||
File(fontsDir, "Literata-Regular.ttf").writeText("regular")
|
||||
File(fontsDir, "Literata-Italic.ttf").writeText("italic")
|
||||
File(fontsDir, "Literata-BoldItalic.ttf").writeText("bold italic")
|
||||
File(fontsDir, "Other-Italic.ttf").writeText("other")
|
||||
|
||||
val expanded = expandFontFacesWithSiblings(
|
||||
fontFaces = listOf(
|
||||
FontFaceInfo(
|
||||
fontFamily = "literata",
|
||||
src = "OEBPS/fonts/Literata-Regular.ttf",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal
|
||||
)
|
||||
),
|
||||
extractionPath = root.absolutePath
|
||||
)
|
||||
|
||||
assertEquals(3, expanded.size)
|
||||
assertTrue(expanded.any { it.src == "OEBPS/fonts/Literata-Italic.ttf" && it.fontStyle == FontStyle.Italic })
|
||||
assertTrue(
|
||||
expanded.any {
|
||||
it.src == "OEBPS/fonts/Literata-BoldItalic.ttf" &&
|
||||
it.fontStyle == FontStyle.Italic &&
|
||||
it.fontWeight == FontWeight.Bold
|
||||
}
|
||||
)
|
||||
assertTrue(expanded.none { it.src.contains("Other") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildEpubFontFaceCss_emitsVariantDescriptorsForSiblings() {
|
||||
val root = createTempRoot()
|
||||
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||
File(fontsDir, "LoraRegular.ttf").writeText("regular")
|
||||
File(fontsDir, "LoraBoldItalic.ttf").writeText("bold italic")
|
||||
|
||||
val css = buildEpubFontFaceCss(
|
||||
fontFaces = listOf(
|
||||
FontFaceInfo(
|
||||
fontFamily = "lora",
|
||||
src = "fonts/LoraRegular.ttf",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal
|
||||
)
|
||||
),
|
||||
extractionPath = root.absolutePath
|
||||
)
|
||||
|
||||
assertTrue(css.contains("font-family: 'lora'"))
|
||||
assertTrue(css.contains("font-weight: 700"))
|
||||
assertTrue(css.contains("font-style: italic"))
|
||||
assertTrue(css.contains("LoraBoldItalic.ttf"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandFontFacesWithSiblings_groupsVariableRegularAndItalicFiles() {
|
||||
val root = createTempRoot()
|
||||
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
|
||||
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
|
||||
|
||||
val expanded = expandFontFacesWithSiblings(
|
||||
fontFaces = listOf(
|
||||
FontFaceInfo(
|
||||
fontFamily = "pliant",
|
||||
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal
|
||||
)
|
||||
),
|
||||
extractionPath = root.absolutePath
|
||||
)
|
||||
|
||||
assertEquals(2, expanded.size)
|
||||
assertTrue(
|
||||
expanded.any {
|
||||
it.src == "fonts/Pliant-Italic-VariableFont_wdth,wght.ttf" &&
|
||||
it.fontStyle == FontStyle.Italic &&
|
||||
it.fontWeight == FontWeight.Normal
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildEpubFontFaceCss_usesWeightRangeForVariableWeightFonts() {
|
||||
val root = createTempRoot()
|
||||
val fontsDir = File(root, "fonts").apply { mkdirs() }
|
||||
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
|
||||
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
|
||||
|
||||
val css = buildEpubFontFaceCss(
|
||||
fontFaces = listOf(
|
||||
FontFaceInfo(
|
||||
fontFamily = "pliant",
|
||||
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal
|
||||
)
|
||||
),
|
||||
extractionPath = root.absolutePath
|
||||
)
|
||||
|
||||
assertTrue(css.contains("font-weight: 100 900"))
|
||||
assertTrue(css.contains("font-style: italic"))
|
||||
assertTrue(css.contains("Pliant-Italic-VariableFont_wdth,wght.ttf"))
|
||||
}
|
||||
|
||||
private fun createTempRoot(): File {
|
||||
return kotlin.io.path.createTempDirectory("epub-font-siblings").toFile().also {
|
||||
it.deleteOnExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeVerticalLocationTest {
|
||||
|
|
@ -27,4 +28,157 @@ class NativeVerticalLocationTest {
|
|||
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
|
||||
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll progress updates within visible item offset`() {
|
||||
val weights = listOf(100, 300, 600)
|
||||
|
||||
assertEquals(
|
||||
25f,
|
||||
estimateNativeVerticalWeightedScrollProgressPercent(
|
||||
itemWeights = weights,
|
||||
firstVisibleItemIndex = 1,
|
||||
firstVisibleItemScrollOffset = 500,
|
||||
firstVisibleItemSize = 1000
|
||||
),
|
||||
0.001f
|
||||
)
|
||||
assertEquals(
|
||||
40f,
|
||||
estimateNativeVerticalWeightedScrollProgressPercent(
|
||||
itemWeights = weights,
|
||||
firstVisibleItemIndex = 1,
|
||||
firstVisibleItemScrollOffset = 1000,
|
||||
firstVisibleItemSize = 1000
|
||||
),
|
||||
0.001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter page info uses chapter local locator offset`() {
|
||||
val pageInfo = nativeVerticalChapterPageInfo(
|
||||
chapterCharOffset = 500,
|
||||
chapterLengthChars = 1000,
|
||||
chapterPageCount = 11,
|
||||
compatPageIndex = 900,
|
||||
chapterStartPageIndex = 850
|
||||
)
|
||||
|
||||
assertEquals(6, pageInfo?.currentPage)
|
||||
assertEquals(11, pageInfo?.totalPages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter page info falls back to absolute page within chapter`() {
|
||||
val pageInfo = nativeVerticalChapterPageInfo(
|
||||
chapterCharOffset = null,
|
||||
chapterLengthChars = 0,
|
||||
chapterPageCount = 7,
|
||||
compatPageIndex = 24,
|
||||
chapterStartPageIndex = 20
|
||||
)
|
||||
|
||||
assertEquals(5, pageInfo?.currentPage)
|
||||
assertEquals(7, pageInfo?.totalPages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter page info follows scroll weight within current chapter`() {
|
||||
val pageInfo = nativeVerticalChapterPageInfoForScroll(
|
||||
itemChapterIndices = listOf(0, 0, 1, 1),
|
||||
itemWeights = listOf(100, 300, 100, 300),
|
||||
firstVisibleItemIndex = 1,
|
||||
firstVisibleItemScrollOffset = 500,
|
||||
firstVisibleItemSize = 1000,
|
||||
chapterPageCount = 9
|
||||
)
|
||||
|
||||
assertEquals(6, pageInfo?.currentPage)
|
||||
assertEquals(9, pageInfo?.totalPages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical image model decodes svg data uris for coil svg fetcher`() {
|
||||
val model = nativeVerticalImageModelData(
|
||||
"data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2010%2010%22%3E%3Ccircle%20r%3D%225%22%2F%3E%3C%2Fsvg%3E"
|
||||
)
|
||||
|
||||
assertTrue(model is SvgData)
|
||||
assertEquals("""<svg viewBox="0 0 10 10"><circle r="5"/></svg>""", (model as SvgData).content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical svg data uri decoding preserves plus signs`() {
|
||||
assertEquals(
|
||||
"""<svg><path d="M1+2"/></svg>""",
|
||||
nativeVerticalSvgContentFromDataUri(
|
||||
"data:image/svg+xml,%3Csvg%3E%3Cpath%20d%3D%22M1+2%22%2F%3E%3C%2Fsvg%3E"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical persistence locator prefers visible text range`() {
|
||||
val location = NativeVerticalLocation(
|
||||
locator = Locator(chapterIndex = 2, blockIndex = 10, charOffset = 100),
|
||||
chapterIndex = 2,
|
||||
progressPercent = 42f,
|
||||
compatPageIndex = 20,
|
||||
compatTotalPages = 100,
|
||||
firstVisibleItemIndex = 4,
|
||||
firstVisibleItemScrollOffset = 250,
|
||||
firstVisibleItemSize = 1000,
|
||||
isAtStart = false,
|
||||
isAtEnd = false,
|
||||
visibleTextRanges = listOf(
|
||||
NativeVerticalVisibleTextRange(
|
||||
chapterIndex = 2,
|
||||
blockIndex = 10,
|
||||
startCharOffset = 380,
|
||||
endCharOffset = 520
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(Locator(chapterIndex = 2, blockIndex = 10, charOffset = 380), location.locatorForPersistence())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical initial restore does not fallback to compat page when locator exists`() {
|
||||
assertEquals(
|
||||
false,
|
||||
shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||
hasInitialLocator = true,
|
||||
didLocatorScroll = false
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
shouldFallbackNativeVerticalInitialScrollToCompatPage(
|
||||
hasInitialLocator = false,
|
||||
didLocatorScroll = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical tts follow centers target offset in viewport`() {
|
||||
assertEquals(
|
||||
100f,
|
||||
nativeVerticalCenteredScrollDelta(
|
||||
targetOffsetInViewport = 500f,
|
||||
viewportHeight = 800f
|
||||
),
|
||||
0.001f
|
||||
)
|
||||
assertEquals(
|
||||
-200f,
|
||||
nativeVerticalCenteredScrollDelta(
|
||||
targetOffsetInViewport = 200f,
|
||||
viewportHeight = 800f
|
||||
),
|
||||
0.001f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class ReaderNavigationTargetsTest {
|
|||
@Test
|
||||
fun `native vertical initial prefetch is bounded around requested chapter`() {
|
||||
assertEquals(
|
||||
listOf(4, 5, 2),
|
||||
listOf(4, 5),
|
||||
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,20 @@ class PdfReaderCoreLogicTest {
|
|||
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf encrypt marker detection matches trailer encrypt entry`() {
|
||||
val bytes = "%PDF-1.7\ntrailer\n<< /Size 4 /Encrypt 2 0 R >>".toByteArray(Charsets.US_ASCII)
|
||||
|
||||
assertTrue(pdfBytesContainEncryptMarker(bytes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf encrypt marker detection ignores longer pdf names`() {
|
||||
val bytes = "<< /EncryptMetadata false /Size 4 >>".toByteArray(Charsets.US_ASCII)
|
||||
|
||||
assertFalse(pdfBytesContainEncryptMarker(bytes))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFastFileId uses stable file name and length for file uris`() {
|
||||
val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply {
|
||||
|
|
@ -383,6 +397,32 @@ class PdfReaderCoreLogicTest {
|
|||
assertTrue(limitedScale >= 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spread page slot width fits page aspect instead of filling half landscape viewport`() {
|
||||
val slotWidth = pdfSpreadPageSlotWidth(
|
||||
containerWidth = 1920f,
|
||||
containerHeight = 900f,
|
||||
pageGap = 0f,
|
||||
spreadPageCount = 2,
|
||||
pageAspectRatio = 612f / 792f
|
||||
)
|
||||
|
||||
assertEquals(695.4545f, slotWidth, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spread page slot width caps pages to available spread width`() {
|
||||
val slotWidth = pdfSpreadPageSlotWidth(
|
||||
containerWidth = 1000f,
|
||||
containerHeight = 900f,
|
||||
pageGap = 20f,
|
||||
spreadPageCount = 2,
|
||||
pageAspectRatio = 1.4f
|
||||
)
|
||||
|
||||
assertEquals(490f, slotWidth, 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
|
||||
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))
|
||||
|
|
|
|||
|
|
@ -281,6 +281,23 @@ class PdfReaderSettingsAndSharedModelsTest {
|
|||
assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf overflow sections hide file actions when only unavailable print remains`() {
|
||||
val sections = pdfOverflowMenuSections(
|
||||
hiddenTools = setOf(
|
||||
PdfReaderTool.SHARE.name,
|
||||
PdfReaderTool.SAVE_COPY.name
|
||||
),
|
||||
hasHiddenToolbarTools = false,
|
||||
isPro = false,
|
||||
effectiveFileType = FileType.PDF,
|
||||
hasFileInfo = false,
|
||||
canPrintDocument = false
|
||||
)
|
||||
|
||||
assertFalse(PdfOverflowMenuSection.FILE_ACTIONS in sections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf overflow sections expose file info only when available and visible`() {
|
||||
val visibleSections = pdfOverflowMenuSections(
|
||||
|
|
|
|||
|
|
@ -96,6 +96,86 @@ class PdfZoomLockStateTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical pdf high res tiles render for settled zoom below one hundred percent`() {
|
||||
assertTrue(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 0.82f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = true,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical pdf high res tiles skip exact one hundred percent unless page is large`() {
|
||||
assertFalse(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 1f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = true,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 1f,
|
||||
targetWidthPx = 3200,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = true,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated pdf high res tiles keep existing zoom threshold`() {
|
||||
assertFalse(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 0.82f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = false,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 1.25f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = false,
|
||||
isActivePage = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldRenderPdfHighResTiles(
|
||||
effectiveScale = 1.25f,
|
||||
targetWidthPx = 1080,
|
||||
targetHeightPx = 1600,
|
||||
isVerticalScroll = false,
|
||||
isActivePage = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom indicator percent rounds displayed scale`() {
|
||||
assertEquals(82, pdfZoomIndicatorPercent(0.824f))
|
||||
assertEquals(83, pdfZoomIndicatorPercent(0.826f))
|
||||
assertEquals(100, pdfZoomIndicatorPercent(0.996f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom indicator hides only at displayed one hundred percent`() {
|
||||
assertFalse(shouldShowPdfZoomIndicator(100))
|
||||
assertTrue(shouldShowPdfZoomIndicator(99))
|
||||
assertTrue(shouldShowPdfZoomIndicator(125))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page change preserves locked zoom scale only in paginated lock mode`() {
|
||||
val lockedState = Triple(2.25f, -12f, 32f)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import org.junit.Test
|
|||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class TtsChunkNavigationTest {
|
||||
@Test
|
||||
|
|
@ -53,6 +54,25 @@ class TtsChunkNavigationTest {
|
|||
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `automatic playlist advance can step over chunks marked skipped after generation failures`() {
|
||||
assertEquals(
|
||||
true,
|
||||
shouldAdvanceToTtsPlaylistChunk(
|
||||
currentChunkIndex = 8,
|
||||
playlistChunkIndex = 10,
|
||||
skippedChunkIndices = setOf(9)
|
||||
)
|
||||
)
|
||||
assertEquals(10, resolveNextPlayableTtsChunkIndex(8, 12, setOf(9)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chunk generation gives up after bounded failures`() {
|
||||
assertEquals(false, shouldGiveUpTtsChunkGeneration(failureCount = 1, maxFailures = 2))
|
||||
assertEquals(true, shouldGiveUpTtsChunkGeneration(failureCount = 2, maxFailures = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transition prefetch is deferred only for the rebuilding generation`() {
|
||||
assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6))
|
||||
|
|
@ -150,6 +170,16 @@ class TtsChunkNavigationTest {
|
|||
assertNull(estimateTtsNotificationDurationMs(text = " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stable sorted snapshot copies concurrent cache keys`() {
|
||||
val cache = ConcurrentHashMap<Int, String>()
|
||||
cache[3] = "three"
|
||||
cache[1] = "one"
|
||||
cache[2] = "two"
|
||||
|
||||
assertEquals(listOf(1, 2, 3), stableSortedIntSnapshot(cache.keys))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wav file duration is read from pcm byte rate`() {
|
||||
val file = createTempWavFile(pcmBytes = 48_000)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue