Windows ga (#358)
* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app * Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app * Persist reader window state and improve slider interaction in desktop app * Improve PDF page transitions and refine focus management in desktop app * Refactor scrollbar interaction and adjust desktop modal focus handling * Improve PDF sidecar synchronization and cross-platform metadata compatibility * Refactor PDF annotation comment logic to shared module and implement Desktop UI * Refactor reader screen to use tap-to-toggle and full-width styling in desktop app * Refactor reader workspace layout and chrome-panel interactions * Implement global search keyboard shortcuts and focusable chrome layers * Implement flavor-specific legal links and update the About UI * Refactor reader UI controls on desktop app * Enhance desktop folder sync with background metadata extraction and improved error handling * Refactor Library UI and remove redundant Home tab in desktop app * Add custom tooltips to reader icon buttons in desktop app * Integrate app theme controls into reader interfaces on desktop * Add right-to-left pagination support and improve focus restoration on desktop app * Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app * Update desktop reader defaults and implement settings migration * Implement block-based position tracking in ReaderLocator * Enhance EPUB highlighting reliability in desktop app * Add support for custom reader themes and update highlight palette logic in desktop app * Replace the Tools panel with a "More" dropdown menu and refactor account UI * Implement account profile header in desktop sidebar * Implement cloud sync reliability improvements and sidebar toggle on desktop app * Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app * Integrate WebView2 for EPUB vertical rendering on Windows * Refactor reader layout logic and enhance WebView2 diagnostics * Improve vertical reading layout and WebView2 resizing on Desktop * Refine vertical reading mode layout and margin handling * Enhance reader locator precision and Desktop mode-switching reliability * Implement chapter-level caching and warm-start pagination in desktop app * Replace bundled KCEF with native system webviews via SWT * Refactor EPUB page info bar visibility and layout logic * Improve PDF toolbar persistence and fix tab reactivation logic * Enable multi-selection and bulk operations for custom fonts * Refactor instrumentation tests * Add EPUB UI test fixture and initial instrumentation tests * Expand EpubReader UI tests and improve accessibility * Add instrumentation tests and test tags for library and reader screens * Enhance OPDS parser logic and catalog integration * Add support for toggling local synchronization on a per-folder basis. * Implement tri-state sizing for the TTS overlay * Persist TTS overlay size across sessions * Refactor reader brightness control and add incremental step buttons * Improve CSS support, pagination control, and style-aware semantic caching * Improve link handling, interaction, and diagnostics in the paginated reader * crash fixes * Implement persistent pending removal for external files * Implement book-specific word replacements * Add native vertical reading mode with custom renderer * Implement text selection and navigation improvements for the native vertical reader * Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub * Implement lazy loading and chapter prefetching for native vertical reader * Improve window lifecycle and disposal handling on Desktop * Optimize vertical reading performance in desktop app * Enhance TTS start accuracy and diagnostic logging on desktop * Refactor AI settings visibility on desktop * Improve pagination height measurement and enhance cutoff diagnostics * Implement lifecycle management and improve justified text splitting for pagination * Refine AI usage tracking and force AI feature visibility on Desktop * Add descriptive context comments and usage examples to string and plural resources. * Optimize performance and memory usage in search and state mapping * Replace reader page sliders with minimal slider and navigation controls * Add support for CBT comic archives * Harden file path validation and XML parsing to prevent security vulnerabilities * Implement local account profile caching and optimize desktop performance * Improve desktop persistence reliability and add Linux secure storage support * Improved PDF zoom stability and layout prediction during zoom commits * Improved PDF spread layout prediction, reader focus restoration, and account profile caching * Enhance highlight precision and scoping using block-local offsets and CFIs * Enhance cloud book content synchronization and background downloads * Implement granular timestamp tracking for reading positions and PDF annotations * Restrict diagnostic logging and stack traces to debug builds * Refine PDF page gaps and reader chrome interaction logic * Refactor PDF highlight rendering and overhaul Desktop sidebar UI * Implement a new interaction dock and undo/redo history for PDF annotations in desktop * Enhance PDF color picker and improve navigation scroll restoration * Add highlight palette customization and improve selection menu UI in desktop app epub reader * Enhance desktop shelf management and library organization
This commit is contained in:
parent
5971eaa571
commit
83dcafa4b6
444 changed files with 47279 additions and 8096 deletions
22
app/src/test/java/com/aryan/reader/AndroidLegalLinksTest.kt
Normal file
22
app/src/test/java/com/aryan/reader/AndroidLegalLinksTest.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidLegalLinksTest {
|
||||
@Test
|
||||
fun `android oss flavor maps to oss legal pages`() {
|
||||
val links = legalLinksForAndroidFlavor("oss")
|
||||
|
||||
assertTrue(links.privacyPolicyUrl.endsWith("/oss-privacy-policy.html"))
|
||||
assertTrue(links.termsUrl.endsWith("/oss-terms-of-service.html"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `android pro flavor maps to standard legal pages`() {
|
||||
val links = legalLinksForAndroidFlavor("pro")
|
||||
|
||||
assertTrue(links.privacyPolicyUrl.endsWith("/privacy-policy.html"))
|
||||
assertTrue(links.termsUrl.endsWith("/terms-and-conditions.html"))
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.aryan.reader
|
|||
|
||||
import com.aryan.reader.shared.SharedSettingsAction
|
||||
import com.aryan.reader.shared.SharedSettingsDestination
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import com.aryan.reader.shared.SharedSettingsHubModel
|
||||
import com.aryan.reader.shared.SharedSettingsItemModel
|
||||
import com.aryan.reader.shared.sharedSettingsHubModel
|
||||
|
|
@ -37,24 +38,23 @@ class AndroidSettingsHubModelsTest {
|
|||
|
||||
@Test
|
||||
fun `oss online settings hide sync rows but keep oss ai key settings`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
androidSettingsHubInput(
|
||||
uiState = ReaderScreenState(
|
||||
currentUser = UserData(
|
||||
uid = "user-id",
|
||||
displayName = "Reader",
|
||||
photoUrl = null,
|
||||
email = "reader@example.com"
|
||||
),
|
||||
isProUser = true,
|
||||
isSyncEnabled = true,
|
||||
isFolderSyncEnabled = true
|
||||
val input = androidSettingsHubInput(
|
||||
uiState = ReaderScreenState(
|
||||
currentUser = UserData(
|
||||
uid = "user-id",
|
||||
displayName = "Reader",
|
||||
photoUrl = null,
|
||||
email = "reader@example.com"
|
||||
),
|
||||
isOssBuild = true,
|
||||
isOfflineBuild = false,
|
||||
isDebugBuild = true
|
||||
)
|
||||
isProUser = true,
|
||||
isSyncEnabled = true,
|
||||
isFolderSyncEnabled = true
|
||||
),
|
||||
isOssBuild = true,
|
||||
isOfflineBuild = false,
|
||||
isDebugBuild = true
|
||||
)
|
||||
val model = sharedSettingsHubModel(input)
|
||||
val actions = model.visibleNestedActions()
|
||||
|
||||
assertTrue(SharedSettingsAction.AI_SETTINGS in actions)
|
||||
|
|
@ -65,6 +65,7 @@ class AndroidSettingsHubModelsTest {
|
|||
assertFalse(SharedSettingsAction.DEVICE_MANAGEMENT in actions)
|
||||
assertFalse(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in actions)
|
||||
assertTrue(SharedSettingsAction.SUPPORT in actions)
|
||||
assertEquals(SharedFeaturePolicy.OssOnline, input.featurePolicy)
|
||||
assertEquals(
|
||||
"TTS & AI",
|
||||
model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title
|
||||
|
|
|
|||
|
|
@ -10,6 +10,22 @@ import org.junit.Test
|
|||
|
||||
class AndroidStringFormatResourcesTest {
|
||||
|
||||
@Test
|
||||
fun `vietnamese strings cover translatable base resources`() {
|
||||
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 }
|
||||
|
||||
assertTrue(
|
||||
"Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}",
|
||||
missingNames.isEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `localized formatted strings use valid formatter syntax`() {
|
||||
val resDirectory = findResDirectory()
|
||||
|
|
@ -48,6 +64,28 @@ class AndroidStringFormatResourcesTest {
|
|||
).first { it.isDirectory }
|
||||
}
|
||||
|
||||
private fun readResourceNames(
|
||||
stringsFile: File,
|
||||
includeNonTranslatable: Boolean = true
|
||||
): List<String> {
|
||||
val document = DocumentBuilderFactory.newInstance()
|
||||
.newDocumentBuilder()
|
||||
.parse(stringsFile)
|
||||
val nodes = document.documentElement.childNodes
|
||||
|
||||
return buildList {
|
||||
for (index in 0 until nodes.length) {
|
||||
val node = nodes.item(index)
|
||||
val attributes = node.attributes ?: continue
|
||||
val name = attributes.getNamedItem("name")?.nodeValue ?: continue
|
||||
val translatable = attributes.getNamedItem("translatable")?.nodeValue
|
||||
if (includeNonTranslatable || translatable != "false") {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readStringResources(stringsFile: File): Map<String, String> {
|
||||
val document = DocumentBuilderFactory.newInstance()
|
||||
.newDocumentBuilder()
|
||||
|
|
|
|||
|
|
@ -99,6 +99,19 @@ class AppLanguageOptionsTest {
|
|||
assertEquals("true", autoStoreLocales!!.androidAttribute("value"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `android manifest exposes cbt comic archive mime types`() {
|
||||
val mimeTypes = readAndroidManifest()
|
||||
.getElementsByTagName("data")
|
||||
.asElements()
|
||||
.mapNotNull { it.androidAttribute("mimeType") }
|
||||
|
||||
assertTrue("application/x-cbt" in mimeTypes)
|
||||
assertTrue("application/vnd.comicbook+tar" in mimeTypes)
|
||||
assertTrue("application/x-tar" in mimeTypes)
|
||||
assertTrue("application/tar" in mimeTypes)
|
||||
}
|
||||
|
||||
private fun readLocaleConfigTags(): List<String> {
|
||||
val localeConfig = listOf(
|
||||
File("src/main/res/xml/locales_config.xml"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import com.aryan.reader.shared.ReaderBookReplacementPreferences
|
||||
import com.aryan.reader.shared.ReaderWordReplacementRule
|
||||
import org.jsoup.Jsoup
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BookReplacementHtmlTest {
|
||||
@Test
|
||||
fun `html replacement rewrites visible text for matching book`() {
|
||||
val document = Jsoup.parse(
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<p id="first">Alice & Alice</p>
|
||||
<a href="chapter.xhtml">Alice link</a>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
)
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf(
|
||||
"book" to listOf(rule(from = "Alice", to = "Alicia")),
|
||||
),
|
||||
)
|
||||
|
||||
val changed = applyBookReplacementsToHtmlDocument(document, preferences, "book")
|
||||
|
||||
assertTrue(changed)
|
||||
assertEquals("Alicia & Alicia", document.selectFirst("p")?.text())
|
||||
assertEquals("Alicia link", document.selectFirst("a")?.text())
|
||||
assertEquals("chapter.xhtml", document.selectFirst("a")?.attr("href"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `html replacement skips blocked script text`() {
|
||||
val document = Jsoup.parse(
|
||||
"""
|
||||
<html>
|
||||
<body>
|
||||
<p>Alice</p>
|
||||
<script>var name = "Alice";</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
)
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf(
|
||||
"book" to listOf(rule(from = "Alice", to = "Alicia")),
|
||||
),
|
||||
)
|
||||
|
||||
val changed = applyBookReplacementsToHtmlDocument(document, preferences, "book")
|
||||
|
||||
assertTrue(changed)
|
||||
assertEquals("Alicia", document.selectFirst("p")?.text())
|
||||
assertTrue(document.selectFirst("script")?.html()?.contains("Alice") == true)
|
||||
}
|
||||
|
||||
private fun rule(
|
||||
id: String = "rule",
|
||||
from: String,
|
||||
to: String,
|
||||
): ReaderWordReplacementRule {
|
||||
return ReaderWordReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = to,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import com.aryan.reader.data.BookMetadata
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CloudEpubAnnotationMetadataTest {
|
||||
@Test
|
||||
fun `remote epub annotations fill local null annotation fields without changing timestamp`() {
|
||||
val local = localBook(
|
||||
highlightsJson = null,
|
||||
bookmarksJson = null,
|
||||
lastModifiedTimestamp = 2_000L
|
||||
)
|
||||
val remote = remoteBook(
|
||||
highlightsJson = """[{"cfi":"/4/2:1"}]""",
|
||||
bookmarksJson = """["{\"cfi\":\"/4/2\",\"chapterTitle\":\"One\",\"snippet\":\"A\",\"chapterIndex\":0}"]""",
|
||||
lastModifiedTimestamp = 3_000L
|
||||
)
|
||||
|
||||
val merged = local.mergeRemoteEpubAnnotationMetadata(remote)
|
||||
|
||||
assertEquals(remote.highlightsJson, merged.highlightsJson)
|
||||
assertEquals(remote.bookmarksJson, merged.bookmarksJson)
|
||||
assertEquals(2_000L, merged.lastModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit local empty epub annotations are not replaced by remote annotations`() {
|
||||
val local = localBook(
|
||||
highlightsJson = "[]",
|
||||
bookmarksJson = "[]"
|
||||
)
|
||||
val remote = remoteBook(
|
||||
highlightsJson = """[{"cfi":"/4/2:1"}]""",
|
||||
bookmarksJson = """["{\"cfi\":\"/4/2\",\"chapterTitle\":\"One\",\"snippet\":\"A\",\"chapterIndex\":0}"]"""
|
||||
)
|
||||
|
||||
val merged = local.mergeRemoteEpubAnnotationMetadata(remote)
|
||||
|
||||
assertEquals("[]", merged.highlightsJson)
|
||||
assertEquals("[]", merged.bookmarksJson)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non epub books do not use epub annotation preservation guard`() {
|
||||
val local = localBook(type = FileType.PDF, highlightsJson = null)
|
||||
val remote = remoteBook(type = FileType.PDF.name, highlightsJson = """[{"cfi":"/4/2:1"}]""")
|
||||
|
||||
assertFalse(local.needsRemoteEpubAnnotationMetadataGuard())
|
||||
assertEquals(local, local.mergeRemoteEpubAnnotationMetadata(remote))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank and empty annotation json are equivalent noops`() {
|
||||
assertTrue(annotationJsonEquivalentForNoop(null, "[]"))
|
||||
assertTrue(annotationJsonEquivalentForNoop("", "[]"))
|
||||
assertFalse(annotationJsonEquivalentForNoop("""[{"id":"h1"}]""", "[]"))
|
||||
}
|
||||
|
||||
private fun localBook(
|
||||
type: FileType = FileType.EPUB,
|
||||
highlightsJson: String? = null,
|
||||
bookmarksJson: String? = null,
|
||||
lastModifiedTimestamp: Long = 1_000L
|
||||
): RecentFileItem {
|
||||
return RecentFileItem(
|
||||
bookId = "book-1",
|
||||
uriString = "content://book",
|
||||
type = type,
|
||||
displayName = "Book.epub",
|
||||
timestamp = 1_000L,
|
||||
lastModifiedTimestamp = lastModifiedTimestamp,
|
||||
bookmarksJson = bookmarksJson,
|
||||
highlightsJson = highlightsJson
|
||||
)
|
||||
}
|
||||
|
||||
private fun remoteBook(
|
||||
type: String = FileType.EPUB.name,
|
||||
highlightsJson: String? = null,
|
||||
bookmarksJson: String? = null,
|
||||
lastModifiedTimestamp: Long = 2_000L
|
||||
): BookMetadata {
|
||||
return BookMetadata(
|
||||
bookId = "book-1",
|
||||
displayName = "Book.epub",
|
||||
type = type,
|
||||
lastModifiedTimestamp = lastModifiedTimestamp,
|
||||
bookmarksJson = bookmarksJson,
|
||||
highlightsJson = highlightsJson
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import com.aryan.reader.data.BookMetadata
|
||||
import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class CloudPdfAnnotationSidecarDecisionsTest {
|
||||
@Test
|
||||
fun `layout-only sidecar does not block newer remote annotations`() {
|
||||
val local = AndroidPdfCloudSidecarState(
|
||||
hasInk = false,
|
||||
inkTimestamp = 0L,
|
||||
hasRichText = false,
|
||||
richTextTimestamp = 0L,
|
||||
hasLayout = true,
|
||||
layoutTimestamp = 2_000L,
|
||||
hasTextBoxes = false,
|
||||
textBoxesTimestamp = 0L,
|
||||
hasHighlights = false,
|
||||
highlightsTimestamp = 0L
|
||||
)
|
||||
|
||||
val localShouldUpload = shouldUploadLocalPdfCloudAnnotations(
|
||||
localSidecars = local,
|
||||
remoteHasAnnotations = true,
|
||||
remoteAnnotationModifiedTimestamp = 1_500L
|
||||
)
|
||||
|
||||
assertFalse(localShouldUpload)
|
||||
assertTrue(
|
||||
shouldDownloadRemotePdfCloudAnnotations(
|
||||
localSidecars = local,
|
||||
localAnnotationsShouldUpload = localShouldUpload,
|
||||
remoteHasAnnotations = true,
|
||||
remoteAnnotationModifiedTimestamp = 1_500L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer local ink payload uploads instead of downloading remote annotations`() {
|
||||
val local = AndroidPdfCloudSidecarState(
|
||||
hasInk = true,
|
||||
inkTimestamp = 2_000L,
|
||||
hasRichText = false,
|
||||
richTextTimestamp = 0L,
|
||||
hasLayout = true,
|
||||
layoutTimestamp = 2_500L,
|
||||
hasTextBoxes = false,
|
||||
textBoxesTimestamp = 0L,
|
||||
hasHighlights = false,
|
||||
highlightsTimestamp = 0L
|
||||
)
|
||||
|
||||
val localShouldUpload = shouldUploadLocalPdfCloudAnnotations(
|
||||
localSidecars = local,
|
||||
remoteHasAnnotations = true,
|
||||
remoteAnnotationModifiedTimestamp = 1_500L
|
||||
)
|
||||
|
||||
assertTrue(localShouldUpload)
|
||||
assertFalse(
|
||||
shouldDownloadRemotePdfCloudAnnotations(
|
||||
localSidecars = local,
|
||||
localAnnotationsShouldUpload = localShouldUpload,
|
||||
remoteHasAnnotations = true,
|
||||
remoteAnnotationModifiedTimestamp = 1_500L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer local deletion tombstone uploads instead of downloading remote annotations`() {
|
||||
val local = AndroidPdfCloudSidecarState(
|
||||
hasInk = false,
|
||||
inkTimestamp = 0L,
|
||||
hasDeletedInk = true,
|
||||
deletedInkTimestamp = 2_000L,
|
||||
hasRichText = false,
|
||||
richTextTimestamp = 0L,
|
||||
hasLayout = false,
|
||||
layoutTimestamp = 0L,
|
||||
hasTextBoxes = false,
|
||||
textBoxesTimestamp = 0L,
|
||||
hasHighlights = false,
|
||||
highlightsTimestamp = 0L
|
||||
)
|
||||
|
||||
val localShouldUpload = shouldUploadLocalPdfCloudAnnotations(
|
||||
localSidecars = local,
|
||||
remoteHasAnnotations = true,
|
||||
remoteAnnotationModifiedTimestamp = 1_500L
|
||||
)
|
||||
|
||||
assertTrue(localShouldUpload)
|
||||
assertFalse(
|
||||
shouldDownloadRemotePdfCloudAnnotations(
|
||||
localSidecars = local,
|
||||
localAnnotationsShouldUpload = localShouldUpload,
|
||||
remoteHasAnnotations = true,
|
||||
remoteAnnotationModifiedTimestamp = 1_500L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer remote metadata alone does not make equal annotation payload download`() {
|
||||
val local = AndroidPdfCloudSidecarState(
|
||||
hasInk = true,
|
||||
inkTimestamp = 2_000L,
|
||||
hasRichText = false,
|
||||
richTextTimestamp = 0L,
|
||||
hasLayout = false,
|
||||
layoutTimestamp = 0L,
|
||||
hasTextBoxes = false,
|
||||
textBoxesTimestamp = 0L,
|
||||
hasHighlights = false,
|
||||
highlightsTimestamp = 0L
|
||||
)
|
||||
|
||||
val localShouldUpload = shouldUploadLocalPdfCloudAnnotations(
|
||||
localSidecars = local,
|
||||
remoteHasAnnotations = true,
|
||||
remoteAnnotationModifiedTimestamp = 2_000L
|
||||
)
|
||||
|
||||
assertFalse(localShouldUpload)
|
||||
assertFalse(
|
||||
shouldDownloadRemotePdfCloudAnnotations(
|
||||
localSidecars = local,
|
||||
localAnnotationsShouldUpload = localShouldUpload,
|
||||
remoteHasAnnotations = true,
|
||||
remoteAnnotationModifiedTimestamp = 2_000L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation freshness does not fall back to book metadata timestamp`() {
|
||||
val remote = BookMetadata(
|
||||
bookId = "book-1",
|
||||
lastModifiedTimestamp = 5_000L,
|
||||
hasAnnotations = true
|
||||
)
|
||||
|
||||
assertEquals(0L, remote.effectiveAnnotationModifiedTimestamp())
|
||||
assertEquals(3_000L, remote.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty sidecar placeholder is not syncable annotation payload`() {
|
||||
assertFalse(tempSidecar("[]").hasSyncableCloudAnnotationPayload())
|
||||
assertFalse(tempSidecar("{}").hasSyncableCloudAnnotationPayload())
|
||||
assertTrue(tempSidecar("[{\"pageIndex\":0}]").hasSyncableCloudAnnotationPayload())
|
||||
}
|
||||
|
||||
private fun tempSidecar(content: String): File {
|
||||
return File.createTempFile("cloud-sidecar", ".json").apply {
|
||||
writeText(content)
|
||||
deleteOnExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,8 +34,10 @@ class FileTypeResolverTest {
|
|||
assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain"))
|
||||
assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json"))
|
||||
assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip"))
|
||||
assertEquals(FileType.CBT, resolveFileTypeFromMetadata("comic.cbt", "application/x-tar"))
|
||||
assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip"))
|
||||
assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip"))
|
||||
assertNull(resolveFileTypeFromMetadata("archive.tar", "application/x-tar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -56,6 +58,7 @@ class FileTypeResolverTest {
|
|||
fun `plain txt remains txt when inner extension is unsupported`() {
|
||||
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
|
||||
assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx"))
|
||||
assertEquals(FileType.CBT, resolveFileTypeFromName("comic.cbt"))
|
||||
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
|
||||
assertNull(resolveFileTypeFromName("archive.zip"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ import android.util.Log
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.credentials.CredentialManager
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.work.WorkManager
|
||||
import com.aryan.reader.data.*
|
||||
import com.aryan.reader.paginatedreader.Locator
|
||||
import com.aryan.reader.paginatedreader.data.BookCacheDao
|
||||
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
|
||||
import com.aryan.reader.pdf.data.PdfMetaDao
|
||||
import com.aryan.reader.pdf.data.PdfTextDao
|
||||
import com.aryan.reader.pdf.data.PdfTextDatabase
|
||||
import com.aryan.reader.tts.TtsController
|
||||
import com.aryan.reader.tts.TtsPlaybackManager
|
||||
import io.mockk.*
|
||||
|
|
@ -24,6 +28,7 @@ import kotlinx.coroutines.flow.flowOf
|
|||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.*
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
|
|
@ -34,7 +39,7 @@ import java.io.File
|
|||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private lateinit var testDispatcher: TestDispatcher
|
||||
|
||||
private lateinit var viewModel: MainViewModel
|
||||
private lateinit var mockApplication: Application
|
||||
|
|
@ -50,8 +55,26 @@ class MainViewModelTest {
|
|||
private val tagsFlow = MutableStateFlow<List<TagEntity>>(emptyList())
|
||||
private val tagRefsFlow = MutableStateFlow<List<BookTagCrossRef>>(emptyList())
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@AfterClass
|
||||
fun resetMainDispatcher() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
private class TestMainViewModel(application: Application) : MainViewModel(application) {
|
||||
fun clearForTest() {
|
||||
ViewModel::class.java
|
||||
.getDeclaredMethod("clear\$lifecycle_viewmodel_release")
|
||||
.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
testDispatcher = StandardTestDispatcher()
|
||||
|
||||
recentFilesFlow.value = emptyList()
|
||||
shelvesFlow.value = emptyList()
|
||||
shelfRefsFlow.value = emptyList()
|
||||
|
|
@ -100,6 +123,11 @@ class MainViewModelTest {
|
|||
val mockBookCacheDb = mockk<BookCacheDatabase>(relaxed = true)
|
||||
every { mockBookCacheDb.bookCacheDao() } returns mockk<BookCacheDao>(relaxed = true)
|
||||
every { BookCacheDatabase.getDatabase(any()) } returns mockBookCacheDb
|
||||
mockkObject(PdfTextDatabase.Companion)
|
||||
val mockPdfTextDb = mockk<PdfTextDatabase>(relaxed = true)
|
||||
every { mockPdfTextDb.pdfTextDao() } returns mockk<PdfTextDao>(relaxed = true)
|
||||
every { mockPdfTextDb.pdfMetaDao() } returns mockk<PdfMetaDao>(relaxed = true)
|
||||
every { PdfTextDatabase.getDatabase(any()) } returns mockPdfTextDb
|
||||
|
||||
mockkObject(WorkManager.Companion)
|
||||
val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||
|
|
@ -114,6 +142,7 @@ class MainViewModelTest {
|
|||
mockkConstructor(FeedbackRepository::class)
|
||||
mockkConstructor(FontsRepository::class)
|
||||
mockkConstructor(TtsController::class)
|
||||
mockkConstructor(BookImporter::class)
|
||||
|
||||
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
|
||||
every { anyConstructed<BillingClientWrapper>().initializeConnection() } just Runs
|
||||
|
|
@ -126,6 +155,7 @@ class MainViewModelTest {
|
|||
every { anyConstructed<BillingClientWrapper>().launchPurchaseFlow(any(), any(), any()) } just Runs
|
||||
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
|
||||
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
|
||||
every { anyConstructed<FirestoreRepository>().removeListener(any()) } just Runs
|
||||
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
|
||||
every { anyConstructed<TtsController>().ttsState } returns ttsStateFlow
|
||||
every { anyConstructed<TtsController>().connect() } just Runs
|
||||
|
|
@ -143,21 +173,30 @@ class MainViewModelTest {
|
|||
coEvery { anyConstructed<RecentFilesRepository>().removeBooksFromShelf(any(), any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
|
||||
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
|
||||
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
|
||||
|
||||
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
|
||||
coEvery { anyConstructed<FontsRepository>().deleteFont(any()) } just Runs
|
||||
|
||||
viewModel = MainViewModel(mockApplication)
|
||||
viewModel = TestMainViewModel(mockApplication)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
unmockkAll()
|
||||
try {
|
||||
if (::viewModel.isInitialized) {
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
(viewModel as? TestMainViewModel)?.clearForTest()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
}
|
||||
} finally {
|
||||
unmockkAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search query updates uiState when search is active`() = runTest {
|
||||
fun `search query updates uiState when search is active`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -173,7 +212,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setSearchActive false clears the search query`() = runTest {
|
||||
fun `setSearchActive false clears the search query`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -187,7 +226,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `search query change is ignored while search is inactive`() = runTest {
|
||||
fun `search query change is ignored while search is inactive`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -199,7 +238,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `switching theme updates internal state and preferences`() = runTest {
|
||||
fun `switching theme updates internal state and preferences`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -212,7 +251,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setAppFontPreference persists app font preference`() = runTest {
|
||||
fun `setAppFontPreference persists app font preference`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -227,7 +266,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `deleteFont resets matching app custom font preference`() = runTest {
|
||||
fun `deleteFont resets matching app custom font preference`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -243,7 +282,59 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setTabsEnabled persists to shared preferences`() = runTest {
|
||||
fun `deleteFonts deletes unique selected fonts and resets matching app custom font preference`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
||||
viewModel.setAppFontPreference(AppFontPreference.custom("font-b"))
|
||||
viewModel.deleteFonts(listOf("font-a", "font-b", "font-a", ""))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { anyConstructed<FontsRepository>().deleteFont("font-a") }
|
||||
coVerify(exactly = 1) { anyConstructed<FontsRepository>().deleteFont("font-b") }
|
||||
coVerify(exactly = 0) { anyConstructed<FontsRepository>().deleteFont("") }
|
||||
assertEquals(AppFontPreference.System, viewModel.uiState.value.appFontPreference)
|
||||
verify { mockEditor.putString("app_font_kind", AppFontPreferenceKind.SYSTEM.name) }
|
||||
verify { mockEditor.remove("app_font_custom_id") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importFonts imports every selected font`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
val firstUri = mockk<Uri>()
|
||||
val secondUri = mockk<Uri>()
|
||||
val firstFont = CustomFontEntity(
|
||||
id = "font-1",
|
||||
displayName = "First",
|
||||
fileName = "font_1.ttf",
|
||||
fileExtension = "ttf",
|
||||
path = "/fonts/font_1.ttf",
|
||||
timestamp = 1L
|
||||
)
|
||||
val secondFont = CustomFontEntity(
|
||||
id = "font-2",
|
||||
displayName = "Second",
|
||||
fileName = "font_2.otf",
|
||||
fileExtension = "otf",
|
||||
path = "/fonts/font_2.otf",
|
||||
timestamp = 2L
|
||||
)
|
||||
coEvery { anyConstructed<FontsRepository>().importFont(firstUri) } returns Result.success(firstFont)
|
||||
coEvery { anyConstructed<FontsRepository>().importFont(secondUri) } returns Result.success(secondFont)
|
||||
|
||||
viewModel.importFonts(listOf(firstUri, secondUri))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { anyConstructed<FontsRepository>().importFont(firstUri) }
|
||||
coVerify(exactly = 1) { anyConstructed<FontsRepository>().importFont(secondUri) }
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setTabsEnabled persists to shared preferences`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -256,7 +347,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setRenderMode persists mode without touching saved epub position`() = runTest {
|
||||
fun `setRenderMode persists mode without touching saved epub position`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -272,7 +363,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest {
|
||||
fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest(testDispatcher) {
|
||||
val uriString = "content://books/one"
|
||||
val uri = mockUri(uriString)
|
||||
val locator = Locator(chapterIndex = 5, blockIndex = 77, charOffset = 14)
|
||||
|
|
@ -301,7 +392,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setRecentFilesLimit persists and limits visible home recents`() = runTest {
|
||||
fun `setRecentFilesLimit persists and limits visible home recents`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -318,7 +409,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest {
|
||||
fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -339,7 +430,35 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `screen capture protection persists and updates state`() = runTest {
|
||||
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)
|
||||
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 {
|
||||
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
|
||||
}
|
||||
coVerify {
|
||||
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
|
||||
}
|
||||
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") }
|
||||
} finally {
|
||||
restored.clearForTest()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -358,7 +477,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest {
|
||||
fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -380,7 +499,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest {
|
||||
fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -393,7 +512,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest {
|
||||
fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -407,7 +526,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `create shelf dialog state opens and dismisses`() = runTest {
|
||||
fun `create shelf dialog state opens and dismisses`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -422,7 +541,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `selectAllRecentFiles toggles only visible recent home items`() = runTest {
|
||||
fun `selectAllRecentFiles toggles only visible recent home items`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -445,7 +564,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `selectAllLibraryFiles toggles all filtered library items`() = runTest {
|
||||
fun `selectAllLibraryFiles toggles all filtered library items`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -465,7 +584,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest {
|
||||
fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -483,7 +602,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest {
|
||||
fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -503,7 +622,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest {
|
||||
fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -525,7 +644,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest {
|
||||
fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -544,7 +663,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest {
|
||||
fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -564,7 +683,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest {
|
||||
fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -586,7 +705,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest {
|
||||
fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -607,16 +726,20 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `saved library file filters drop stale unknown values during restore`() = runTest {
|
||||
fun `saved library file filters drop stale unknown values during restore`() = runTest(testDispatcher) {
|
||||
every { mockPrefs.getStringSet(KEY_FILTER_FILE_TYPES, any()) } returns mutableSetOf("PDF", "UNKNOWN")
|
||||
|
||||
val restored = MainViewModel(mockApplication)
|
||||
|
||||
assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes)
|
||||
val restored = TestMainViewModel(mockApplication)
|
||||
try {
|
||||
assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes)
|
||||
} finally {
|
||||
restored.clearForTest()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
|
||||
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -641,7 +764,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `tag selection ignores empty targets and closes after opening`() = runTest {
|
||||
fun `tag selection ignores empty targets and closes after opening`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -659,7 +782,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest {
|
||||
fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -679,7 +802,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest {
|
||||
fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -701,7 +824,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest {
|
||||
fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -723,7 +846,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest {
|
||||
fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -740,7 +863,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `shelf navigation sets library landing state and can be cleared`() = runTest {
|
||||
fun `shelf navigation sets library landing state and can be cleared`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -759,7 +882,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `clearShelfContextualAction clears selected shelves`() = runTest {
|
||||
fun `clearShelfContextualAction clears selected shelves`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -776,7 +899,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest {
|
||||
fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -803,7 +926,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `add books mode resets selection and tracks source changes`() = runTest {
|
||||
fun `add books mode resets selection and tracks source changes`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -837,7 +960,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `toggleBookSelectionForAdding toggles individual books`() = runTest {
|
||||
fun `toggleBookSelectionForAdding toggles individual books`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -852,7 +975,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest {
|
||||
fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -876,7 +999,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest {
|
||||
fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -893,7 +1016,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest {
|
||||
fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -915,7 +1038,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest {
|
||||
fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -951,7 +1074,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `setAppSeedColor can clear a selected seed color`() = runTest {
|
||||
fun `setAppSeedColor can clear a selected seed color`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -968,7 +1091,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `addCustomAppTheme replaces existing theme with the same id`() = runTest {
|
||||
fun `addCustomAppTheme replaces existing theme with the same id`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -985,7 +1108,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `banner message logic works correctly`() = runTest {
|
||||
fun `banner message logic works correctly`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
@ -1004,7 +1127,7 @@ class MainViewModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `persistent banner is not auto dismissed`() = runTest {
|
||||
fun `persistent banner is not auto dismissed`() = runTest(testDispatcher) {
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,17 @@ class ReaderBrightnessSettingsTest {
|
|||
|
||||
assertTrue(defaults.useSystemBrightness)
|
||||
assertEquals(0.75f, defaults.safeCustomBrightness, 0.0001f)
|
||||
assertEquals(0.05f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f)
|
||||
assertEquals(0.01f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f)
|
||||
assertEquals(0.02f, defaults.copy(customBrightness = 0.02f).safeCustomBrightness, 0.0001f)
|
||||
assertEquals(0.23f, defaults.copy(customBrightness = 0.234f).safeCustomBrightness, 0.0001f)
|
||||
assertEquals(1f, defaults.copy(customBrightness = 2f).safeCustomBrightness, 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `brightness step controls move by one percent and clamp`() {
|
||||
assertEquals(0.74f, stepReaderBrightness(0.75f, -1), 0.0001f)
|
||||
assertEquals(0.76f, stepReaderBrightness(0.75f, 1), 0.0001f)
|
||||
assertEquals(0.01f, stepReaderBrightness(0.01f, -1), 0.0001f)
|
||||
assertEquals(1f, stepReaderBrightness(1f, 1), 0.0001f)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,68 @@ class ReaderSliderChromeStateTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one based slider stepping clamps to epub page range`() {
|
||||
assertEquals(
|
||||
1,
|
||||
readerSliderStepPage(
|
||||
currentPage = 1,
|
||||
delta = -1,
|
||||
minPage = 1,
|
||||
maxPage = 20
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
11,
|
||||
readerSliderStepPage(
|
||||
currentPage = 10,
|
||||
delta = 1,
|
||||
minPage = 1,
|
||||
maxPage = 20
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
20,
|
||||
readerSliderStepPage(
|
||||
currentPage = 20,
|
||||
delta = 1,
|
||||
minPage = 1,
|
||||
maxPage = 20
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero based slider stepping clamps to pdf display page range`() {
|
||||
assertEquals(
|
||||
0,
|
||||
readerSliderStepPage(
|
||||
currentPage = 0,
|
||||
delta = -1,
|
||||
minPage = 0,
|
||||
maxPage = 9
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
6,
|
||||
readerSliderStepPage(
|
||||
currentPage = 5,
|
||||
delta = 1,
|
||||
minPage = 0,
|
||||
maxPage = 9
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
9,
|
||||
readerSliderStepPage(
|
||||
currentPage = 9,
|
||||
delta = 1,
|
||||
minPage = 0,
|
||||
maxPage = 9
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slider content color falls back on light page when theme text is low contrast`() {
|
||||
val colors = readerSliderChromeColors(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.aryan.reader
|
|||
import com.aryan.reader.data.BookTagCrossRef
|
||||
import com.aryan.reader.data.RecentFileItem
|
||||
import com.aryan.reader.data.TagEntity
|
||||
import com.aryan.reader.data.toBookMetadata
|
||||
import com.aryan.reader.data.toRecentFileItem
|
||||
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||
import com.aryan.reader.shared.FileType as SharedFileType
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
|
|
@ -46,6 +48,48 @@ class SharedModelMappersTest {
|
|||
assertEquals(listOf(tag), mapped.tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book mapper carries android epub block locator through shared model`() {
|
||||
val original = recentFile(
|
||||
id = "book",
|
||||
type = FileType.EPUB,
|
||||
lastChapterIndex = 3,
|
||||
lastPage = 18,
|
||||
lastPositionCfi = "android-locator:3:44:120",
|
||||
locatorBlockIndex = 44,
|
||||
locatorCharOffset = 120
|
||||
)
|
||||
|
||||
val shared = original.toSharedBookItem()
|
||||
val mapped = shared.toRecentFileItem()
|
||||
|
||||
assertEquals(3, shared.readerPosition?.chapterIndex)
|
||||
assertEquals(18, shared.readerPosition?.pageIndex)
|
||||
assertEquals(44, shared.readerPosition?.blockIndex)
|
||||
assertEquals(120, shared.readerPosition?.charOffset)
|
||||
assertEquals(original.lastChapterIndex, mapped.lastChapterIndex)
|
||||
assertEquals(original.lastPage, mapped.lastPage)
|
||||
assertEquals(original.lastPositionCfi, mapped.lastPositionCfi)
|
||||
assertEquals(original.locatorBlockIndex, mapped.locatorBlockIndex)
|
||||
assertEquals(original.locatorCharOffset, mapped.locatorCharOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `android cloud metadata preserves file content timestamp`() {
|
||||
val original = recentFile(
|
||||
id = "book",
|
||||
type = FileType.EPUB,
|
||||
fileContentModifiedTimestamp = 1_500L
|
||||
)
|
||||
|
||||
val metadata = original.toBookMetadata()
|
||||
val restored = metadata.toRecentFileItem()
|
||||
|
||||
assertEquals(1_500L, metadata.fileContentModifiedTimestamp)
|
||||
assertEquals(1_500L, restored.fileContentModifiedTimestamp)
|
||||
assertFalse(restored.isAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared projection state maps shelves tabs selections and tags back to android state`() {
|
||||
val tag = TagEntity(id = "tag", name = "Queued", createdAt = 1L)
|
||||
|
|
@ -92,6 +136,41 @@ class SharedModelMappersTest {
|
|||
assertEquals(listOf(tag), android.allTags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared projection state reuses mapped android book instances by id`() {
|
||||
val book = recentFile("book")
|
||||
val sharedBook = book.toSharedBookItem()
|
||||
val sharedShelf = SharedShelf(
|
||||
id = "manual",
|
||||
name = "Manual",
|
||||
type = SharedShelfType.MANUAL,
|
||||
books = listOf(sharedBook),
|
||||
directBooks = listOf(sharedBook)
|
||||
)
|
||||
val projected = SharedReaderScreenState(
|
||||
recentBooks = listOf(sharedBook),
|
||||
libraryBooks = listOf(sharedBook),
|
||||
rawLibraryBooks = listOf(sharedBook),
|
||||
shelves = listOf(sharedShelf),
|
||||
openTabs = listOf(sharedBook),
|
||||
booksAvailableForAdding = listOf(sharedBook)
|
||||
)
|
||||
|
||||
val android = projected.toAndroidReaderScreenState(
|
||||
base = ReaderScreenState(),
|
||||
androidBooksById = mapOf(book.bookId to book)
|
||||
)
|
||||
|
||||
val mappedBook = android.rawLibraryFiles.single()
|
||||
assertSame(book, mappedBook)
|
||||
assertSame(mappedBook, android.recentFiles.single())
|
||||
assertSame(mappedBook, android.allRecentFiles.single())
|
||||
assertSame(mappedBook, android.shelves.single().books.single())
|
||||
assertSame(mappedBook, android.shelves.single().directBooks.single())
|
||||
assertSame(mappedBook, android.openTabs.single())
|
||||
assertSame(mappedBook, android.booksAvailableForAdding.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enum filter and folder mappers round trip between android and shared`() {
|
||||
val filters = LibraryFilters(
|
||||
|
|
@ -115,6 +194,7 @@ class SharedModelMappersTest {
|
|||
assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters())
|
||||
assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder())
|
||||
assertTrue(FileType.PPTX in PDF_VIEWER_FILE_TYPES)
|
||||
assertTrue(FileType.CBT in PDF_VIEWER_FILE_TYPES)
|
||||
assertEquals(ReaderFeatureSurface.PDF_VIEWER, FileType.PPTX.readerSurfaceOnAndroid())
|
||||
assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES)
|
||||
assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES)
|
||||
|
|
@ -133,6 +213,18 @@ class SharedModelMappersTest {
|
|||
assertEquals(listOf(tag), tagged.single().tags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tag resolver reuses book item when resolved tags are unchanged`() {
|
||||
val file = recentFile("book")
|
||||
|
||||
val resolved = listOf(file).withResolvedTags(
|
||||
dbTags = emptyList(),
|
||||
tagRefs = emptyList()
|
||||
)
|
||||
|
||||
assertSame(file, resolved.single())
|
||||
}
|
||||
|
||||
private fun recentFile(
|
||||
id: String,
|
||||
type: FileType = FileType.EPUB,
|
||||
|
|
@ -141,6 +233,12 @@ class SharedModelMappersTest {
|
|||
isAvailable: Boolean = true,
|
||||
bookmarksJson: String? = null,
|
||||
sourceFolderUri: String? = null,
|
||||
lastChapterIndex: Int? = null,
|
||||
lastPage: Int? = null,
|
||||
lastPositionCfi: String? = null,
|
||||
locatorBlockIndex: Int? = null,
|
||||
locatorCharOffset: Int? = null,
|
||||
fileContentModifiedTimestamp: Long = 0L,
|
||||
tags: List<TagEntity> = emptyList()
|
||||
) = RecentFileItem(
|
||||
bookId = id,
|
||||
|
|
@ -152,6 +250,12 @@ class SharedModelMappersTest {
|
|||
bookmarksJson = bookmarksJson,
|
||||
sourceFolderUri = sourceFolderUri,
|
||||
customName = customName,
|
||||
lastChapterIndex = lastChapterIndex,
|
||||
lastPage = lastPage,
|
||||
lastPositionCfi = lastPositionCfi,
|
||||
locatorBlockIndex = locatorBlockIndex,
|
||||
locatorCharOffset = locatorCharOffset,
|
||||
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
|
||||
tags = tags
|
||||
)
|
||||
|
||||
|
|
|
|||
69
app/src/test/java/com/aryan/reader/SyncedFolderPrefsTest.kt
Normal file
69
app/src/test/java/com/aryan/reader/SyncedFolderPrefsTest.kt
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package com.aryan.reader
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SyncedFolderPrefsTest {
|
||||
@Test
|
||||
fun `missing local sync flag defaults enabled`() {
|
||||
val folders = SyncedFolderPrefs.decodeSyncedFolders(
|
||||
jsonString = """
|
||||
[
|
||||
{
|
||||
"uri": "content://folder",
|
||||
"name": "Books",
|
||||
"lastScanTime": 12,
|
||||
"allowedFileTypes": ["PDF"]
|
||||
}
|
||||
]
|
||||
""".trimIndent(),
|
||||
legacyUri = null,
|
||||
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
|
||||
)
|
||||
|
||||
assertTrue(folders.single().localSyncEnabled)
|
||||
assertTrue(
|
||||
SyncedFolderPrefs.isLocalSyncEnabled(
|
||||
jsonString = SyncedFolderPrefs.encodeSyncedFolders(
|
||||
folders,
|
||||
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
|
||||
),
|
||||
legacyUri = null,
|
||||
folderUriString = "content://folder",
|
||||
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled local sync flag persists and is checked`() {
|
||||
val encoded = SyncedFolderPrefs.encodeSyncedFolders(
|
||||
listOf(
|
||||
SyncedFolder(
|
||||
uriString = "content://folder",
|
||||
name = "Books",
|
||||
lastScanTime = 12L,
|
||||
allowedFileTypes = setOf(FileType.PDF),
|
||||
localSyncEnabled = false
|
||||
)
|
||||
),
|
||||
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
|
||||
)
|
||||
val decoded = SyncedFolderPrefs.decodeSyncedFolders(
|
||||
jsonString = encoded,
|
||||
legacyUri = null,
|
||||
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
|
||||
)
|
||||
|
||||
assertFalse(decoded.single().localSyncEnabled)
|
||||
assertFalse(
|
||||
SyncedFolderPrefs.isLocalSyncEnabled(
|
||||
jsonString = encoded,
|
||||
legacyUri = null,
|
||||
folderUriString = "content://folder",
|
||||
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ class RecentFileDaoReadingPositionTest {
|
|||
assertEquals(58.5f, saved.progressPercentage)
|
||||
assertEquals(9_000L, saved.timestamp)
|
||||
assertEquals(9_000L, saved.lastModifiedTimestamp)
|
||||
assertEquals(9_000L, saved.readingPositionModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -100,6 +101,7 @@ class RecentFileDaoReadingPositionTest {
|
|||
assertEquals(21, item.locatorBlockIndex)
|
||||
assertEquals(12, item.locatorCharOffset)
|
||||
assertEquals(44f, item.progressPercentage)
|
||||
assertEquals(3_000L, item.readingPositionModifiedTimestamp)
|
||||
assertTrue(item.isRecent)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class RecentFileItemReadingPositionMappingTest {
|
|||
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
|
||||
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
|
||||
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
|
||||
assertEquals(item.readingPositionModifiedTimestamp, roundTripped.readingPositionModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -32,6 +33,7 @@ class RecentFileItemReadingPositionMappingTest {
|
|||
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
|
||||
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
|
||||
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
|
||||
assertEquals(item.readingPositionModifiedTimestamp, roundTripped.readingPositionModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -101,6 +103,7 @@ class RecentFileItemReadingPositionMappingTest {
|
|||
locatorCharOffset = 88,
|
||||
progressPercentage = 61.5f,
|
||||
lastModifiedTimestamp = 2_000L,
|
||||
readingPositionModifiedTimestamp = 1_900L,
|
||||
fileContentModifiedTimestamp = 3_000L,
|
||||
bookmarksJson = """[{"cfi":"/4/2"}]""",
|
||||
highlightsJson = """[{"cfi":"/4/2/6:88"}]"""
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ class RecentFilesRepositoryReadingPositionMergeTest {
|
|||
locatorBlockIndex = 31,
|
||||
locatorCharOffset = 12,
|
||||
progressPercentage = 82f,
|
||||
lastModifiedTimestamp = 2_000L,
|
||||
readingPositionModifiedTimestamp = 2_000L,
|
||||
isRecent = true
|
||||
)
|
||||
)
|
||||
|
|
@ -113,6 +115,41 @@ class RecentFilesRepositoryReadingPositionMergeTest {
|
|||
assertEquals(31, inserted.captured.locatorBlockIndex)
|
||||
assertEquals(12, inserted.captured.locatorCharOffset)
|
||||
assertEquals(82f, inserted.captured.progressPercentage)
|
||||
assertEquals(2_000L, inserted.captured.readingPositionModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addRecentFile preserves newer existing reading position when incoming metadata timestamp is newer`() = runTest {
|
||||
val inserted = slot<RecentFileEntity>()
|
||||
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity().copy(
|
||||
readingPositionModifiedTimestamp = 1_800L
|
||||
)
|
||||
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
|
||||
|
||||
repository.addRecentFile(
|
||||
RecentFileItem(
|
||||
bookId = "book-1",
|
||||
uriString = "content://new",
|
||||
type = FileType.EPUB,
|
||||
displayName = "New.epub",
|
||||
timestamp = 3_000L,
|
||||
lastChapterIndex = 1,
|
||||
lastPositionCfi = "/old/remote",
|
||||
locatorBlockIndex = 2,
|
||||
locatorCharOffset = 3,
|
||||
progressPercentage = 12f,
|
||||
lastModifiedTimestamp = 3_000L,
|
||||
readingPositionModifiedTimestamp = 1_200L,
|
||||
isRecent = true
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("/4/2/6:44", inserted.captured.lastPositionCfi)
|
||||
assertEquals(6, inserted.captured.lastChapterIndex)
|
||||
assertEquals(24, inserted.captured.locatorBlockIndex)
|
||||
assertEquals(44, inserted.captured.locatorCharOffset)
|
||||
assertEquals(71.5f, inserted.captured.progressPercentage)
|
||||
assertEquals(1_800L, inserted.captured.readingPositionModifiedTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
package com.aryan.reader.epub
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class EpubImportSecurityTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `safeFileInRoot rejects traversal outside extraction root`() {
|
||||
val root = temp.newFolder("root")
|
||||
|
||||
assertNotNull(safeFileInRoot(root, "OEBPS/image.png"))
|
||||
assertTrue(safeFileInRoot(root, "../outside.txt") == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `xml parser rejects doctypes from untrusted book metadata`() {
|
||||
val xml = """
|
||||
<!DOCTYPE root [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
|
||||
<root>&xxe;</root>
|
||||
""".trimIndent()
|
||||
|
||||
assertThrows(Exception::class.java) {
|
||||
parseXMLFile(ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `odt parser skips zip entries that escape extraction root`() = runTest {
|
||||
val extractionDir = temp.newFolder("odt-root")
|
||||
val outside = File(extractionDir.parentFile, "odt-evil.txt")
|
||||
val parser = OdtParser(contextWithCache(temp.newFolder("odt-cache")))
|
||||
|
||||
parser.createOdtBook(
|
||||
inputStream = ByteArrayInputStream(
|
||||
zipBytes(
|
||||
"content.xml" to minimalOdtContent().toByteArray(Charsets.UTF_8),
|
||||
"../odt-evil.txt" to "evil".toByteArray(Charsets.UTF_8)
|
||||
)
|
||||
),
|
||||
bookId = "odt-book",
|
||||
originalBookNameHint = "book.odt",
|
||||
isFlat = false,
|
||||
parseContent = false,
|
||||
extractionDirOverride = extractionDir
|
||||
)
|
||||
|
||||
assertFalse(outside.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fb2 parser sanitizes binary image ids before writing files`() = runTest {
|
||||
val extractionDir = temp.newFolder("fb2-root")
|
||||
val outside = File(extractionDir.parentFile, "fb2-evil.png")
|
||||
val parser = Fb2Parser(contextWithCache(temp.newFolder("fb2-cache")))
|
||||
|
||||
parser.createFb2Book(
|
||||
inputStream = ByteArrayInputStream(minimalFb2WithUnsafeImage().toByteArray(Charsets.UTF_8)),
|
||||
bookId = "fb2-book",
|
||||
originalBookNameHint = "book.fb2",
|
||||
parseContent = true,
|
||||
extractionDirOverride = extractionDir
|
||||
)
|
||||
|
||||
assertFalse(outside.exists())
|
||||
assertTrue(extractionDir.listFiles().orEmpty().any { it.name.startsWith("fb2-evil_") && it.extension == "png" })
|
||||
}
|
||||
|
||||
private fun contextWithCache(cacheDir: File): Context {
|
||||
val context = mockk<Context>(relaxed = true)
|
||||
every { context.cacheDir } returns cacheDir
|
||||
return context
|
||||
}
|
||||
|
||||
private fun zipBytes(vararg entries: Pair<String, ByteArray>): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
ZipOutputStream(output).use { zip ->
|
||||
entries.forEach { (name, bytes) ->
|
||||
zip.putNextEntry(ZipEntry(name))
|
||||
zip.write(bytes)
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun minimalOdtContent(): String {
|
||||
return """
|
||||
<office:document-content
|
||||
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||||
<office:body><office:text><text:p>Hello</text:p></office:text></office:body>
|
||||
</office:document-content>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun minimalFb2WithUnsafeImage(): String {
|
||||
val payload = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3, 4))
|
||||
return """
|
||||
<FictionBook xmlns:l="http://www.w3.org/1999/xlink">
|
||||
<description><title-info><book-title>Unsafe image</book-title></title-info></description>
|
||||
<body>
|
||||
<section>
|
||||
<p>Hello</p>
|
||||
<image l:href="#../fb2-evil.png"/>
|
||||
</section>
|
||||
</body>
|
||||
<binary id="../fb2-evil.png" content-type="image/png">$payload</binary>
|
||||
</FictionBook>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +56,11 @@ class SingleFileImporterTest {
|
|||
assertEquals("Part 1", book.chapters.single().title)
|
||||
assertTrue(book.chapters.single().plainTextContent.contains("First <line> continues"))
|
||||
assertTrue(File(book.extractionBasePath, "part_1.html").readText().contains("First <line>"))
|
||||
assertTrue(File(book.extractionBasePath, "book_metadata.json").isFile)
|
||||
val metadata = File(book.extractionBasePath, "book_metadata.json")
|
||||
assertTrue(metadata.isFile)
|
||||
val metadataText = metadata.readText()
|
||||
assertFalse(metadataText.contains("First <line> continues"))
|
||||
assertTrue(metadataText.contains("plainTextLength"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -79,8 +83,29 @@ class SingleFileImporterTest {
|
|||
)
|
||||
|
||||
assertEquals(first.title, second.title)
|
||||
assertEquals(first.chapters.single().plainTextContent, second.chapters.single().plainTextContent)
|
||||
assertTrue(second.chapters.single().plainTextContent.contains("Cached content"))
|
||||
assertEquals(first.chapters.single().plainTextLength, second.chapters.single().plainTextLength)
|
||||
assertEquals("", second.chapters.single().plainTextContent)
|
||||
assertTrue(File(second.extractionBasePath, second.chapters.single().htmlFilePath).readText().contains("Cached content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain text import ignores oversized legacy cached metadata before reading it`() = runTest {
|
||||
val cache = temp.newFolder("txt-cache-oversized")
|
||||
val context = contextWithCache(cache)
|
||||
val bookId = "oversized-cache-book"
|
||||
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
|
||||
File(extractionDir, "book_metadata.json").writeText("x".repeat((2L * 1024L * 1024L + 1L).toInt()))
|
||||
val importer = SingleFileImporter(context)
|
||||
|
||||
val book = importer.importSingleFile(
|
||||
inputStream = ByteArrayInputStream("Fresh content after oversized cache".toByteArray()),
|
||||
type = FileType.TXT,
|
||||
originalBookNameHint = "Fresh.txt",
|
||||
bookId = bookId
|
||||
)
|
||||
|
||||
assertEquals("Fresh", book.title)
|
||||
assertTrue(book.chapters.single().plainTextContent.contains("Fresh content"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.aryan.reader.epubreader
|
||||
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import org.json.JSONArray
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChapterWebViewHighlightJsonTest {
|
||||
|
||||
@Test
|
||||
fun `webview highlight json keeps shared locator offsets`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "desktop:6:120:145",
|
||||
text = "synced desktop text",
|
||||
color = HighlightColor.GREEN,
|
||||
chapterIndex = 6,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 6,
|
||||
pageIndex = 2,
|
||||
startOffset = 120,
|
||||
endOffset = 145,
|
||||
textQuote = "synced desktop text",
|
||||
cfi = "desktop:6:120:145"
|
||||
)
|
||||
)
|
||||
|
||||
val obj = JSONArray(highlightsJsonForWebView(listOf(highlight))).getJSONObject(0)
|
||||
val locator = obj.getJSONObject("locator")
|
||||
|
||||
assertEquals("desktop:6:120:145", obj.getString("cfi"))
|
||||
assertEquals("user-highlight-green", obj.getString("cssClass"))
|
||||
assertEquals(6, locator.getInt("chapterIndex"))
|
||||
assertEquals(120, locator.getInt("startOffset"))
|
||||
assertEquals(145, locator.getInt("endOffset"))
|
||||
assertEquals("synced desktop text", locator.getString("textQuote"))
|
||||
}
|
||||
}
|
||||
|
|
@ -257,7 +257,8 @@ class EpubReaderBridgeAndControlsTest {
|
|||
val sections = epubOverflowMenuSections(
|
||||
hiddenTools = setOf(
|
||||
ReaderTool.TTS_SETTINGS.name,
|
||||
ReaderTool.TTS_REPLACEMENTS.name
|
||||
ReaderTool.TTS_REPLACEMENTS.name,
|
||||
ReaderTool.BOOK_REPLACEMENTS.name
|
||||
),
|
||||
hasHiddenToolbarTools = false,
|
||||
hasToggleReflow = false,
|
||||
|
|
@ -267,6 +268,21 @@ class EpubReaderBridgeAndControlsTest {
|
|||
|
||||
assertEquals(EpubOverflowMenuSection.AUTO_SCROLL, sections.last())
|
||||
assertTrue(EpubOverflowMenuSection.TTS_SETTINGS !in sections)
|
||||
assertTrue(EpubOverflowMenuSection.BOOK_REPLACEMENTS !in sections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub overflow sections expose book replacements when visible`() {
|
||||
val sections = epubOverflowMenuSections(
|
||||
hiddenTools = emptySet(),
|
||||
hasHiddenToolbarTools = false,
|
||||
hasToggleReflow = false,
|
||||
hasDeleteReflow = false,
|
||||
hasFileInfo = false
|
||||
)
|
||||
|
||||
assertTrue(EpubOverflowMenuSection.BOOK_REPLACEMENTS in sections)
|
||||
assertTrue(sections.indexOf(EpubOverflowMenuSection.BOOK_REPLACEMENTS) < sections.indexOf(EpubOverflowMenuSection.TTS_SETTINGS))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
|
|||
assertEquals(ReaderFont.ORIGINAL, format.font)
|
||||
assertEquals(ReaderTextAlign.DEFAULT, format.textAlign)
|
||||
assertNull(format.customPath)
|
||||
assertFalse(loadNativeVerticalRenderer(context))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -136,6 +137,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
|
|||
saveVolumeScrollSetting(context, true)
|
||||
saveRemoveEdgePadding(context, true)
|
||||
saveFormatIsLocal(context, "book", true)
|
||||
saveNativeVerticalRenderer(context, true)
|
||||
|
||||
assertEquals(1.35f, loadTtsSpeechRate(context), 0.0001f)
|
||||
assertEquals(0.85f, loadTtsPitch(context), 0.0001f)
|
||||
|
|
@ -149,6 +151,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
|
|||
assertTrue(loadVolumeScrollSetting(context))
|
||||
assertTrue(loadRemoveEdgePadding(context))
|
||||
assertTrue(loadFormatIsLocal(context, "book"))
|
||||
assertTrue(loadNativeVerticalRenderer(context))
|
||||
assertEquals(0f, loadHorizontalMargin(context), 0.0001f)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,24 @@ class EpubReaderSearchTest {
|
|||
assertEquals("Chunky", result.locationTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search scans oversized text nodes in bounded windows`() = runTest {
|
||||
val root = temp.newFolder("bounded-window")
|
||||
val filler = "alpha ".repeat(7_000)
|
||||
writeChapter(
|
||||
root,
|
||||
"chapter.xhtml",
|
||||
"<html><body><p>${filler}Needle ${filler}pineedle ${filler}Needle</p></body></html>"
|
||||
)
|
||||
val book = epubBook(root, listOf(chapter("ch1", "Large", "chapter.xhtml")))
|
||||
|
||||
val results = createEpubSearcher(book)("needle")
|
||||
|
||||
assertEquals(2, results.size)
|
||||
assertEquals(listOf(0, 1), results.map { it.occurrenceIndexInLocation })
|
||||
assertTrue(results.all { it.snippet.text.contains("Needle", ignoreCase = true) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search currently requires only a word start and highlights the matched substring`() = runTest {
|
||||
val root = temp.newFolder("word-start")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
package com.aryan.reader.epubreader
|
||||
|
||||
import com.aryan.reader.shared.PageInfoMode
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class EpubReaderVisualOptionsStateTest {
|
||||
|
||||
@Test
|
||||
fun `page info always show remains visible independent of reader chrome`() {
|
||||
assertTrue(
|
||||
shouldShowEpubPageInfoBar(
|
||||
pageInfoMode = PageInfoMode.DEFAULT,
|
||||
showReaderChrome = false
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldShowEpubPageInfoBar(
|
||||
pageInfoMode = PageInfoMode.DEFAULT,
|
||||
showReaderChrome = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page info sync follows reader chrome and hidden never shows`() {
|
||||
assertFalse(
|
||||
shouldShowEpubPageInfoBar(
|
||||
pageInfoMode = PageInfoMode.SYNC,
|
||||
showReaderChrome = false
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldShowEpubPageInfoBar(
|
||||
pageInfoMode = PageInfoMode.SYNC,
|
||||
showReaderChrome = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldShowEpubPageInfoBar(
|
||||
pageInfoMode = PageInfoMode.HIDDEN,
|
||||
showReaderChrome = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -47,4 +47,19 @@ class EpubTtsChunkMatchingTest {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chunk start matching accepts target offset inside matching source block`() {
|
||||
val chunks = listOf(
|
||||
TtsChunk("Alpha beta gamma", "/4/8/2", 10),
|
||||
TtsChunk("Delta epsilon", "/4/10/2", 0)
|
||||
)
|
||||
val nativeVerticalTarget = TtsChunk(
|
||||
text = "",
|
||||
sourceCfi = "/4/8",
|
||||
startOffsetInSource = 16
|
||||
)
|
||||
|
||||
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,7 +193,8 @@ class OpdsParserTest {
|
|||
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
),
|
||||
OpdsAcquisition("epub", "application/epub+zip"),
|
||||
OpdsAcquisition("unknown", "application/octet-stream")
|
||||
OpdsAcquisition("unknown", "application/octet-stream"),
|
||||
OpdsAcquisition("cbt", "application/vnd.comicbook+tar")
|
||||
)
|
||||
val entry = OpdsEntry(
|
||||
id = "id",
|
||||
|
|
@ -208,6 +209,7 @@ class OpdsParserTest {
|
|||
assertEquals("PPTX", acquisitions[2].formatName)
|
||||
assertEquals("TXT", acquisitions[0].formatName)
|
||||
assertEquals("OCTET-STREAM", acquisitions[4].formatName)
|
||||
assertEquals("CBT", acquisitions[5].formatName)
|
||||
assertEquals(acquisitions[3], entry.bestAcquisition)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,24 @@ class OpdsRepositoryTest {
|
|||
assertNotNull(Regex("""response="[a-f0-9]{32}"""").find(header))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `digest authenticator selects auth from qop list`() {
|
||||
val request = Request.Builder()
|
||||
.url("https://example.org/catalog/feed")
|
||||
.build()
|
||||
val response = responseFor(
|
||||
request,
|
||||
"Digest realm=\"realm\", nonce=\"abc\", qop=\"auth,auth-int\""
|
||||
)
|
||||
|
||||
val authenticated = OpdsRepository.OpdsAuthenticator("user", "pass")
|
||||
.authenticate(null, response)
|
||||
val header = authenticated?.header("Authorization").orEmpty()
|
||||
|
||||
assertTrue(header.contains("qop=auth"))
|
||||
assertTrue(!header.contains("auth,auth-int"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticator ignores unsupported challenge`() {
|
||||
val request = Request.Builder().url("https://example.org/feed").build()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
class AndroidHtmlResourceResolverTest {
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `resolvePath returns files inside extraction root`() {
|
||||
val root = temp.newFolder("book")
|
||||
val image = File(root, "OEBPS/images/picture.png").apply {
|
||||
parentFile?.mkdirs()
|
||||
writeText("image")
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
image.canonicalPath,
|
||||
AndroidHtmlResourceResolver.resolvePath(
|
||||
chapterAbsPath = "OEBPS/chapter.xhtml",
|
||||
extractionBasePath = root.absolutePath,
|
||||
src = "images/picture.png"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvePath rejects paths that escape extraction root`() {
|
||||
val root = temp.newFolder("book")
|
||||
File(root.parentFile, "outside.png").writeText("outside")
|
||||
|
||||
assertNull(
|
||||
AndroidHtmlResourceResolver.resolvePath(
|
||||
chapterAbsPath = "OEBPS/chapter.xhtml",
|
||||
extractionBasePath = root.absolutePath,
|
||||
src = "../../outside.png"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ class ContentStylerTest {
|
|||
).single() as ParagraphBlock
|
||||
|
||||
assertEquals(TextAlign.Justify, block.textAlign)
|
||||
assertEquals(TextAlign.Justify, block.content.paragraphStyles.first().item.textAlign)
|
||||
assertEquals("p1", block.elementId)
|
||||
assertEquals("/4/2", block.cfi)
|
||||
assertEquals(7, block.startCharOffsetInSource)
|
||||
|
|
@ -46,6 +47,22 @@ class ContentStylerTest {
|
|||
assertEquals("Aligned text", block.content.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paragraph styling downgrades css justify unless user explicitly forces alignment`() {
|
||||
val block = styler(userTextAlign = null).style(
|
||||
listOf(
|
||||
paragraph(
|
||||
text = "Justified text",
|
||||
blockIndex = 20,
|
||||
style = CssStyle(paragraphStyle = androidx.compose.ui.text.ParagraphStyle(textAlign = TextAlign.Justify))
|
||||
)
|
||||
)
|
||||
).single() as ParagraphBlock
|
||||
|
||||
assertEquals(TextAlign.Left, block.textAlign)
|
||||
assertEquals(TextAlign.Left, block.content.paragraphStyles.first().item.textAlign)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `floating image is grouped with following paragraphs until clear`() {
|
||||
val blocks = styler().style(
|
||||
|
|
@ -136,6 +153,52 @@ class ContentStylerTest {
|
|||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `link styling is applied after nested epub span styling`() {
|
||||
val label = "Nested link"
|
||||
val paragraph = SemanticParagraph(
|
||||
text = label,
|
||||
spans = listOf(
|
||||
SemanticSpan(
|
||||
start = 0,
|
||||
end = label.length,
|
||||
style = CssStyle(),
|
||||
linkHref = "https://example.org",
|
||||
tag = "a"
|
||||
),
|
||||
SemanticSpan(
|
||||
start = 0,
|
||||
end = label.length,
|
||||
style = CssStyle(
|
||||
spanStyle = SpanStyle(
|
||||
color = Color.Red,
|
||||
background = Color.Yellow,
|
||||
textDecoration = TextDecoration.None
|
||||
)
|
||||
),
|
||||
tag = "span"
|
||||
)
|
||||
),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2",
|
||||
blockIndex = 21
|
||||
)
|
||||
|
||||
val styled = styler().style(listOf(paragraph)).single() as ParagraphBlock
|
||||
val finalCoveringStyle = styled.content.spanStyles
|
||||
.filter { it.start <= 0 && it.end >= label.length }
|
||||
.last()
|
||||
.item
|
||||
|
||||
assertEquals("https://example.org", styled.content.getStringAnnotations("URL", 0, label.length).single().item)
|
||||
assertTrue(finalCoveringStyle.color.isSpecified)
|
||||
assertTrue(finalCoveringStyle.color != Color.Red)
|
||||
assertTrue(finalCoveringStyle.background.isSpecified)
|
||||
assertTrue(finalCoveringStyle.background != Color.Yellow)
|
||||
assertTrue(finalCoveringStyle.textDecoration?.contains(TextDecoration.Underline) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runtime theme reapplies visible link style for cached paginated text`() {
|
||||
val linkText = "Cached link"
|
||||
|
|
@ -166,6 +229,30 @@ class ContentStylerTest {
|
|||
range.item.color != Color(0xFFE0E0E0) &&
|
||||
range.item.background.isSpecified &&
|
||||
range.item.textDecoration?.contains(TextDecoration.Underline) == true
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `block anchor from html is styled and annotated as paginated link`() {
|
||||
val semanticBlocks = htmlToSemanticBlocks(
|
||||
html = """<html><body><a href="chapter2.xhtml#start"><p>Continue reading</p></a></body></html>""",
|
||||
cssRules = OptimizedCssRules(),
|
||||
textStyle = TextStyle(fontSize = 16.sp, color = Color.Black),
|
||||
chapterAbsPath = "OEBPS/chapter1.xhtml",
|
||||
extractionBasePath = "",
|
||||
density = Density(1f),
|
||||
fontFamilyMap = emptyMap(),
|
||||
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 400, maxHeight = 800)
|
||||
)
|
||||
|
||||
val paragraph = styler().style(semanticBlocks).single() as ParagraphBlock
|
||||
|
||||
assertEquals("chapter2.xhtml#start", paragraph.content.getStringAnnotations("URL", 0, paragraph.content.length).single().item)
|
||||
assertTrue(paragraph.content.spanStyles.any { range ->
|
||||
range.start == 0 &&
|
||||
range.end == paragraph.content.length &&
|
||||
range.item.background.isSpecified &&
|
||||
range.item.textDecoration?.contains(TextDecoration.Underline) == true
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class LocatorConverterTest {
|
||||
|
|
@ -41,6 +43,31 @@ class LocatorConverterTest {
|
|||
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 13), locator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cfi local offsets become absolute locators and serialize back locally`() = runTest {
|
||||
val converter = converterFor(
|
||||
listOf(paragraph("Offset paragraph", blockIndex = 2, cfi = "/4/2/6", offset = 100))
|
||||
)
|
||||
val book = book()
|
||||
|
||||
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6:7")
|
||||
val cfi = locator?.let { converter.getCfiFromLocator(book, it) }
|
||||
|
||||
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 107), locator)
|
||||
assertEquals("/4/2/6:7", cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multipart cfi uses first point local offset when resolving locator`() = runTest {
|
||||
val converter = converterFor(
|
||||
listOf(paragraph("Offset paragraph", blockIndex = 2, cfi = "/4/2/6", offset = 100))
|
||||
)
|
||||
|
||||
val locator = converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2/6:7|/4/2/6:12")
|
||||
|
||||
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 107), locator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero estimate semantic cache remains usable`() = runTest {
|
||||
val converter = converterFor(semanticBlocks(), estimatedPageCount = 0)
|
||||
|
|
@ -170,6 +197,27 @@ class LocatorConverterTest {
|
|||
assertNull(converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `large uncached chapter file is skipped instead of parsed on demand`() = runTest {
|
||||
val tempDir = Files.createTempDirectory("large-locator-chapter").toFile()
|
||||
try {
|
||||
File(tempDir, "c1.xhtml").writeText("<html><body>${"x".repeat(2_200_000)}</body></html>")
|
||||
val dao = FakeBookCacheDao(null)
|
||||
val converter = LocatorConverter(dao, proto, mockk<Context>(relaxed = true))
|
||||
|
||||
val locator = converter.getLocatorFromCfi(
|
||||
book = book(extractionBasePath = tempDir.absolutePath),
|
||||
chapterIndex = 0,
|
||||
cfi = "/4/2"
|
||||
)
|
||||
|
||||
assertNull(locator)
|
||||
assertTrue(dao.insertedChapters.isEmpty())
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun converterFor(blocks: List<SemanticBlock>, estimatedPageCount: Int = 1): LocatorConverter {
|
||||
val chapter = ProcessedChapter(
|
||||
bookId = "Book",
|
||||
|
|
@ -211,7 +259,7 @@ class LocatorConverterTest {
|
|||
)
|
||||
}
|
||||
|
||||
private fun book(): EpubBook {
|
||||
private fun book(extractionBasePath: String = ""): EpubBook {
|
||||
return EpubBook(
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
|
|
@ -228,7 +276,7 @@ class LocatorConverterTest {
|
|||
htmlContent = ""
|
||||
)
|
||||
),
|
||||
extractionBasePath = ""
|
||||
extractionBasePath = extractionBasePath
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -236,12 +284,15 @@ class LocatorConverterTest {
|
|||
private val chapter: ProcessedChapter?
|
||||
) : BookCacheDao() {
|
||||
val requestedBookIds = mutableListOf<String>()
|
||||
val insertedChapters = mutableListOf<ProcessedChapter>()
|
||||
|
||||
override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? {
|
||||
override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int?): ProcessedChapter? {
|
||||
requestedBookIds += bookId
|
||||
return chapter
|
||||
}
|
||||
override suspend fun insertProcessedChapters(chapters: List<ProcessedChapter>) = Unit
|
||||
override suspend fun insertProcessedChapters(chapters: List<ProcessedChapter>) {
|
||||
insertedChapters += chapters
|
||||
}
|
||||
|
||||
override suspend fun getProcessedBook(bookId: String): ProcessedBook? = null
|
||||
override suspend fun insertProcessedBook(book: ProcessedBook) = Unit
|
||||
|
|
@ -260,11 +311,13 @@ class LocatorConverterTest {
|
|||
override suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List<PageIndexEntry> = emptyList()
|
||||
override suspend fun cleanupOldPageCaches(bookId: String) = Unit
|
||||
|
||||
protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null
|
||||
protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List<ByteArray> = emptyList()
|
||||
protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int, styleConfigHash: Int): ProcessedChapterMetadata? = null
|
||||
protected override suspend fun getAnyChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null
|
||||
protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int, styleConfigHash: Int): List<ByteArray> = emptyList()
|
||||
protected override suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata) = Unit
|
||||
protected override suspend fun insertChapterChunks(chunks: List<ProcessedChapterChunk>) = Unit
|
||||
protected override suspend fun deleteChapterMetadataForBook(bookId: String) = Unit
|
||||
protected override suspend fun deleteChapterChunksForChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int) = Unit
|
||||
protected override suspend fun deleteAllChapterMetadata() = Unit
|
||||
protected override suspend fun deletePageCacheMetadataForBook(bookId: String) = Unit
|
||||
protected override suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int) = Unit
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class NativeVerticalLocationTest {
|
||||
|
||||
@Test
|
||||
fun `compat page follows native progress`() {
|
||||
assertEquals(0, nativeVerticalCompatPageForProgress(0f, 101))
|
||||
assertEquals(50, nativeVerticalCompatPageForProgress(50f, 101))
|
||||
assertEquals(100, nativeVerticalCompatPageForProgress(100f, 101))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress follows compat page`() {
|
||||
assertEquals(0f, nativeVerticalProgressForCompatPage(0, 101), 0.001f)
|
||||
assertEquals(50f, nativeVerticalProgressForCompatPage(50, 101), 0.001f)
|
||||
assertEquals(100f, nativeVerticalProgressForCompatPage(100, 101), 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress target skips zero weight chapter gaps`() {
|
||||
val weights = listOf(0, 100, 300, 600)
|
||||
|
||||
assertEquals(1, nativeVerticalProgressToItemIndex(weights, 0f))
|
||||
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
|
||||
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.aryan.reader.paginatedreader
|
|||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.aryan.reader.epubreader.HighlightColor
|
||||
import com.aryan.reader.epubreader.UserHighlight
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
|
@ -40,7 +41,7 @@ class PaginatedHighlightMappingTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `same path split block outside stored offsets is ignored`() {
|
||||
fun `same path split uses cfi offsets as local to block`() {
|
||||
val block = paragraph(
|
||||
text = "repeat",
|
||||
cfi = "/4/2",
|
||||
|
|
@ -51,9 +52,146 @@ class PaginatedHighlightMappingTest {
|
|||
text = "repeat"
|
||||
)
|
||||
|
||||
assertEquals(0 until 6, getHighlightOffsetsInBlock(block, highlight))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same path split block outside stored offsets is ignored`() {
|
||||
val block = paragraph(
|
||||
text = "repeat",
|
||||
cfi = "/4/2",
|
||||
startOffset = 20
|
||||
)
|
||||
val highlight = highlight(
|
||||
cfi = "/4/2:40|/4/2:46",
|
||||
text = "repeat"
|
||||
)
|
||||
|
||||
assertNull(getHighlightOffsetsInBlock(block, highlight))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop locator highlight maps by source offsets`() {
|
||||
val block = paragraph(
|
||||
text = "alpha beta gamma",
|
||||
cfi = null,
|
||||
startOffset = 20
|
||||
)
|
||||
val highlight = highlight(
|
||||
cfi = "desktop:0:26:30",
|
||||
text = "beta"
|
||||
)
|
||||
|
||||
assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locator offsets win over cfi offsets for synced highlights`() {
|
||||
val block = paragraph(
|
||||
text = "alpha beta gamma",
|
||||
cfi = "/4/2",
|
||||
startOffset = 200
|
||||
)
|
||||
val highlight = highlight(
|
||||
cfi = "/4/2:6|/4/2:10",
|
||||
text = "beta",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 206,
|
||||
endOffset = 210,
|
||||
cfi = "/4/2:6|/4/2:10",
|
||||
textQuote = "beta"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locator offsets prevent cfi fallback from painting unrelated duplicate block`() {
|
||||
val block = paragraph(
|
||||
text = "alpha beta gamma",
|
||||
cfi = "/4/4",
|
||||
startOffset = 300
|
||||
)
|
||||
val highlight = highlight(
|
||||
cfi = "/4/2:6|/4/2:10",
|
||||
text = "beta",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 206,
|
||||
endOffset = 210,
|
||||
cfi = "/4/2:6|/4/2:10",
|
||||
textQuote = "beta"
|
||||
)
|
||||
)
|
||||
|
||||
assertNull(getHighlightOffsetsInBlock(block, highlight))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `block local locator offsets do not paint sibling blocks with overlapping local ranges`() {
|
||||
val highlight = highlight(
|
||||
cfi = "/4/4/6:124|/4/4/6:248",
|
||||
text = "selected text",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 8,
|
||||
pageIndex = 49,
|
||||
startOffset = 124,
|
||||
endOffset = 248,
|
||||
blockIndex = 1,
|
||||
charOffset = 124,
|
||||
textQuote = "selected text",
|
||||
cfi = "/4/4/6:124|/4/4/6:248"
|
||||
)
|
||||
)
|
||||
val selectedBlock = paragraph(
|
||||
text = "x".repeat(260),
|
||||
cfi = "/4/4/6",
|
||||
startOffset = 0,
|
||||
blockIndex = 1
|
||||
)
|
||||
val siblingBlock = paragraph(
|
||||
text = "x".repeat(684),
|
||||
cfi = "/4/4/8",
|
||||
startOffset = 0,
|
||||
blockIndex = 2
|
||||
)
|
||||
|
||||
assertEquals(124 until 248, getHighlightOffsetsInBlock(selectedBlock, highlight))
|
||||
assertNull(getHighlightOffsetsInBlock(siblingBlock, highlight))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `source cfi local offsets map within nonzero source block`() {
|
||||
val block = paragraph(
|
||||
text = "alpha beta gamma",
|
||||
cfi = "/4/2",
|
||||
startOffset = 200
|
||||
)
|
||||
val highlight = highlight(
|
||||
cfi = "/4/2:6|/4/2:10",
|
||||
text = "beta"
|
||||
)
|
||||
|
||||
assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy absolute cfi offsets remain supported for synced highlights`() {
|
||||
val block = paragraph(
|
||||
text = "alpha beta gamma",
|
||||
cfi = "/4/2",
|
||||
startOffset = 200
|
||||
)
|
||||
val highlight = highlight(
|
||||
cfi = "/4/2:206|/4/2:210",
|
||||
text = "beta"
|
||||
)
|
||||
|
||||
assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated page highlights are scoped to page chapter`() {
|
||||
val chapterFourHighlight = highlight(
|
||||
|
|
@ -85,29 +223,36 @@ class PaginatedHighlightMappingTest {
|
|||
|
||||
private fun paragraph(
|
||||
text: String,
|
||||
cfi: String,
|
||||
startOffset: Int
|
||||
cfi: String?,
|
||||
startOffset: Int,
|
||||
blockIndex: Int = startOffset
|
||||
): ParagraphBlock {
|
||||
return ParagraphBlock(
|
||||
content = AnnotatedString(text),
|
||||
cfi = cfi,
|
||||
startCharOffsetInSource = startOffset,
|
||||
endCharOffsetInSource = startOffset + text.length,
|
||||
blockIndex = startOffset
|
||||
blockIndex = blockIndex
|
||||
)
|
||||
}
|
||||
|
||||
private fun highlight(
|
||||
cfi: String,
|
||||
text: String,
|
||||
chapterIndex: Int = 0
|
||||
chapterIndex: Int = 0,
|
||||
locator: ReaderLocator = ReaderLocator.fromLegacy(
|
||||
chapterIndex = chapterIndex,
|
||||
cfi = cfi,
|
||||
textQuote = text
|
||||
)
|
||||
): UserHighlight {
|
||||
return UserHighlight(
|
||||
id = "highlight",
|
||||
cfi = cfi,
|
||||
text = text,
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = chapterIndex
|
||||
chapterIndex = chapterIndex,
|
||||
locator = locator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PaginatorMeasurementContractTest {
|
||||
@Test
|
||||
fun measuredTextHeightForPagination_keepsLayoutHeightWhenItContainsLastLineBottom() {
|
||||
val measuredHeight = measuredTextHeightForPagination(
|
||||
layoutHeightPx = 120,
|
||||
lastLineBottomPx = 119.2f
|
||||
)
|
||||
|
||||
assertEquals(120, measuredHeight)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun measuredTextHeightForPagination_usesCeiledLastLineBottomWhenItExceedsLayoutHeight() {
|
||||
val measuredHeight = measuredTextHeightForPagination(
|
||||
layoutHeightPx = 120,
|
||||
lastLineBottomPx = 132.1f
|
||||
)
|
||||
|
||||
assertEquals(133, measuredHeight)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReaderLinkAnnotationTest {
|
||||
@Test
|
||||
fun urlAnnotationAtOffsetFindsLinkInsideRange() {
|
||||
val text = linkText()
|
||||
|
||||
assertEquals("chapter2.xhtml#start", text.readerUrlAnnotationAtOffset(4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun urlAnnotationAtOffsetFindsLinkAtEndBoundary() {
|
||||
val text = linkText()
|
||||
|
||||
assertEquals("chapter2.xhtml#start", text.readerUrlAnnotationAtOffset("Read more".length))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun urlAnnotationAtOffsetReturnsNullOutsideRange() {
|
||||
val text = buildAnnotatedString {
|
||||
append("Read more later")
|
||||
addStringAnnotation("URL", "chapter2.xhtml#start", 0, "Read more".length)
|
||||
}
|
||||
|
||||
assertNull(text.readerUrlAnnotationAtOffset(text.length))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readerExternalHrefDetectsCommonExternalSchemesCaseInsensitively() {
|
||||
assertTrue("HTTPS://example.com".isReaderExternalHref())
|
||||
assertTrue("//example.com/path".isReaderExternalHref())
|
||||
assertTrue("mailto:test@example.com".isReaderExternalHref())
|
||||
assertTrue("tel:+1234567890".isReaderExternalHref())
|
||||
|
||||
assertFalse("chapter2.xhtml#start".isReaderExternalHref())
|
||||
assertFalse("#footnote-1".isReaderExternalHref())
|
||||
}
|
||||
|
||||
private fun linkText() = buildAnnotatedString {
|
||||
val label = "Read more"
|
||||
append(label)
|
||||
addStringAnnotation("URL", "chapter2.xhtml#start", 0, label.length)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package com.aryan.reader.paginatedreader
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.aryan.reader.SearchResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ReaderNavigationTargetsTest {
|
||||
@Test
|
||||
fun `search locator resolves exact occurrence offset in text block`() {
|
||||
val blocks = listOf(
|
||||
ParagraphBlock(
|
||||
content = AnnotatedString("first target then second target"),
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 100,
|
||||
endCharOffsetInSource = 131,
|
||||
blockIndex = 7
|
||||
)
|
||||
)
|
||||
val result = SearchResult(
|
||||
locationInSource = 3,
|
||||
locationTitle = "Chapter",
|
||||
snippet = AnnotatedString("second target"),
|
||||
query = "target",
|
||||
occurrenceIndexInLocation = 1,
|
||||
chunkIndex = 0
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
Locator(chapterIndex = 3, blockIndex = 7, charOffset = 125),
|
||||
findLocatorForSearchResultInBlocks(result, blocks)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anchor locator resolves string annotation offset`() {
|
||||
val content = buildAnnotatedString {
|
||||
append("before anchored text")
|
||||
addStringAnnotation(tag = "ID", annotation = "anchor-1", start = 7, end = 15)
|
||||
}
|
||||
val blocks = listOf(
|
||||
ParagraphBlock(
|
||||
content = content,
|
||||
cfi = "/4/4",
|
||||
startCharOffsetInSource = 40,
|
||||
endCharOffsetInSource = 60,
|
||||
blockIndex = 9
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
Locator(chapterIndex = 2, blockIndex = 9, charOffset = 47),
|
||||
findLocatorForAnchorInBlocks(chapterIndex = 2, anchor = "anchor-1", blocks = blocks)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anchor locator resolves non text block element id`() {
|
||||
val blocks = listOf(
|
||||
ImageBlock(
|
||||
path = "images/cover.jpg",
|
||||
altText = "Cover",
|
||||
elementId = "cover-image",
|
||||
cfi = "/4/6",
|
||||
blockIndex = 11
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
Locator(chapterIndex = 5, blockIndex = 11, charOffset = 0),
|
||||
findLocatorForAnchorInBlocks(chapterIndex = 5, anchor = "cover-image", blocks = blocks)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native vertical initial prefetch is bounded around requested chapter`() {
|
||||
assertEquals(
|
||||
listOf(4, 5, 2),
|
||||
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,42 @@ class BookCacheDaoTest {
|
|||
assertArrayEquals(largePayload, large.contentBlocksProto)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processed chapters are isolated by style config hash`() = runTest {
|
||||
val firstPayload = ByteArray(950 * 1024) { 1 }
|
||||
val secondPayload = byteArrayOf(2, 3, 4)
|
||||
|
||||
dao.insertProcessedChapters(
|
||||
listOf(
|
||||
ProcessedChapter(
|
||||
bookId = "book",
|
||||
chapterIndex = 0,
|
||||
contentBlocksProto = firstPayload,
|
||||
estimatedPageCount = 10,
|
||||
styleConfigHash = 111
|
||||
)
|
||||
)
|
||||
)
|
||||
dao.insertProcessedChapters(
|
||||
listOf(
|
||||
ProcessedChapter(
|
||||
bookId = "book",
|
||||
chapterIndex = 0,
|
||||
contentBlocksProto = secondPayload,
|
||||
estimatedPageCount = 2,
|
||||
styleConfigHash = 222
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val firstCached = dao.getProcessedChapter("book", 0, 111)!!
|
||||
val secondCached = dao.getProcessedChapter("book", 0, 222)!!
|
||||
assertEquals(111, firstCached.styleConfigHash)
|
||||
assertEquals(222, secondCached.styleConfigHash)
|
||||
assertArrayEquals(firstPayload, firstCached.contentBlocksProto)
|
||||
assertArrayEquals(secondPayload, secondCached.contentBlocksProto)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete and clear operations remove book chapters anchors and configuration cache`() = runTest {
|
||||
dao.insertProcessedBook(ProcessedBook("book", LATEST_PROCESSING_VERSION, 10))
|
||||
|
|
|
|||
|
|
@ -50,17 +50,54 @@ class PdfReaderPreferencesTest {
|
|||
val context = contextWithPrefs(prefs)
|
||||
|
||||
savePdfHiddenTools(context, setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name))
|
||||
savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name))
|
||||
savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name))
|
||||
savePdfToolOrder(context, listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH))
|
||||
|
||||
assertEquals(setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name), loadPdfHiddenTools(context))
|
||||
assertFalse(PdfReaderTool.SCREEN_ORIENTATION.name in loadPdfHiddenTools(context))
|
||||
assertFalse(PdfReaderTool.HIGHLIGHT_ALL.name in loadPdfHiddenTools(context))
|
||||
assertFalse(PdfReaderTool.BRIGHTNESS.name in loadPdfHiddenTools(context))
|
||||
assertEquals(setOf(PdfReaderTool.SEARCH.name), loadPdfBottomTools(context))
|
||||
assertEquals(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name), loadPdfBottomTools(context))
|
||||
assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar restore helpers keep saveable tab switch state sanitized`() {
|
||||
val restoredOrder = restorePdfToolOrderNames(
|
||||
listOf(
|
||||
PdfReaderTool.SEARCH.name,
|
||||
"NO_SUCH_TOOL",
|
||||
PdfReaderTool.TOC.name,
|
||||
PdfReaderTool.SEARCH.name
|
||||
)
|
||||
)
|
||||
val expectedTools = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable)
|
||||
|
||||
assertEquals(listOf(PdfReaderTool.SEARCH, PdfReaderTool.TOC), restoredOrder.take(2))
|
||||
assertEquals(expectedTools.size, restoredOrder.size)
|
||||
assertEquals(expectedTools.toSet(), restoredOrder.toSet())
|
||||
assertEquals(
|
||||
setOf(PdfReaderTool.PRINT.name),
|
||||
sanitizePdfHiddenToolNames(listOf(PdfReaderTool.PRINT.name, "NO_SUCH_TOOL"))
|
||||
)
|
||||
assertEquals(
|
||||
setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name),
|
||||
sanitizePdfBottomToolNames(listOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name, PdfReaderTool.PRINT.name))
|
||||
)
|
||||
assertEquals(
|
||||
defaultPdfBottomTools(),
|
||||
loadPdfBottomTools(
|
||||
contextWithPrefs(InMemorySharedPreferences(PDF_BOTTOM_TOOLS_KEY to setOf("NO_SUCH_TOOL")))
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
emptySet<String>(),
|
||||
loadPdfBottomTools(
|
||||
contextWithPrefs(InMemorySharedPreferences(PDF_BOTTOM_TOOLS_KEY to emptySet<String>()))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader mode and enum preferences default safely when saved values are invalid`() {
|
||||
val prefs = InMemorySharedPreferences(
|
||||
|
|
|
|||
|
|
@ -60,6 +60,45 @@ class PdfReaderRepositoryTest {
|
|||
assertNull(repository.getAnnotationFileForSync("book"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PdfAnnotationRepository does not rewrite unchanged annotation file`() = runTest {
|
||||
val context = contextWithFilesDir(tempRoot("annotation-noop"))
|
||||
val repository = PdfAnnotationRepository(context)
|
||||
val annotations = mapOf(
|
||||
0 to listOf(
|
||||
PdfAnnotation(
|
||||
type = AnnotationType.INK,
|
||||
inkType = InkType.PEN,
|
||||
pageIndex = 0,
|
||||
points = listOf(PdfPoint(0.1f, 0.2f, 123L)),
|
||||
color = Color.Blue,
|
||||
strokeWidth = 0.01f
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
repository.saveAnnotations("book", annotations)
|
||||
val file = requireNotNull(repository.getAnnotationFileForSync("book"))
|
||||
val previousModified = 1_700_000_000_000L
|
||||
assertTrue(file.setLastModified(previousModified))
|
||||
|
||||
repository.saveAnnotations("book", annotations)
|
||||
|
||||
assertEquals(previousModified, file.lastModified())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PdfAnnotationRepository stores deleted annotation tombstones for sync`() = runTest {
|
||||
val context = contextWithFilesDir(tempRoot("annotation-deleted"))
|
||||
val repository = PdfAnnotationRepository(context)
|
||||
|
||||
repository.markAnnotationsDeleted("book", listOf("old-ink"), deletedAt = 123L)
|
||||
|
||||
val file = requireNotNull(repository.getDeletedAnnotationsFileForSync("book"))
|
||||
assertTrue(file.readText().contains("old-ink"))
|
||||
assertTrue(file.readText().contains("123"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PdfHighlightRepository saves loads deletes empty highlights and clears all`() = runTest {
|
||||
val context = contextWithFilesDir(tempRoot("highlights"))
|
||||
|
|
@ -86,6 +125,29 @@ class PdfReaderRepositoryTest {
|
|||
assertFalse(File(context.filesDir, "pdf_highlights").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PdfHighlightRepository does not rewrite unchanged highlight file`() = runTest {
|
||||
val context = contextWithFilesDir(tempRoot("highlights-noop"))
|
||||
val repository = PdfHighlightRepository(context)
|
||||
val highlight = PdfUserHighlight(
|
||||
id = "h1",
|
||||
pageIndex = 2,
|
||||
bounds = emptyList(),
|
||||
color = PdfHighlightColor.GREEN,
|
||||
text = "quote",
|
||||
range = 5 to 10
|
||||
)
|
||||
|
||||
repository.saveHighlights("book", listOf(highlight))
|
||||
val file = repository.getFileForSync("book")
|
||||
val previousModified = 1_700_000_000_000L
|
||||
assertTrue(file.setLastModified(previousModified))
|
||||
|
||||
repository.saveHighlights("book", listOf(highlight))
|
||||
|
||||
assertEquals(previousModified, file.lastModified())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PdfTextBoxRepository saves loads deletes and clears files`() = runTest {
|
||||
val context = contextWithFilesDir(tempRoot("textboxes"))
|
||||
|
|
@ -112,6 +174,30 @@ class PdfReaderRepositoryTest {
|
|||
assertTrue(File(context.filesDir, "textboxes").listFiles().orEmpty().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PdfTextBoxRepository does not rewrite unchanged textbox file`() = runTest {
|
||||
val context = contextWithFilesDir(tempRoot("textboxes-noop"))
|
||||
val repository = PdfTextBoxRepository(context)
|
||||
val box = PdfTextBox(
|
||||
id = "box",
|
||||
pageIndex = 0,
|
||||
relativeBounds = Rect(0.1f, 0.2f, 0.3f, 0.4f),
|
||||
text = "Text box",
|
||||
color = Color.Black,
|
||||
backgroundColor = Color.White,
|
||||
fontSize = 16f
|
||||
)
|
||||
|
||||
repository.saveTextBoxes("book", listOf(box))
|
||||
val file = repository.getFileForSync("book")
|
||||
val previousModified = 1_700_000_000_000L
|
||||
assertTrue(file.setLastModified(previousModified))
|
||||
|
||||
repository.saveTextBoxes("book", listOf(box))
|
||||
|
||||
assertEquals(previousModified, file.lastModified())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PageLayoutRepository returns default pdf pages when no layout exists`() = runTest {
|
||||
val repository = PageLayoutRepository(contextWithFilesDir(tempRoot("layout-default")))
|
||||
|
|
|
|||
|
|
@ -181,6 +181,13 @@ class PdfReaderRichTextTest {
|
|||
assertTrue("${PAGE_BREAK_CHAR}\nVisible".hasRenderableRichText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection bounds normalize reversed and clamped rich text selections`() {
|
||||
assertEquals(44 to 45, androidPdfRichTextSelectionBounds(45, 44, textLength = 45))
|
||||
assertEquals(0 to 5, androidPdfRichTextSelectionBounds(-3, 99, textLength = 5))
|
||||
assertEquals(null, androidPdfRichTextSelectionBounds(3, 3, textLength = 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank page insertion uses one page break when the rich text boundary is already explicit`() {
|
||||
val text = "Page 1${PAGE_BREAK_CHAR}Page 2"
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class PdfReaderSettingsAndSharedModelsTest {
|
|||
@Test
|
||||
fun `SharedPdfAnnotationDefaults supplies expected tool defaults and palettes`() {
|
||||
assertEquals(5, SharedPdfAnnotationDefaults.penPalette.size)
|
||||
assertEquals(5, SharedPdfAnnotationDefaults.highlighterPalette.size)
|
||||
assertEquals(SharedPdfHighlighterPalette.MaxColors, SharedPdfAnnotationDefaults.highlighterPalette.size)
|
||||
|
||||
val pen = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN)
|
||||
val eraser = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER)
|
||||
|
|
@ -204,6 +204,17 @@ class PdfReaderSettingsAndSharedModelsTest {
|
|||
PdfToolbarSection.BOTTOM,
|
||||
defaultItems.single { it.tool == PdfReaderTool.SLIDER }.section
|
||||
)
|
||||
|
||||
val customPlacementItems = buildPdfToolbarItems(
|
||||
hiddenTools = emptySet(),
|
||||
toolOrder = defaultPdfToolOrder(),
|
||||
bottomTools = setOf(PdfReaderTool.THEME.name)
|
||||
)
|
||||
assertEquals(
|
||||
PdfToolbarSection.BOTTOM,
|
||||
customPlacementItems.single { it.tool == PdfReaderTool.THEME }.section
|
||||
)
|
||||
|
||||
val expectedMoreTools = buildSet {
|
||||
addAll(
|
||||
setOf(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package com.aryan.reader.tts
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class TtsCacheManagerSecurityTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book cache directory for traversal title remains inside tts cache root`() {
|
||||
val manager = TtsCacheManager(context)
|
||||
val root = File(context.filesDir, "TTS_Cache").canonicalFile
|
||||
val cacheDir = manager.getBookCacheDir("..").canonicalFile
|
||||
|
||||
assertTrue(cacheDir.path.startsWith(root.path + File.separator))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearBookCache with traversal title does not delete app files directory`() {
|
||||
val sentinel = File(context.filesDir, "tts-sentinel-${System.nanoTime()}.txt")
|
||||
sentinel.writeText("keep")
|
||||
|
||||
TtsCacheManager(context).clearBookCache("..")
|
||||
|
||||
assertTrue(sentinel.exists())
|
||||
sentinel.delete()
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,13 @@ class TtsChunkNavigationTest {
|
|||
assertEquals(true, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = -1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prefetch stops only when generated chunk is neither loaded nor queued`() {
|
||||
assertEquals(true, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = false, playlistIndex = null))
|
||||
assertEquals(false, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = true, playlistIndex = null))
|
||||
assertEquals(false, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = false, playlistIndex = 2))
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@Test
|
||||
fun `reader tts mini bar is visible only for active reader playback outside reader routes`() {
|
||||
|
|
@ -90,6 +97,45 @@ class TtsChunkNavigationTest {
|
|||
assertEquals(16, readerTtsMiniBarBottomPaddingDp(isOnMainRoute = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tts overlay size exposes the other two sizes as choices`() {
|
||||
assertEquals(
|
||||
listOf(ReaderTtsOverlaySize.MEDIUM, ReaderTtsOverlaySize.SMALL),
|
||||
readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.LARGE)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(ReaderTtsOverlaySize.LARGE, ReaderTtsOverlaySize.SMALL),
|
||||
readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.MEDIUM)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(ReaderTtsOverlaySize.LARGE, ReaderTtsOverlaySize.MEDIUM),
|
||||
readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.SMALL)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tts overlay stored size defaults to large for missing or invalid values`() {
|
||||
assertEquals(ReaderTtsOverlaySize.MEDIUM, resolveReaderTtsOverlaySize("MEDIUM"))
|
||||
assertEquals(ReaderTtsOverlaySize.LARGE, resolveReaderTtsOverlaySize(null))
|
||||
assertEquals(ReaderTtsOverlaySize.LARGE, resolveReaderTtsOverlaySize("FULL"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tts overlay only aligns small state to the trailing edge`() {
|
||||
assertEquals(0f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.LARGE), 0f)
|
||||
assertEquals(0f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.MEDIUM), 0f)
|
||||
assertEquals(1f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.SMALL), 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tts chunk label uses one based progress`() {
|
||||
assertEquals("Chunk 1/4", formatReaderTtsChunkLabel(currentChunkIndex = 0, totalChunks = 4))
|
||||
assertEquals("Chunk 4/4", formatReaderTtsChunkLabel(currentChunkIndex = 3, totalChunks = 4))
|
||||
assertNull(formatReaderTtsChunkLabel(currentChunkIndex = -1, totalChunks = 4))
|
||||
assertNull(formatReaderTtsChunkLabel(currentChunkIndex = 4, totalChunks = 4))
|
||||
assertNull(formatReaderTtsChunkLabel(currentChunkIndex = 0, totalChunks = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream pcm duration uses cloud tts audio format`() {
|
||||
assertEquals(1_000L, resolveTtsStreamPcmDurationMs(totalBytes = 44L + 48_000L))
|
||||
|
|
|
|||
|
|
@ -76,6 +76,14 @@ class TtsModePolicyTest {
|
|||
assertEquals(TtsPlaybackManager.TtsMode.BASE, mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native tts voice list is resolved only when required`() {
|
||||
assertEquals(false, shouldResolveNativeTtsVoice(preferredVoiceName = null, isOfflineBuild = false))
|
||||
assertEquals(false, shouldResolveNativeTtsVoice(preferredVoiceName = " ", isOfflineBuild = false))
|
||||
assertEquals(true, shouldResolveNativeTtsVoice(preferredVoiceName = "voice-id", isOfflineBuild = false))
|
||||
assertEquals(true, shouldResolveNativeTtsVoice(preferredVoiceName = null, isOfflineBuild = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline native tts ignores saved network voice`() {
|
||||
val localVoice = voice("local", requiresNetwork = false)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue