feat(build): KMP → Android-only migration (Phase 1.2)
- Delete desktopApp/ and shared/ modules.
- Merge shared commonMain + androidMain + readerJvmMain sources into app/src/main/kotlin.
- Resolve expect/actual declarations by keeping android implementations.
- Remove project(':shared') dependency and KMP plugins from build files.
- Simplify settings.gradle.kts to single :app module; root project renamed BookReader.
- Remove JVM-only code (javax.imageio/ImageIO in SharedJvmBookLoader).
- Make LocalFolderSync helpers public for cross-package use in Android module.
- Baseline ':app:assembleOssDebug' passes (APK ~80 MB).
This commit is contained in:
parent
ab9a2dcfeb
commit
0d9439e8c3
323 changed files with 74 additions and 50598 deletions
|
|
@ -0,0 +1,122 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CloudSyncDecisionsTest {
|
||||
|
||||
@Test
|
||||
fun `newer remote metadata applies over local metadata`() {
|
||||
assertEquals(
|
||||
SharedCloudBookMetadataWinner.REMOTE,
|
||||
sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldApplyRemoteCloudBookUpdate(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer local sidecar wins even when book metadata is older`() {
|
||||
assertEquals(
|
||||
SharedCloudBookMetadataWinner.LOCAL,
|
||||
sharedCloudBookMetadataWinner(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L,
|
||||
localSidecarModifiedTimestamp = 300L
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
SharedCloudBookMetadataWinner.REMOTE,
|
||||
sharedCloudBookReadingMetadataWinner(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldUploadLocalCloudBookUpdate(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L,
|
||||
localSidecarModifiedTimestamp = 300L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldApplyRemoteCloudBookMetadataUpdate(
|
||||
localModifiedTimestamp = 100L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale remote metadata is ignored`() {
|
||||
assertFalse(
|
||||
shouldApplyRemoteCloudBookUpdate(
|
||||
localModifiedTimestamp = 300L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldUploadLocalCloudBookUpdate(
|
||||
localModifiedTimestamp = 300L,
|
||||
remoteModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content sync only moves changed file payloads`() {
|
||||
assertTrue(
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = true,
|
||||
localContentModifiedTimestamp = 100L,
|
||||
remoteContentModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = false,
|
||||
localContentModifiedTimestamp = 0L,
|
||||
remoteContentModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = true,
|
||||
localContentModifiedTimestamp = 300L,
|
||||
remoteContentModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldDownloadRemoteCloudBookContent(
|
||||
localFileAvailable = false,
|
||||
localContentModifiedTimestamp = 0L,
|
||||
remoteContentModifiedTimestamp = 200L,
|
||||
remoteDeleted = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
shouldUploadLocalCloudBookContent(
|
||||
localFileAvailable = true,
|
||||
localContentModifiedTimestamp = 300L,
|
||||
remoteContentModifiedTimestamp = 200L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud book content file name uses shared primary extension`() {
|
||||
assertEquals("book-1.epub", sharedCloudBookContentFileName("book-1", FileType.EPUB))
|
||||
assertEquals("book-1.md", sharedCloudBookContentFileName("book-1", FileType.MD))
|
||||
assertEquals("book-1.mobi", sharedCloudBookContentFileName("book-1", FileType.MOBI))
|
||||
assertEquals(null, sharedCloudBookContentFileName("book-1", FileType.UNKNOWN))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class EpubAnnotationSerializerTest {
|
||||
|
||||
@Test
|
||||
fun `highlights json round trips and tolerates legacy missing ids`() {
|
||||
val highlights = listOf(
|
||||
UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "epubcfi(/6/2!/4/2)",
|
||||
text = "A marked sentence",
|
||||
color = HighlightColor.BLUE,
|
||||
chapterIndex = 2,
|
||||
note = "Important",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 2,
|
||||
chapterId = "chapter-2",
|
||||
pageIndex = 5,
|
||||
startOffset = 120,
|
||||
endOffset = 137,
|
||||
textQuote = "A marked sentence",
|
||||
cfi = "epubcfi(/6/2!/4/2)"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = EpubAnnotationSerializer.parseHighlightsJson(
|
||||
EpubAnnotationSerializer.highlightsToJson(highlights)
|
||||
)
|
||||
val legacyDecoded = EpubAnnotationSerializer.parseHighlightsJson(
|
||||
"""[{"cfi":"legacy","text":"Legacy mark","colorId":"missing","chapterIndex":1,"note":""}]"""
|
||||
)
|
||||
|
||||
assertEquals(highlights, decoded)
|
||||
assertEquals(HighlightColor.YELLOW, legacyDecoded.single().color)
|
||||
assertEquals(null, legacyDecoded.single().note)
|
||||
assertEquals(1, legacyDecoded.single().locator.chapterIndex)
|
||||
assertEquals("legacy", legacyDecoded.single().locator.cfi)
|
||||
assertTrue(legacyDecoded.single().id.startsWith("highlight_"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookmarks json supports stored string entries and object arrays`() {
|
||||
val bookmark = EpubBookmark(
|
||||
cfi = "epubcfi(/6/4!/4/8)",
|
||||
chapterTitle = "Two",
|
||||
label = "Saved place",
|
||||
snippet = "A useful bookmark",
|
||||
pageInChapter = 3,
|
||||
totalPagesInChapter = 9,
|
||||
chapterIndex = 1,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 1,
|
||||
pageIndex = 2,
|
||||
startOffset = 80,
|
||||
endOffset = 110,
|
||||
textQuote = "A useful bookmark",
|
||||
cfi = "epubcfi(/6/4!/4/8)"
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = EpubAnnotationSerializer.parseBookmarksJson(
|
||||
EpubAnnotationSerializer.bookmarksToJson(listOf(bookmark)),
|
||||
chapterTitles = listOf("One", "Two")
|
||||
)
|
||||
val objectDecoded = EpubAnnotationSerializer.parseBookmarksJson(
|
||||
"""[{"cfi":"cfi","chapterTitle":"Two","snippet":"By title"}]""",
|
||||
chapterTitles = listOf("One", "Two")
|
||||
)
|
||||
|
||||
assertEquals(setOf(bookmark), decoded)
|
||||
assertEquals(1, objectDecoded.single().chapterIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processAndAddHighlight updates exact matches and appends new highlights`() {
|
||||
val highlights = mutableListOf<UserHighlight>()
|
||||
val cfi = EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "same-cfi",
|
||||
newText = "First",
|
||||
newColor = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights
|
||||
)
|
||||
val initialId = highlights.single().id
|
||||
|
||||
EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "same-cfi",
|
||||
newText = "Updated",
|
||||
newColor = HighlightColor.GREEN,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights
|
||||
)
|
||||
EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "other-cfi",
|
||||
newText = "Other",
|
||||
newColor = HighlightColor.BLUE,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights
|
||||
)
|
||||
|
||||
assertEquals("same-cfi", cfi)
|
||||
assertEquals(2, highlights.size)
|
||||
assertEquals(initialId, highlights.first().id)
|
||||
assertEquals("Updated", highlights.first().text)
|
||||
assertEquals(HighlightColor.GREEN, highlights.first().color)
|
||||
assertNotEquals(initialId, highlights.last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `processAndAddHighlight matches shared locator ranges when cfi changes`() {
|
||||
val highlights = mutableListOf<UserHighlight>()
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 3,
|
||||
startOffset = 42,
|
||||
endOffset = 58,
|
||||
textQuote = "Stable quote",
|
||||
cfi = "desktop:0:42:58"
|
||||
)
|
||||
|
||||
EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "desktop:0:42:58",
|
||||
newText = "Stable quote",
|
||||
newColor = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights,
|
||||
locator = locator
|
||||
)
|
||||
val initialId = highlights.single().id
|
||||
|
||||
EpubAnnotationSerializer.processAndAddHighlight(
|
||||
newCfi = "changed-cfi",
|
||||
newText = "Stable quote updated",
|
||||
newColor = HighlightColor.BLUE,
|
||||
chapterIndex = 0,
|
||||
currentList = highlights,
|
||||
locator = locator.copy(cfi = "changed-cfi", textQuote = "Stable quote updated")
|
||||
)
|
||||
|
||||
assertEquals(1, highlights.size)
|
||||
assertEquals(initialId, highlights.single().id)
|
||||
assertEquals(HighlightColor.BLUE, highlights.single().color)
|
||||
assertEquals(42, highlights.single().locator.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight bridge parser accepts raw or wrapped json payloads`() {
|
||||
val payload = """{"cfi":"desktop:0:4:9","text":"word","colorId":"yellow","chapterIndex":0,"locator":{"chapterIndex":0,"startOffset":4,"endOffset":9,"textQuote":"word","cfi":"desktop:0:4:9"}}"""
|
||||
val wrappedPayload = "\"${payload.replace("\"", "\\\"")}\""
|
||||
val arrayPayload = "[$wrappedPayload]"
|
||||
|
||||
assertEquals(4, EpubAnnotationSerializer.parseHighlightJsonLenient(payload)?.locator?.startOffset)
|
||||
assertEquals(9, EpubAnnotationSerializer.parseHighlightJsonLenient(wrappedPayload)?.locator?.endOffset)
|
||||
assertEquals("word", EpubAnnotationSerializer.parseHighlightJsonLenient(arrayPayload)?.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy desktop cfi values hydrate shared locators`() {
|
||||
val oldDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:123456:abc")
|
||||
val timestampFallbackLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:7:1780000000000")
|
||||
val rangedDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop:2:40:55")
|
||||
val scrollWrappedDesktopLocator = ReaderLocator.fromLegacy(cfi = "desktop-scroll:5238:5238:desktop:2:40:55")
|
||||
val androidLocator = ReaderLocator.fromLegacy(cfi = "android-locator:3:42:128")
|
||||
|
||||
assertEquals(2, oldDesktopLocator.chapterIndex)
|
||||
assertEquals(7, oldDesktopLocator.pageIndex)
|
||||
assertEquals(7, timestampFallbackLocator.pageIndex)
|
||||
assertEquals(null, timestampFallbackLocator.startOffset)
|
||||
assertEquals(null, timestampFallbackLocator.endOffset)
|
||||
assertEquals(2, rangedDesktopLocator.chapterIndex)
|
||||
assertEquals(40, rangedDesktopLocator.startOffset)
|
||||
assertEquals(55, rangedDesktopLocator.endOffset)
|
||||
assertEquals(2, scrollWrappedDesktopLocator.chapterIndex)
|
||||
assertEquals(40, scrollWrappedDesktopLocator.startOffset)
|
||||
assertEquals(55, scrollWrappedDesktopLocator.endOffset)
|
||||
assertEquals("desktop:2:40:55", scrollWrappedDesktopLocator.cfi)
|
||||
assertEquals(3, androidLocator.chapterIndex)
|
||||
assertEquals(42, androidLocator.blockIndex)
|
||||
assertEquals(128, androidLocator.charOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `android locator with quote hydrates absolute text range for synced highlights`() {
|
||||
val locator = ReaderLocator.fromLegacy(
|
||||
cfi = "android-locator:3:42:128",
|
||||
textQuote = "marked"
|
||||
)
|
||||
|
||||
assertEquals(3, locator.chapterIndex)
|
||||
assertEquals(42, locator.blockIndex)
|
||||
assertEquals(128, locator.charOffset)
|
||||
assertEquals(128, locator.startOffset)
|
||||
assertEquals(134, locator.endOffset)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FileCapabilitiesTest {
|
||||
|
||||
@Test
|
||||
fun `shared file capabilities expose Android and desktop readable formats`() {
|
||||
assertEquals(
|
||||
PDF_VIEWER_FILE_TYPES + EPUB_READER_FILE_TYPES,
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertEquals(
|
||||
PDF_VIEWER_FILE_TYPES,
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID, ReaderFeatureSurface.PDF_VIEWER)
|
||||
)
|
||||
assertEquals(
|
||||
EPUB_READER_FILE_TYPES,
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID, ReaderFeatureSurface.EPUB_READER)
|
||||
)
|
||||
assertFalse(FileType.UNKNOWN in SharedFileCapabilities.knownFileTypes)
|
||||
assertFalse(FileType.UNKNOWN in SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID))
|
||||
assertNull(SharedFileCapabilities.primaryExtensionFor(FileType.UNKNOWN))
|
||||
assertNull(SharedFileCapabilities.mimeTypeFor(FileType.UNKNOWN))
|
||||
assertEquals("epub", SharedFileCapabilities.primaryExtensionFor(FileType.EPUB))
|
||||
assertEquals("application/pdf", SharedFileCapabilities.mimeTypeFor(FileType.PDF))
|
||||
assertEquals("pptx", SharedFileCapabilities.primaryExtensionFor(FileType.PPTX))
|
||||
assertEquals(
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
SharedFileCapabilities.mimeTypeFor(FileType.PPTX)
|
||||
)
|
||||
assertTrue("application/pdf" in SharedFileCapabilities.androidFilePickerMimeTypes)
|
||||
assertTrue("application/x-tar" in SharedFileCapabilities.androidFilePickerMimeTypes)
|
||||
assertTrue("text/x-kotlin" in SharedFileCapabilities.androidFilePickerMimeTypes)
|
||||
assertFalse("*/*" in SharedFileCapabilities.androidFilePickerMimeTypes)
|
||||
assertEquals(
|
||||
setOf(
|
||||
FileType.EPUB,
|
||||
FileType.PDF,
|
||||
FileType.TXT,
|
||||
FileType.MD,
|
||||
FileType.HTML,
|
||||
FileType.MOBI,
|
||||
FileType.FB2,
|
||||
FileType.CBZ,
|
||||
FileType.CBR,
|
||||
FileType.CB7,
|
||||
FileType.CBT,
|
||||
FileType.DOCX,
|
||||
FileType.PPTX,
|
||||
FileType.ODT,
|
||||
FileType.FODT
|
||||
),
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP),
|
||||
SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared file capabilities map reader surfaces per platform`() {
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.PDF, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.PPTX, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.TEXT_READER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.TEXT_READER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.DOCX, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.PDF_VIEWER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.CBT, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
ReaderFeatureSurface.EPUB_READER,
|
||||
SharedFileCapabilities.surfaceFor(FileType.MD, ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBZ, ReaderPlatform.ANDROID))
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBT, ReaderPlatform.ANDROID))
|
||||
assertTrue(SharedFileCapabilities.canOpen(FileType.CBT, ReaderPlatform.DESKTOP))
|
||||
assertTrue(SharedFileCapabilities.isComicArchive(FileType.CBT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared file type resolver recognizes aliases used by desktop imports`() {
|
||||
assertEquals(FileType.MD, SharedFileCapabilities.fileTypeForName("notes.markdown"))
|
||||
assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("chapter.xhtml"))
|
||||
assertEquals(FileType.HTML, "chapter.xhtml".toFileType())
|
||||
assertEquals(FileType.MOBI, SharedFileCapabilities.fileTypeForName("book.azw3"))
|
||||
assertEquals(FileType.FB2, SharedFileCapabilities.fileTypeForName("book.fb2.zip"))
|
||||
assertEquals(FileType.CBT, SharedFileCapabilities.fileTypeForName("comic.cbt"))
|
||||
assertEquals(FileType.PPTX, SharedFileCapabilities.fileTypeForName("slides.pptx"))
|
||||
assertEquals(FileType.HTML, SharedFileCapabilities.fileTypeForName("payload.json.txt"))
|
||||
assertEquals(FileType.EPUB, SharedFileCapabilities.fileTypeForName("book.epub.txt"))
|
||||
assertEquals(FileType.UNKNOWN, SharedFileCapabilities.fileTypeForName("archive.zip"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared metadata resolver handles provider mime types and guarded archive fallbacks`() {
|
||||
assertEquals(FileType.PDF, SharedFileCapabilities.resolveFileTypeForMetadata("download", "application/pdf"))
|
||||
assertEquals(
|
||||
FileType.DOCX,
|
||||
SharedFileCapabilities.resolveFileTypeForMetadata(
|
||||
"download",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
FileType.MD,
|
||||
SharedFileCapabilities.resolveFileTypeForMetadata("notes.md.txt", "text/plain; charset=utf-8")
|
||||
)
|
||||
assertEquals(FileType.HTML, SharedFileCapabilities.resolveFileTypeForMetadata("payload", "application/json"))
|
||||
assertEquals(FileType.CBZ, SharedFileCapabilities.resolveFileTypeForMetadata("comic.cbz", "application/zip"))
|
||||
assertEquals(FileType.CBT, SharedFileCapabilities.resolveFileTypeForMetadata("comic.cbt", "application/x-tar"))
|
||||
assertEquals(FileType.FB2, SharedFileCapabilities.resolveFileTypeForMetadata("book.fb2.zip", "application/zip"))
|
||||
assertNull(SharedFileCapabilities.resolveFileTypeForMetadata("archive.zip", "application/zip"))
|
||||
assertNull(SharedFileCapabilities.resolveFileTypeForMetadata("archive.tar", "application/x-tar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared file name policy detects manual only files and suffixes`() {
|
||||
assertTrue(SharedFileCapabilities.isCodeOrDataFileName("table.csv"))
|
||||
assertTrue(SharedFileCapabilities.isManualOnlyReaderFileName("script.kt.txt"))
|
||||
assertFalse(SharedFileCapabilities.isManualOnlyReaderFileName("chapter.html"))
|
||||
assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("table.csv", "text/csv"))
|
||||
assertFalse(SharedFileCapabilities.isLocalFolderSyncEligibleFile("payload", "application/json; charset=utf-8"))
|
||||
assertTrue(SharedFileCapabilities.isLocalFolderSyncEligibleFile("book.fodt", "text/xml"))
|
||||
assertEquals(".md.txt", SharedFileCapabilities.fileExtensionSuffixForName("notes.md.txt"))
|
||||
assertEquals(".fb2.zip.txt", SharedFileCapabilities.fileExtensionSuffixForName("book.fb2.zip.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop parity gaps list Android readable formats not yet available on desktop`() {
|
||||
assertEquals(emptyList(), SharedFileCapabilities.desktopParityGaps())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FontVariantInferenceTest {
|
||||
@Test
|
||||
fun variableRegularAndItalicFilesShareFamilySignature() {
|
||||
val regular = "Pliant-VariableFont_wdth,wght"
|
||||
val italic = "Pliant-Italic-VariableFont_wdth,wght"
|
||||
|
||||
assertEquals(regular.familyFilenameSignature(), italic.familyFilenameSignature())
|
||||
assertEquals("pliant", regular.familyFilenameSignature())
|
||||
assertEquals(FontStyle.Italic, italic.detectFontVariant()?.style)
|
||||
assertTrue(regular.supportsVariableWeightAxis())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun familyGroupingUsesBaseFamilyForVariableFontVariants() {
|
||||
val fonts = listOf(
|
||||
fontItem("1", "Pliant-VariableFont_wdth,wght.ttf"),
|
||||
fontItem("2", "Pliant-Italic-VariableFont_wdth,wght.ttf")
|
||||
)
|
||||
|
||||
val family = fonts.groupByFamily().single()
|
||||
|
||||
assertEquals("Pliant", family.familyName)
|
||||
assertEquals(2, family.variants.size)
|
||||
assertTrue(family.variants.any { it.variant?.style == FontStyle.Italic })
|
||||
assertTrue(family.variants.any { it.variant?.weight == FontWeight.Normal })
|
||||
assertEquals("Regular, Italic", family.fontFaceSummary())
|
||||
assertTrue(family.hasVariableWeightFace())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun variableWeightAxisEmitsCssWeightRange() {
|
||||
assertEquals(
|
||||
"100 900",
|
||||
"Pliant-VariableFont_wdth,wght".fontWeightCssDescriptor(FontWeight.Normal)
|
||||
)
|
||||
assertEquals(
|
||||
"700",
|
||||
"Literata-Bold".fontWeightCssDescriptor(FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
|
||||
private fun fontItem(id: String, fileName: String): CustomFontItem {
|
||||
return CustomFontItem(
|
||||
id = id,
|
||||
displayName = fileName.substringBeforeLast('.'),
|
||||
fileName = fileName,
|
||||
fileExtension = fileName.substringAfterLast('.'),
|
||||
path = "/fonts/$fileName",
|
||||
timestamp = id.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,567 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LocalFolderSyncEngineTest {
|
||||
@Test
|
||||
fun `stable ids match android folder-relative scheme`() {
|
||||
assertEquals(
|
||||
"local_Book.pdf",
|
||||
LocalFolderSyncEngine.buildStableBookId("Book.pdf", "Book.pdf")
|
||||
)
|
||||
assertEquals(
|
||||
"local_Book.pdf_488206341973",
|
||||
LocalFolderSyncEngine.buildStableBookId("Book.pdf", "Series/Book.pdf")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local folder sidecar filenames stay short for long book ids`() {
|
||||
val bookId = "local_" + "Very Long Book Name ".repeat(20) + ".pdf"
|
||||
|
||||
assertEquals(".book_37739e3be68f.json", localFolderSyncMetadataFileName(bookId))
|
||||
assertEquals(".book_37739e3be68f.tmp", localFolderSyncMetadataTempFileName(bookId))
|
||||
assertEquals(".book_37739e3be68f_annotations.json", localFolderSyncAnnotationFileName(bookId))
|
||||
assertEquals(".book_37739e3be68f_annotations.tmp", localFolderSyncAnnotationTempFileName(bookId))
|
||||
assertTrue(localFolderSyncAnnotationFileName(bookId).length < 80)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync imports scanned folder books with remote metadata`() {
|
||||
val state = SharedReaderScreenState()
|
||||
val folder = syncedFolder()
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = state,
|
||||
folder = folder,
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
title = "Remote Title",
|
||||
lastPage = 4,
|
||||
progress = 25f,
|
||||
modified = 2_000L
|
||||
)
|
||||
),
|
||||
nowMillis = 3_000L
|
||||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals("local_Book.pdf", book.id)
|
||||
assertEquals("Book", book.title)
|
||||
assertEquals(4, book.lastPageIndex)
|
||||
assertEquals(25f, book.progressPercentage)
|
||||
assertEquals("C:/Library", book.sourceFolder)
|
||||
assertEquals(1, result.stats.newBooks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer remote metadata updates existing folder book`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
timestamp = 100L,
|
||||
title = "Local",
|
||||
progress = 10f
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
title = "Remote",
|
||||
progress = 80f,
|
||||
modified = 500L
|
||||
)
|
||||
),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals("Local", book.title)
|
||||
assertEquals(80f, book.progressPercentage)
|
||||
assertEquals(1, result.stats.remoteMetadataUpdates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older remote metadata does not clobber local book state`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
timestamp = 500L,
|
||||
title = "Local",
|
||||
progress = 60f
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
title = "Remote",
|
||||
progress = 5f,
|
||||
modified = 100L
|
||||
)
|
||||
),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals("Local", book.title)
|
||||
assertEquals(60f, book.progressPercentage)
|
||||
assertEquals(0, result.stats.remoteMetadataUpdates)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar display name survives physical folder scan`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
timestamp = 500L,
|
||||
displayName = "Reader Name",
|
||||
title = "Local"
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
displayName = "Reader Name",
|
||||
modified = 500L
|
||||
)
|
||||
),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
assertEquals("Reader Name", result.state.rawLibraryBooks.single().displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync migrates desktop path ids and preserves references`() {
|
||||
val oldId = "C:/Library/Series/Book.pdf"
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(
|
||||
book(
|
||||
id = oldId,
|
||||
path = oldId,
|
||||
displayName = "Book.pdf",
|
||||
sourceFolder = "C:/Library"
|
||||
)
|
||||
),
|
||||
selectedBookIds = setOf(oldId),
|
||||
pinnedHomeBookIds = setOf(oldId),
|
||||
openTabIds = listOf(oldId),
|
||||
activeTabBookId = oldId
|
||||
)
|
||||
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = state,
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Series/Book.pdf")),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
val newId = "local_Book.pdf_488206341973"
|
||||
|
||||
assertEquals(newId, result.state.rawLibraryBooks.single().id)
|
||||
assertEquals(setOf(newId), result.state.selectedBookIds)
|
||||
assertEquals(setOf(newId), result.state.pinnedHomeBookIds)
|
||||
assertEquals(listOf(newId), result.state.openTabIds)
|
||||
assertEquals(newId, result.state.activeTabBookId)
|
||||
assertEquals(mapOf(oldId to newId), result.idMigrations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync resolves legacy root id collision before migrating subfolder book`() {
|
||||
val oldId = "local_Book.pdf"
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(
|
||||
book(
|
||||
id = oldId,
|
||||
path = "C:/Library/Series/Book.pdf",
|
||||
displayName = "Book.pdf",
|
||||
sourceFolder = "C:/Library"
|
||||
)
|
||||
),
|
||||
selectedBookIds = setOf(oldId)
|
||||
)
|
||||
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = state,
|
||||
folder = syncedFolder(),
|
||||
files = listOf(
|
||||
scannedFile("Book.pdf", "Book.pdf"),
|
||||
scannedFile("Book.pdf", "Series/Book.pdf")
|
||||
),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
val migratedId = "local_Book.pdf_488206341973"
|
||||
|
||||
assertEquals(
|
||||
listOf("local_Book.pdf", migratedId),
|
||||
result.state.rawLibraryBooks.map { it.id }.sorted()
|
||||
)
|
||||
assertEquals(setOf(migratedId), result.state.selectedBookIds)
|
||||
assertEquals(mapOf(oldId to migratedId), result.idMigrations)
|
||||
assertEquals(1, result.stats.newBooks)
|
||||
assertEquals(1, result.stats.migratedBooks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync removes missing books from linked folder only`() {
|
||||
val missing = book(id = "local_Missing.pdf", path = "C:/Library/Missing.pdf")
|
||||
val keptExternal = book(
|
||||
id = "external",
|
||||
path = "C:/Other/External.pdf",
|
||||
sourceFolder = "C:/Other"
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(missing, keptExternal),
|
||||
selectedBookIds = setOf(missing.id),
|
||||
pinnedHomeBookIds = setOf(missing.id),
|
||||
openTabIds = listOf(missing.id),
|
||||
activeTabBookId = missing.id
|
||||
),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf")),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
assertNull(result.state.rawLibraryBooks.firstOrNull { it.id == "local_Missing.pdf" })
|
||||
assertTrue(result.state.rawLibraryBooks.any { it.id == "external" })
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertTrue(result.state.openTabIds.isEmpty())
|
||||
assertNull(result.state.activeTabBookId)
|
||||
assertEquals(setOf("local_Missing.pdf"), result.removedBookIds)
|
||||
assertEquals(1, result.stats.removedBooks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync ignores unknown scanned files even with default allowed types`() {
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(),
|
||||
folder = SyncedFolder(
|
||||
uriString = "C:/Library",
|
||||
name = "Library",
|
||||
lastScanTime = 0L
|
||||
),
|
||||
files = listOf(scannedFile("archive.zip", "archive.zip", type = FileType.UNKNOWN)),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
assertTrue(result.state.rawLibraryBooks.isEmpty())
|
||||
assertEquals(0, result.stats.supportedFiles)
|
||||
assertEquals(0, result.stats.newBooks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default synced folder allowed types exclude unknown`() {
|
||||
assertFalse(FileType.UNKNOWN in SyncedFolder("C:/Library", "Library", lastScanTime = 0L).allowedFileTypes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled synced folder does not import files or metadata`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
timestamp = 100L,
|
||||
progress = 10f
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(existing),
|
||||
syncedFolders = listOf(syncedFolder().copy(localSyncEnabled = false))
|
||||
),
|
||||
folder = syncedFolder().copy(localSyncEnabled = false),
|
||||
files = listOf(scannedFile("New.pdf", "New.pdf")),
|
||||
remoteMetadata = mapOf(
|
||||
"local_Book.pdf" to metadata(
|
||||
id = "local_Book.pdf",
|
||||
progress = 80f,
|
||||
modified = 500L
|
||||
)
|
||||
),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
assertEquals(listOf(existing), result.state.rawLibraryBooks)
|
||||
assertEquals(0, result.stats.newBooks)
|
||||
assertEquals(0, result.stats.remoteMetadataUpdates)
|
||||
assertTrue(result.removedBookIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar is skipped for clean unread folder books`() {
|
||||
assertNull(book(id = "local_Book.pdf", isRecent = false, progress = null).toSharedFolderBookMetadata())
|
||||
assertNotNull(book(id = "local_Book.pdf", isRecent = true).toSharedFolderBookMetadata())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar preserves precise reader position`() {
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = 2,
|
||||
pageIndex = 7,
|
||||
startOffset = 320,
|
||||
endOffset = 320,
|
||||
cfi = "desktop:2:320:320"
|
||||
)
|
||||
|
||||
val metadata = book(
|
||||
id = "local_Book.pdf",
|
||||
progress = 45f,
|
||||
readerPosition = locator
|
||||
).toSharedFolderBookMetadata() ?: error("Expected sidecar")
|
||||
val restored = metadata.toBookItem(
|
||||
file = scannedFile("Book.pdf", "Book.pdf"),
|
||||
existing = null,
|
||||
nowMillis = 2_000L
|
||||
)
|
||||
|
||||
assertEquals(2, metadata.lastChapterIndex)
|
||||
assertEquals(7, metadata.lastPage)
|
||||
assertEquals("desktop:2:320:320", metadata.lastPositionCfi)
|
||||
assertNull(metadata.locatorBlockIndex)
|
||||
assertNull(metadata.locatorCharOffset)
|
||||
assertEquals(locator, restored.readerPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar preserves android block reader position`() {
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = 1,
|
||||
pageIndex = 5,
|
||||
blockIndex = 44,
|
||||
charOffset = 120,
|
||||
cfi = "android-locator:1:44:120"
|
||||
)
|
||||
|
||||
val metadata = book(
|
||||
id = "local_Book.epub",
|
||||
type = FileType.EPUB,
|
||||
progress = 37f,
|
||||
readerPosition = locator
|
||||
).toSharedFolderBookMetadata() ?: error("Expected sidecar")
|
||||
val restored = metadata.toBookItem(
|
||||
file = scannedFile("Book.epub", "Book.epub"),
|
||||
existing = null,
|
||||
nowMillis = 2_000L
|
||||
)
|
||||
|
||||
assertEquals(1, metadata.lastChapterIndex)
|
||||
assertEquals(5, metadata.lastPage)
|
||||
assertEquals("android-locator:1:44:120", metadata.lastPositionCfi)
|
||||
assertEquals(44, metadata.locatorBlockIndex)
|
||||
assertEquals(120, metadata.locatorCharOffset)
|
||||
assertEquals(locator, restored.readerPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata sidecar ignores legacy editable metadata`() {
|
||||
val local = book(id = "local_Book.pdf")
|
||||
.copy(
|
||||
isRecent = true,
|
||||
title = "Edited Title",
|
||||
author = "Edited Author",
|
||||
seriesName = "Edited Series",
|
||||
seriesIndex = 2.0,
|
||||
description = "<p>Edited summary</p>",
|
||||
originalTitle = "Original Title",
|
||||
originalAuthor = "Original Author",
|
||||
originalSeriesName = "Original Series",
|
||||
originalSeriesIndex = 1.0,
|
||||
originalDescription = "Original summary"
|
||||
)
|
||||
|
||||
val metadata = local.toSharedFolderBookMetadata() ?: error("Expected sidecar")
|
||||
val legacyMetadata = metadata.copy(
|
||||
title = "Legacy Sidecar Title",
|
||||
author = "Legacy Sidecar Author",
|
||||
seriesName = "Legacy Sidecar Series",
|
||||
seriesIndex = 2.0,
|
||||
description = "<p>Legacy summary</p>",
|
||||
originalTitle = "Legacy Original Title",
|
||||
originalAuthor = "Legacy Original Author",
|
||||
originalSeriesName = "Legacy Original Series",
|
||||
originalSeriesIndex = 1.0,
|
||||
originalDescription = "Legacy original summary"
|
||||
)
|
||||
val restored = metadata.toBookItem(
|
||||
file = scannedFile("Book.pdf", "Book.pdf"),
|
||||
existing = book(id = "local_Book.pdf", title = "Stale"),
|
||||
nowMillis = 2_000L
|
||||
)
|
||||
val restoredFromLegacy = legacyMetadata.toBookItem(
|
||||
file = scannedFile("Book.pdf", "Book.pdf"),
|
||||
existing = book(id = "local_Book.pdf", title = "Stale"),
|
||||
nowMillis = 2_000L
|
||||
)
|
||||
|
||||
assertNull(metadata.title)
|
||||
assertNull(metadata.description)
|
||||
assertNull(metadata.originalTitle)
|
||||
assertEquals("Stale", restored.title)
|
||||
assertNull(restored.author)
|
||||
assertNull(restored.seriesName)
|
||||
assertNull(restored.description)
|
||||
assertEquals("Stale", restoredFromLegacy.title)
|
||||
assertNull(restoredFromLegacy.author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync resets extracted metadata and cover when folder file modified time changes`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
fileSize = 123L,
|
||||
title = "Extracted title",
|
||||
coverImagePath = "C:/Covers/book.png",
|
||||
folderTextMetadataParsed = true
|
||||
).copy(
|
||||
author = "Extracted author",
|
||||
description = "Extracted summary",
|
||||
seriesName = "Extracted series",
|
||||
seriesIndex = 1.0,
|
||||
originalTitle = "Extracted title",
|
||||
originalAuthor = "Extracted author",
|
||||
originalDescription = "Extracted summary",
|
||||
fileContentModifiedTimestamp = 100L
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf", size = 123L, lastModified = 500L)),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
val updated = result.state.rawLibraryBooks.single()
|
||||
assertNull(updated.coverImagePath)
|
||||
assertFalse(updated.folderTextMetadataParsed)
|
||||
assertEquals(500L, updated.fileContentModifiedTimestamp)
|
||||
assertEquals("Book", updated.title)
|
||||
assertNull(updated.author)
|
||||
assertNull(updated.description)
|
||||
assertNull(updated.seriesName)
|
||||
assertNull(updated.originalTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync resets extracted metadata and cover when folder file size changes`() {
|
||||
val existing = book(
|
||||
id = "local_Book.pdf",
|
||||
fileSize = 123L,
|
||||
coverImagePath = "C:/Covers/book.png",
|
||||
folderTextMetadataParsed = true
|
||||
)
|
||||
val result = LocalFolderSyncEngine.syncFolder(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(existing)),
|
||||
folder = syncedFolder(),
|
||||
files = listOf(scannedFile("Book.pdf", "Book.pdf", size = 456L)),
|
||||
remoteMetadata = emptyMap(),
|
||||
nowMillis = 1_000L
|
||||
)
|
||||
|
||||
val book = result.state.rawLibraryBooks.single()
|
||||
assertEquals(456L, book.fileSize)
|
||||
assertNull(book.coverImagePath)
|
||||
assertFalse(book.folderTextMetadataParsed)
|
||||
assertEquals(1, result.stats.updatedBooks)
|
||||
}
|
||||
|
||||
private fun syncedFolder(): SyncedFolder {
|
||||
return SyncedFolder(
|
||||
uriString = "C:/Library",
|
||||
name = "Library",
|
||||
lastScanTime = 0L,
|
||||
allowedFileTypes = setOf(FileType.PDF, FileType.EPUB)
|
||||
)
|
||||
}
|
||||
|
||||
private fun scannedFile(
|
||||
name: String,
|
||||
relativePath: String,
|
||||
size: Long = 123L,
|
||||
type: FileType = FileType.PDF,
|
||||
lastModified: Long = 100L
|
||||
): SharedFolderScannedFile {
|
||||
return SharedFolderScannedFile(
|
||||
name = name,
|
||||
path = "C:/Library/$relativePath",
|
||||
sourceFolder = "C:/Library",
|
||||
relativePath = relativePath,
|
||||
type = type,
|
||||
size = size,
|
||||
lastModified = lastModified
|
||||
)
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
path: String = "C:/Library/Book.pdf",
|
||||
displayName: String = "Book.pdf",
|
||||
sourceFolder: String = "C:/Library",
|
||||
timestamp: Long = 100L,
|
||||
title: String = "Book",
|
||||
type: FileType = FileType.PDF,
|
||||
progress: Float? = null,
|
||||
isRecent: Boolean = false,
|
||||
fileSize: Long = 0L,
|
||||
coverImagePath: String? = null,
|
||||
folderTextMetadataParsed: Boolean = false,
|
||||
readerPosition: ReaderLocator? = null
|
||||
): BookItem {
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = timestamp,
|
||||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
progressPercentage = progress,
|
||||
fileSize = fileSize,
|
||||
fileContentModifiedTimestamp = 100L,
|
||||
sourceFolder = sourceFolder,
|
||||
isRecent = isRecent,
|
||||
folderTextMetadataParsed = folderTextMetadataParsed,
|
||||
readerPosition = readerPosition
|
||||
)
|
||||
}
|
||||
|
||||
private fun metadata(
|
||||
id: String,
|
||||
title: String = "Book",
|
||||
displayName: String = "Book.pdf",
|
||||
lastPage: Int? = null,
|
||||
progress: Float = 0f,
|
||||
modified: Long
|
||||
): SharedFolderBookMetadata {
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = title,
|
||||
author = null,
|
||||
displayName = displayName,
|
||||
type = FileType.PDF.name,
|
||||
lastChapterIndex = null,
|
||||
lastPage = lastPage,
|
||||
lastPositionCfi = null,
|
||||
progressPercentage = progress,
|
||||
isRecent = true,
|
||||
lastModifiedTimestamp = modified,
|
||||
bookmarksJson = null,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
customName = null,
|
||||
highlightsJson = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderEngine
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSearchOptions
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderActionReducerTest {
|
||||
|
||||
@Test
|
||||
fun `reader actions navigate search and toggle bookmarks through shared reducer`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
assertTrue(session.reader.pages.size > 2)
|
||||
|
||||
val pageTwo = session.reduce(ReaderAction.NextPage, engine)
|
||||
assertEquals(1, pageTwo.reader.currentPageIndex)
|
||||
|
||||
val previous = pageTwo.reduce(ReaderAction.PreviousPage, engine)
|
||||
assertEquals(0, previous.reader.currentPageIndex)
|
||||
|
||||
val pageByNumber = previous.reduce(ReaderAction.GoToPageNumber(2), engine)
|
||||
assertEquals(1, pageByNumber.reader.currentPageIndex)
|
||||
|
||||
val lastPage = previous.reduce(ReaderAction.GoToProgress(1f), engine)
|
||||
assertEquals(lastPage.reader.pages.lastIndex, lastPage.reader.currentPageIndex)
|
||||
|
||||
val chapterTwo = lastPage.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
assertEquals(1, chapterTwo.reader.currentPage?.chapterIndex)
|
||||
|
||||
val searched = chapterTwo.reduce(ReaderAction.SearchChanged("needle"), engine)
|
||||
assertTrue(searched.searchResults.size >= 2)
|
||||
assertEquals(-1, searched.activeSearchResultIndex)
|
||||
assertEquals(chapterTwo.reader.currentPageIndex, searched.reader.currentPageIndex)
|
||||
|
||||
val nextSearch = searched.reduce(ReaderAction.NextSearchResult, engine)
|
||||
assertEquals(
|
||||
searched.searchResults.indexOfFirst { it.pageIndex >= searched.reader.currentPageIndex },
|
||||
nextSearch.activeSearchResultIndex
|
||||
)
|
||||
|
||||
val directSearch = searched.reduce(ReaderAction.GoToSearchResult(0), engine)
|
||||
assertEquals(0, directSearch.activeSearchResultIndex)
|
||||
|
||||
val bookmarked = directSearch.reduce(ReaderAction.ToggleBookmark, engine)
|
||||
assertEquals(listOf(directSearch.reader.currentPageIndex), bookmarked.bookmarks.map { it.pageIndex })
|
||||
|
||||
val unbookmarked = bookmarked.reduce(ReaderAction.ToggleBookmark, engine)
|
||||
assertTrue(unbookmarked.bookmarks.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search options and search chrome state are owned by shared reducer`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = SharedEpubBook(
|
||||
id = "search",
|
||||
fileName = "search.epub",
|
||||
title = "Search",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Alpha alphabet alpha ALPHA"
|
||||
)
|
||||
)
|
||||
),
|
||||
settings = compactSettings()
|
||||
)
|
||||
|
||||
val opened = session.reduce(ReaderAction.SearchOpened, engine)
|
||||
val caseSensitive = opened
|
||||
.reduce(ReaderAction.SearchOptionsChanged(ReaderSearchOptions(matchCase = true)), engine)
|
||||
.reduce(ReaderAction.SearchChanged("alpha"), engine)
|
||||
val wholeWords = caseSensitive
|
||||
.reduce(
|
||||
ReaderAction.SearchOptionsChanged(
|
||||
ReaderSearchOptions(matchCase = true, wholeWords = true)
|
||||
),
|
||||
engine
|
||||
)
|
||||
val hiddenPanel = wholeWords.reduce(ReaderAction.SearchResultsPanelToggled, engine)
|
||||
val closed = hiddenPanel.reduce(ReaderAction.SearchClosed, engine)
|
||||
|
||||
assertTrue(opened.isSearchActive)
|
||||
assertTrue(opened.showSearchResultsPanel)
|
||||
assertEquals(2, caseSensitive.searchResults.size)
|
||||
assertEquals(-1, caseSensitive.activeSearchResultIndex)
|
||||
assertEquals(session.reader.currentPageIndex, caseSensitive.reader.currentPageIndex)
|
||||
assertEquals(1, wholeWords.searchResults.size)
|
||||
assertEquals(false, hiddenPanel.showSearchResultsPanel)
|
||||
assertEquals("", closed.searchQuery)
|
||||
assertTrue(closed.searchResults.isEmpty())
|
||||
assertEquals(-1, closed.activeSearchResultIndex)
|
||||
assertTrue(closed.showSearchResultsPanel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search navigation resumes from page position after page slider moves off a match`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = SharedEpubBook(
|
||||
id = "spaced-search",
|
||||
fileName = "spaced.epub",
|
||||
title = "Spaced",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = buildString {
|
||||
append("needle\n\n")
|
||||
repeat(320) { index ->
|
||||
append("Paragraph ")
|
||||
append(index)
|
||||
append(" contains filler words for pagination only.\n\n")
|
||||
}
|
||||
append("final needle")
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
val session = engine.createSession(book, settings = compactSettings())
|
||||
val searched = session.reduce(ReaderAction.SearchChanged("needle"), engine)
|
||||
val middlePage = searched.reader.pages.indices.first { pageIndex ->
|
||||
searched.searchResults.none { result -> result.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
val moved = searched.reduce(ReaderAction.GoToPage(middlePage), engine)
|
||||
val next = moved.reduce(ReaderAction.NextSearchResult, engine)
|
||||
val previous = moved.reduce(ReaderAction.PreviousSearchResult, engine)
|
||||
|
||||
assertEquals(2, searched.searchResults.size)
|
||||
assertEquals(-1, moved.activeSearchResultIndex)
|
||||
assertTrue(moved.canGoToPreviousSearchResult)
|
||||
assertTrue(moved.canGoToNextSearchResult)
|
||||
assertEquals(1, next.activeSearchResultIndex)
|
||||
assertEquals(0, previous.activeSearchResultIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings theme and render actions update shared reader settings`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
|
||||
val settings = session.reader.settings.copy(fontSize = 24, pageWidth = 900, textAlign = SharedReaderTextAlign.CENTER)
|
||||
val changed = session.reduce(ReaderAction.SettingsChanged(settings), engine)
|
||||
assertEquals(24, changed.reader.settings.fontSize)
|
||||
assertEquals(900, changed.reader.settings.pageWidth)
|
||||
assertEquals(SharedReaderTextAlign.CENTER, changed.reader.settings.textAlign)
|
||||
|
||||
val vertical = changed.reduce(ReaderAction.RenderModeChanged(RenderMode.VERTICAL_SCROLL), engine)
|
||||
assertEquals(ReaderReadingMode.VERTICAL, vertical.reader.settings.readingMode)
|
||||
|
||||
val dark = vertical.reduce(
|
||||
ReaderAction.ThemeChanged(
|
||||
ReaderTheme(
|
||||
id = "dark",
|
||||
name = "Dark",
|
||||
backgroundColor = Color.Black,
|
||||
textColor = Color.White,
|
||||
isDark = true
|
||||
)
|
||||
),
|
||||
engine
|
||||
)
|
||||
assertTrue(dark.reader.settings.darkMode)
|
||||
assertEquals(-16777216L, dark.reader.settings.backgroundColorArgb)
|
||||
assertEquals(-1L, dark.reader.settings.textColorArgb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation actions use shared locators for navigation and edits`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
.reduce(ReaderAction.GoToPage(1), engine)
|
||||
val page = session.reader.currentPage ?: error("Expected current page")
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + 4,
|
||||
endOffset = page.startOffset + 18,
|
||||
textQuote = "shared locator",
|
||||
cfi = "desktop:${page.chapterIndex}:${page.startOffset + 4}:${page.startOffset + 18}"
|
||||
)
|
||||
|
||||
val highlighted = session.reduce(
|
||||
ReaderAction.HighlightCreated(
|
||||
UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = locator.cfi ?: "desktop",
|
||||
text = "shared locator",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = page.chapterIndex,
|
||||
locator = locator
|
||||
)
|
||||
),
|
||||
engine
|
||||
)
|
||||
val noted = highlighted.reduce(ReaderAction.HighlightUpdated("highlight-1", note = "Keep this"), engine)
|
||||
val recolored = noted.reduce(ReaderAction.HighlightUpdated("highlight-1", color = HighlightColor.GREEN), engine)
|
||||
val jumped = session.reduce(ReaderAction.GoToLocator(locator), engine)
|
||||
val deleted = recolored.reduce(ReaderAction.HighlightDeleted("highlight-1"), engine)
|
||||
|
||||
assertEquals(locator.startOffset, highlighted.highlights.single().locator.startOffset)
|
||||
assertEquals("Keep this", recolored.highlights.single().note)
|
||||
assertEquals(HighlightColor.GREEN, recolored.highlights.single().color)
|
||||
assertEquals(page.pageIndex, jumped.reader.currentPageIndex)
|
||||
assertEquals(locator.startOffset, jumped.navigationLocator?.startOffset)
|
||||
assertEquals(locator.endOffset, jumped.navigationLocator?.endOffset)
|
||||
assertTrue(deleted.highlights.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader navigation stores locator for vertical scroll targets`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
val secondPage = session.reduce(ReaderAction.GoToPage(1), engine)
|
||||
val secondChapter = secondPage.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
val search = secondChapter.reduce(ReaderAction.SearchChanged("needle"), engine)
|
||||
val searchTarget = search.searchResults.first()
|
||||
val jumpedToSearch = search.reduce(ReaderAction.GoToSearchResult(0), engine)
|
||||
|
||||
assertEquals(secondPage.reader.currentPage?.startOffset, secondPage.navigationLocator?.startOffset)
|
||||
assertEquals(1, secondChapter.navigationLocator?.chapterIndex)
|
||||
assertEquals(searchTarget.locator.startOffset, jumpedToSearch.navigationLocator?.startOffset)
|
||||
assertEquals(searchTarget.locator.endOffset, jumpedToSearch.navigationLocator?.endOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible page sync updates slider position without creating navigation request`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
val navigated = session.reduce(ReaderAction.GoToPage(1), engine)
|
||||
val requestId = navigated.navigationRequestId
|
||||
val synced = navigated.reduce(ReaderAction.VisiblePageChanged(3), engine)
|
||||
|
||||
assertEquals(3, synced.reader.currentPageIndex)
|
||||
assertEquals(requestId, synced.navigationRequestId)
|
||||
assertEquals(navigated.navigationLocator, synced.navigationLocator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible locator sync feeds top visible bookmark location`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook(), settings = compactSettings())
|
||||
val page = session.reader.pages[1]
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + 25,
|
||||
endOffset = page.startOffset + 25,
|
||||
textQuote = "top visible text",
|
||||
cfi = "desktop:${page.chapterIndex}:${page.startOffset + 25}:${page.startOffset + 25}"
|
||||
)
|
||||
|
||||
val synced = session.reduce(ReaderAction.VisiblePageChanged(page.pageIndex, locator), engine)
|
||||
val bookmarked = synced.reduce(ReaderAction.ToggleBookmark, engine)
|
||||
|
||||
assertEquals(locator.startOffset, synced.navigationLocator?.startOffset)
|
||||
assertEquals(locator.startOffset, bookmarked.bookmarks.single().locator.startOffset)
|
||||
assertEquals("top visible text", bookmarked.bookmarks.single().preview)
|
||||
assertTrue(bookmarked.reduce(ReaderAction.ToggleBookmark, engine).bookmarks.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `format action maps Android style reader appearance to shared reader settings`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = longBook(),
|
||||
settings = compactSettings().copy(darkMode = true, readingMode = ReaderReadingMode.VERTICAL, pageWidth = 812)
|
||||
)
|
||||
|
||||
val updated = session.reduce(
|
||||
ReaderAction.FormatChanged(
|
||||
FormatSettings(
|
||||
fontSize = 1.5f,
|
||||
lineHeight = 1.2f,
|
||||
paragraphGap = 0.8f,
|
||||
imageSize = 1.3f,
|
||||
horizontalMargin = 0.5f,
|
||||
verticalMargin = 2.0f,
|
||||
font = ReaderFont.ROBOTO_MONO,
|
||||
customPath = null,
|
||||
textAlign = ReaderTextAlign.RIGHT
|
||||
)
|
||||
),
|
||||
engine
|
||||
)
|
||||
|
||||
assertEquals(27, updated.reader.settings.fontSize)
|
||||
assertEquals(1.74f, updated.reader.settings.lineSpacing, 0.0001f)
|
||||
assertEquals(96, updated.reader.settings.margin)
|
||||
assertEquals(24, updated.reader.settings.resolvedHorizontalMargin)
|
||||
assertEquals(96, updated.reader.settings.resolvedVerticalMargin)
|
||||
assertEquals(0.8f, updated.reader.settings.paragraphSpacing, 0.0001f)
|
||||
assertEquals(1.3f, updated.reader.settings.imageScale, 0.0001f)
|
||||
assertEquals("Mono", updated.reader.settings.fontFamily)
|
||||
assertEquals(SharedReaderTextAlign.RIGHT, updated.reader.settings.textAlign)
|
||||
assertTrue(updated.reader.settings.darkMode)
|
||||
assertEquals(ReaderReadingMode.VERTICAL, updated.reader.settings.readingMode)
|
||||
assertEquals(812, updated.reader.settings.pageWidth)
|
||||
}
|
||||
|
||||
private fun compactSettings(): ReaderSettings {
|
||||
return ReaderSettings(fontSize = 14, margin = 16, lineSpacing = 1.1f, pageWidth = 560)
|
||||
}
|
||||
|
||||
private fun longBook(): SharedEpubBook {
|
||||
val repeated = List(240) { index ->
|
||||
"Paragraph $index gives the paginator enough text to create several pages with a needle hidden inside."
|
||||
}.joinToString("\n\n")
|
||||
return SharedEpubBook(
|
||||
id = "long",
|
||||
fileName = "long.epub",
|
||||
title = "Long",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = repeated
|
||||
),
|
||||
SharedEpubChapter(
|
||||
id = "two",
|
||||
title = "Two",
|
||||
plainText = "Second chapter starts here. Another needle appears for search navigation. $repeated"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import org.dueattendant149.bookreader.shared.pdf.PdfInkTool
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotationDefaults
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfHighlighterPalette
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderAppearanceModelsTest {
|
||||
|
||||
@Test
|
||||
fun `pdf built in themes include android pdf defaults and textured presets`() {
|
||||
assertEquals("no_theme", BuiltInPdfReaderThemes.first().id)
|
||||
assertNotNull(BuiltInPdfReaderThemes.firstOrNull { it.id == "reverse" })
|
||||
|
||||
val texturedThemeIds = BuiltInPdfReaderThemes
|
||||
.filter { it.textureId != null }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
assertEquals(
|
||||
setOf(
|
||||
"pdf_natural_white_texture",
|
||||
"pdf_retina_texture",
|
||||
"pdf_veneer_texture",
|
||||
"pdf_grey_wash_texture",
|
||||
"pdf_fabric_texture",
|
||||
"pdf_retro_texture"
|
||||
),
|
||||
texturedThemeIds
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf and epub built in themes share the standard reader palette`() {
|
||||
data class ThemeToken(
|
||||
val name: String,
|
||||
val backgroundArgb: Int,
|
||||
val textArgb: Int,
|
||||
val isDark: Boolean,
|
||||
val textureId: String?
|
||||
)
|
||||
|
||||
val epubPalette = BuiltInReaderThemes
|
||||
.drop(1)
|
||||
.map { theme ->
|
||||
ThemeToken(
|
||||
name = theme.name,
|
||||
backgroundArgb = theme.backgroundColor.toArgb(),
|
||||
textArgb = theme.textColor.toArgb(),
|
||||
isDark = theme.isDark,
|
||||
textureId = theme.textureId
|
||||
)
|
||||
}
|
||||
val pdfPalette = BuiltInPdfReaderThemes
|
||||
.drop(2)
|
||||
.map { theme ->
|
||||
ThemeToken(
|
||||
name = theme.name,
|
||||
backgroundArgb = theme.backgroundColor.toArgb(),
|
||||
textArgb = theme.textColor.toArgb(),
|
||||
isDark = theme.isDark,
|
||||
textureId = theme.textureId
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(epubPalette, pdfPalette)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf highlighter defaults follow android pdf highlight slots`() {
|
||||
val expectedPdfColors = SharedPdfAndroidHighlightColors.palette.take(5)
|
||||
|
||||
assertEquals(5, SharedPdfHighlighterPalette.MaxColors)
|
||||
assertEquals(expectedPdfColors, SharedPdfHighlighterPalette.defaultColors)
|
||||
assertEquals(expectedPdfColors[0], SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER).colorArgb)
|
||||
assertEquals(expectedPdfColors[1], SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER_ROUND).colorArgb)
|
||||
|
||||
val custom = SharedPdfHighlighterPalette(
|
||||
colors = expectedPdfColors + listOf(0xFFFF00FF.toInt())
|
||||
).sanitized()
|
||||
assertEquals(5, custom.colors.size)
|
||||
assertEquals(expectedPdfColors, custom.colors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom reader themes keep android persisted shape`() {
|
||||
val first = ReaderTheme(
|
||||
id = "custom",
|
||||
name = "Custom",
|
||||
backgroundColor = Color(0xFFF5F5F5),
|
||||
textColor = Color(0xFF111111),
|
||||
isDark = false,
|
||||
isCustom = true
|
||||
)
|
||||
val replacement = first.copy(name = "Replacement")
|
||||
val builtIn = BuiltInReaderThemes.first().copy(isCustom = false)
|
||||
|
||||
assertEquals(listOf(replacement), listOf(first, builtIn, replacement).sanitizeCustomReaderThemes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader textures expose shared desktop resource paths`() {
|
||||
assertTrue(ReaderTexture.entries.all { it.assetPath.startsWith("textures/") })
|
||||
assertEquals("textures/ep_naturalwhite.webp", ReaderTexture.NATURAL_WHITE.assetPath)
|
||||
assertEquals("textures/texture_paper.png", ReaderTexture.PAPER.assetPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file texture display names use imported file names`() {
|
||||
assertEquals("custom-paper", readerTextureDisplayName("${ReaderTextureFilePrefix}C:\\textures\\custom-paper.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader texture helpers normalize extensions and resolve mime types`() {
|
||||
assertEquals("jpg", normalizeReaderTextureExtension("JPEG"))
|
||||
assertEquals("webp", normalizeReaderTextureExtension(" webp "))
|
||||
assertNull(normalizeReaderTextureExtension("svg"))
|
||||
|
||||
assertEquals("image/jpeg", readerTextureMimeTypeForExtension("jpg"))
|
||||
assertEquals("image/webp", readerTextureMimeTypeForExtension("webp"))
|
||||
assertEquals("image/png", readerTextureMimeTypeForExtension("unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf textured theme maps into reader settings`() {
|
||||
val theme = BuiltInPdfReaderThemes.first { it.id == "pdf_fabric_texture" }
|
||||
val settings = theme.toReaderSettings()
|
||||
|
||||
assertEquals("pdf_fabric_texture", settings.themeId)
|
||||
assertEquals(ReaderTexture.CLASSY_FABRIC.id, settings.textureId)
|
||||
assertTrue(settings.darkMode)
|
||||
assertEquals(theme.backgroundColor.toArgb().toLong(), settings.backgroundColorArgb)
|
||||
assertEquals(theme.textColor.toArgb().toLong(), settings.textColorArgb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset reader format settings keeps reader mode and appearance choices`() {
|
||||
val settings = ReaderSettings(
|
||||
fontSize = 26,
|
||||
lineSpacing = 2.0f,
|
||||
margin = 96,
|
||||
horizontalMargin = 128,
|
||||
verticalMargin = 72,
|
||||
textAlign = SharedReaderTextAlign.RIGHT,
|
||||
pageWidth = 1040,
|
||||
fontFamily = "Imported",
|
||||
customFontPath = "C:\\fonts\\Imported.ttf",
|
||||
paragraphSpacing = 2.2f,
|
||||
imageScale = 1.8f,
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
themeId = "sepia",
|
||||
textureId = ReaderTexture.PAPER.id,
|
||||
textureAlpha = 0.35f,
|
||||
darkMode = true,
|
||||
backgroundColorArgb = 0xFF101010,
|
||||
textColorArgb = 0xFFEAEAEA
|
||||
)
|
||||
|
||||
val reset = settings.resetReaderFormatSettings()
|
||||
val defaults = ReaderSettings()
|
||||
|
||||
assertEquals(defaults.fontSize, reset.fontSize)
|
||||
assertEquals(defaults.lineSpacing, reset.lineSpacing)
|
||||
assertEquals(defaults.margin, reset.margin)
|
||||
assertEquals(defaults.horizontalMargin, reset.horizontalMargin)
|
||||
assertEquals(defaults.verticalMargin, reset.verticalMargin)
|
||||
assertEquals(defaults.textAlign, reset.textAlign)
|
||||
assertEquals(defaults.pageWidth, reset.pageWidth)
|
||||
assertEquals(defaults.fontFamily, reset.fontFamily)
|
||||
assertEquals(defaults.customFontPath, reset.customFontPath)
|
||||
assertEquals(defaults.paragraphSpacing, reset.paragraphSpacing)
|
||||
assertEquals(defaults.imageScale, reset.imageScale)
|
||||
|
||||
assertEquals(ReaderReadingMode.PAGINATED, reset.readingMode)
|
||||
assertEquals(ReaderPageSpreadMode.TWO_PAGE, reset.pageSpreadMode)
|
||||
assertEquals("sepia", reset.themeId)
|
||||
assertEquals(ReaderTexture.PAPER.id, reset.textureId)
|
||||
assertEquals(0.35f, reset.textureAlpha)
|
||||
assertEquals(true, reset.darkMode)
|
||||
assertEquals(0xFF101010, reset.backgroundColorArgb)
|
||||
assertEquals(0xFFEAEAEA, reset.textColorArgb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `axis margin updates keep the opposite axis fixed`() {
|
||||
val defaultSettings = ReaderSettings()
|
||||
|
||||
val horizontalOnly = defaultSettings.withHorizontalReaderMargin(96)
|
||||
assertEquals(96, horizontalOnly.resolvedHorizontalMargin)
|
||||
assertEquals(defaultSettings.resolvedVerticalMargin, horizontalOnly.resolvedVerticalMargin)
|
||||
assertEquals(96, horizontalOnly.margin)
|
||||
assertEquals(defaultSettings.resolvedVerticalMargin, horizontalOnly.verticalMargin)
|
||||
|
||||
val verticalOnly = horizontalOnly.withVerticalReaderMargin(24)
|
||||
assertEquals(96, verticalOnly.resolvedHorizontalMargin)
|
||||
assertEquals(24, verticalOnly.resolvedVerticalMargin)
|
||||
assertEquals(96, verticalOnly.margin)
|
||||
assertEquals(96, verticalOnly.horizontalMargin)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page width format control is only shown for paginated mode`() {
|
||||
assertEquals(
|
||||
false,
|
||||
ReaderSettings(readingMode = ReaderReadingMode.VERTICAL).shouldShowPageWidthFormatControl()
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
ReaderSettings(readingMode = ReaderReadingMode.PAGINATED).shouldShowPageWidthFormatControl()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderBookReplacementEngineTest {
|
||||
@Test
|
||||
fun `book replacements apply only to matching file id`() {
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf(
|
||||
"book-a" to listOf(rule(from = "Alice", to = "Alicia")),
|
||||
"book-b" to listOf(rule(from = "Alice", to = "Alix")),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Alicia looked around.",
|
||||
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "book-a").text,
|
||||
)
|
||||
assertEquals(
|
||||
"Alix looked around.",
|
||||
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "book-b").text,
|
||||
)
|
||||
assertEquals(
|
||||
"Alice looked around.",
|
||||
ReaderBookReplacementEngine.apply("Alice looked around.", preferences, "missing").text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book replacements have no global fallback`() {
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf("" to listOf(rule(from = "global", to = "local"))),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"global rule",
|
||||
ReaderBookReplacementEngine.apply("global rule", preferences, "book").text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book replacements serialize and preserve per file rules`() {
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf(
|
||||
"book" to listOf(
|
||||
rule(
|
||||
id = "regex",
|
||||
from = """A(\w+)""",
|
||||
to = "B\$1",
|
||||
isRegex = true,
|
||||
wholeWord = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val decoded = ReaderBookReplacementPreferencesJson.decodeOrEmpty(
|
||||
ReaderBookReplacementPreferencesJson.encode(preferences),
|
||||
)
|
||||
|
||||
assertEquals(preferences, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signature only reflects active rules for file`() {
|
||||
val preferences = ReaderBookReplacementPreferences(
|
||||
fileRules = mapOf(
|
||||
"book" to listOf(
|
||||
rule(id = "on", from = "old", to = "new"),
|
||||
rule(id = "off", from = "draft", to = "unused", enabled = false),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val signature = preferences.signatureForFile("book")
|
||||
|
||||
assertTrue("old" in signature)
|
||||
assertTrue("draft" !in signature)
|
||||
assertEquals("", preferences.signatureForFile("missing"))
|
||||
}
|
||||
|
||||
private fun rule(
|
||||
id: String = "rule",
|
||||
from: String,
|
||||
to: String,
|
||||
enabled: Boolean = true,
|
||||
isRegex: Boolean = false,
|
||||
matchCase: Boolean = false,
|
||||
wholeWord: Boolean = true,
|
||||
): ReaderWordReplacementRule {
|
||||
return ReaderWordReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = to,
|
||||
enabled = enabled,
|
||||
isRegex = isRegex,
|
||||
matchCase = matchCase,
|
||||
wholeWord = wholeWord,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ReaderDefaultSettingsStateTest {
|
||||
|
||||
@Test
|
||||
fun `epub reader defaults to vertical mode`() {
|
||||
assertEquals(ReaderReadingMode.VERTICAL, ReaderSettings().readingMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader default settings reducer updates shared state`() {
|
||||
val defaults = ReaderSettings(
|
||||
fontSize = 24,
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
textAlign = SharedReaderTextAlign.JUSTIFY,
|
||||
themeId = "sepia"
|
||||
)
|
||||
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.ReaderDefaultSettingsChanged(defaults))
|
||||
|
||||
assertEquals(defaults, state.readerDefaultSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf reader default settings reducer updates separate shared state`() {
|
||||
val epubDefaults = ReaderSettings(themeId = "sepia")
|
||||
val pdfDefaults = ReaderSettings(
|
||||
themeId = "reverse",
|
||||
pdfFirstPageStandaloneInSpread = true
|
||||
)
|
||||
|
||||
val state = SharedReaderScreenState(readerDefaultSettings = epubDefaults)
|
||||
.reduce(AppAction.PdfReaderDefaultSettingsChanged(pdfDefaults))
|
||||
|
||||
assertEquals(epubDefaults, state.readerDefaultSettings)
|
||||
assertEquals(pdfDefaults, state.pdfReaderDefaultSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader default settings persist in shared snapshot json`() {
|
||||
val defaults = ReaderSettings(
|
||||
fontSize = 21,
|
||||
lineSpacing = 1.8f,
|
||||
margin = 72,
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
textAlign = SharedReaderTextAlign.CENTER,
|
||||
pageWidth = 920,
|
||||
fontFamily = "Serif",
|
||||
themeId = "dark",
|
||||
textureId = "paper",
|
||||
textureAlpha = 0.25f,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
SharedLibrarySnapshotJson.encode(
|
||||
SharedLibrarySnapshot(readerDefaultSettings = defaults)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(defaults, decoded.readerDefaultSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf reader default settings persist separately in shared snapshot json`() {
|
||||
val epubDefaults = ReaderSettings(themeId = "sepia")
|
||||
val pdfDefaults = ReaderSettings(
|
||||
themeId = "reverse",
|
||||
pdfFirstPageStandaloneInSpread = true,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
SharedLibrarySnapshotJson.encode(
|
||||
SharedLibrarySnapshot(
|
||||
readerDefaultSettings = epubDefaults,
|
||||
pdfReaderDefaultSettings = pdfDefaults
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(epubDefaults, decoded.readerDefaultSettings)
|
||||
assertEquals(pdfDefaults, decoded.pdfReaderDefaultSettings)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import org.dueattendant149.bookreader.paginatedreader.CssStyle
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderEngine
|
||||
import org.dueattendant149.bookreader.shared.reader.PaginatedReaderState
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPage
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSessionState
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderExtrasModelsTest {
|
||||
|
||||
@Test
|
||||
fun `reader ai settings require BYO key and selected model`() {
|
||||
val missingModel = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(groqKey = "gsk_test"),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
|
||||
assertIs<ReaderByokTextRequestResult.MissingModel>(missingModel)
|
||||
|
||||
val missingKey = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(modelForAll = "groq:qwen/qwen3-32b"),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
|
||||
assertIs<ReaderByokTextRequestResult.MissingKey>(missingKey)
|
||||
|
||||
val ready = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(
|
||||
groqKey = "gsk_test",
|
||||
modelForAll = "groq:qwen/qwen3-32b"
|
||||
),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
|
||||
assertIs<ReaderByokTextRequestResult.Ready>(ready)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader ai one model setting matches Android model selection logic`() {
|
||||
val oneModel = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(
|
||||
geminiKey = "gemini_test",
|
||||
groqKey = "gsk_test",
|
||||
useOneModel = true,
|
||||
modelForAll = "groq:qwen/qwen3-32b",
|
||||
defineModel = "gemini:gemini-flash-lite-latest"
|
||||
),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
val perFeature = ReaderByokTextRequests.build(
|
||||
settings = ReaderAiByokSettings(
|
||||
geminiKey = "gemini_test",
|
||||
groqKey = "gsk_test",
|
||||
useOneModel = false,
|
||||
modelForAll = "groq:qwen/qwen3-32b",
|
||||
defineModel = "gemini:gemini-flash-lite-latest"
|
||||
),
|
||||
feature = ReaderAiFeature.DEFINE,
|
||||
text = "epistemic"
|
||||
)
|
||||
|
||||
assertEquals("groq:qwen/qwen3-32b", assertIs<ReaderByokTextRequestResult.Ready>(oneModel).request.model.id)
|
||||
assertEquals("gemini:gemini-flash-lite-latest", assertIs<ReaderByokTextRequestResult.Ready>(perFeature).request.model.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BYOK cloud tts is available only with gemini key and cloud tts model`() {
|
||||
assertFalse(ReaderAiByokSettings(geminiKey = "key").isCloudTtsAvailable)
|
||||
assertFalse(ReaderAiByokSettings(ttsModel = GEMINI_CLOUD_TTS_MODEL_ID).isCloudTtsAvailable)
|
||||
|
||||
assertTrue(
|
||||
ReaderAiByokSettings(
|
||||
geminiKey = "key",
|
||||
ttsModel = GEMINI_CLOUD_TTS_MODEL_ID
|
||||
).isCloudTtsAvailable
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server backed reader AI and cloud tts availability do not require BYOK keys`() {
|
||||
val serverBacked = ReaderAiByokSettings(
|
||||
serverBackedReaderAiFeatures = true,
|
||||
serverBackedCloudTts = true
|
||||
)
|
||||
|
||||
assertTrue(serverBacked.areReaderAiFeaturesAvailable)
|
||||
assertTrue(serverBacked.isCloudTtsAvailable)
|
||||
assertFalse(serverBacked.isByokCloudTtsAvailable)
|
||||
assertFalse(serverBacked.copy(hideReaderAiFeatures = true).areReaderAiFeaturesAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared cloud tts voices mirror android voice catalog`() {
|
||||
assertEquals("Aoede", DEFAULT_CLOUD_TTS_SPEAKER_ID)
|
||||
assertTrue(ReaderCloudTtsVoices.size >= 30)
|
||||
assertEquals(ReaderCloudTtsVoices.map { it.id }, ReaderCloudTtsSpeakers)
|
||||
assertEquals("Breezy, Middle pitch", readerCloudTtsVoiceById("Aoede")?.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared cloud tts chunking keeps android sentence behavior`() {
|
||||
val chunks = splitReaderTextIntoTtsChunks(
|
||||
"First sentence. Second sentence? Third sentence!",
|
||||
maxLength = 32
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("First sentence. Second sentence?", "Third sentence!"),
|
||||
chunks
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared cloud tts cache summary formats current voice label`() {
|
||||
val empty = ReaderTtsCacheSummary()
|
||||
val populated = ReaderTtsCacheSummary(
|
||||
cachedChapterCount = 2,
|
||||
cachedChunkCount = 3,
|
||||
currentVoiceChunkCount = 2,
|
||||
totalSizeBytes = 4096,
|
||||
currentVoiceSizeBytes = 2048
|
||||
)
|
||||
|
||||
assertEquals("No cached chunks for this voice", empty.currentVoiceLabel)
|
||||
assertEquals("2 chunks, 2.0 KB", populated.currentVoiceLabel)
|
||||
assertFalse(empty.hasCurrentVoiceCachedAudio)
|
||||
assertTrue(populated.hasCurrentVoiceCachedAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud tts overlay is visible only for active reader playback`() {
|
||||
assertFalse(readerCloudTtsControlsModel(ReaderCloudTtsState(isAvailable = true)).isVisible)
|
||||
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isLoading = true)).isVisible)
|
||||
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isPlaying = true)).isVisible)
|
||||
assertTrue(readerCloudTtsControlsModel(ReaderCloudTtsState(isPaused = true)).isVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cloud tts overlay exposes chunk navigation only when a chunk can be skipped`() {
|
||||
val chunks = List(3) { index ->
|
||||
ReaderTtsChunk(
|
||||
index = index,
|
||||
pageIndex = index,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "Chapter",
|
||||
text = "Part ${index + 1}.",
|
||||
startOffset = index * 10,
|
||||
endOffset = index * 10 + 7
|
||||
)
|
||||
}
|
||||
|
||||
val first = readerCloudTtsControlsModel(
|
||||
ReaderCloudTtsState(
|
||||
isPlaying = true,
|
||||
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 0)
|
||||
)
|
||||
)
|
||||
val middle = readerCloudTtsControlsModel(
|
||||
ReaderCloudTtsState(
|
||||
isPlaying = true,
|
||||
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 1)
|
||||
)
|
||||
)
|
||||
val loading = readerCloudTtsControlsModel(
|
||||
ReaderCloudTtsState(
|
||||
isLoading = true,
|
||||
progress = ReaderTtsProgress(chunks = chunks, currentChunkIndex = 1)
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(first.canSkipPrevious)
|
||||
assertTrue(first.canSkipNext)
|
||||
assertTrue(first.canLocateCurrentChunk)
|
||||
assertTrue(middle.canSkipPrevious)
|
||||
assertTrue(middle.canSkipNext)
|
||||
assertFalse(loading.canSkipPrevious)
|
||||
assertFalse(loading.canSkipNext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden reader ai follows android availability logic`() {
|
||||
val visible = ReaderAiByokSettings(
|
||||
groqKey = "gsk_test",
|
||||
modelForAll = "groq:qwen/qwen3-32b"
|
||||
)
|
||||
val hidden = visible.copy(hideReaderAiFeatures = true)
|
||||
|
||||
assertTrue(visible.areReaderAiFeaturesAvailable)
|
||||
assertFalse(hidden.areReaderAiFeaturesAvailable)
|
||||
assertIs<ReaderByokTextRequestResult.Hidden>(
|
||||
ReaderByokTextRequests.build(hidden, ReaderAiFeature.DEFINE, "epistemic")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chapter summary context follows current chapter in pagination and vertical modes`() {
|
||||
val book = SharedEpubBook(
|
||||
id = "context",
|
||||
fileName = "context.epub",
|
||||
title = "Context",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter("one", "One", "First chapter text"),
|
||||
SharedEpubChapter("two", "Two", "Second chapter text")
|
||||
)
|
||||
)
|
||||
val engine = ReaderEngine()
|
||||
val paginated = engine.createSession(book, settings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED))
|
||||
.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
val vertical = engine.createSession(book, settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL))
|
||||
.reduce(ReaderAction.GoToChapter(1), engine)
|
||||
|
||||
assertEquals("Second chapter text", ReaderContextExtractor.currentChapterText(paginated))
|
||||
assertEquals("Second chapter text", ReaderContextExtractor.currentChapterText(vertical))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner follows android sentence chunking`() {
|
||||
val sentenceOne = "First " + "word ".repeat(20).trim() + "."
|
||||
val sentenceTwo = "Second " + "word ".repeat(20).trim() + "!"
|
||||
val sentenceThree = "Third " + "word ".repeat(20).trim() + "?"
|
||||
val text = listOf(sentenceOne, sentenceTwo, sentenceThree).joinToString(" ")
|
||||
val chunks = ReaderTtsPlanner.chunksForText(
|
||||
text = text,
|
||||
pageIndex = 4,
|
||||
chapterIndex = 2,
|
||||
chapterTitle = "Offsets",
|
||||
sourceStartOffset = 12
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"$sentenceOne $sentenceTwo",
|
||||
sentenceThree
|
||||
),
|
||||
chunks.map { it.text }
|
||||
)
|
||||
assertTrue(chunks.all { it.text.length <= READER_TTS_CHUNK_MAX_LENGTH })
|
||||
assertEquals(chunks.indices.toList(), chunks.map { it.index })
|
||||
assertEquals(12, chunks.first().startOffset)
|
||||
assertEquals(12 + text.trimEnd().length, chunks.last().endOffset)
|
||||
assertTrue(chunks.all { it.pageIndex == 4 && it.chapterIndex == 2 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner keeps android long sentence behavior`() {
|
||||
val text = "word ".repeat(80).trim()
|
||||
val chunks = ReaderTtsPlanner.chunksForText(
|
||||
text = text,
|
||||
pageIndex = 4,
|
||||
chapterIndex = 2,
|
||||
chapterTitle = "Offsets"
|
||||
)
|
||||
|
||||
assertEquals(listOf(text), chunks.map { it.text })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner can read page chapter or onward from current location`() {
|
||||
val book = SharedEpubBook(
|
||||
id = "tts",
|
||||
fileName = "tts.epub",
|
||||
title = "TTS",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter("one", "One", "First page text."),
|
||||
SharedEpubChapter("two", "Two", "Second page text.")
|
||||
)
|
||||
)
|
||||
val session = ReaderEngine().createSession(book)
|
||||
|
||||
assertEquals(listOf(0), ReaderTtsPlanner.chunksForCurrentPage(session).map { it.chapterIndex }.distinct())
|
||||
assertEquals(listOf(0), ReaderTtsPlanner.chunksForCurrentChapter(session).map { it.chapterIndex }.distinct())
|
||||
assertEquals(listOf(0, 1), ReaderTtsPlanner.chunksFromCurrentLocation(session).map { it.chapterIndex }.distinct())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner starts onward reading at visible locator offset`() {
|
||||
val source = "First hidden sentence. Second visible sentence. Third visible sentence."
|
||||
val visibleOffset = source.indexOf("Second")
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-visible",
|
||||
fileName = "tts-visible.epub",
|
||||
title = "TTS visible",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", source))
|
||||
)
|
||||
val session = ReaderEngine().createSession(book).copy(
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = visibleOffset,
|
||||
endOffset = visibleOffset,
|
||||
textQuote = "Second visible sentence."
|
||||
)
|
||||
)
|
||||
|
||||
val chunks = ReaderTtsPlanner.chunksFromCurrentLocation(session)
|
||||
|
||||
assertEquals(visibleOffset, chunks.first().startOffset)
|
||||
assertTrue(chunks.first().text.startsWith("Second visible sentence."))
|
||||
assertFalse(chunks.any { it.text.startsWith("First hidden") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner keeps synthetic desktop locators at android style chunk boundary`() {
|
||||
val visibleLine = "Gilberte's either noticing or suffering by his peculations. Tears came to my eyes."
|
||||
val source = "Hidden before this visual line. $visibleLine Later visible sentence."
|
||||
val visibleStart = source.indexOf(visibleLine)
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-desktop-line",
|
||||
fileName = "tts-desktop-line.epub",
|
||||
title = "TTS desktop line",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", source))
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = source,
|
||||
startOffset = 0,
|
||||
endOffset = source.length
|
||||
)
|
||||
val session = ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = listOf(page),
|
||||
currentPageIndex = 0
|
||||
),
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = visibleStart,
|
||||
endOffset = visibleStart,
|
||||
textQuote = visibleLine,
|
||||
cfi = "desktop:0:$visibleStart:$visibleStart"
|
||||
)
|
||||
)
|
||||
|
||||
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
|
||||
|
||||
assertEquals(visibleStart, first.startOffset)
|
||||
assertTrue(first.text.startsWith(visibleLine))
|
||||
assertFalse(first.text.startsWith("Hidden before"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner trims onward chunks with source offsets after sentence gaps`() {
|
||||
val source = "First hidden sentence.\n\nSecond visible sentence starts on the top line."
|
||||
val visibleOffset = source.indexOf("Second")
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-visible-gap",
|
||||
fileName = "tts-visible-gap.epub",
|
||||
title = "TTS visible gap",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", source))
|
||||
)
|
||||
val session = ReaderEngine().createSession(book).copy(
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = visibleOffset,
|
||||
endOffset = visibleOffset,
|
||||
textQuote = "Second visible sentence starts on the top line."
|
||||
)
|
||||
)
|
||||
|
||||
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
|
||||
|
||||
assertEquals(visibleOffset, first.startOffset)
|
||||
assertTrue(first.text.startsWith("Second visible sentence starts"))
|
||||
assertFalse(first.text.startsWith("cond visible"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner matches android source cfi before slicing initial chunk`() {
|
||||
val hidden = "Hidden block text that should never be trimmed into."
|
||||
val visible = "Visible line starts here and should be spoken."
|
||||
val visibleOffset = 20
|
||||
val hiddenBlock = SemanticParagraph(
|
||||
text = hidden,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 0
|
||||
)
|
||||
val visibleBlock = SemanticParagraph(
|
||||
text = visible,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/4",
|
||||
startCharOffsetInSource = visibleOffset,
|
||||
blockIndex = 1
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-cfi-match",
|
||||
fileName = "tts-cfi-match.epub",
|
||||
title = "TTS CFI match",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "$hidden\n$visible",
|
||||
semanticBlocks = listOf(hiddenBlock, visibleBlock)
|
||||
)
|
||||
)
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "$hidden\n$visible",
|
||||
startOffset = 0,
|
||||
endOffset = hidden.length + visible.length + visibleOffset
|
||||
)
|
||||
val session = ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = listOf(page),
|
||||
currentPageIndex = 0
|
||||
),
|
||||
navigationLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = visibleOffset,
|
||||
endOffset = visibleOffset,
|
||||
textQuote = visible,
|
||||
cfi = "/4/4:0"
|
||||
)
|
||||
)
|
||||
|
||||
val first = ReaderTtsPlanner.chunksFromCurrentLocation(session).first()
|
||||
|
||||
assertEquals("/4/4", first.sourceCfi)
|
||||
assertTrue(first.text.startsWith("Visible line starts here"))
|
||||
assertFalse(first.text.contains("Hidden block"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner maps trimmed page text back to source offsets`() {
|
||||
val source = "Intro.\n\n Leading words continue."
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-offsets",
|
||||
fileName = "tts-offsets.epub",
|
||||
title = "TTS offsets",
|
||||
chapters = listOf(SharedEpubChapter("one", "One", source))
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "Leading words continue.",
|
||||
startOffset = 8,
|
||||
endOffset = source.length
|
||||
)
|
||||
val session = ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = listOf(page),
|
||||
currentPageIndex = 0
|
||||
)
|
||||
)
|
||||
|
||||
val chunk = ReaderTtsPlanner.chunksForCurrentPage(session).first()
|
||||
|
||||
assertEquals(source.indexOf("Leading"), chunk.startOffset)
|
||||
assertEquals("Leading words continue.", source.substring(chunk.startOffset, chunk.endOffset))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts planner prefers semantic source cfi chunks when available`() {
|
||||
val source = "First sentence. Second sentence."
|
||||
val semanticBlock = SemanticParagraph(
|
||||
text = source,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 5,
|
||||
blockIndex = 1
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "tts-semantic",
|
||||
fileName = "tts-semantic.epub",
|
||||
title = "TTS semantic",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = source,
|
||||
semanticBlocks = listOf(semanticBlock)
|
||||
)
|
||||
)
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = source,
|
||||
startOffset = 0,
|
||||
endOffset = source.length + 5
|
||||
)
|
||||
val session = ReaderSessionState(
|
||||
reader = PaginatedReaderState(
|
||||
book = book,
|
||||
pages = listOf(page),
|
||||
currentPageIndex = 0
|
||||
)
|
||||
)
|
||||
|
||||
val chunks = ReaderTtsPlanner.chunksForCurrentPage(session)
|
||||
|
||||
assertEquals("/4/2", chunks.first().sourceCfi)
|
||||
assertEquals(5, chunks.first().startOffset)
|
||||
assertEquals("/4/2", chunks.first().toLocator().cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external lookup urls encode selected text`() {
|
||||
assertEquals(
|
||||
"https://www.google.com/search?q=define+hello+world",
|
||||
externalLookupUrl(ReaderExternalLookupAction.DICTIONARY, "hello world")
|
||||
)
|
||||
assertEquals(
|
||||
"https://translate.google.com/?sl=auto&tl=en&text=hello+world&op=translate",
|
||||
externalLookupUrl(ReaderExternalLookupAction.TRANSLATE, "hello world")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class ReaderMarkdownParserTest {
|
||||
@Test
|
||||
fun `parses headings lists quotes and code blocks`() {
|
||||
val document = ReaderMarkdownParser.parse(
|
||||
"""
|
||||
## Summary
|
||||
|
||||
- first point
|
||||
- second point
|
||||
|
||||
> quoted context
|
||||
|
||||
```
|
||||
code line
|
||||
```
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertIs<ReaderMarkdownBlock.Heading>(document.blocks[0])
|
||||
assertEquals("Summary", (document.blocks[0] as ReaderMarkdownBlock.Heading).text)
|
||||
assertEquals(listOf("first point", "second point"), (document.blocks[1] as ReaderMarkdownBlock.ListItems).items)
|
||||
assertEquals("quoted context", (document.blocks[2] as ReaderMarkdownBlock.Quote).text)
|
||||
assertEquals("code line", (document.blocks[3] as ReaderMarkdownBlock.CodeBlock).text)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderToolbarPreferencesTest {
|
||||
|
||||
@Test
|
||||
fun `toolbar preferences sanitize unknown ids and preserve missing tools`() {
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.SEARCH.id, "missing"),
|
||||
toolOrder = listOf(ReaderTool.BOOKMARK, ReaderTool.THEME),
|
||||
bottomToolIds = setOf(ReaderTool.BOOKMARK.id, "missing")
|
||||
).sanitized()
|
||||
|
||||
assertEquals(setOf(ReaderTool.SEARCH.id), preferences.hiddenToolIds)
|
||||
assertEquals(ReaderTool.BOOKMARK, preferences.toolOrder.first())
|
||||
assertEquals(ReaderTool.THEME, preferences.toolOrder[1])
|
||||
assertTrue(ReaderTool.SEARCH in preferences.toolOrder)
|
||||
assertEquals(setOf(ReaderTool.BOOKMARK.id), preferences.bottomToolIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar reducers update shared screen state`() {
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.ReaderToolVisibilityChanged(ReaderTool.SEARCH, hidden = true))
|
||||
.reduce(AppAction.ReaderToolPlacementChanged(ReaderTool.BOOKMARK, bottom = true))
|
||||
.reduce(AppAction.ReaderToolOrderChanged(listOf(ReaderTool.BOOKMARK, ReaderTool.THEME)))
|
||||
|
||||
assertFalse(state.readerToolbarPreferences.isVisible(ReaderTool.SEARCH))
|
||||
assertTrue(state.readerToolbarPreferences.isBottom(ReaderTool.BOOKMARK))
|
||||
assertEquals(ReaderTool.BOOKMARK, state.readerToolbarPreferences.toolOrder.first())
|
||||
assertEquals(ReaderTool.THEME, state.readerToolbarPreferences.toolOrder[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight palette reducer follows android four slot palette`() {
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(
|
||||
AppAction.ReaderHighlightPaletteChanged(
|
||||
ReaderHighlightPalette(
|
||||
colors = listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.YELLOW)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(ReaderHighlightPalette.defaultColors, state.readerHighlightPalette.colors)
|
||||
|
||||
val customized = state.reduce(
|
||||
AppAction.ReaderHighlightPaletteChanged(
|
||||
ReaderHighlightPalette(
|
||||
colors = listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.PINK, HighlightColor.WHITE)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.PINK, HighlightColor.WHITE),
|
||||
customized.readerHighlightPalette.colors
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderTtsReplacementEngineTest {
|
||||
@Test
|
||||
fun `literal replacement changes spoken text only`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "Dr.", to = "Doctor", wholeWord = false))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Dr. Smith arrived.", preferences)
|
||||
|
||||
assertEquals("Doctor Smith arrived.", result.text)
|
||||
assertEquals(listOf("rule"), result.appliedRuleIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `phrase replacement handles multi word phrases`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "et al.", to = "and others", wholeWord = false))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Smith et al. wrote it.", preferences)
|
||||
|
||||
assertEquals("Smith and others wrote it.", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole word replacement does not replace inside larger words`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "he", to = "they", wholeWord = true))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("he heard the theme", preferences)
|
||||
|
||||
assertEquals("they heard the theme", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `case sensitivity can be required per rule`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "NASA", to = "N A S A", matchCase = true))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("NASA and nasa", preferences)
|
||||
|
||||
assertEquals("N A S A and nasa", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `regex rule supports capture replacements`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(
|
||||
rule(
|
||||
from = """\b([A-Z])\.\s*([A-Z])\.""",
|
||||
to = "\$1 \$2",
|
||||
isRegex = true,
|
||||
wholeWord = false
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("J. R. wrote it.", preferences)
|
||||
|
||||
assertEquals("J R wrote it.", result.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid regex is skipped and reported`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(from = "(", to = "open", isRegex = true))
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Keep this text.", preferences)
|
||||
|
||||
assertEquals("Keep this text.", result.text)
|
||||
assertTrue(result.errors.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `global rules run before book rules`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(id = "global", from = "Dr.", to = "Doctor", wholeWord = false)),
|
||||
bookRules = mapOf(
|
||||
"book" to listOf(rule(id = "book", from = "Doctor", to = "Professor"))
|
||||
)
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Dr. Smith", preferences, bookId = "book")
|
||||
|
||||
assertEquals("Professor Smith", result.text)
|
||||
assertEquals(listOf("global", "book"), result.appliedRuleIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book settings can disable inherited global rules`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(rule(id = "global", from = "Dr.", to = "Doctor", wholeWord = false)),
|
||||
bookSettings = mapOf(
|
||||
"book" to ReaderTtsReplacementBookSettings(disabledGlobalRuleIds = setOf("global"))
|
||||
)
|
||||
)
|
||||
|
||||
val result = ReaderTtsReplacementEngine.apply("Dr. Smith", preferences, bookId = "book")
|
||||
|
||||
assertEquals("Dr. Smith", result.text)
|
||||
assertTrue(result.appliedRuleIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preferences serialize and deserialize without losing rules`() {
|
||||
val preferences = ReaderTtsReplacementPreferences(
|
||||
isEnabled = false,
|
||||
globalRules = listOf(rule(id = "global", from = "Mr.", to = "Mister", wholeWord = false)),
|
||||
bookRules = mapOf(
|
||||
"book" to listOf(rule(id = "book", from = "St.", to = "Saint", wholeWord = false))
|
||||
),
|
||||
bookSettings = mapOf(
|
||||
"book" to ReaderTtsReplacementBookSettings(
|
||||
localRulesEnabled = false,
|
||||
globalRulesEnabled = true,
|
||||
disabledGlobalRuleIds = setOf("global")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = ReaderTtsReplacementPreferencesJson.decodeOrEmpty(
|
||||
ReaderTtsReplacementPreferencesJson.encode(preferences)
|
||||
)
|
||||
|
||||
assertEquals(preferences, decoded)
|
||||
}
|
||||
|
||||
private fun rule(
|
||||
id: String = "rule",
|
||||
from: String,
|
||||
to: String,
|
||||
enabled: Boolean = true,
|
||||
isRegex: Boolean = false,
|
||||
matchCase: Boolean = false,
|
||||
wholeWord: Boolean = true
|
||||
): ReaderTtsReplacementRule {
|
||||
return ReaderTtsReplacementRule(
|
||||
id = id,
|
||||
from = from,
|
||||
to = to,
|
||||
enabled = enabled,
|
||||
isRegex = isRegex,
|
||||
matchCase = matchCase,
|
||||
wholeWord = wholeWord
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SettingsHubModelsTest {
|
||||
|
||||
@Test
|
||||
fun `settings hub root shows parent categories only`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedSettingsDestination.EPUB_TEXT,
|
||||
SharedSettingsDestination.PDF_COMICS,
|
||||
SharedSettingsDestination.THEME_APPEARANCE,
|
||||
SharedSettingsDestination.TTS_AI,
|
||||
SharedSettingsDestination.LIBRARY_SYNC_STORAGE,
|
||||
SharedSettingsDestination.SYNC_ACCOUNTS,
|
||||
SharedSettingsDestination.EXTRA
|
||||
),
|
||||
model.rootCategories.map { it.destination }
|
||||
)
|
||||
assertTrue(model.page(SharedSettingsDestination.ROOT).items.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline feature policy hides network backed nested settings`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.ANDROID,
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline,
|
||||
aiSettingsAvailable = true,
|
||||
isSignedIn = false
|
||||
)
|
||||
)
|
||||
val actions = model.visibleNestedActions()
|
||||
|
||||
assertFalse(SharedSettingsAction.AI_SETTINGS in actions)
|
||||
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||
assertFalse(SharedSettingsAction.SIGN_IN in actions)
|
||||
assertTrue(SharedSettingsAction.TTS_SETTINGS in actions)
|
||||
assertTrue(SharedSettingsAction.ABOUT in actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync unavailable hides cloud sync while preserving account and folder sync`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.DESKTOP,
|
||||
syncAvailable = false,
|
||||
folderSyncAvailable = true,
|
||||
isSignedIn = true,
|
||||
isProUser = true
|
||||
)
|
||||
)
|
||||
val actions = model.visibleNestedActions()
|
||||
|
||||
assertFalse(SharedSettingsAction.SIGN_IN in actions)
|
||||
assertTrue(SharedSettingsAction.SIGN_OUT in actions)
|
||||
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `account unavailable hides sign-in rows independently from cloud sync`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.DESKTOP,
|
||||
accountAvailable = false,
|
||||
syncAvailable = false,
|
||||
folderSyncAvailable = true,
|
||||
isSignedIn = true,
|
||||
isProUser = true
|
||||
)
|
||||
)
|
||||
val actions = model.visibleNestedActions()
|
||||
|
||||
assertFalse(SharedSettingsAction.SIGN_IN in actions)
|
||||
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
|
||||
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop can hide account auth rows while preserving sync controls`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.DESKTOP,
|
||||
includeAccountAuthActions = false,
|
||||
accountAvailable = true,
|
||||
syncAvailable = true,
|
||||
folderSyncAvailable = true,
|
||||
isSignedIn = true,
|
||||
isProUser = true
|
||||
)
|
||||
)
|
||||
val actions = model.visibleNestedActions()
|
||||
|
||||
assertFalse(SharedSettingsAction.SIGN_IN in actions)
|
||||
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
|
||||
assertTrue(SharedSettingsAction.CLOUD_SYNC in actions)
|
||||
assertTrue(SharedSettingsAction.FOLDER_SYNC in actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tabs setting can be omitted for platforms without visible tabs`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.DESKTOP,
|
||||
includeReaderTabs = false
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(SharedSettingsAction.TABS_TOGGLE in model.visibleNestedActions())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `language setting can show current platform selection`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(
|
||||
platform = SharedSettingsPlatform.DESKTOP,
|
||||
includeLanguage = true,
|
||||
languageTitle = "App language",
|
||||
languageSummary = "Deutsch"
|
||||
)
|
||||
)
|
||||
|
||||
val item = model.page(SharedSettingsDestination.EXTRA)
|
||||
.items
|
||||
.single { it.action == SharedSettingsAction.LANGUAGE }
|
||||
|
||||
assertEquals("App language", item.title)
|
||||
assertEquals("Deutsch", item.summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local override note appears on reader detail pages only`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
model.page(SharedSettingsDestination.EPUB_TEXT)
|
||||
.items
|
||||
.any { it.action == SharedSettingsAction.LOCAL_OVERRIDE_NOTE }
|
||||
)
|
||||
val note = model.page(SharedSettingsDestination.EPUB_FORMAT).localOverrideNote
|
||||
|
||||
assertEquals(SharedSettingsItemKind.INFO, note?.kind)
|
||||
assertTrue(note?.summary.orEmpty().contains("Local overrides"))
|
||||
assertTrue(note?.summary.orEmpty().contains("reader"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search returns nested results with breadcrumbs`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP)
|
||||
)
|
||||
|
||||
val results = model.searchResults("custom fonts")
|
||||
|
||||
assertEquals(1, results.size)
|
||||
assertEquals(SharedSettingsAction.CUSTOM_FONTS, results.first().action)
|
||||
assertEquals("Settings / Library & Files", results.first().breadcrumb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings destinations expose stable parents`() {
|
||||
assertEquals(SharedSettingsDestination.ROOT, SharedSettingsDestination.EPUB_TEXT.parentDestination())
|
||||
assertEquals(SharedSettingsDestination.EPUB_TEXT, SharedSettingsDestination.EPUB_FORMAT.parentDestination())
|
||||
assertEquals(SharedSettingsDestination.PDF_COMICS, SharedSettingsDestination.PDF_READER_TOOLS.parentDestination())
|
||||
assertEquals(SharedSettingsDestination.TTS_AI, SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS.parentDestination())
|
||||
assertEquals(SharedSettingsDestination.ROOT, SharedSettingsDestination.EXTRA.parentDestination())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts replacements are only exposed from global tts area`() {
|
||||
val model = sharedSettingsHubModel(
|
||||
SharedSettingsHubInput(platform = SharedSettingsPlatform.DESKTOP)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
model.page(SharedSettingsDestination.EPUB_TEXT)
|
||||
.items
|
||||
.any { it.action == SharedSettingsAction.TTS_REPLACEMENTS }
|
||||
)
|
||||
assertEquals(
|
||||
SharedSettingsDestination.GLOBAL_TTS_REPLACEMENTS,
|
||||
model.page(SharedSettingsDestination.TTS_AI)
|
||||
.items
|
||||
.single { it.action == SharedSettingsAction.TTS_REPLACEMENTS }
|
||||
.destination
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSettingsHubModel.visibleNestedActions(): List<SharedSettingsAction> {
|
||||
return rootCategories.flatMap { category ->
|
||||
page(category.destination).items.map { it.action }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedAppThemeReducerTest {
|
||||
|
||||
@Test
|
||||
fun `app appearance actions update shared settings`() {
|
||||
val seedColor = Color(0xFF006C4C)
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.AppThemeChanged(AppThemeMode.DARK))
|
||||
.reduce(AppAction.AppContrastChanged(AppContrastOption.HIGH))
|
||||
.reduce(AppAction.AppTextDimFactorLightChanged(0.75f))
|
||||
.reduce(AppAction.AppTextDimFactorDarkChanged(0.65f))
|
||||
.reduce(AppAction.AppSeedColorChanged(seedColor))
|
||||
.reduce(AppAction.AppFontPreferenceChanged(AppFontPreference.Monospace))
|
||||
|
||||
assertEquals(AppThemeMode.DARK, state.appThemeMode)
|
||||
assertEquals(AppContrastOption.HIGH, state.appContrastOption)
|
||||
assertEquals(0.75f, state.appTextDimFactorLight)
|
||||
assertEquals(0.65f, state.appTextDimFactorDark)
|
||||
assertEquals(seedColor, state.appSeedColor)
|
||||
assertEquals(AppFontPreference.Monospace, state.appFontPreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom app theme add replaces matching id and selects seed color`() {
|
||||
val first = CustomAppTheme(id = "theme", name = "First", seedColor = Color(0xFF123456))
|
||||
val second = CustomAppTheme(id = "theme", name = "Second", seedColor = Color(0xFF654321))
|
||||
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.CustomAppThemeAdded(first))
|
||||
.reduce(AppAction.CustomAppThemeAdded(second))
|
||||
|
||||
assertEquals(listOf(second), state.customAppThemes)
|
||||
assertEquals(second.seedColor, state.appSeedColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting selected custom app theme clears orphaned seed color`() {
|
||||
val theme = CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C))
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.CustomAppThemeAdded(theme))
|
||||
.reduce(AppAction.CustomAppThemeDeleted(theme.id))
|
||||
|
||||
assertTrue(state.customAppThemes.isEmpty())
|
||||
assertNull(state.appSeedColor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text dim factors stay inside supported slider range`() {
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.AppTextDimFactorLightChanged(0.1f))
|
||||
.reduce(AppAction.AppTextDimFactorDarkChanged(1.2f))
|
||||
|
||||
assertEquals(0.3f, state.appTextDimFactorLight)
|
||||
assertEquals(1.0f, state.appTextDimFactorDark)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom app font preference without an id falls back to system`() {
|
||||
val state = SharedReaderScreenState()
|
||||
.reduce(AppAction.AppFontPreferenceChanged(AppFontPreference(AppFontPreferenceKind.CUSTOM)))
|
||||
|
||||
assertEquals(AppFontPreference.System, state.appFontPreference)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedImportPlannerTest {
|
||||
|
||||
@Test
|
||||
fun `plan classifies importable duplicate and unsupported files`() {
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = listOf(
|
||||
ImportedBookFile(name = "existing.epub", uriString = null, localPath = "/books/existing.epub", size = 1L),
|
||||
ImportedBookFile(name = "new.md", uriString = null, localPath = "/books/new.md", size = 2L),
|
||||
ImportedBookFile(name = "archive.zip", uriString = null, localPath = "/books/archive.zip", size = 3L),
|
||||
ImportedBookFile(name = "new.md", uriString = null, localPath = "/books/new.md", size = 2L)
|
||||
),
|
||||
existingBookIds = setOf("/books/existing.epub"),
|
||||
platform = ReaderPlatform.DESKTOP,
|
||||
nowMillis = 100L
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedImportDecisionStatus.DUPLICATE,
|
||||
SharedImportDecisionStatus.IMPORTABLE,
|
||||
SharedImportDecisionStatus.UNSUPPORTED,
|
||||
SharedImportDecisionStatus.DUPLICATE
|
||||
),
|
||||
plan.decisions.map { it.status }
|
||||
)
|
||||
assertEquals(listOf("/books/new.md"), plan.importedBooks.map { it.id })
|
||||
assertEquals(listOf("existing.epub", "new.md", "new.md"), plan.supportedFiles.map { it.name })
|
||||
assertEquals(FileType.MD, plan.importedBooks.single().type)
|
||||
assertEquals(101L, plan.importedBooks.single().timestamp)
|
||||
assertEquals(null, plan.importedBooks.single().sourceFolder)
|
||||
assertFalse(plan.importedBooks.single().isRecent)
|
||||
assertEquals(1, plan.importedCount)
|
||||
assertEquals(2, plan.duplicateCount)
|
||||
assertEquals(1, plan.unsupportedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan uses uri as stable id when local path is absent`() {
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = listOf(
|
||||
ImportedBookFile(name = "scan.pdf", uriString = "content://scan", localPath = null, size = 4L, sourceFolder = "content://folder")
|
||||
),
|
||||
existingBookIds = emptySet(),
|
||||
platform = ReaderPlatform.ANDROID,
|
||||
nowMillis = 5L
|
||||
)
|
||||
|
||||
val book = plan.importedBooks.single()
|
||||
assertEquals("content://scan", book.id)
|
||||
assertEquals("content://scan", book.path)
|
||||
assertEquals("content://folder", book.sourceFolder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan prefers prepared file id over storage path`() {
|
||||
val plan = SharedImportPlanner.plan(
|
||||
files = listOf(
|
||||
ImportedBookFile(
|
||||
name = "novel.epub",
|
||||
uriString = null,
|
||||
localPath = "/app/books/copied.epub",
|
||||
size = 4L,
|
||||
id = "content-sha"
|
||||
)
|
||||
),
|
||||
existingBookIds = emptySet(),
|
||||
platform = ReaderPlatform.DESKTOP,
|
||||
nowMillis = 5L
|
||||
)
|
||||
|
||||
val book = plan.importedBooks.single()
|
||||
assertEquals("content-sha", book.id)
|
||||
assertEquals("/app/books/copied.epub", book.path)
|
||||
assertEquals(null, book.sourceFolder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `feedback prefers imported duplicate unsupported then failed outcomes`() {
|
||||
val imported = SharedImportPlanner.feedbackForCounts(
|
||||
counts = SharedImportOutcomeCounts(addedCount = 2, duplicateCount = 1, unsupportedCount = 1),
|
||||
importedMessage = "imported",
|
||||
duplicateMessage = "duplicate",
|
||||
unsupportedMessage = "unsupported",
|
||||
failedMessage = "failed"
|
||||
)
|
||||
val duplicate = SharedImportPlanner.feedbackForCounts(
|
||||
counts = SharedImportOutcomeCounts(duplicateCount = 1),
|
||||
importedMessage = "imported",
|
||||
duplicateMessage = "duplicate",
|
||||
unsupportedMessage = "unsupported",
|
||||
failedMessage = "failed"
|
||||
)
|
||||
val unsupported = SharedImportPlanner.feedbackForCounts(
|
||||
counts = SharedImportOutcomeCounts(unsupportedCount = 1),
|
||||
importedMessage = "imported",
|
||||
duplicateMessage = "duplicate",
|
||||
unsupportedMessage = "unsupported",
|
||||
failedMessage = "failed"
|
||||
)
|
||||
|
||||
assertEquals("imported", imported.message)
|
||||
assertFalse(imported.isError)
|
||||
assertEquals("duplicate", duplicate.message)
|
||||
assertFalse(duplicate.isError)
|
||||
assertEquals("unsupported", unsupported.message)
|
||||
assertTrue(unsupported.isError)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedLegalLinksTest {
|
||||
@Test
|
||||
fun `standard and oss legal profiles use separate policy pages`() {
|
||||
val standard = sharedLegalLinksForProfile(SharedLegalProfile.STANDARD)
|
||||
val oss = sharedLegalLinksForProfile(SharedLegalProfile.OSS)
|
||||
|
||||
assertEquals("$EPISTEME_POLICY_BASE_URL/privacy-policy.html", standard.privacyPolicyUrl)
|
||||
assertEquals("$EPISTEME_POLICY_BASE_URL/terms-and-conditions.html", standard.termsUrl)
|
||||
assertEquals("$EPISTEME_POLICY_BASE_URL/oss-privacy-policy.html", oss.privacyPolicyUrl)
|
||||
assertEquals("$EPISTEME_POLICY_BASE_URL/oss-terms-of-service.html", oss.termsUrl)
|
||||
assertEquals(standard.licensesUrl, oss.licensesUrl)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedLibraryEditorTest {
|
||||
|
||||
@Test
|
||||
fun `clean helpers trim names and reject blank values`() {
|
||||
assertEquals("Favorites", SharedLibraryEditor.cleanShelfName(" Favorites "))
|
||||
assertEquals("Reference", SharedLibraryEditor.cleanTagName(" Reference "))
|
||||
assertNull(SharedLibraryEditor.cleanShelfName(" "))
|
||||
assertNull(SharedLibraryEditor.cleanTagName(""))
|
||||
assertTrue(SharedLibraryEditor.canMutateShelf("manual"))
|
||||
assertTrue(!SharedLibraryEditor.canMutateShelf("unshelved"))
|
||||
assertTrue(!SharedLibraryEditor.canMutateShelf(" "))
|
||||
assertEquals(setOf("a", "b"), SharedLibraryEditor.cleanBookIds(listOf(" a ", "", "b", "a")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create records trim input and reject blank ids`() {
|
||||
val shelf = SharedLibraryEditor.createShelfRecord(" Manual ", " shelf ")
|
||||
val tag = SharedLibraryEditor.createTag(" Sci-Fi ", " tag ", color = 7)
|
||||
|
||||
assertEquals(ShelfRecord(id = "shelf", name = "Manual"), shelf)
|
||||
assertEquals(Tag(id = "tag", name = "Sci-Fi", color = 7), tag)
|
||||
assertNull(SharedLibraryEditor.createShelfRecord("Manual", " "))
|
||||
assertNull(SharedLibraryEditor.createTag(" ", "tag"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeSelectedBooks removes books and shelf refs then clears selection`() {
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(book("keep"), book("remove")),
|
||||
selectedBookIds = setOf("remove")
|
||||
)
|
||||
val refs = listOf(
|
||||
BookShelfRef(bookId = "keep", shelfId = "manual", addedAt = 1L),
|
||||
BookShelfRef(bookId = "remove", shelfId = "manual", addedAt = 2L)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.removeSelectedBooks(state, shelfRecords = emptyList(), shelfRefs = refs)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(listOf("keep"), result.state.rawLibraryBooks.ids())
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertEquals(listOf("keep"), result.shelfRefs.map { it.bookId })
|
||||
assertEquals("1 book removed from library.", result.state.bannerMessage?.message)
|
||||
assertEquals("banner_books_removed_library", result.state.bannerMessage?.text?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addSelectedBooksToShelf adds only missing refs and clears selection`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("existing", "new"))
|
||||
val refs = listOf(BookShelfRef(bookId = "existing", shelfId = "manual", addedAt = 1L))
|
||||
|
||||
val result = SharedLibraryEditor.addSelectedBooksToShelf(
|
||||
state = state,
|
||||
shelfRecords = listOf(ShelfRecord("manual", "Manual")),
|
||||
shelfRefs = refs,
|
||||
shelfId = "manual",
|
||||
nowMillis = 5L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertEquals(
|
||||
listOf(
|
||||
BookShelfRef(bookId = "existing", shelfId = "manual", addedAt = 1L),
|
||||
BookShelfRef(bookId = "new", shelfId = "manual", addedAt = 5L)
|
||||
),
|
||||
result.shelfRefs
|
||||
)
|
||||
assertEquals("1 book added to shelf.", result.state.bannerMessage?.message)
|
||||
assertEquals("banner_books_added_to_shelf", result.state.bannerMessage?.text?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addBooksToShelves adds missing refs to multiple shelves and can keep selection`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("selected"))
|
||||
val refs = listOf(BookShelfRef(bookId = "one", shelfId = "manual_a", addedAt = 1L))
|
||||
|
||||
val result = SharedLibraryEditor.addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = listOf(ShelfRecord("manual_a", "A"), ShelfRecord("manual_b", "B")),
|
||||
shelfRefs = refs,
|
||||
bookIds = listOf(" one ", "two", "one"),
|
||||
shelfIds = listOf("manual_a", "manual_b", "unshelved", " "),
|
||||
clearSelection = false,
|
||||
nowMillis = 9L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(setOf("selected"), result.state.selectedBookIds)
|
||||
assertEquals(
|
||||
listOf(
|
||||
BookShelfRef(bookId = "one", shelfId = "manual_a", addedAt = 1L),
|
||||
BookShelfRef(bookId = "two", shelfId = "manual_a", addedAt = 9L),
|
||||
BookShelfRef(bookId = "one", shelfId = "manual_b", addedAt = 9L),
|
||||
BookShelfRef(bookId = "two", shelfId = "manual_b", addedAt = 9L)
|
||||
),
|
||||
result.shelfRefs
|
||||
)
|
||||
assertEquals("3 shelf entries added.", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addBooksToShelves clears selection when requested`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("one"))
|
||||
|
||||
val result = SharedLibraryEditor.addBooksToShelves(
|
||||
state = state,
|
||||
shelfRecords = listOf(ShelfRecord("manual", "Manual")),
|
||||
shelfRefs = emptyList(),
|
||||
bookIds = listOf("one"),
|
||||
shelfIds = listOf("manual"),
|
||||
clearSelection = true,
|
||||
nowMillis = 10L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertEquals(
|
||||
listOf(BookShelfRef(bookId = "one", shelfId = "manual", addedAt = 10L)),
|
||||
result.shelfRefs
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceShelfBooks only rewrites target shelf refs`() {
|
||||
val state = SharedReaderScreenState(
|
||||
shelves = listOf(Shelf("manual", "Manual", ShelfType.MANUAL, emptyList()))
|
||||
)
|
||||
val refs = listOf(
|
||||
BookShelfRef(bookId = "old", shelfId = "manual", addedAt = 1L),
|
||||
BookShelfRef(bookId = "keep", shelfId = "other", addedAt = 2L)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.replaceShelfBooks(
|
||||
state = state,
|
||||
shelfRecords = listOf(ShelfRecord("manual", "Manual")),
|
||||
shelfRefs = refs,
|
||||
shelfId = "manual",
|
||||
bookIds = listOf("new", "new", " "),
|
||||
nowMillis = 7L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(
|
||||
listOf(
|
||||
BookShelfRef(bookId = "keep", shelfId = "other", addedAt = 2L),
|
||||
BookShelfRef(bookId = "new", shelfId = "manual", addedAt = 7L)
|
||||
),
|
||||
result.shelfRefs
|
||||
)
|
||||
assertEquals("Updated \"Manual\" with 1 book.", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createShelfWithBooks creates shelf refs and clears selection`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("one", "two"))
|
||||
|
||||
val result = SharedLibraryEditor.createShelfWithBooks(
|
||||
state = state,
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
name = " Favorites ",
|
||||
bookIds = listOf("one", "two", "one"),
|
||||
nowMillis = 12L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
assertEquals(listOf(ShelfRecord("shelf_12", "Favorites")), result.shelfRecords)
|
||||
assertEquals(
|
||||
listOf(
|
||||
BookShelfRef(bookId = "one", shelfId = "shelf_12", addedAt = 12L),
|
||||
BookShelfRef(bookId = "two", shelfId = "shelf_12", addedAt = 12L)
|
||||
),
|
||||
result.shelfRefs
|
||||
)
|
||||
assertEquals("Created shelf \"Favorites\" with 2 books.", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSmartShelf stores trimmed shared rules and rejects blank definitions`() {
|
||||
val definition = SmartCollectionDefinition(
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, " dune "),
|
||||
SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, " ")
|
||||
)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.createSmartShelf(
|
||||
state = SharedReaderScreenState(),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
name = " Smart Picks ",
|
||||
definition = definition,
|
||||
nowMillis = 7L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
val shelf = result.shelfRecords.single()
|
||||
val decoded = SmartCollectionEngine.fromJson(shelf.smartRulesJson)
|
||||
assertEquals(ShelfRecord("smart_7", "Smart Picks", isSmart = true, smartRulesJson = shelf.smartRulesJson), shelf)
|
||||
assertEquals(listOf(SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune")), decoded?.rules)
|
||||
assertEquals("Created smart shelf \"Smart Picks\".", result.state.bannerMessage?.message)
|
||||
assertEquals("banner_smart_shelf_created", result.state.bannerMessage?.text?.name)
|
||||
assertNull(
|
||||
SharedLibraryEditor.createSmartShelf(
|
||||
state = SharedReaderScreenState(),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
name = "Blank",
|
||||
definition = SmartCollectionDefinition(rules = listOf(SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, " "))),
|
||||
nowMillis = 8L
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tagSelectedBooks reuses matching tags case insensitively`() {
|
||||
val favorite = Tag(id = "favorite", name = "Favorite")
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(book("one"), book("two", tags = listOf(favorite))),
|
||||
allTags = listOf(favorite),
|
||||
selectedBookIds = setOf("one", "two")
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.tagSelectedBooks(
|
||||
state = state,
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tagName = " favorite ",
|
||||
nowMillis = 10L
|
||||
)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(listOf(favorite), result.state.allTags)
|
||||
assertEquals(listOf(favorite), result.state.rawLibraryBooks.first { it.id == "one" }.tags)
|
||||
assertEquals(listOf(favorite), result.state.rawLibraryBooks.first { it.id == "two" }.tags)
|
||||
assertTrue(result.state.selectedBookIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateBookMetadata updates book timestamp and merges tags`() {
|
||||
val old = book("book", title = "Old")
|
||||
val newTag = Tag("new", "New")
|
||||
|
||||
val result = SharedLibraryEditor.updateBookMetadata(
|
||||
state = SharedReaderScreenState(rawLibraryBooks = listOf(old)),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
updated = old.copy(title = "New", tags = listOf(newTag)),
|
||||
nowMillis = 99L
|
||||
)
|
||||
|
||||
val updatedBook = result.state.rawLibraryBooks.single()
|
||||
assertEquals("New", updatedBook.title)
|
||||
assertEquals(99L, updatedBook.timestamp)
|
||||
assertEquals(listOf(newTag), result.state.allTags)
|
||||
assertEquals("Updated \"New\".", result.state.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeFolder removes folder books tabs pins refs and synced folder metadata`() {
|
||||
val folderBook = book("folder_book").copy(sourceFolder = "C:/Books")
|
||||
val otherBook = book("other")
|
||||
val folder = Shelf(
|
||||
id = "folder_C:/Books",
|
||||
name = "Books",
|
||||
type = ShelfType.FOLDER,
|
||||
books = listOf(folderBook)
|
||||
)
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(folderBook, otherBook),
|
||||
selectedBookIds = setOf("folder_book", "other"),
|
||||
pinnedHomeBookIds = setOf("folder_book"),
|
||||
pinnedLibraryBookIds = setOf("folder_book", "other"),
|
||||
openTabIds = listOf("folder_book", "other"),
|
||||
activeTabBookId = "folder_book",
|
||||
syncedFolders = listOf(SyncedFolder("C:/Books", "Books", lastScanTime = 1L)),
|
||||
libraryFilters = LibraryFilters(sourceFolders = setOf("C:/Books"))
|
||||
)
|
||||
val refs = listOf(
|
||||
BookShelfRef(bookId = "folder_book", shelfId = "manual", addedAt = 1L),
|
||||
BookShelfRef(bookId = "other", shelfId = "manual", addedAt = 2L)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.removeFolder(state, emptyList(), refs, folder)
|
||||
|
||||
requireNotNull(result)
|
||||
assertEquals(listOf("other"), result.state.rawLibraryBooks.ids())
|
||||
assertEquals(setOf("other"), result.state.selectedBookIds)
|
||||
assertTrue(result.state.pinnedHomeBookIds.isEmpty())
|
||||
assertEquals(setOf("other"), result.state.pinnedLibraryBookIds)
|
||||
assertEquals(listOf("other"), result.state.openTabIds)
|
||||
assertNull(result.state.activeTabBookId)
|
||||
assertTrue(result.state.syncedFolders.isEmpty())
|
||||
assertTrue(result.state.libraryFilters.sourceFolders.isEmpty())
|
||||
assertEquals(listOf("other"), result.shelfRefs.map { it.bookId })
|
||||
assertEquals("Removed folder \"Books\" and 1 book from the app.", result.state.bannerMessage?.message)
|
||||
assertEquals("banner_folder_removed_with_book_count", result.state.bannerMessage?.text?.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markBookOpened marks book recent and updates timestamp`() {
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(
|
||||
book("opened").copy(isRecent = false, timestamp = 1L),
|
||||
book("other").copy(isRecent = false, timestamp = 2L)
|
||||
)
|
||||
)
|
||||
|
||||
val result = SharedLibraryEditor.markBookOpened(state, "opened", nowMillis = 99L)
|
||||
|
||||
assertTrue(result.rawLibraryBooks.first { it.id == "opened" }.isRecent)
|
||||
assertEquals(99L, result.rawLibraryBooks.first { it.id == "opened" }.timestamp)
|
||||
assertTrue(!result.rawLibraryBooks.first { it.id == "other" }.isRecent)
|
||||
assertEquals(2L, result.rawLibraryBooks.first { it.id == "other" }.timestamp)
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
title: String? = id,
|
||||
tags: List<Tag> = emptyList()
|
||||
) = BookItem(
|
||||
id = id,
|
||||
path = "/library/$id.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "$id.epub",
|
||||
timestamp = 1L,
|
||||
title = title,
|
||||
tags = tags
|
||||
)
|
||||
|
||||
private fun List<BookItem>.ids() = map { it.id }
|
||||
}
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedLibraryProjectorTest {
|
||||
|
||||
@Test
|
||||
fun `LibraryProjector searches filters sorts and builds selected library model`() {
|
||||
val tag = Tag("favorite", "Favorite")
|
||||
val matching = book(
|
||||
id = "matching",
|
||||
title = "Clean Android",
|
||||
author = "Ada",
|
||||
type = FileType.PDF,
|
||||
progressPercentage = 50f,
|
||||
sourceFolder = "/books",
|
||||
tags = listOf(tag),
|
||||
timestamp = 3L
|
||||
)
|
||||
val wrongTag = book("wrong_tag", title = "Clean Kotlin", type = FileType.PDF, progressPercentage = 50f)
|
||||
val wrongStatus = book("wrong_status", title = "Clean Done", type = FileType.PDF, progressPercentage = 100f, tags = listOf(tag))
|
||||
|
||||
val model = LibraryProjector().library(
|
||||
LibraryState(
|
||||
books = listOf(wrongTag, matching, wrongStatus),
|
||||
searchQuery = "clean",
|
||||
sortOrder = SortOrder.TITLE_ASC,
|
||||
filters = LibraryFilters(
|
||||
fileTypes = setOf(FileType.PDF),
|
||||
sourceFolders = setOf("/books"),
|
||||
readStatus = ReadStatusFilter.IN_PROGRESS,
|
||||
tagIds = setOf(tag.id)
|
||||
),
|
||||
selectedBookIds = setOf("matching", "missing")
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("matching"), model.books.ids())
|
||||
assertEquals(listOf("matching"), model.selectedBooks.ids())
|
||||
assertEquals(SortOrder.TITLE_ASC, model.sortOrder)
|
||||
assertEquals("clean", model.searchQuery)
|
||||
assertTrue(model.filters.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LibraryProjector home limits sorted recent books and keeps selected books`() {
|
||||
val model = LibraryProjector().home(
|
||||
LibraryState(
|
||||
books = listOf(
|
||||
book("old", timestamp = 1L),
|
||||
book("new", timestamp = 3L),
|
||||
book("archived", timestamp = 2L, isRecent = false)
|
||||
),
|
||||
selectedBookIds = setOf("old", "archived"),
|
||||
recentLimit = 1,
|
||||
sortOrder = SortOrder.RECENT
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("new"), model.recentBooks.ids())
|
||||
assertEquals(listOf("old", "archived"), model.selectedBooks.ids())
|
||||
assertFalse(model.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LibraryProjector imports only new files and maps extensions and folders`() {
|
||||
val projector = LibraryProjector()
|
||||
val state = LibraryState(books = listOf(book("C:/books/existing.pdf", displayName = "existing.pdf", isRecent = false)))
|
||||
|
||||
val result = projector.withImportedFiles(
|
||||
state,
|
||||
listOf(
|
||||
ImportedFile(name = "existing.pdf", path = "C:/books/existing.pdf", size = 1L),
|
||||
ImportedFile(name = "notes.md", path = "C:/books/notes.md", size = 2L, sourceFolder = "C:/books"),
|
||||
ImportedFile(name = "mystery.bin", path = null, size = 3L)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("C:/books/notes.md", "C:/books/existing.pdf"), result.books.ids())
|
||||
assertEquals(FileType.MD, result.books[0].type)
|
||||
assertEquals("C:/books", result.books[0].sourceFolder)
|
||||
assertFalse(result.books[0].isRecent)
|
||||
assertTrue(projector.home(result).recentBooks.isEmpty())
|
||||
assertEquals("Imported 1 file. Reader support comes later.", result.message)
|
||||
assertEquals("desktop_imported_file_count_reader_support_later", result.messageText?.name)
|
||||
|
||||
val unsupportedOnly = projector.withImportedFiles(
|
||||
state,
|
||||
listOf(ImportedFile(name = "mystery.bin", path = null, size = 3L))
|
||||
)
|
||||
assertEquals(state.books.ids(), unsupportedOnly.books.ids())
|
||||
assertEquals("No supported files were imported.", unsupportedOnly.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector prunes stale selections tabs and shelf state`() {
|
||||
val existing = book("existing")
|
||||
val result = SharedLibraryStateProjector().project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(
|
||||
selectedBookIds = setOf("existing", "missing"),
|
||||
openTabIds = listOf("missing", "existing"),
|
||||
activeTabBookId = "missing",
|
||||
viewingShelfId = "missing_shelf",
|
||||
isAddingBooksToShelf = true,
|
||||
selectedShelfIds = setOf("missing_shelf")
|
||||
),
|
||||
booksFromStore = listOf(existing),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(setOf("existing"), result.selectedBookIds)
|
||||
assertEquals(listOf("existing"), result.openTabs.ids())
|
||||
assertEquals(listOf("existing"), result.openTabIds)
|
||||
assertNull(result.activeTabBookId)
|
||||
assertNull(result.viewingShelfId)
|
||||
assertFalse(result.isAddingBooksToShelf)
|
||||
assertTrue(result.selectedShelfIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector keeps pinned home and library books first`() {
|
||||
val older = book("older", title = "Zulu", timestamp = 1L)
|
||||
val newer = book("newer", title = "Alpha", timestamp = 2L)
|
||||
|
||||
val result = SharedLibraryStateProjector().project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(older, newer),
|
||||
pinnedHomeBookIds = setOf("older"),
|
||||
pinnedLibraryBookIds = setOf("older"),
|
||||
sortOrder = SortOrder.TITLE_ASC
|
||||
),
|
||||
booksFromStore = listOf(older, newer),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("older", "newer"), result.recentBooks.ids())
|
||||
assertEquals(listOf("older", "newer"), result.libraryBooks.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared app actions manage tabs and pins`() {
|
||||
val opened = SharedReaderScreenState()
|
||||
.reduce(AppAction.BookTabOpened("one"))
|
||||
.reduce(AppAction.BookTabOpened("two"))
|
||||
.reduce(AppAction.HomePinToggled("one"))
|
||||
.reduce(AppAction.LibraryPinToggled("two"))
|
||||
|
||||
assertTrue(opened.isTabsEnabled)
|
||||
assertEquals(listOf("one", "two"), opened.openTabIds)
|
||||
assertEquals("two", opened.activeTabBookId)
|
||||
assertEquals(setOf("one"), opened.pinnedHomeBookIds)
|
||||
assertEquals(setOf("two"), opened.pinnedLibraryBookIds)
|
||||
|
||||
val reactivated = opened.reduce(AppAction.BookTabOpened("one"))
|
||||
|
||||
assertEquals(listOf("one", "two"), reactivated.openTabIds)
|
||||
assertEquals("one", reactivated.activeTabBookId)
|
||||
|
||||
val closedActive = opened.reduce(AppAction.BookTabClosed("two"))
|
||||
|
||||
assertEquals(listOf("one"), closedActive.openTabIds)
|
||||
assertEquals("one", closedActive.activeTabBookId)
|
||||
assertTrue(closedActive.reduce(AppAction.TabsEnabledChanged(false)).openTabIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector builds manual tag series folder and unshelved shelves`() {
|
||||
val tag = Tag("favorite", "Favorite")
|
||||
val manual = book("manual")
|
||||
val tagged = book("tagged", tags = listOf(tag))
|
||||
val seriesOne = book("series_1", seriesName = "Saga", seriesIndex = 1.0)
|
||||
val seriesTwo = book("series_2", seriesName = "Saga", seriesIndex = 2.0)
|
||||
val folderBook = book("folder", sourceFolder = "content://library")
|
||||
val loose = book("loose")
|
||||
|
||||
val result = SharedLibraryStateProjector(
|
||||
SharedFolderPathResolver { item ->
|
||||
if (item.id == "folder") listOf("Nested") else emptyList()
|
||||
}
|
||||
).project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(
|
||||
syncedFolders = listOf(SyncedFolder("content://library", "Library", lastScanTime = 1L)),
|
||||
sortOrder = SortOrder.TITLE_ASC
|
||||
),
|
||||
booksFromStore = listOf(tagged, seriesTwo, loose, folderBook, manual, seriesOne),
|
||||
shelfRecords = listOf(ShelfRecord("manual_shelf", "Manual")),
|
||||
shelfRefs = listOf(BookShelfRef(bookId = "manual", shelfId = "manual_shelf", addedAt = 1L)),
|
||||
tags = listOf(tag)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("manual"), result.shelves.first { it.id == "manual_shelf" }.books.ids())
|
||||
assertEquals(listOf("tagged"), result.shelves.first { it.id == "tag_favorite" }.books.ids())
|
||||
assertEquals(listOf("series_1", "series_2"), result.shelves.first { it.id == "series_Saga" }.books.ids())
|
||||
assertEquals(listOf("folder"), result.shelves.first { it.id == "folder_content://library" }.books.ids())
|
||||
assertEquals(listOf("folder"), result.shelves.first { it.id == "folder_content://library::Nested" }.directBooks.ids())
|
||||
assertEquals(listOf("loose", "tagged"), result.shelves.first { it.id == "unshelved" }.books.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector auto creates synced folder fallback shelves from source folders`() {
|
||||
val folderBook = book(
|
||||
id = "folder_book",
|
||||
sourceFolder = "C:/Library",
|
||||
path = "C:/Library/Nested/Book.epub"
|
||||
)
|
||||
|
||||
val result = SharedLibraryStateProjector(
|
||||
SharedFolderPathResolver { item ->
|
||||
if (item.id == "folder_book") listOf("Nested") else emptyList()
|
||||
}
|
||||
).project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(),
|
||||
booksFromStore = listOf(folderBook),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf("C:/Library"), result.syncedFolders.map { it.uriString })
|
||||
assertEquals("Library", result.syncedFolders.single().name)
|
||||
assertEquals(listOf("folder_book"), result.shelves.first { it.id == "folder_C:/Library" }.books.ids())
|
||||
assertEquals(listOf("folder_book"), result.shelves.first { it.id == "folder_C:/Library::Nested" }.directBooks.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedLibraryStateProjector builds smart shelves from shared rules`() {
|
||||
val smartRules = SmartCollectionEngine.toJson(
|
||||
SmartCollectionDefinition(
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "PDF"),
|
||||
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "75")
|
||||
)
|
||||
)
|
||||
)
|
||||
val matching = book("matching", type = FileType.PDF, progressPercentage = 90f)
|
||||
val wrongType = book("wrong_type", type = FileType.EPUB, progressPercentage = 90f)
|
||||
val wrongProgress = book("wrong_progress", type = FileType.PDF, progressPercentage = 20f)
|
||||
|
||||
val result = SharedLibraryStateProjector().project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = SharedReaderScreenState(sortOrder = SortOrder.TITLE_ASC),
|
||||
booksFromStore = listOf(wrongType, wrongProgress, matching),
|
||||
shelfRecords = listOf(ShelfRecord("smart", "Almost Done PDFs", isSmart = true, smartRulesJson = smartRules)),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
|
||||
val smartShelf = result.shelves.first { it.id == "smart" }
|
||||
assertEquals(ShelfType.SMART, smartShelf.type)
|
||||
assertEquals(listOf("matching"), smartShelf.books.ids())
|
||||
assertEquals(listOf("wrong_progress", "wrong_type"), result.shelves.first { it.id == "unshelved" }.books.ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SharedReaderScreenState withImportedFiles dedupes imports and reports duplicates`() {
|
||||
val state = SharedReaderScreenState(rawLibraryBooks = listOf(book("/books/existing.epub", isRecent = false)))
|
||||
|
||||
val imported = state.withImportedFiles(
|
||||
listOf(
|
||||
ImportedBookFile(name = "existing.epub", uriString = null, localPath = "/books/existing.epub", size = 1L),
|
||||
ImportedBookFile(name = "new.pdf", uriString = "content://new", localPath = null, size = 2L, sourceFolder = "content://folder")
|
||||
),
|
||||
now = 10L
|
||||
)
|
||||
val duplicateOnly = imported.withImportedFiles(
|
||||
listOf(ImportedBookFile(name = "new.pdf", uriString = "content://new", localPath = null, size = 2L)),
|
||||
now = 20L
|
||||
)
|
||||
val unsupportedOnly = imported.withImportedFiles(
|
||||
listOf(ImportedBookFile(name = "archive.zip", uriString = null, localPath = "/books/archive.zip", size = 2L)),
|
||||
now = 30L
|
||||
)
|
||||
|
||||
assertEquals(listOf("content://new", "/books/existing.epub"), imported.rawLibraryBooks.ids())
|
||||
assertEquals(FileType.PDF, imported.rawLibraryBooks.first().type)
|
||||
assertEquals("content://folder", imported.rawLibraryBooks.first().sourceFolder)
|
||||
assertEquals(11L, imported.rawLibraryBooks.first().timestamp)
|
||||
assertFalse(imported.rawLibraryBooks.first().isRecent)
|
||||
val projected = SharedLibraryStateProjector().project(
|
||||
SharedLibraryProjectionInput(
|
||||
state = imported,
|
||||
booksFromStore = imported.rawLibraryBooks,
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList(),
|
||||
tags = emptyList()
|
||||
)
|
||||
)
|
||||
assertTrue(projected.recentBooks.isEmpty())
|
||||
assertEquals("Imported 1 file.", imported.bannerMessage?.message)
|
||||
assertEquals("desktop_imported_file_count", imported.bannerMessage?.text?.name)
|
||||
assertEquals("Those files are already in the library.", duplicateOnly.bannerMessage?.message)
|
||||
assertEquals(imported.rawLibraryBooks.ids(), unsupportedOnly.rawLibraryBooks.ids())
|
||||
assertEquals("No supported files were imported.", unsupportedOnly.bannerMessage?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared filters treat in app storage separately from opds streams`() {
|
||||
val localBook = book("local", sourceFolder = null, path = "file:///local/book.epub")
|
||||
val streamedBook = book("streamed", sourceFolder = null, path = "opds-pse://book")
|
||||
val syncedBook = book("synced", sourceFolder = "content://sync", path = "content://synced")
|
||||
|
||||
assertEquals(
|
||||
listOf("local"),
|
||||
applyLibraryFilters(
|
||||
listOf(localBook, streamedBook, syncedBook),
|
||||
LibraryFilters(sourceFolders = setOf(IN_APP_STORAGE_SOURCE))
|
||||
).ids()
|
||||
)
|
||||
assertEquals(
|
||||
listOf("synced"),
|
||||
applyLibraryFilters(
|
||||
listOf(localBook, streamedBook, syncedBook),
|
||||
LibraryFilters(sourceFolders = setOf("content://sync"))
|
||||
).ids()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared sort keeps books without authors last`() {
|
||||
val unknown = book("unknown", title = null, author = null, displayName = "Zulu.epub")
|
||||
val known = book("known", title = null, author = "Ada", displayName = "Beta.epub")
|
||||
val title = book("title", title = "Omega", author = "Grace", displayName = "Alpha.epub")
|
||||
|
||||
assertEquals(listOf("known", "title", "unknown"), sortBooks(listOf(unknown, known, title), SortOrder.AUTHOR_ASC).ids())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared screen models expose home and library derived state`() {
|
||||
val folderBook = book("folder", sourceFolder = "/books")
|
||||
val recent = book("recent")
|
||||
val state = SharedReaderScreenState(
|
||||
recentBooks = listOf(recent),
|
||||
openTabs = listOf(folderBook),
|
||||
rawLibraryBooks = listOf(folderBook, recent),
|
||||
selectedBookIds = setOf("folder"),
|
||||
selectedShelfIds = setOf("manual"),
|
||||
isTabsEnabled = true,
|
||||
deviceLimitState = DeviceLimitReachedState(isLimitReached = true),
|
||||
searchQuery = "folder",
|
||||
isSearchActive = true
|
||||
)
|
||||
|
||||
val home = state.toHomeScreenModel()
|
||||
val library = state.toLibraryScreenModel()
|
||||
|
||||
assertEquals(listOf("recent"), home.recentBooks.ids())
|
||||
assertEquals(listOf("folder"), home.openTabs.ids())
|
||||
assertEquals(listOf("folder"), home.selectedBooks.ids())
|
||||
assertTrue(home.isContextualModeActive)
|
||||
assertFalse(home.isEmpty)
|
||||
assertFalse(home.isLibraryEmpty)
|
||||
assertTrue(home.deviceLimitState.isLimitReached)
|
||||
|
||||
assertEquals(listOf("folder"), library.selectedBooks.ids())
|
||||
assertEquals(setOf("manual"), library.selectedShelves)
|
||||
assertTrue(library.containsFolderItemsInSelection)
|
||||
assertTrue(library.isSearchActive)
|
||||
assertEquals("folder", library.searchQuery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toFileType maps known document and archive extensions case insensitively`() {
|
||||
assertEquals(FileType.PDF, "REPORT.PDF".toFileType())
|
||||
assertEquals(FileType.HTML, "page.htm".toFileType())
|
||||
assertEquals(FileType.CBZ, "comic.cbz".toFileType())
|
||||
assertEquals(FileType.CBT, "comic.cbt".toFileType())
|
||||
assertEquals(FileType.UNKNOWN, "archive.zip".toFileType())
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
displayName: String = "$id.epub",
|
||||
type: FileType = FileType.EPUB,
|
||||
title: String? = id,
|
||||
author: String? = null,
|
||||
timestamp: Long = 1L,
|
||||
progressPercentage: Float? = null,
|
||||
isRecent: Boolean = true,
|
||||
fileSize: Long = 0L,
|
||||
sourceFolder: String? = null,
|
||||
path: String? = "/library/$displayName",
|
||||
seriesName: String? = null,
|
||||
seriesIndex: Double? = null,
|
||||
tags: List<Tag> = emptyList()
|
||||
) = BookItem(
|
||||
id = id,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = timestamp,
|
||||
title = title,
|
||||
author = author,
|
||||
progressPercentage = progressPercentage,
|
||||
isRecent = isRecent,
|
||||
fileSize = fileSize,
|
||||
sourceFolder = sourceFolder,
|
||||
seriesName = seriesName,
|
||||
seriesIndex = seriesIndex,
|
||||
tags = tags
|
||||
)
|
||||
|
||||
private fun List<BookItem>.ids() = map { it.id }
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderViewport
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderReadingMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedLibrarySnapshotJsonTest {
|
||||
|
||||
@Test
|
||||
fun `snapshot json round trips library records used by desktop persistence`() {
|
||||
val tag = Tag(id = "favorite", name = "Favorite", color = 7)
|
||||
val snapshot = SharedLibrarySnapshot(
|
||||
books = listOf(
|
||||
BookItem(
|
||||
id = "book",
|
||||
path = "C:/Books/book.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "book.epub",
|
||||
timestamp = 10L,
|
||||
coverImagePath = "C:/Covers/book.png",
|
||||
title = "Book",
|
||||
author = "Ada",
|
||||
description = "<p>A compact shared summary.</p>",
|
||||
originalTitle = "Original Book",
|
||||
originalAuthor = "Original Ada",
|
||||
originalSeriesName = "Original Series",
|
||||
originalSeriesIndex = 1.0,
|
||||
originalDescription = "Original summary",
|
||||
progressPercentage = 42f,
|
||||
fileSize = 99L,
|
||||
fileContentModifiedTimestamp = 123_456L,
|
||||
sourceFolder = "C:/Books",
|
||||
folderTextMetadataParsed = true,
|
||||
seriesName = "Series",
|
||||
seriesIndex = 2.0,
|
||||
tags = listOf(tag),
|
||||
lastPageIndex = 4,
|
||||
readerPosition = ReaderLocator(
|
||||
chapterIndex = 1,
|
||||
pageIndex = 4,
|
||||
startOffset = 220,
|
||||
endOffset = 220,
|
||||
textQuote = "Precise place",
|
||||
cfi = "desktop:1:220:220"
|
||||
),
|
||||
readerSettings = ReaderSettings(
|
||||
fontSize = 22,
|
||||
lineSpacing = 1.7f,
|
||||
margin = 64,
|
||||
darkMode = true,
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
textAlign = SharedReaderTextAlign.JUSTIFY,
|
||||
pageWidth = 840,
|
||||
fontFamily = "Serif",
|
||||
paragraphSpacing = 1.4f,
|
||||
imageScale = 1.2f,
|
||||
horizontalMargin = 40,
|
||||
verticalMargin = 72,
|
||||
themeId = "sepia",
|
||||
textureId = "paper",
|
||||
textureAlpha = 0.35f,
|
||||
customFontPath = "C:/Fonts/custom.ttf",
|
||||
backgroundColorArgb = -328967L,
|
||||
textColorArgb = -12345678L,
|
||||
systemUiMode = SystemUiMode.HIDDEN,
|
||||
pageInfoMode = PageInfoMode.SYNC,
|
||||
pageInfoPosition = PageInfoPosition.TOP,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
rightToLeftPagination = true,
|
||||
pdfVerticalPageGapVisible = false,
|
||||
pdfPageNumberOverlayVisible = false,
|
||||
pdfFirstPageStandaloneInSpread = true,
|
||||
seamlessChapterNavigation = false,
|
||||
chapterTurnDragMultiplier = 1.6f
|
||||
),
|
||||
readerBookmarks = listOf(
|
||||
ReaderBookmark(
|
||||
id = "book_4",
|
||||
pageIndex = 4,
|
||||
chapterTitle = "Chapter",
|
||||
preview = "A useful paragraph",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 4,
|
||||
startOffset = 100,
|
||||
endOffset = 180,
|
||||
textQuote = "A useful paragraph"
|
||||
)
|
||||
)
|
||||
),
|
||||
readerHighlights = listOf(
|
||||
UserHighlight(
|
||||
id = "highlight_1",
|
||||
cfi = "desktop:0:128:144",
|
||||
text = "useful paragraph",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
note = "Remember this",
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 4,
|
||||
startOffset = 128,
|
||||
endOffset = 144,
|
||||
textQuote = "useful paragraph",
|
||||
cfi = "desktop:0:128:144"
|
||||
)
|
||||
)
|
||||
),
|
||||
pdfReaderViewport = SharedPdfReaderViewport(
|
||||
pageIndex = 4,
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
zoom = 1.8f,
|
||||
horizontalScrollOffset = 90,
|
||||
paginatedVerticalScrollOffset = 140,
|
||||
verticalFirstPageIndex = 3,
|
||||
verticalFirstPageScrollOffset = 44
|
||||
),
|
||||
readingPositionModifiedTimestamp = 9_000L
|
||||
)
|
||||
),
|
||||
shelfRecords = listOf(ShelfRecord(id = "shelf", name = "Shelf", isSmart = true, smartRulesJson = "{}")),
|
||||
shelfRefs = listOf(BookShelfRef(bookId = "book", shelfId = "shelf", addedAt = 11L)),
|
||||
tags = listOf(tag),
|
||||
customFonts = listOf(
|
||||
CustomFontItem(
|
||||
id = "font",
|
||||
displayName = "Literata",
|
||||
fileName = "font.ttf",
|
||||
fileExtension = "ttf",
|
||||
path = "C:/Fonts/font.ttf",
|
||||
timestamp = 13L
|
||||
)
|
||||
),
|
||||
syncedFolders = listOf(SyncedFolder("C:/Books", "Books", lastScanTime = 12L, allowedFileTypes = setOf(FileType.EPUB, FileType.PDF))),
|
||||
recentFilesLimit = 20,
|
||||
isTabsEnabled = true,
|
||||
openTabIds = listOf("book"),
|
||||
activeTabBookId = "book",
|
||||
pinnedHomeBookIds = setOf("book"),
|
||||
pinnedLibraryBookIds = setOf("book"),
|
||||
useStrictFileFilter = true,
|
||||
appThemeMode = AppThemeMode.DARK,
|
||||
appContrastOption = AppContrastOption.HIGH,
|
||||
appTextDimFactorLight = 0.75f,
|
||||
appTextDimFactorDark = 0.65f,
|
||||
appSeedColor = Color(0xFF006C4C),
|
||||
appFontPreference = AppFontPreference.custom("font"),
|
||||
customAppThemes = listOf(
|
||||
CustomAppTheme(id = "forest", name = "Forest", seedColor = Color(0xFF006C4C))
|
||||
),
|
||||
customReaderThemes = listOf(
|
||||
ReaderTheme(
|
||||
id = "my_solid",
|
||||
name = "My Solid",
|
||||
backgroundColor = Color(0xFFF5F5F5),
|
||||
textColor = Color(0xFF111111),
|
||||
isDark = false,
|
||||
isCustom = true
|
||||
),
|
||||
ReaderTheme(
|
||||
id = "my_texture",
|
||||
name = "My Texture",
|
||||
backgroundColor = Color(0xFF222222),
|
||||
textColor = Color(0xFFEFEFEF),
|
||||
isDark = true,
|
||||
textureId = ReaderTexture.CANVAS.id,
|
||||
isCustom = true
|
||||
)
|
||||
),
|
||||
readerDefaultSettings = ReaderSettings(themeId = "sepia"),
|
||||
pdfReaderDefaultSettings = ReaderSettings(themeId = "reverse"),
|
||||
readerToolbarPreferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.SEARCH.id),
|
||||
toolOrder = listOf(ReaderTool.BOOKMARK, ReaderTool.THEME, ReaderTool.SEARCH),
|
||||
bottomToolIds = setOf(ReaderTool.BOOKMARK.id)
|
||||
).sanitized(),
|
||||
readerHighlightPalette = ReaderHighlightPalette(
|
||||
colors = listOf(HighlightColor.YELLOW, HighlightColor.CYAN, HighlightColor.CYAN, HighlightColor.WHITE)
|
||||
),
|
||||
readerTtsReplacementPreferences = ReaderTtsReplacementPreferences(
|
||||
globalRules = listOf(
|
||||
ReaderTtsReplacementRule(
|
||||
id = "dr",
|
||||
from = "Dr.",
|
||||
to = "Doctor",
|
||||
wholeWord = false
|
||||
)
|
||||
),
|
||||
bookRules = mapOf(
|
||||
"book" to listOf(
|
||||
ReaderTtsReplacementRule(
|
||||
id = "st",
|
||||
from = "St.",
|
||||
to = "Saint",
|
||||
wholeWord = false
|
||||
)
|
||||
)
|
||||
),
|
||||
bookSettings = mapOf(
|
||||
"book" to ReaderTtsReplacementBookSettings(disabledGlobalRuleIds = setOf("dr"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(SharedLibrarySnapshotJson.encode(snapshot))
|
||||
|
||||
assertEquals(snapshot, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snapshot json tolerates malformed or missing data`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty("""{"books":[{"id":"missingName"}]}""")
|
||||
|
||||
assertTrue(SharedLibrarySnapshotJson.decodeOrEmpty("not json").books.isEmpty())
|
||||
assertTrue(decoded.books.isEmpty())
|
||||
assertEquals(AppFontPreference.System, decoded.appFontPreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing tab setting defaults to enabled for new desktop snapshots`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty("""{"schemaVersion":14}""")
|
||||
|
||||
assertTrue(decoded.isTabsEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy untouched epub default settings migrate to vertical mode`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 16,
|
||||
"readerDefaultSettings": {
|
||||
"readingMode": "PAGINATED"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertEquals(ReaderReadingMode.VERTICAL, decoded.readerDefaultSettings.readingMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy reader settings default pdf visual options to current behavior`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
"""
|
||||
{
|
||||
"books": [
|
||||
{
|
||||
"id": "book",
|
||||
"path": "C:/Books/book.pdf",
|
||||
"type": "PDF",
|
||||
"displayName": "book.pdf",
|
||||
"timestamp": 10,
|
||||
"readerSettings": {
|
||||
"themeId": "no_theme"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
val settings = decoded.books.single().readerSettings ?: error("Expected settings")
|
||||
|
||||
assertTrue(settings.pdfVerticalPageGapVisible)
|
||||
assertTrue(settings.pdfPageNumberOverlayVisible)
|
||||
assertFalse(settings.rightToLeftPagination)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy snapshot hides imported only books from recent home`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
"""
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"books": [
|
||||
{
|
||||
"id": "imported",
|
||||
"path": "C:/Books/imported.epub",
|
||||
"type": "EPUB",
|
||||
"displayName": "imported.epub",
|
||||
"timestamp": 10,
|
||||
"isRecent": true
|
||||
},
|
||||
{
|
||||
"id": "opened",
|
||||
"path": "C:/Books/opened.epub",
|
||||
"type": "EPUB",
|
||||
"displayName": "opened.epub",
|
||||
"timestamp": 11,
|
||||
"isRecent": true
|
||||
}
|
||||
],
|
||||
"openTabIds": ["opened"]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
assertFalse(decoded.books.first { it.id == "imported" }.isRecent)
|
||||
assertTrue(decoded.books.first { it.id == "opened" }.isRecent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synced folder allowed types exclude unknown while preserving valid selections`() {
|
||||
val decoded = SharedLibrarySnapshotJson.decodeOrEmpty(
|
||||
"""
|
||||
{
|
||||
"syncedFolders": [
|
||||
{
|
||||
"uriString": "C:/Books",
|
||||
"name": "Books",
|
||||
"lastScanTime": 12,
|
||||
"allowedFileTypes": ["PDF", "UNKNOWN", "EPUB"],
|
||||
"localSyncEnabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
val folder = decoded.syncedFolders.single()
|
||||
|
||||
assertEquals(setOf(FileType.PDF, FileType.EPUB), folder.allowedFileTypes)
|
||||
assertFalse(folder.localSyncEnabled)
|
||||
assertFalse(FileType.UNKNOWN in folder.allowedFileTypes)
|
||||
|
||||
val encoded = SharedLibrarySnapshotJson.encode(
|
||||
SharedLibrarySnapshot(
|
||||
syncedFolders = listOf(
|
||||
SyncedFolder("C:/Books", "Books", lastScanTime = 12L, allowedFileTypes = setOf(FileType.PDF, FileType.UNKNOWN))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse("\"UNKNOWN\"" in encoded)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedReducersTest {
|
||||
|
||||
@Test
|
||||
fun `book selection can be replaced in one reducer action`() {
|
||||
val state = SharedReaderScreenState(selectedBookIds = setOf("old"))
|
||||
|
||||
val result = state.reduce(LibraryAction.BookSelectionReplaced(setOf("one", "two")))
|
||||
|
||||
assertEquals(setOf("one", "two"), result.selectedBookIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible selection helper selects visible books and clears when all are selected`() {
|
||||
val visibleBooks = listOf(
|
||||
BookItem("one", "/books/one.epub", FileType.EPUB, "one.epub", timestamp = 1L),
|
||||
BookItem("two", "/books/two.epub", FileType.EPUB, "two.epub", timestamp = 2L)
|
||||
)
|
||||
|
||||
val selected = SharedReaderScreenState()
|
||||
.replaceBookSelectionWithVisibleBooks(visibleBooks)
|
||||
|
||||
assertEquals(setOf("one", "two"), selected.selectedBookIds)
|
||||
assertEquals(
|
||||
emptySet(),
|
||||
selected.replaceBookSelectionWithVisibleBooks(visibleBooks).selectedBookIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package org.dueattendant149.bookreader.shared
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SmartCollectionEngineTest {
|
||||
|
||||
@Test
|
||||
fun `definition JSON round trips and ignores unknown fields`() {
|
||||
val definition = SmartCollectionDefinition(
|
||||
matchAll = false,
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"),
|
||||
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "50")
|
||||
)
|
||||
)
|
||||
|
||||
val encoded = SmartCollectionEngine.toJson(definition)
|
||||
val decoded = SmartCollectionEngine.fromJson(
|
||||
encoded.replaceFirst("{", """{"unknown":"kept-for-forward-compat",""")
|
||||
)
|
||||
|
||||
assertEquals(definition, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromJson returns null for blank malformed and incompatible payloads`() {
|
||||
assertNull(SmartCollectionEngine.fromJson(null))
|
||||
assertNull(SmartCollectionEngine.fromJson(" "))
|
||||
assertNull(SmartCollectionEngine.fromJson("{not json"))
|
||||
assertNull(SmartCollectionEngine.fromJson("""{"matchAll":true,"rules":[{"field":"NOPE"}]}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matchAll requires every rule while matchAny accepts a single matching rule`() {
|
||||
val book = book(
|
||||
title = "Dune Messiah",
|
||||
author = "Frank Herbert",
|
||||
progressPercentage = 41f,
|
||||
type = FileType.EPUB
|
||||
)
|
||||
|
||||
val titleAndHighProgress = SmartCollectionDefinition(
|
||||
matchAll = true,
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.TITLE, SmartOperator.CONTAINS, "dune"),
|
||||
SmartRule(SmartField.PROGRESS, SmartOperator.GREATER_THAN, "80")
|
||||
)
|
||||
)
|
||||
val titleOrHighProgress = titleAndHighProgress.copy(matchAll = false)
|
||||
|
||||
assertFalse(SmartCollectionEngine.evaluate(book, titleAndHighProgress))
|
||||
assertTrue(SmartCollectionEngine.evaluate(book, titleOrHighProgress))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `string folder file type and tag rules are case insensitive`() {
|
||||
val book = book(
|
||||
displayName = "fallback-name.pdf",
|
||||
title = null,
|
||||
author = "Ursula K. Le Guin",
|
||||
sourceFolder = "content://library/Sci-Fi",
|
||||
type = FileType.PDF,
|
||||
tags = listOf(
|
||||
Tag(id = "t1", name = "Classic Science Fiction"),
|
||||
Tag(id = "t2", name = "Queued")
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
SmartCollectionEngine.evaluate(
|
||||
book,
|
||||
SmartCollectionDefinition(
|
||||
rules = listOf(
|
||||
SmartRule(SmartField.TITLE, SmartOperator.EQUALS, "fallback-name.pdf"),
|
||||
SmartRule(SmartField.AUTHOR, SmartOperator.CONTAINS, "le guin"),
|
||||
SmartRule(SmartField.FOLDER, SmartOperator.CONTAINS, "SCI-FI"),
|
||||
SmartRule(SmartField.FILE_TYPE, SmartOperator.EQUALS, "pdf"),
|
||||
SmartRule(SmartField.TAG, SmartOperator.CONTAINS, "science")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numeric rules handle equals greater less missing progress and invalid values`() {
|
||||
val startedBook = book(progressPercentage = 33.5f)
|
||||
val missingProgressBook = book(progressPercentage = null)
|
||||
|
||||
assertTrue(matchesProgress(startedBook, SmartOperator.EQUALS, "33.5"))
|
||||
assertTrue(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "33"))
|
||||
assertTrue(matchesProgress(startedBook, SmartOperator.LESS_THAN, "34"))
|
||||
assertFalse(matchesProgress(startedBook, SmartOperator.GREATER_THAN, "not-a-number"))
|
||||
assertTrue(matchesProgress(missingProgressBook, SmartOperator.EQUALS, "0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty definitions never match`() {
|
||||
assertFalse(SmartCollectionEngine.evaluate(book(), SmartCollectionDefinition()))
|
||||
}
|
||||
|
||||
private fun matchesProgress(
|
||||
book: BookItem,
|
||||
operator: SmartOperator,
|
||||
value: String
|
||||
): Boolean {
|
||||
return SmartCollectionEngine.evaluate(
|
||||
book,
|
||||
SmartCollectionDefinition(
|
||||
rules = listOf(SmartRule(SmartField.PROGRESS, operator, value))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String = "book-id",
|
||||
displayName: String = "display.epub",
|
||||
title: String? = "Display",
|
||||
author: String? = null,
|
||||
progressPercentage: Float? = null,
|
||||
sourceFolder: String? = null,
|
||||
type: FileType = FileType.EPUB,
|
||||
tags: List<Tag> = emptyList()
|
||||
): BookItem {
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = "/library/$displayName",
|
||||
type = type,
|
||||
displayName = displayName,
|
||||
timestamp = 1L,
|
||||
title = title,
|
||||
author = author,
|
||||
progressPercentage = progressPercentage,
|
||||
sourceFolder = sourceFolder,
|
||||
tags = tags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package org.dueattendant149.bookreader.shared.opds
|
||||
|
||||
import org.dueattendant149.bookreader.shared.BookItem
|
||||
import org.dueattendant149.bookreader.shared.FileType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedOpdsCatalogsTest {
|
||||
@Test
|
||||
fun `catalog json seeds defaults and preserves edits`() {
|
||||
var nextId = 0
|
||||
fun id() = "id-${nextId++}"
|
||||
|
||||
val defaults = SharedOpdsCatalogs.decodeOrSeed(null, ::id)
|
||||
assertEquals(2, defaults.size)
|
||||
assertTrue(defaults.all { it.isDefault })
|
||||
|
||||
val added = SharedOpdsCatalogs.addCatalog(defaults, " Custom ", " https://example.org/opds ", " user ", " pass ", ::id)
|
||||
val updated = SharedOpdsCatalogs.updateCatalog(
|
||||
catalogs = added,
|
||||
id = "id-2",
|
||||
title = " Updated ",
|
||||
url = " https://example.org/new ",
|
||||
username = " ",
|
||||
password = " token "
|
||||
)
|
||||
val custom = updated.single { !it.isDefault }
|
||||
assertEquals("Updated", custom.title)
|
||||
assertEquals("https://example.org/new", custom.url)
|
||||
assertNull(custom.username)
|
||||
assertEquals("token", custom.password)
|
||||
|
||||
val encoded = SharedOpdsCatalogs.encode(updated)
|
||||
assertEquals(updated, SharedOpdsCatalogs.decode(encoded))
|
||||
assertEquals(updated, SharedOpdsCatalogs.removeCatalog(updated, defaults.first().id))
|
||||
assertTrue(SharedOpdsCatalogs.removeCatalog(updated, custom.id).all { it.isDefault })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `catalog json decodes null credentials as absent credentials`() {
|
||||
val catalogs = SharedOpdsCatalogs.decode(
|
||||
"""
|
||||
[
|
||||
{
|
||||
"id": "catalog",
|
||||
"title": "Catalog",
|
||||
"url": "https://example.org/opds",
|
||||
"username": null,
|
||||
"password": null
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val catalog = catalogs.single()
|
||||
assertNull(catalog.username)
|
||||
assertNull(catalog.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search templates expand opds uri template variants`() {
|
||||
assertEquals(
|
||||
"https://example.org/search?query=ada%20lovelace",
|
||||
SharedOpdsSearch.expandSearchTemplate("https://example.org/search{?query}", "ada lovelace")
|
||||
)
|
||||
assertEquals(
|
||||
"https://example.org/search?q=ada%20lovelace",
|
||||
SharedOpdsSearch.expandSearchTemplate("https://example.org/search?q={searchTerms}", "ada lovelace")
|
||||
)
|
||||
assertEquals(
|
||||
"https://example.org/search?q=ada%20lovelace&per-page=12&page=1",
|
||||
SharedOpdsSearch.expandSearchTemplate(
|
||||
"https://example.org/search?q={searchTerms}&per-page={count}&page={startPage}",
|
||||
"ada lovelace"
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
"https://example.org/search?existing=1&query=ada%20lovelace",
|
||||
SharedOpdsSearch.expandSearchTemplate("https://example.org/search?existing=1", "ada lovelace")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream uri round trips encoded template and catalog`() {
|
||||
val reference = OpdsStreamReference(
|
||||
id = "book 1",
|
||||
count = 12,
|
||||
urlTemplate = "https://example.org/page/{pageNumber}?w={maxWidth}",
|
||||
catalogId = "catalog 1"
|
||||
)
|
||||
|
||||
assertEquals(reference, SharedOpdsStreamUri.parse(SharedOpdsStreamUri.build(reference)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `download namer prefers content disposition and falls back to acquisition format`() {
|
||||
assertEquals(
|
||||
".azw3",
|
||||
SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = OpdsAcquisition("https://example.org/download", "application/octet-stream"),
|
||||
contentDisposition = "attachment; filename*=UTF-8''Book.azw3",
|
||||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
".pdf",
|
||||
SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = OpdsAcquisition("https://example.org/download", "application/pdf"),
|
||||
contentDisposition = null,
|
||||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
".pptx",
|
||||
SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = OpdsAcquisition(
|
||||
"https://example.org/download",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
),
|
||||
contentDisposition = null,
|
||||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
".cbt",
|
||||
SharedOpdsDownloadNamer.resolveExtension(
|
||||
acquisition = OpdsAcquisition("https://example.org/download", "application/vnd.comicbook+tar"),
|
||||
contentDisposition = null,
|
||||
urlPathSegment = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local book matcher recognizes opds download temp names and acquisition filenames`() {
|
||||
val entry = OpdsEntry(
|
||||
id = "entry",
|
||||
title = "A Catalog Book",
|
||||
summary = null,
|
||||
coverUrl = null,
|
||||
acquisitions = listOf(
|
||||
OpdsAcquisition("https://example.org/files/alternate-title.epub", "application/epub+zip")
|
||||
),
|
||||
navigationUrl = null
|
||||
)
|
||||
val books = listOf(
|
||||
BookItem(
|
||||
id = "book",
|
||||
path = "file:///library/opds_dl_A_Catalog_Book.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "opds_dl_A_Catalog_Book.epub",
|
||||
timestamp = 1L,
|
||||
title = "Embedded Title"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(books.single(), SharedOpdsLocalBookMatcher.findBook(entry, books))
|
||||
|
||||
val acquisitionNamedBook = books.single().copy(
|
||||
path = "file:///library/alternate-title.epub",
|
||||
displayName = "alternate-title.epub",
|
||||
title = "Different Embedded Title"
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
acquisitionNamedBook,
|
||||
SharedOpdsLocalBookMatcher.findBook(entry, listOf(acquisitionNamedBook))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package org.dueattendant149.bookreader.shared.opds
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedOpdsControllerTest {
|
||||
@Test
|
||||
fun `controller opens paginates and navigates shared feed state`() = runBlocking {
|
||||
val catalog = OpdsCatalog(id = "catalog", title = "Catalog", url = "root")
|
||||
val repository = FakeOpdsRepository(
|
||||
catalogs = listOf(catalog),
|
||||
feeds = mapOf(
|
||||
"root" to feed("Root", entry("one"), nextUrl = "next"),
|
||||
"next" to feed("Next", entry("two")),
|
||||
"child" to feed("Child", entry("child"))
|
||||
)
|
||||
)
|
||||
val controller = SharedOpdsController(
|
||||
repository = repository,
|
||||
idFactory = { "generated" }
|
||||
)
|
||||
val emissions = mutableListOf<SharedOpdsScreenState>()
|
||||
|
||||
controller.openCatalog(catalog, emissions::add)
|
||||
assertEquals("Root", controller.state.currentFeed?.title)
|
||||
assertEquals(listOf("one"), controller.state.currentFeed?.entries?.map { it.id })
|
||||
assertFalse(controller.hasFeedHistory())
|
||||
|
||||
controller.loadNextPage(emissions::add)
|
||||
assertEquals(listOf("one", "two"), controller.state.currentFeed?.entries?.map { it.id })
|
||||
|
||||
controller.openFeedUrl("child", emissions::add)
|
||||
assertEquals("Child", controller.state.currentFeed?.title)
|
||||
assertTrue(controller.hasFeedHistory())
|
||||
|
||||
assertTrue(controller.navigateBack(emissions::add))
|
||||
assertEquals("Root", controller.state.currentFeed?.title)
|
||||
assertFalse(controller.hasFeedHistory())
|
||||
|
||||
assertFalse(controller.navigateBack(emissions::add))
|
||||
assertFalse(controller.state.isViewingCatalog)
|
||||
assertNull(controller.state.currentFeed)
|
||||
assertEquals(listOf("root", "next", "child", "root"), repository.requestedUrls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `controller search fetches expanded query through repository`() = runBlocking {
|
||||
val catalog = OpdsCatalog(id = "catalog", title = "Catalog", url = "root")
|
||||
val repository = FakeOpdsRepository(
|
||||
catalogs = listOf(catalog),
|
||||
feeds = mapOf(
|
||||
"root" to feed("Root", entry("one"), searchUrl = "https://example.org/search{?query}"),
|
||||
"https://example.org/search?query=ada%20lovelace" to feed("Search", entry("result"))
|
||||
)
|
||||
)
|
||||
val controller = SharedOpdsController(
|
||||
repository = repository,
|
||||
idFactory = { "generated" }
|
||||
)
|
||||
val emissions = mutableListOf<SharedOpdsScreenState>()
|
||||
|
||||
controller.openCatalog(catalog, emissions::add)
|
||||
controller.search("ada lovelace", emissions::add)
|
||||
|
||||
assertEquals("Search", controller.state.currentFeed?.title)
|
||||
assertEquals(listOf("root", "https://example.org/search?query=ada%20lovelace"), repository.requestedUrls)
|
||||
}
|
||||
|
||||
private fun feed(
|
||||
title: String,
|
||||
vararg entries: OpdsEntry,
|
||||
nextUrl: String? = null,
|
||||
searchUrl: String? = null
|
||||
): OpdsFeed {
|
||||
return OpdsFeed(
|
||||
title = title,
|
||||
entries = entries.toList(),
|
||||
nextUrl = nextUrl,
|
||||
searchUrl = searchUrl
|
||||
)
|
||||
}
|
||||
|
||||
private fun entry(id: String): OpdsEntry {
|
||||
return OpdsEntry(
|
||||
id = id,
|
||||
title = id,
|
||||
summary = null,
|
||||
coverUrl = null,
|
||||
navigationUrl = null
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeOpdsRepository(
|
||||
catalogs: List<OpdsCatalog>,
|
||||
private val feeds: Map<String, OpdsFeed>
|
||||
) : SharedOpdsRepository {
|
||||
private var storedCatalogs = catalogs
|
||||
val requestedUrls = mutableListOf<String>()
|
||||
|
||||
override fun loadCatalogs(): List<OpdsCatalog> = storedCatalogs
|
||||
|
||||
override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
|
||||
storedCatalogs = catalogs
|
||||
}
|
||||
|
||||
override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> {
|
||||
requestedUrls += url
|
||||
return feeds[url]?.let { Result.success(it) }
|
||||
?: Result.failure(IllegalArgumentException("Missing feed: $url"))
|
||||
}
|
||||
|
||||
override suspend fun getSearchTemplate(
|
||||
openSearchUrl: String,
|
||||
username: String?,
|
||||
password: String?
|
||||
): String? = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,470 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.dueattendant149.bookreader.shared.PdfDisplayMode
|
||||
import org.dueattendant149.bookreader.shared.SearchHighlightMode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PdfReaderSessionTest {
|
||||
|
||||
@Test
|
||||
fun `initial state clamps page and reports progress`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 5, initialPageIndex = 99)
|
||||
|
||||
assertEquals(4, state.pageIndex)
|
||||
assertEquals(5, state.pageCount)
|
||||
assertEquals(100f, state.progressPercent)
|
||||
assertTrue(state.canGoPrevious)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial interaction mode is neutral`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
|
||||
assertEquals(PdfInkTool.NONE, state.selectedTool)
|
||||
assertEquals(false, state.isTextSelectionMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page navigation clamps to document bounds`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 3, initialPageIndex = 1)
|
||||
.reduce(SharedPdfReaderAction.NextPage)
|
||||
.reduce(SharedPdfReaderAction.NextPage)
|
||||
.reduce(SharedPdfReaderAction.PreviousPage)
|
||||
.reduce(SharedPdfReaderAction.GoToPage(-20))
|
||||
|
||||
assertEquals(0, state.pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first last and display mode actions are shared`() {
|
||||
val vertical = SharedPdfReaderState.initial(pageCount = 4, initialPageIndex = 1)
|
||||
.reduce(SharedPdfReaderAction.LastPage)
|
||||
.reduce(SharedPdfReaderAction.FirstPage)
|
||||
.reduce(SharedPdfReaderAction.DisplayModeToggled)
|
||||
val state = vertical.reduce(SharedPdfReaderAction.DisplayModeChanged(PdfDisplayMode.PAGINATION))
|
||||
|
||||
assertEquals(0, state.pageIndex)
|
||||
assertEquals(PdfDisplayMode.VERTICAL_SCROLL, vertical.displayMode)
|
||||
assertEquals(PdfDisplayMode.PAGINATION, state.displayMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zoom changes use provided zoom spec`() {
|
||||
val zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1f)
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1, zoomSpec = zoomSpec)
|
||||
.reduce(SharedPdfReaderAction.ZoomChanged(10f), zoomSpec)
|
||||
.reduce(SharedPdfReaderAction.ZoomBy(-10f), zoomSpec)
|
||||
|
||||
assertEquals(0.5f, state.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial zoom is clamped to provided zoom spec`() {
|
||||
val zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 10f)
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1, zoomSpec = zoomSpec)
|
||||
|
||||
assertEquals(4f, state.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader viewport clamps zoom pages and scroll offsets`() {
|
||||
val viewport = SharedPdfReaderViewport(
|
||||
pageIndex = 99,
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
zoom = Float.NaN,
|
||||
horizontalScrollOffset = -10,
|
||||
paginatedVerticalScrollOffset = -20,
|
||||
verticalFirstPageIndex = 40,
|
||||
verticalFirstPageScrollOffset = -30
|
||||
).sanitized(
|
||||
pageCount = 5,
|
||||
zoomSpec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1.25f)
|
||||
)
|
||||
|
||||
assertEquals(PdfDisplayMode.VERTICAL_SCROLL, viewport.displayMode)
|
||||
assertEquals(4, viewport.pageIndex)
|
||||
assertEquals(4, viewport.verticalFirstPageIndex)
|
||||
assertEquals(1.25f, viewport.zoom)
|
||||
assertEquals(0, viewport.horizontalScrollOffset)
|
||||
assertEquals(0, viewport.paginatedVerticalScrollOffset)
|
||||
assertEquals(0, viewport.verticalFirstPageScrollOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search query resets active result and result navigation wraps`() {
|
||||
val results = listOf(
|
||||
SharedPdfSearchResult(pageIndex = 1, preview = "first", matchIndex = 5),
|
||||
SharedPdfSearchResult(pageIndex = 3, preview = "second", matchIndex = 7)
|
||||
)
|
||||
|
||||
val changed = SharedPdfReaderState.initial(pageCount = 5)
|
||||
.reduce(SharedPdfReaderAction.GoToSearchResult(0, results))
|
||||
.reduce(SharedPdfReaderAction.SearchHighlightModeChanged(SearchHighlightMode.FOCUSED))
|
||||
.reduce(SharedPdfReaderAction.SearchChanged("needle"))
|
||||
val state = changed
|
||||
.reduce(SharedPdfReaderAction.GoToSearchResult(-1, results))
|
||||
|
||||
assertEquals("needle", changed.searchQuery)
|
||||
assertEquals(-1, changed.activeSearchResultIndex)
|
||||
assertEquals(SearchHighlightMode.FOCUSED, changed.searchHighlightMode)
|
||||
assertEquals(1, changed.pageIndex)
|
||||
assertEquals(1, state.activeSearchResultIndex)
|
||||
assertEquals(3, state.pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search chrome actions open toggle and close shared state`() {
|
||||
val opened = SharedPdfReaderState.initial(pageCount = 4)
|
||||
.reduce(SharedPdfReaderAction.SearchOpened)
|
||||
val typed = opened.reduce(SharedPdfReaderAction.SearchChanged("alpha"))
|
||||
val hidden = typed.reduce(SharedPdfReaderAction.SearchResultsPanelToggled)
|
||||
val closed = hidden.reduce(SharedPdfReaderAction.SearchClosed)
|
||||
|
||||
assertTrue(opened.isSearchActive)
|
||||
assertTrue(opened.showSearchResultsPanel)
|
||||
assertEquals("alpha", typed.searchQuery)
|
||||
assertTrue(typed.isSearchActive)
|
||||
assertTrue(typed.showSearchResultsPanel)
|
||||
assertEquals(-1, typed.activeSearchResultIndex)
|
||||
assertEquals(false, hidden.showSearchResultsPanel)
|
||||
assertEquals(false, closed.isSearchActive)
|
||||
assertTrue(closed.showSearchResultsPanel)
|
||||
assertEquals("", closed.searchQuery)
|
||||
assertEquals(-1, closed.activeSearchResultIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search highlight mode toggles between all and focused`() {
|
||||
val focused = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.SearchHighlightModeToggled)
|
||||
val all = focused.reduce(SharedPdfReaderAction.SearchHighlightModeToggled)
|
||||
val explicit = all.reduce(SharedPdfReaderAction.SearchHighlightModeChanged(SearchHighlightMode.FOCUSED))
|
||||
|
||||
assertEquals(SearchHighlightMode.FOCUSED, focused.searchHighlightMode)
|
||||
assertEquals(SearchHighlightMode.ALL, all.searchHighlightMode)
|
||||
assertEquals(SearchHighlightMode.FOCUSED, explicit.searchHighlightMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool selection applies shared defaults`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER))
|
||||
|
||||
val config = SharedPdfAnnotationDefaults.configFor(PdfInkTool.HIGHLIGHTER)
|
||||
assertEquals(PdfInkTool.HIGHLIGHTER, state.selectedTool)
|
||||
assertEquals(config.colorArgb, state.selectedColorArgb)
|
||||
assertEquals(config.strokeWidth, state.strokeWidth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool color and thickness changes persist per active tool`() {
|
||||
val penColor = 0xFF123456.toInt()
|
||||
val highlighterColor = 0x8CABCDEF.toInt()
|
||||
|
||||
val penConfigured = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN))
|
||||
.reduce(SharedPdfReaderAction.ColorSelected(penColor))
|
||||
.reduce(SharedPdfReaderAction.StrokeWidthChanged(0.012f))
|
||||
val highlighterConfigured = penConfigured
|
||||
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER))
|
||||
.reduce(SharedPdfReaderAction.ColorSelected(highlighterColor))
|
||||
val penAgain = highlighterConfigured.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN))
|
||||
val highlighterAgain = penAgain.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.HIGHLIGHTER))
|
||||
|
||||
assertEquals(penColor, penAgain.selectedColorArgb)
|
||||
assertEquals(0.012f, penAgain.strokeWidth)
|
||||
assertEquals(highlighterColor, highlighterAgain.selectedColorArgb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pen palette changes follow android fixed slot behavior`() {
|
||||
val customColor = 0xFF010203.toInt()
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.PenPaletteChanged(listOf(0, customColor, 0xFF040506.toInt())))
|
||||
|
||||
assertEquals(SharedPdfAnnotationDefaults.penPalette.size, state.penPalette.size)
|
||||
assertEquals(customColor, state.penPalette.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text selection markup tools and neutral mode are exclusive`() {
|
||||
val selectingText = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.PEN))
|
||||
.reduce(SharedPdfReaderAction.TextSelectionModeChanged(true))
|
||||
val addingTextAnnotation = selectingText.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.TEXT))
|
||||
val neutral = addingTextAnnotation.reduce(SharedPdfReaderAction.ToolSelected(PdfInkTool.NONE))
|
||||
|
||||
assertEquals(true, selectingText.isTextSelectionMode)
|
||||
assertEquals(PdfInkTool.NONE, selectingText.selectedTool)
|
||||
assertEquals(false, addingTextAnnotation.isTextSelectionMode)
|
||||
assertEquals(PdfInkTool.TEXT, addingTextAnnotation.selectedTool)
|
||||
assertEquals(false, neutral.isTextSelectionMode)
|
||||
assertEquals(PdfInkTool.NONE, neutral.selectedTool)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation actions mutate immutable annotation list`() {
|
||||
val first = annotation("first", pageIndex = 0)
|
||||
val second = annotation("second", pageIndex = 0)
|
||||
val third = annotation("third", pageIndex = 1)
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 2)
|
||||
.reduce(SharedPdfReaderAction.AnnotationsLoaded(listOf(first)))
|
||||
.reduce(SharedPdfReaderAction.AnnotationAdded(second))
|
||||
.reduce(SharedPdfReaderAction.AnnotationAdded(third))
|
||||
.reduce(SharedPdfReaderAction.UndoLastAnnotationOnPage(0))
|
||||
.reduce(SharedPdfReaderAction.ClearPageAnnotations(1))
|
||||
|
||||
assertEquals(listOf(first), state.annotations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation undo and redo follow add remove history`() {
|
||||
val first = annotation("first", pageIndex = 0)
|
||||
val second = annotation("second", pageIndex = 0)
|
||||
|
||||
val added = SharedPdfReaderState.initial(pageCount = 1)
|
||||
.reduce(SharedPdfReaderAction.AnnotationAdded(first))
|
||||
.reduce(SharedPdfReaderAction.AnnotationAdded(second))
|
||||
|
||||
val undoneAdd = added.reduce(SharedPdfReaderAction.UndoAnnotationEdit)
|
||||
val redoneAdd = undoneAdd.reduce(SharedPdfReaderAction.RedoAnnotationEdit)
|
||||
val removed = redoneAdd.reduce(SharedPdfReaderAction.ClearPageAnnotations(0))
|
||||
val undoneRemove = removed.reduce(SharedPdfReaderAction.UndoAnnotationEdit)
|
||||
val redoneRemove = undoneRemove.reduce(SharedPdfReaderAction.RedoAnnotationEdit)
|
||||
|
||||
assertEquals(listOf(first), undoneAdd.annotations)
|
||||
assertEquals(true, undoneAdd.canRedoAnnotationEdit)
|
||||
assertEquals(listOf(first, second), redoneAdd.annotations)
|
||||
assertEquals(emptyList(), removed.annotations)
|
||||
assertEquals(listOf(first, second), undoneRemove.annotations)
|
||||
assertEquals(emptyList(), redoneRemove.annotations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookmark actions toggle and normalize pages`() {
|
||||
val state = SharedPdfReaderState.initial(pageCount = 4)
|
||||
.reduce(
|
||||
SharedPdfReaderAction.BookmarksLoaded(
|
||||
listOf(
|
||||
SharedPdfBookmark(pageIndex = 2, label = "Two"),
|
||||
SharedPdfBookmark(pageIndex = 99, label = "Invalid"),
|
||||
SharedPdfBookmark(pageIndex = 2, label = "Duplicate")
|
||||
)
|
||||
)
|
||||
)
|
||||
.reduce(SharedPdfReaderAction.BookmarkToggled(pageIndex = 1, createdAt = 10L))
|
||||
.reduce(SharedPdfReaderAction.BookmarkToggled(pageIndex = 2))
|
||||
|
||||
assertEquals(listOf(1), state.bookmarks.map { it.pageIndex })
|
||||
assertEquals("Page 2", state.bookmarks.single().label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bookmark serializer round trips store and legacy arrays`() {
|
||||
val bookmarks = listOf(
|
||||
SharedPdfBookmark(pageIndex = 0, label = "Start", createdAt = 11L),
|
||||
SharedPdfBookmark(pageIndex = 3, label = "Appendix", createdAt = 22L)
|
||||
)
|
||||
|
||||
assertEquals(bookmarks, SharedPdfBookmarkSerializer.decode(SharedPdfBookmarkSerializer.encode(bookmarks)))
|
||||
assertEquals(
|
||||
listOf(SharedPdfBookmark(pageIndex = 1, label = "Legacy", createdAt = 33L)),
|
||||
SharedPdfBookmarkSerializer.decode("""[{"pageIndex":1,"label":"Legacy","createdAt":33}]""")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jump history records explicit jumps and exposes back and forward pages`() {
|
||||
val recorded = SharedPdfJumpHistory()
|
||||
.record(currentPageIndex = 0, targetPageIndex = 4, pageCount = 10)
|
||||
.record(currentPageIndex = 4, targetPageIndex = 8, pageCount = 10)
|
||||
|
||||
val steppedBack = recorded.stepBack()
|
||||
val branched = steppedBack.record(currentPageIndex = 4, targetPageIndex = 2, pageCount = 10)
|
||||
|
||||
assertEquals(listOf(0, 4, 8), recorded.pages)
|
||||
assertEquals(4, recorded.backPage)
|
||||
assertEquals(null, recorded.forwardPage)
|
||||
assertEquals(0, steppedBack.backPage)
|
||||
assertEquals(8, steppedBack.forwardPage)
|
||||
assertEquals(listOf(0, 4, 2), branched.pages)
|
||||
assertEquals(4, branched.backPage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jump history ignores invalid jumps prunes document bounds and caps entries`() {
|
||||
val unchanged = SharedPdfJumpHistory()
|
||||
.record(currentPageIndex = 0, targetPageIndex = 0, pageCount = 10)
|
||||
.record(currentPageIndex = 0, targetPageIndex = 99, pageCount = 10)
|
||||
|
||||
val pruned = SharedPdfJumpHistory(pages = listOf(0, 3, 99, 4), cursor = 3)
|
||||
.pruned(pageCount = 5)
|
||||
|
||||
val capped = (0 until 40).fold(SharedPdfJumpHistory(maxEntries = 5)) { history, page ->
|
||||
history.record(
|
||||
currentPageIndex = page,
|
||||
targetPageIndex = page + 1,
|
||||
pageCount = 50
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(unchanged.pages.isEmpty())
|
||||
assertEquals(listOf(0, 3, 4), pruned.pages)
|
||||
assertEquals(2, pruned.cursor)
|
||||
assertEquals(listOf(36, 37, 38, 39, 40), capped.pages)
|
||||
assertEquals(4, capped.cursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotation selection update and delete are shared`() {
|
||||
val first = annotation("first", pageIndex = 0)
|
||||
val second = annotation("second", pageIndex = 1)
|
||||
val updated = second.copy(text = "changed", colorArgb = 0xFF222222.toInt())
|
||||
|
||||
val state = SharedPdfReaderState.initial(pageCount = 2)
|
||||
.reduce(SharedPdfReaderAction.AnnotationsLoaded(listOf(first, second)))
|
||||
.reduce(SharedPdfReaderAction.AnnotationSelected("second"))
|
||||
.reduce(SharedPdfReaderAction.AnnotationUpdated(updated))
|
||||
.reduce(SharedPdfReaderAction.AnnotationDeleted("second"))
|
||||
|
||||
assertEquals(listOf(first), state.annotations)
|
||||
assertEquals(null, state.selectedAnnotationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search engine finds all case-insensitive matches with previews`() {
|
||||
val results = SharedPdfSearchEngine.search(
|
||||
pageTexts = listOf("Alpha beta alpha", "nothing", "ALPHA at the end"),
|
||||
query = "alpha"
|
||||
)
|
||||
|
||||
assertEquals(listOf(0, 0, 2), results.map { it.pageIndex })
|
||||
assertEquals(listOf(0, 11, 0), results.map { it.matchIndex })
|
||||
assertEquals(listOf(5, 5, 5), results.map { it.matchLength })
|
||||
assertTrue(results.first().preview.contains("Alpha"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search index reuses indexed page text and preserves raw match ranges`() {
|
||||
val index = SharedPdfSearchIndex(pageCount = 3)
|
||||
index.putPage(0, "Alpha beta")
|
||||
index.putPage(1, "hello,\nworld appears here")
|
||||
index.putPage(2, "alpha again")
|
||||
|
||||
val punctuationResults = index.search("hello, world")
|
||||
val alphaResults = index.search("alp")
|
||||
|
||||
assertEquals(3, index.indexedPageCount)
|
||||
assertEquals(listOf(1), punctuationResults.map { it.pageIndex })
|
||||
assertEquals(0, punctuationResults.single().matchIndex)
|
||||
assertEquals("hello,\nworld".length, punctuationResults.single().matchLength)
|
||||
assertEquals(listOf(0, 2), alphaResults.map { it.pageIndex })
|
||||
assertEquals(listOf(3, 3), alphaResults.map { it.matchLength })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search highlights return all page matches or only focused match`() {
|
||||
val results = listOf(
|
||||
SharedPdfSearchResult(pageIndex = 0, preview = "first", matchIndex = 0),
|
||||
SharedPdfSearchResult(pageIndex = 0, preview = "second", matchIndex = 12),
|
||||
SharedPdfSearchResult(pageIndex = 1, preview = "third", matchIndex = 3)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(results[0], results[1]),
|
||||
SharedPdfSearchEngine.highlightsForPage(
|
||||
results = results,
|
||||
pageIndex = 0,
|
||||
activeResultIndex = 2,
|
||||
mode = SearchHighlightMode.ALL
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(results[1]),
|
||||
SharedPdfSearchEngine.highlightsForPage(
|
||||
results = results,
|
||||
pageIndex = 0,
|
||||
activeResultIndex = 1,
|
||||
mode = SearchHighlightMode.FOCUSED
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `most visible page follows largest viewport overlap`() {
|
||||
val visiblePages = listOf(
|
||||
PdfVisiblePageLayout(pageIndex = 2, top = -120f, bottom = 320f),
|
||||
PdfVisiblePageLayout(pageIndex = 3, top = 320f, bottom = 920f),
|
||||
PdfVisiblePageLayout(pageIndex = 4, top = 920f, bottom = 1300f)
|
||||
)
|
||||
|
||||
val pageIndex = mostVisiblePdfPageIndex(
|
||||
visiblePages = visiblePages,
|
||||
viewportTop = 0f,
|
||||
viewportBottom = 800f,
|
||||
fallbackPageIndex = 2
|
||||
)
|
||||
|
||||
assertEquals(3, pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `most visible page falls back when no measured page overlaps`() {
|
||||
val pageIndex = mostVisiblePdfPageIndex(
|
||||
visiblePages = listOf(PdfVisiblePageLayout(pageIndex = 8, top = 900f, bottom = 1200f)),
|
||||
viewportTop = 0f,
|
||||
viewportBottom = 800f,
|
||||
fallbackPageIndex = 5
|
||||
)
|
||||
|
||||
assertEquals(5, pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical page gap option keeps default spacing or removes it`() {
|
||||
assertEquals(8.dp, pdfVerticalPageGapDp(isPageGapVisible = true, defaultGap = 8.dp))
|
||||
assertEquals(0.dp, pdfVerticalPageGapDp(isPageGapVisible = false, defaultGap = 8.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical page layout removes fractional pixel seams when gap is hidden`() {
|
||||
val layout = calculatePdfVerticalPageLayoutPx(
|
||||
pageAspectRatios = listOf(0.707f, 0.721f, 0.69f),
|
||||
viewportWidthPx = 1081,
|
||||
viewportHeightPx = 1920,
|
||||
pageGapPx = 0
|
||||
)
|
||||
|
||||
layout.pages.zipWithNext().forEach { (previous, next) ->
|
||||
assertEquals(previous.bottomPx, next.topPx)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical page layout keeps exact configured page gap`() {
|
||||
val layout = calculatePdfVerticalPageLayoutPx(
|
||||
pageAspectRatios = listOf(0.707f, 0.721f),
|
||||
viewportWidthPx = 1081,
|
||||
viewportHeightPx = 1920,
|
||||
pageGapPx = 12
|
||||
)
|
||||
|
||||
assertEquals(layout.pages.first().bottomPx + 12, layout.pages.last().topPx)
|
||||
}
|
||||
|
||||
private fun annotation(id: String, pageIndex: Int): SharedPdfAnnotation {
|
||||
return SharedPdfAnnotation(
|
||||
id = id,
|
||||
pageIndex = pageIndex,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f)),
|
||||
colorArgb = 0xFF111111.toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class PdfSelectionGeometryTest {
|
||||
|
||||
@Test
|
||||
fun `normalizes points against the current viewport size`() {
|
||||
val point = PdfSelectionGeometry.normalizedPoint(
|
||||
pointX = 50f,
|
||||
pointY = 200f,
|
||||
viewportWidth = 200,
|
||||
viewportHeight = 400
|
||||
)
|
||||
|
||||
assertEquals(PdfNormalizedPoint(0.25f, 0.5f), point)
|
||||
assertNull(PdfSelectionGeometry.normalizedPoint(50f, 200f, 0, 400))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `line fallback picks the nearest character only on a matching line`() {
|
||||
val chars = listOf(
|
||||
PdfTextCharBounds(index = 1, left = 0.10f, top = 0.10f, right = 0.12f, bottom = 0.13f),
|
||||
PdfTextCharBounds(index = 2, left = 0.13f, top = 0.10f, right = 0.15f, bottom = 0.13f),
|
||||
PdfTextCharBounds(index = 20, left = 0.10f, top = 0.30f, right = 0.12f, bottom = 0.33f)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
PdfSelectionGeometry.nearestCharOnLine(chars, PdfNormalizedPoint(0.90f, 0.115f))?.index
|
||||
)
|
||||
assertNull(PdfSelectionGeometry.nearestCharOnLine(chars, PdfNormalizedPoint(0.90f, 0.22f)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merges text rects by visual line`() {
|
||||
val merged = PdfSelectionGeometry.mergeBoundsByLine(
|
||||
listOf(
|
||||
PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.20f, bottom = 0.13f),
|
||||
PdfPageBounds(left = 0.21f, top = 0.101f, right = 0.35f, bottom = 0.131f),
|
||||
PdfPageBounds(left = 0.10f, top = 0.20f, right = 0.25f, bottom = 0.23f)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.35f, bottom = 0.131f),
|
||||
PdfPageBounds(left = 0.10f, top = 0.20f, right = 0.25f, bottom = 0.23f)
|
||||
),
|
||||
merged
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps nearby paragraph lines separate`() {
|
||||
val merged = PdfSelectionGeometry.mergeBoundsByLine(
|
||||
listOf(
|
||||
PdfPageBounds(left = 0.10f, top = 0.10f, right = 0.80f, bottom = 0.13f),
|
||||
PdfPageBounds(left = 0.10f, top = 0.118f, right = 0.75f, bottom = 0.148f)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, merged.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `line fallback collapses overlapping glyph bands on the same visual line`() {
|
||||
val bounds = PdfSelectionGeometry.lineBoundsForChars(
|
||||
listOf(
|
||||
PdfTextCharBounds(index = 1, left = 0.10f, top = 0.100f, right = 0.13f, bottom = 0.130f),
|
||||
PdfTextCharBounds(index = 2, left = 0.14f, top = 0.116f, right = 0.17f, bottom = 0.146f),
|
||||
PdfTextCharBounds(index = 3, left = 0.18f, top = 0.101f, right = 0.21f, bottom = 0.131f)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(PdfPageBounds(left = 0.10f, top = 0.100f, right = 0.21f, bottom = 0.146f)),
|
||||
bounds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPageSpreadMode
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderSettings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PdfSpreadLayoutTest {
|
||||
|
||||
@Test
|
||||
fun `single page mode keeps direct page indexes`() {
|
||||
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE)
|
||||
|
||||
assertEquals(3, PdfSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3), PdfSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals("4", PdfSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page mode pairs pages from the first page by default`() {
|
||||
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
|
||||
|
||||
assertEquals(2, PdfSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(2, 3), PdfSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals("3-4", PdfSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(4), PdfSpreadLayout.visiblePageIndices(4, pageCount = 5, settings = settings))
|
||||
assertEquals("5", PdfSpreadLayout.pageRangeLabel(4, pageCount = 5, settings = settings))
|
||||
assertEquals(listOf(0, 2, 4), PdfSpreadLayout.spreadStartPageIndices(pageCount = 5, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `right to left pagination reverses only the displayed pdf spread order`() {
|
||||
val settings = ReaderSettings(
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
|
||||
assertEquals(listOf(2, 3), PdfSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3, 2), PdfSpreadLayout.visiblePageIndicesForDisplay(3, pageCount = 10, settings = settings))
|
||||
assertEquals(4, PdfSpreadLayout.nextPageIndex(2, pageCount = 10, settings = settings))
|
||||
assertEquals(0, PdfSpreadLayout.previousPageIndex(2, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page mode can keep the first page alone`() {
|
||||
val settings = ReaderSettings(
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
pdfFirstPageStandaloneInSpread = true
|
||||
)
|
||||
|
||||
assertEquals(listOf(0), PdfSpreadLayout.visiblePageIndices(0, pageCount = 6, settings = settings))
|
||||
assertEquals(listOf(1, 2), PdfSpreadLayout.visiblePageIndices(2, pageCount = 6, settings = settings))
|
||||
assertEquals(listOf(3, 4), PdfSpreadLayout.visiblePageIndices(4, pageCount = 6, settings = settings))
|
||||
assertEquals(listOf(5), PdfSpreadLayout.visiblePageIndices(5, pageCount = 6, settings = settings))
|
||||
assertEquals(listOf(0, 1, 3, 5), PdfSpreadLayout.spreadStartPageIndices(pageCount = 6, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page navigation follows spread boundaries`() {
|
||||
val normal = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
|
||||
val firstStandalone = ReaderSettings(
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
pdfFirstPageStandaloneInSpread = true
|
||||
)
|
||||
|
||||
assertEquals(2, PdfSpreadLayout.nextPageIndex(0, pageCount = 5, settings = normal))
|
||||
assertEquals(4, PdfSpreadLayout.nextPageIndex(2, pageCount = 5, settings = normal))
|
||||
assertEquals(2, PdfSpreadLayout.previousPageIndex(4, pageCount = 5, settings = normal))
|
||||
assertFalse(PdfSpreadLayout.canGoNext(4, pageCount = 5, settings = normal))
|
||||
|
||||
assertEquals(1, PdfSpreadLayout.nextPageIndex(0, pageCount = 6, settings = firstStandalone))
|
||||
assertEquals(3, PdfSpreadLayout.nextPageIndex(1, pageCount = 6, settings = firstStandalone))
|
||||
assertEquals(1, PdfSpreadLayout.previousPageIndex(3, pageCount = 6, settings = firstStandalone))
|
||||
assertTrue(PdfSpreadLayout.canGoPrevious(1, pageCount = 6, settings = firstStandalone))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spread progress uses the visible spread end`() {
|
||||
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
|
||||
|
||||
assertEquals(80f, PdfSpreadLayout.progressPercent(2, pageCount = 5, settings = settings))
|
||||
assertEquals(100f, PdfSpreadLayout.progressPercent(4, pageCount = 5, settings = settings))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedPdfAnnotationCommentsTest {
|
||||
@Test
|
||||
fun `visible comments filter blanks and promote orphan replies`() {
|
||||
val comments = listOf(
|
||||
SharedPdfAnnotationComment(id = "root", contents = "Root"),
|
||||
SharedPdfAnnotationComment(id = "reply", parentId = "root", contents = "Reply"),
|
||||
SharedPdfAnnotationComment(id = "blank-parent", contents = ""),
|
||||
SharedPdfAnnotationComment(id = "orphan", parentId = "blank-parent", contents = "Orphan")
|
||||
)
|
||||
|
||||
val visible = comments.visiblePdfAnnotationComments()
|
||||
|
||||
assertEquals(listOf("root", "reply", "orphan"), visible.map { it.id })
|
||||
assertEquals("root", visible.single { it.id == "reply" }.parentId)
|
||||
assertEquals(null, visible.single { it.id == "orphan" }.parentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comment helpers preserve nested thread behavior`() {
|
||||
val comments = listOf(
|
||||
SharedPdfAnnotationComment(id = "undated", contents = "Undated"),
|
||||
SharedPdfAnnotationComment(id = "newer", contents = "Newer", createdAt = 30L),
|
||||
SharedPdfAnnotationComment(id = "older", contents = "Older", createdAt = 10L),
|
||||
SharedPdfAnnotationComment(id = "child", parentId = "older", contents = "Child"),
|
||||
SharedPdfAnnotationComment(id = "grandchild", parentId = "child", contents = "Grandchild"),
|
||||
SharedPdfAnnotationComment(id = "sibling", contents = "Sibling", createdAt = 20L)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("older", "sibling", "newer", "undated"),
|
||||
comments.pdfCommentChildren(parentId = null).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("undated", "newer", "older", "sibling"),
|
||||
comments.withoutPdfCommentThread("child").map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfAnnotationExportMapperTest {
|
||||
|
||||
@Test
|
||||
fun `mapper exports ink annotations and skips non-drawing tools`() {
|
||||
val ink = SharedPdfAnnotation(
|
||||
id = "ink-1",
|
||||
pageIndex = 2,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.PEN,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f), PdfPagePoint(0.3f, 0.4f)),
|
||||
note = "Check curve",
|
||||
colorArgb = 0xFF336699.toInt(),
|
||||
strokeWidth = 0.0125f
|
||||
)
|
||||
val noneTool = ink.copy(id = "none", tool = PdfInkTool.NONE)
|
||||
val eraser = ink.copy(id = "eraser", tool = PdfInkTool.ERASER)
|
||||
val textTool = ink.copy(id = "text-tool", tool = PdfInkTool.TEXT)
|
||||
val tooShort = ink.copy(id = "short", points = listOf(PdfPagePoint(0.1f, 0.2f)))
|
||||
|
||||
val payload = SharedPdfAnnotationExportMapper.build(listOf(ink, noneTool, eraser, textTool, tooShort))
|
||||
|
||||
assertTrue(payload.hasPdfAnnotations)
|
||||
assertEquals(listOf("ink-1"), payload.inkAnnotations.map { it.id })
|
||||
assertEquals(PdfInkTool.PEN, payload.inkAnnotations.single().tool)
|
||||
assertEquals("Check curve", payload.inkAnnotations.single().contents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapper preserves highlight bounds order without adding fake contents`() {
|
||||
val highlight = SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
boundsList = listOf(
|
||||
PdfPageBounds(0.1f, 0.2f, 0.4f, 0.25f),
|
||||
PdfPageBounds(0.5f, 0.3f, 0.8f, 0.35f)
|
||||
),
|
||||
text = "Selected text",
|
||||
colorArgb = 0x8C64B5F6.toInt()
|
||||
)
|
||||
|
||||
val payload = SharedPdfAnnotationExportMapper.build(listOf(highlight))
|
||||
|
||||
assertEquals(listOf("highlight-1"), payload.highlightAnnotations.map { it.id })
|
||||
assertEquals(highlight.boundsList, payload.highlightAnnotations.single().boundsList)
|
||||
assertEquals("", payload.highlightAnnotations.single().contents)
|
||||
assertEquals(
|
||||
"Actual note",
|
||||
SharedPdfAnnotationExportMapper.build(listOf(highlight.copy(note = " Actual note ")))
|
||||
.highlightAnnotations
|
||||
.single()
|
||||
.contents
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapper exports text highlight comment threads with stable ids and parent order`() {
|
||||
val highlight = SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
boundsList = listOf(PdfPageBounds(0.1f, 0.2f, 0.4f, 0.25f)),
|
||||
text = "Selected text",
|
||||
comments = listOf(
|
||||
SharedPdfAnnotationComment(
|
||||
id = "reply-1",
|
||||
parentId = "root-1",
|
||||
author = "Bea",
|
||||
contents = " Reply body ",
|
||||
createdAt = 20L
|
||||
),
|
||||
SharedPdfAnnotationComment(
|
||||
id = "blank",
|
||||
author = "Nope",
|
||||
contents = " "
|
||||
),
|
||||
SharedPdfAnnotationComment(
|
||||
id = "root-1",
|
||||
author = "Ada",
|
||||
contents = "Root body",
|
||||
createdAt = 10L,
|
||||
modifiedAt = 15L
|
||||
)
|
||||
),
|
||||
colorArgb = 0x8C64B5F6.toInt()
|
||||
)
|
||||
|
||||
val comments = SharedPdfAnnotationExportMapper.build(listOf(highlight))
|
||||
.highlightAnnotations
|
||||
.single()
|
||||
.comments
|
||||
|
||||
val comment = comments.single()
|
||||
assertEquals("highlight-1_comments", comment.id)
|
||||
assertEquals(null, comment.parentId)
|
||||
assertEquals("Ada", comment.author)
|
||||
assertEquals(
|
||||
"Ada:\nRoot body\n\n Bea:\n Reply body",
|
||||
comment.contents
|
||||
)
|
||||
assertEquals(10L, comment.createdAt)
|
||||
assertEquals(20L, comment.modifiedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapper folds multiple top level highlight comments into one export thread`() {
|
||||
val highlight = SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
boundsList = listOf(PdfPageBounds(0.1f, 0.2f, 0.4f, 0.25f)),
|
||||
text = "Selected text",
|
||||
comments = listOf(
|
||||
SharedPdfAnnotationComment(id = "root-1", contents = "First"),
|
||||
SharedPdfAnnotationComment(id = "root-2", contents = "Second")
|
||||
),
|
||||
colorArgb = 0x8C64B5F6.toInt()
|
||||
)
|
||||
|
||||
val comments = SharedPdfAnnotationExportMapper.build(listOf(highlight))
|
||||
.highlightAnnotations
|
||||
.single()
|
||||
.comments
|
||||
|
||||
val comment = comments.single()
|
||||
assertEquals("highlight-1_comments", comment.id)
|
||||
assertEquals(null, comment.parentId)
|
||||
assertEquals("Reader", comment.author)
|
||||
assertEquals("Reader:\nFirst\n\nReader:\nSecond", comment.contents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapper uses resolver bounds and skips malformed highlights`() {
|
||||
val missingBounds = SharedPdfAnnotation(
|
||||
id = "resolved",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
text = "Resolved",
|
||||
colorArgb = 0x8CFFEB3B.toInt(),
|
||||
rangeStartIndex = 2,
|
||||
rangeEndIndex = 9
|
||||
)
|
||||
val malformed = missingBounds.copy(
|
||||
id = "bad",
|
||||
bounds = PdfPageBounds(1.1f, 0.2f, 1.2f, 0.3f)
|
||||
)
|
||||
val malformedWithRange = missingBounds.copy(
|
||||
id = "fallback",
|
||||
bounds = PdfPageBounds(1.1f, 0.2f, 1.2f, 0.3f)
|
||||
)
|
||||
val textAnnotation = missingBounds.copy(
|
||||
id = "text",
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
bounds = PdfPageBounds(0.1f, 0.2f, 0.2f, 0.3f)
|
||||
)
|
||||
|
||||
val payload = SharedPdfAnnotationExportMapper.build(
|
||||
listOf(missingBounds, malformed, malformedWithRange, textAnnotation)
|
||||
) { annotation ->
|
||||
when (annotation.id) {
|
||||
"resolved" -> listOf(PdfPageBounds(0.2f, 0.4f, 0.6f, 0.45f))
|
||||
"fallback" -> listOf(PdfPageBounds(0.3f, 0.5f, 0.7f, 0.55f))
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(listOf("resolved", "fallback"), payload.highlightAnnotations.map { it.id })
|
||||
assertEquals(
|
||||
listOf(PdfPageBounds(0.2f, 0.4f, 0.6f, 0.45f)),
|
||||
payload.highlightAnnotations.first().boundsList
|
||||
)
|
||||
assertEquals(
|
||||
listOf(PdfPageBounds(0.3f, 0.5f, 0.7f, 0.55f)),
|
||||
payload.highlightAnnotations.last().boundsList
|
||||
)
|
||||
assertFalse(SharedPdfAnnotationExportMapper.build(listOf(malformed, textAnnotation)).hasPdfAnnotations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapper normalizes reversed highlight bounds`() {
|
||||
val highlight = SharedPdfAnnotation(
|
||||
id = "highlight",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
bounds = PdfPageBounds(0.6f, 0.4f, 0.2f, 0.3f),
|
||||
text = "Selected",
|
||||
colorArgb = 0x8CFFEB3B.toInt()
|
||||
)
|
||||
|
||||
val payload = SharedPdfAnnotationExportMapper.build(listOf(highlight))
|
||||
|
||||
assertEquals(
|
||||
PdfPageBounds(0.2f, 0.3f, 0.6f, 0.4f),
|
||||
payload.highlightAnnotations.single().boundsList.single()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ink appearance trims chisel highlighter endpoints only`() {
|
||||
val chisel = SharedPdfInkAnnotationExport(
|
||||
id = "highlight",
|
||||
pageIndex = 0,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f), PdfPagePoint(0.9f, 0.2f)),
|
||||
colorArgb = 0x8CFFEB3B.toInt(),
|
||||
strokeWidth = 0.1f,
|
||||
contents = ""
|
||||
)
|
||||
val round = chisel.copy(tool = PdfInkTool.HIGHLIGHTER_ROUND)
|
||||
val trimmed = chisel.pdfInkAppearancePoints(pageWidth = 100f, pageHeight = 100f)
|
||||
val duplicatedEndpointTrimmed = chisel.copy(
|
||||
points = listOf(
|
||||
PdfPagePoint(0.1f, 0.2f),
|
||||
PdfPagePoint(0.1f, 0.2f),
|
||||
PdfPagePoint(0.9f, 0.2f),
|
||||
PdfPagePoint(0.9f, 0.2f)
|
||||
)
|
||||
).pdfInkAppearancePoints(pageWidth = 100f, pageHeight = 100f)
|
||||
|
||||
assertEquals(0.165f, trimmed.first().x, 0.0001f)
|
||||
assertEquals(0.2f, trimmed.first().y, 0.0001f)
|
||||
assertEquals(0.835f, trimmed.last().x, 0.0001f)
|
||||
assertEquals(0.2f, trimmed.last().y, 0.0001f)
|
||||
assertEquals(0.165f, duplicatedEndpointTrimmed[1].x, 0.0001f)
|
||||
assertEquals(0.835f, duplicatedEndpointTrimmed[2].x, 0.0001f)
|
||||
assertEquals(round.points, round.pdfInkAppearancePoints(pageWidth = 100f, pageHeight = 100f))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfAnnotationSerializerTest {
|
||||
|
||||
@Test
|
||||
fun `serializer round trips text highlight annotations`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "highlight",
|
||||
pageIndex = 3,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
bounds = PdfPageBounds(left = 0.1f, top = 0.2f, right = 0.5f, bottom = 0.24f),
|
||||
text = "Selected text",
|
||||
colorArgb = 0x8CFFEB3B.toInt(),
|
||||
createdAt = 42L
|
||||
)
|
||||
|
||||
val decoded = SharedPdfAnnotationSerializer.decode(
|
||||
SharedPdfAnnotationSerializer.encode(listOf(annotation))
|
||||
)
|
||||
|
||||
assertEquals(listOf(annotation), decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec canonicalizes legacy android annotation payloads`() {
|
||||
val legacyPayload = """
|
||||
{
|
||||
"ink": [
|
||||
{
|
||||
"pageIndex": 1,
|
||||
"id": "ink-1",
|
||||
"annotationType": "INK",
|
||||
"inkType": "PENCIL",
|
||||
"color": -16777216,
|
||||
"strokeWidth": 0.008,
|
||||
"note": "Desktop-only ink note",
|
||||
"points": [{"x":0.1,"y":0.2,"t":10},{"x":0.3,"y":0.4,"t":12}]
|
||||
}
|
||||
],
|
||||
"textBoxes": [
|
||||
{
|
||||
"id": "box-1",
|
||||
"pageIndex": 2,
|
||||
"text": "Typed note",
|
||||
"color": -15654349,
|
||||
"backgroundColor": 1712398870,
|
||||
"fontSize": 0.032,
|
||||
"isBold": true,
|
||||
"bounds": {"left":0.1,"top":0.2,"right":0.5,"bottom":0.3}
|
||||
}
|
||||
],
|
||||
"highlights": [
|
||||
{
|
||||
"id": "highlight-1",
|
||||
"pageIndex": 3,
|
||||
"color": "BLUE",
|
||||
"text": "Selected text",
|
||||
"rangeStart": 4,
|
||||
"rangeEnd": 18,
|
||||
"note": "Keep this",
|
||||
"comments": [
|
||||
{
|
||||
"id": "comment-1",
|
||||
"author": "Ada",
|
||||
"contents": "First comment",
|
||||
"createdAt": 100,
|
||||
"modifiedAt": 120
|
||||
},
|
||||
{
|
||||
"id": "comment-2",
|
||||
"parentId": "comment-1",
|
||||
"author": "Bea",
|
||||
"contents": "Reply",
|
||||
"createdAt": 130
|
||||
}
|
||||
],
|
||||
"bounds": []
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val canonical = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(legacyPayload)
|
||||
val data = testJson.parseToJsonElement(canonical).jsonObject
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(data)
|
||||
|
||||
assertNotNull(data[SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS])
|
||||
assertEquals(listOf(PdfAnnotationKind.INK, PdfAnnotationKind.TEXT, PdfAnnotationKind.HIGHLIGHT), annotations.map { it.kind })
|
||||
assertEquals("ink-1", annotations[0].id)
|
||||
assertEquals(PdfInkTool.PENCIL, annotations[0].tool)
|
||||
assertEquals("Desktop-only ink note", annotations[0].note)
|
||||
assertEquals(16f, annotations[1].fontSize, 0.001f)
|
||||
assertEquals(0.032f, annotations[1].pageRelativeFontSize ?: 0f, 0.0001f)
|
||||
assertTrue(annotations[1].isBold)
|
||||
assertEquals("Keep this", annotations[2].note)
|
||||
assertEquals(listOf("comment-1", "comment-2"), annotations[2].comments.map { it.id })
|
||||
assertEquals("comment-1", annotations[2].comments[1].parentId)
|
||||
assertEquals("Ada", annotations[2].comments[0].author)
|
||||
assertEquals(120L, annotations[2].comments[0].modifiedAt)
|
||||
assertEquals(4, annotations[2].rangeStartIndex)
|
||||
assertEquals(17, annotations[2].rangeEndIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec expands canonical annotations for android legacy readers`() {
|
||||
val annotations = listOf(
|
||||
SharedPdfAnnotation(
|
||||
id = "ink-1",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.FOUNTAIN_PEN,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f, 1L), PdfPagePoint(0.2f, 0.3f, 2L)),
|
||||
colorArgb = 0xFF0000FF.toInt(),
|
||||
strokeWidth = 0.009f
|
||||
),
|
||||
SharedPdfAnnotation(
|
||||
id = "text-1",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = PdfPageBounds(0.2f, 0.3f, 0.6f, 0.5f),
|
||||
text = "Desktop text",
|
||||
colorArgb = 0xFF112233.toInt(),
|
||||
backgroundArgb = 0x66112233,
|
||||
fontSize = 20f,
|
||||
pageRelativeFontSize = 0.031f
|
||||
),
|
||||
SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 2,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
text = "Desktop highlight",
|
||||
note = "Synced note",
|
||||
comments = listOf(
|
||||
SharedPdfAnnotationComment(
|
||||
id = "comment-1",
|
||||
author = "Ada",
|
||||
contents = "Sidecar comment",
|
||||
createdAt = 100L,
|
||||
modifiedAt = 110L
|
||||
),
|
||||
SharedPdfAnnotationComment(
|
||||
id = "comment-2",
|
||||
parentId = "comment-1",
|
||||
author = "Bea",
|
||||
contents = "Nested reply",
|
||||
createdAt = 120L
|
||||
)
|
||||
),
|
||||
colorArgb = 0x8C64B5F6.toInt(),
|
||||
rangeStartIndex = 7,
|
||||
rangeEndIndex = 21
|
||||
)
|
||||
)
|
||||
val canonicalPayload = testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val legacyPayload = SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(canonicalPayload)
|
||||
val legacy = testJson.parseToJsonElement(legacyPayload).jsonObject
|
||||
|
||||
assertEquals(1, legacy.getValue("ink").jsonArray.size)
|
||||
assertEquals("FOUNTAIN_PEN", legacy.getValue("ink").jsonArray[0].jsonObject.getValue("inkType").jsonPrimitive.content)
|
||||
assertEquals(1, legacy.getValue("textBoxes").jsonArray.size)
|
||||
assertEquals(
|
||||
0.031,
|
||||
legacy.getValue("textBoxes").jsonArray[0].jsonObject.getValue("fontSize").jsonPrimitive.content.toDouble(),
|
||||
0.0001
|
||||
)
|
||||
assertEquals(1, legacy.getValue("highlights").jsonArray.size)
|
||||
assertEquals("BLUE", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("color").jsonPrimitive.content)
|
||||
assertEquals("Synced note", legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("note").jsonPrimitive.content)
|
||||
assertEquals(22, legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("rangeEnd").jsonPrimitive.content.toInt())
|
||||
val comments = legacy.getValue("highlights").jsonArray[0].jsonObject.getValue("comments").jsonArray
|
||||
assertEquals(2, comments.size)
|
||||
assertEquals("comment-1", comments[0].jsonObject.getValue("id").jsonPrimitive.content)
|
||||
assertEquals("comment-1", comments[1].jsonObject.getValue("parentId").jsonPrimitive.content)
|
||||
assertEquals("Nested reply", comments[1].jsonObject.getValue("contents").jsonPrimitive.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec treats canonical annotations as authoritative for android legacy expansion`() {
|
||||
val canonicalAnnotation = SharedPdfAnnotation(
|
||||
id = "desktop-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.PEN,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f, 100L)),
|
||||
note = "Edited on desktop",
|
||||
colorArgb = 0xFF112233.toInt(),
|
||||
strokeWidth = 0.01f
|
||||
)
|
||||
val payload = testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(listOf(canonicalAnnotation)),
|
||||
"ink" to testJson.parseToJsonElement(
|
||||
"""[{"id":"stale","pageIndex":9,"annotationType":"INK","inkType":"PENCIL","color":0,"strokeWidth":1,"points":[{"x":0.9,"y":0.9}]}]"""
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val legacy = testJson.parseToJsonElement(
|
||||
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(payload)
|
||||
).jsonObject
|
||||
val ink = legacy.getValue("ink").jsonArray.single().jsonObject
|
||||
|
||||
assertEquals("desktop-ink", ink.getValue("id").jsonPrimitive.content)
|
||||
assertEquals("Edited on desktop", ink.getValue("note").jsonPrimitive.content)
|
||||
assertEquals(0, ink.getValue("pageIndex").jsonPrimitive.content.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec expands empty canonical annotations to empty android legacy arrays`() {
|
||||
val payload = testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(emptyList())
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val legacy = testJson.parseToJsonElement(
|
||||
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(payload)
|
||||
).jsonObject
|
||||
|
||||
assertEquals(0, legacy.getValue("ink").jsonArray.size)
|
||||
assertEquals(0, legacy.getValue("textBoxes").jsonArray.size)
|
||||
assertEquals(0, legacy.getValue("highlights").jsonArray.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec merges local and remote annotation additions`() {
|
||||
val local = SharedPdfAnnotation(
|
||||
id = "local-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f, 10L)),
|
||||
colorArgb = 0xFF000000.toInt(),
|
||||
createdAt = 10L
|
||||
)
|
||||
val remote = SharedPdfAnnotation(
|
||||
id = "remote-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.3f, 0.4f, 20L)),
|
||||
colorArgb = 0xFFFF0000.toInt(),
|
||||
createdAt = 20L
|
||||
)
|
||||
fun payload(annotation: SharedPdfAnnotation): String {
|
||||
return testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS to
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(listOf(annotation))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val merged = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
|
||||
localDataJson = payload(local),
|
||||
remoteDataJson = payload(remote),
|
||||
preferRemoteOnConflict = false
|
||||
)
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(
|
||||
testJson.parseToJsonElement(merged).jsonObject
|
||||
)
|
||||
|
||||
assertEquals(listOf("local-ink", "remote-ink"), annotations.map { it.id })
|
||||
assertEquals(2, SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidecar codec deletion tombstones remove stale remote annotations`() {
|
||||
val deletedRemote = SharedPdfAnnotation(
|
||||
id = "deleted-remote-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f, 10L)),
|
||||
colorArgb = 0xFF000000.toInt(),
|
||||
createdAt = 10L
|
||||
)
|
||||
val local = SharedPdfAnnotation(
|
||||
id = "local-ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
points = listOf(PdfPagePoint(0.3f, 0.4f, 20L)),
|
||||
colorArgb = 0xFFFF0000.toInt(),
|
||||
createdAt = 20L
|
||||
)
|
||||
fun payload(
|
||||
annotations: List<SharedPdfAnnotation>,
|
||||
deletions: Map<String, Long> = emptyMap()
|
||||
): String {
|
||||
return testJson.encodeToString(
|
||||
JsonElement.serializer(),
|
||||
JsonObject(
|
||||
buildMap {
|
||||
put(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
||||
)
|
||||
if (deletions.isNotEmpty()) {
|
||||
put(
|
||||
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
|
||||
SharedPdfAnnotationSidecarCodec.encodeAnnotationDeletionsElement(deletions)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val merged = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
|
||||
localDataJson = payload(
|
||||
annotations = listOf(local),
|
||||
deletions = mapOf(deletedRemote.id to 100L)
|
||||
),
|
||||
remoteDataJson = payload(listOf(deletedRemote)),
|
||||
preferRemoteOnConflict = false
|
||||
)
|
||||
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(
|
||||
testJson.parseToJsonElement(merged).jsonObject
|
||||
)
|
||||
|
||||
assertEquals(listOf("local-ink"), annotations.map { it.id })
|
||||
assertEquals(mapOf(deletedRemote.id to 100L), SharedPdfAnnotationSidecarCodec.annotationDeletionsFromJson(merged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `embedded annotation threads link replies and nearby orphan comments`() {
|
||||
val root = embeddedAnnotation(
|
||||
id = "root",
|
||||
index = 0,
|
||||
contents = "Root comment",
|
||||
name = "root-name",
|
||||
bounds = PdfPageBounds(0.1f, 0.1f, 0.2f, 0.2f)
|
||||
)
|
||||
val reply = embeddedAnnotation(
|
||||
id = "reply",
|
||||
index = 1,
|
||||
contents = "Reply comment",
|
||||
name = "reply-name",
|
||||
inReplyTo = "root-name",
|
||||
bounds = PdfPageBounds(0.11f, 0.11f, 0.21f, 0.21f)
|
||||
)
|
||||
val nearbyOrphan = embeddedAnnotation(
|
||||
id = "nearby",
|
||||
index = 2,
|
||||
contents = "Nearby comment",
|
||||
name = "nearby-name",
|
||||
bounds = PdfPageBounds(0.12f, 0.12f, 0.22f, 0.22f)
|
||||
)
|
||||
val empty = embeddedAnnotation(
|
||||
id = "empty",
|
||||
index = 3,
|
||||
contents = "",
|
||||
name = "empty-name",
|
||||
bounds = PdfPageBounds(0.8f, 0.8f, 0.9f, 0.9f)
|
||||
)
|
||||
|
||||
val grouped = SharedPdfEmbeddedAnnotationThreads.group(listOf(root, reply, nearbyOrphan, empty))
|
||||
|
||||
assertEquals(listOf("root"), grouped.map { it.id })
|
||||
assertEquals(listOf("reply", "nearby"), grouped.single().replies.map { it.id })
|
||||
}
|
||||
|
||||
private fun embeddedAnnotation(
|
||||
id: String,
|
||||
index: Int,
|
||||
contents: String,
|
||||
name: String,
|
||||
bounds: PdfPageBounds,
|
||||
inReplyTo: String = ""
|
||||
): SharedPdfEmbeddedAnnotation {
|
||||
return SharedPdfEmbeddedAnnotation(
|
||||
id = id,
|
||||
pageIndex = 0,
|
||||
index = index,
|
||||
subtype = PdfiumAnnotationSubtype.TEXT,
|
||||
bounds = bounds,
|
||||
contents = contents,
|
||||
author = "Reader",
|
||||
name = name,
|
||||
inReplyTo = inReplyTo
|
||||
)
|
||||
}
|
||||
|
||||
private val testJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfInkRenderingTest {
|
||||
|
||||
@Test
|
||||
fun `normalized Android stroke widths scale from page width`() {
|
||||
assertEquals(
|
||||
expected = 8f,
|
||||
actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(0.008f, pageWidthPx = 1_000f),
|
||||
absoluteTolerance = 0.0001f
|
||||
)
|
||||
assertEquals(
|
||||
expected = 35f,
|
||||
actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(0.035f, pageWidthPx = 1_000f),
|
||||
absoluteTolerance = 0.0001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy desktop pixel stroke widths remain usable`() {
|
||||
assertEquals(
|
||||
expected = 12f,
|
||||
actual = SharedPdfInkRenderer.effectiveStrokeWidthPx(12f, pageWidthPx = 1_000f),
|
||||
absoluteTolerance = 0.0001f
|
||||
)
|
||||
assertEquals(
|
||||
expected = 0.012f,
|
||||
actual = SharedPdfInkRenderer.effectiveStrokeWidthNorm(12f, pageWidthPx = 1_000f),
|
||||
absoluteTolerance = 0.0001f
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `snap helper follows Android horizontal and vertical threshold behavior`() {
|
||||
val start = PdfPagePoint(0.2f, 0.2f)
|
||||
val horizontal = SharedPdfInkRenderer.calculateSnappedPoint(
|
||||
currentPoint = PdfPagePoint(0.8f, 0.215f),
|
||||
startPoint = start,
|
||||
pageAspectRatio = 1f
|
||||
)
|
||||
val vertical = SharedPdfInkRenderer.calculateSnappedPoint(
|
||||
currentPoint = PdfPagePoint(0.215f, 0.8f),
|
||||
startPoint = start,
|
||||
pageAspectRatio = 1f
|
||||
)
|
||||
|
||||
assertEquals(start.y, horizontal.y)
|
||||
assertEquals(start.x, vertical.x)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eraser hit test checks full ink segments instead of only sampled points`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.PEN,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.2f), PdfPagePoint(0.9f, 0.2f)),
|
||||
colorArgb = 0xFFFF0000.toInt(),
|
||||
strokeWidth = 0.008f
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
SharedPdfInkRenderer.isAnnotationHit(
|
||||
annotation = annotation,
|
||||
hitPoint = PdfPagePoint(0.5f, 0.205f),
|
||||
pageWidthPx = 1_000f,
|
||||
pageAspectRatio = 1f,
|
||||
eraserStrokeWidth = 0.01f
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
SharedPdfInkRenderer.isAnnotationHit(
|
||||
annotation = annotation,
|
||||
hitPoint = PdfPagePoint(0.5f, 0.4f),
|
||||
pageWidthPx = 1_000f,
|
||||
pageAspectRatio = 1f,
|
||||
eraserStrokeWidth = 0.01f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializer preserves richer shared text annotation style`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "text",
|
||||
pageIndex = 2,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = PdfPageBounds(0.1f, 0.2f, 0.5f, 0.3f),
|
||||
text = "Styled note",
|
||||
colorArgb = 0xFF101010.toInt(),
|
||||
backgroundArgb = 0x55FFEB3B,
|
||||
fontSize = 20f,
|
||||
isBold = true,
|
||||
isItalic = true,
|
||||
isUnderline = true,
|
||||
isStrikeThrough = true,
|
||||
fontName = "Merriweather",
|
||||
fontPath = "asset:fonts/merriweather.ttf"
|
||||
)
|
||||
|
||||
val decoded = SharedPdfAnnotationSerializer.decode(
|
||||
SharedPdfAnnotationSerializer.encode(listOf(annotation))
|
||||
)
|
||||
|
||||
assertEquals(listOf(annotation), decoded)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfReflowTest {
|
||||
@Test
|
||||
fun `detectRepeatingHeaderFooter returns edge text repeated across sampled pages`() {
|
||||
val samples = listOf(
|
||||
listOf("Book Title", "Chapter", "Body one", "12"),
|
||||
listOf("Book Title", "Chapter", "Body two", "13"),
|
||||
listOf("Book Title", "Chapter", "Body three", "14"),
|
||||
listOf("Book Title", "Chapter", "Body four", "15"),
|
||||
listOf("Other Title", "Chapter", "Body five", "16")
|
||||
)
|
||||
|
||||
val result = SharedPdfReflowHtml.detectRepeatingHeaderFooter(samples)
|
||||
|
||||
assertTrue("Book Title" in result)
|
||||
assertTrue("Chapter" in result)
|
||||
assertFalse("Body one" in result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPageHtml maps Android-style spans headings and lists`() {
|
||||
val page = SharedPdfReflowPage(
|
||||
pageNumber = 3,
|
||||
elements = listOf(
|
||||
textLine("Repeated Header", size = 10f),
|
||||
textLine("Chapter Heading", size = 22f, bold = true),
|
||||
textLine("This is a paragraph that should keep bold and italic words.", size = 12f, bold = true, italic = true),
|
||||
textLine("- first item", size = 12f),
|
||||
textLine("2. second item", size = 12f)
|
||||
)
|
||||
)
|
||||
|
||||
val html = SharedPdfReflowHtml.buildPageHtml(
|
||||
page = page,
|
||||
headerFooterStrings = setOf("Repeated Header")
|
||||
)
|
||||
|
||||
assertTrue("<p class=\"page-marker\">-- Page 3 --</p>" in html)
|
||||
assertFalse("Repeated Header" in html)
|
||||
assertTrue("<h1>Chapter Heading</h1>" in html)
|
||||
assertTrue("<strong><em>This is a paragraph" in html)
|
||||
assertTrue("<ul>" in html)
|
||||
assertTrue("<li>first item</li>" in html)
|
||||
assertTrue("<ol>" in html)
|
||||
assertTrue("<li>second item</li>" in html)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty page renders fallback section`() {
|
||||
val html = SharedPdfReflowHtml.buildPageHtml(
|
||||
SharedPdfReflowPage(pageNumber = 1, elements = emptyList())
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"<section class=\"page-section\">\n" +
|
||||
"<p class=\"page-marker\">-- Page 1 --</p>\n" +
|
||||
"<p><em>(No text on this page)</em></p>\n</section>\n",
|
||||
html
|
||||
)
|
||||
}
|
||||
|
||||
private fun textLine(
|
||||
text: String,
|
||||
size: Float,
|
||||
bold: Boolean = false,
|
||||
italic: Boolean = false
|
||||
): SharedPdfReflowTextElement {
|
||||
return SharedPdfReflowTextElement(
|
||||
SharedPdfReflowTextLine(
|
||||
spans = listOf(
|
||||
SharedPdfReflowTextSpan(
|
||||
text = text,
|
||||
size = size,
|
||||
isBold = bold,
|
||||
isItalic = italic
|
||||
)
|
||||
),
|
||||
yPos = 0f,
|
||||
charCount = text.length
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfRichTextTest {
|
||||
|
||||
@Test
|
||||
fun `mapper clips global rich spans into requested local range`() {
|
||||
val document = SharedPdfRichDocument(
|
||||
text = "0123456789",
|
||||
spans = listOf(
|
||||
SharedPdfRichSpan(
|
||||
start = 2,
|
||||
end = 6,
|
||||
color = Color.Red.toArgb(),
|
||||
backgroundColor = Color.Yellow.toArgb(),
|
||||
fontSizeNorm = 0.02f,
|
||||
isBold = true,
|
||||
isItalic = true,
|
||||
isUnderline = true,
|
||||
isStrikethrough = true,
|
||||
fontPath = "asset:fonts/lora.ttf"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val annotated = SharedPdfRichTextMapper.toAnnotatedString(
|
||||
document = document,
|
||||
pageHeightPx = 1_000f,
|
||||
rangeStart = 4,
|
||||
rangeEnd = 8
|
||||
)
|
||||
|
||||
assertEquals("4567", annotated.text)
|
||||
val range = annotated.spanStyles.single()
|
||||
assertEquals(0, range.start)
|
||||
assertEquals(2, range.end)
|
||||
assertEquals(Color.Red, range.item.color)
|
||||
assertEquals(Color.Yellow, range.item.background)
|
||||
assertEquals(20.sp, range.item.fontSize)
|
||||
assertEquals(FontWeight.Bold, range.item.fontWeight)
|
||||
assertEquals(FontStyle.Italic, range.item.fontStyle)
|
||||
assertTrue(range.item.textDecoration!!.contains(TextDecoration.Underline))
|
||||
assertTrue(range.item.textDecoration!!.contains(TextDecoration.LineThrough))
|
||||
|
||||
val roundTrip = SharedPdfRichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 1_000f)
|
||||
assertEquals("4567", roundTrip.text)
|
||||
assertEquals("asset:fonts/lora.ttf", roundTrip.spans.single().fontPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mapper fromAnnotatedString splits overlapping styles and preserves page breaks`() {
|
||||
val text = "Hello${SHARED_PDF_PAGE_BREAK_CHAR}World"
|
||||
val annotated = buildAnnotatedString {
|
||||
append(text)
|
||||
addStyle(
|
||||
SpanStyle(
|
||||
color = Color.Black,
|
||||
background = Color.Transparent,
|
||||
fontSize = 20.sp
|
||||
),
|
||||
start = 0,
|
||||
end = text.length
|
||||
)
|
||||
addStyle(
|
||||
SpanStyle(
|
||||
color = Color.Magenta,
|
||||
background = Color.Cyan,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic,
|
||||
textDecoration = TextDecoration.combine(
|
||||
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
|
||||
)
|
||||
),
|
||||
start = 0,
|
||||
end = 5
|
||||
)
|
||||
}
|
||||
|
||||
val document = SharedPdfRichTextMapper.fromAnnotatedString(annotated, pageHeightPx = 1_000f)
|
||||
|
||||
assertEquals(text, document.text)
|
||||
assertEquals(2, document.spans.size)
|
||||
val first = document.spans[0]
|
||||
assertEquals(0, first.start)
|
||||
assertEquals(5, first.end)
|
||||
assertEquals(Color.Magenta.toArgb(), first.color)
|
||||
assertEquals(Color.Cyan.toArgb(), first.backgroundColor)
|
||||
assertEquals(0.024f, first.fontSizeNorm, 0.0001f)
|
||||
assertTrue(first.isBold)
|
||||
assertTrue(first.isItalic)
|
||||
assertTrue(first.isUnderline)
|
||||
assertTrue(first.isStrikethrough)
|
||||
val second = document.spans[1]
|
||||
assertEquals(5, second.start)
|
||||
assertEquals(text.length, second.end)
|
||||
assertEquals(Color.Black.toArgb(), second.color)
|
||||
assertFalse(second.isBold)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializer uses android rich text sidecar schema`() {
|
||||
val document = SharedPdfRichDocument(
|
||||
text = "Saved rich text",
|
||||
spans = listOf(
|
||||
SharedPdfRichSpan(
|
||||
start = 0,
|
||||
end = 5,
|
||||
color = Color.Red.toArgb(),
|
||||
backgroundColor = Color.Transparent.toArgb(),
|
||||
fontSizeNorm = 0.018f,
|
||||
isBold = true,
|
||||
isItalic = false,
|
||||
isUnderline = true,
|
||||
isStrikethrough = false,
|
||||
fontPath = "asset:fonts/lora.ttf"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val encoded = SharedPdfRichTextSerializer.encode(document)
|
||||
val decoded = SharedPdfRichTextSerializer.decode(encoded)
|
||||
|
||||
assertTrue(encoded.contains("\"s\""))
|
||||
assertTrue(encoded.contains("\"fp\""))
|
||||
assertEquals(document, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loaded rich text rescales font spans when real page height arrives`() {
|
||||
val document = SharedPdfRichDocument(
|
||||
text = "Stable size",
|
||||
spans = listOf(
|
||||
SharedPdfRichSpan(
|
||||
start = 0,
|
||||
end = 6,
|
||||
color = Color.Black.toArgb(),
|
||||
backgroundColor = Color.Transparent.toArgb(),
|
||||
fontSizeNorm = 0.02f,
|
||||
isBold = false,
|
||||
isItalic = false,
|
||||
isUnderline = false,
|
||||
isStrikethrough = false
|
||||
)
|
||||
)
|
||||
)
|
||||
val referenceHeight = 1_414f
|
||||
val actualHeight = 1_000f
|
||||
val loadedBeforeLayout = SharedPdfRichTextMapper.toAnnotatedString(document, referenceHeight)
|
||||
|
||||
val loadedAtActualHeight = loadedBeforeLayout.withScaledSharedPdfRichFontSizes(actualHeight / referenceHeight)
|
||||
val savedAgain = SharedPdfRichTextMapper.fromAnnotatedString(loadedAtActualHeight, actualHeight)
|
||||
|
||||
assertEquals(20.sp, loadedAtActualHeight.spanStyles.single().item.fontSize)
|
||||
assertEquals(0.02f, savedAgain.spans.single().fontSizeNorm, 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `serializer returns empty document for blank and corrupt payloads`() {
|
||||
assertEquals(SharedPdfRichDocument(), SharedPdfRichTextSerializer.decode(""))
|
||||
assertEquals(SharedPdfRichDocument(), SharedPdfRichTextSerializer.decode("{not json"))
|
||||
assertEquals(
|
||||
SharedPdfRichDocument("", emptyList()),
|
||||
SharedPdfRichTextMapper.fromAnnotatedString(AnnotatedString(""), pageHeightPx = 1_000f)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection bounds normalize reversed and clamped rich text selections`() {
|
||||
assertEquals(44 to 45, sharedPdfRichTextSelectionBounds(45, 44, textLength = 45))
|
||||
assertEquals(0 to 5, sharedPdfRichTextSelectionBounds(-3, 99, textLength = 5))
|
||||
assertEquals(null, sharedPdfRichTextSelectionBounds(3, 3, textLength = 5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing page break creates editable blank page layout`() {
|
||||
val globalText = AnnotatedString("$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
val layouts = listOf(
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 0,
|
||||
visibleText = globalText,
|
||||
globalStartIndex = 0,
|
||||
globalEndIndex = 1,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
)
|
||||
|
||||
val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded(
|
||||
globalText = globalText,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
|
||||
assertEquals(2, withBlankPage.size)
|
||||
assertEquals(1, withBlankPage.last().pageIndex)
|
||||
assertEquals("", withBlankPage.last().visibleText.text)
|
||||
assertEquals(1, withBlankPage.last().globalStartIndex)
|
||||
assertEquals(1, withBlankPage.last().globalEndIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trailing blank page helper is idempotent`() {
|
||||
val globalText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
val layouts = listOf(
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 0,
|
||||
visibleText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR"),
|
||||
globalStartIndex = 0,
|
||||
globalEndIndex = 2,
|
||||
pageHeightPx = 1_000f
|
||||
),
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 1,
|
||||
visibleText = AnnotatedString(""),
|
||||
globalStartIndex = 2,
|
||||
globalEndIndex = 2,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
)
|
||||
|
||||
val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded(
|
||||
globalText = globalText,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
|
||||
assertEquals(layouts, withBlankPage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consecutive explicit page breaks keep editable blank pages`() {
|
||||
val globalText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
val layouts = listOf(
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 0,
|
||||
visibleText = AnnotatedString("A$SHARED_PDF_PAGE_BREAK_CHAR"),
|
||||
globalStartIndex = 0,
|
||||
globalEndIndex = 2,
|
||||
pageHeightPx = 1_000f
|
||||
),
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 1,
|
||||
visibleText = AnnotatedString("$SHARED_PDF_PAGE_BREAK_CHAR"),
|
||||
globalStartIndex = 2,
|
||||
globalEndIndex = 3,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
)
|
||||
|
||||
val withBlankPage = layouts.withTrailingBlankRichTextPageIfNeeded(
|
||||
globalText = globalText,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
|
||||
assertEquals(3, withBlankPage.size)
|
||||
assertEquals("A$SHARED_PDF_PAGE_BREAK_CHAR", withBlankPage[0].visibleText.text)
|
||||
assertEquals("$SHARED_PDF_PAGE_BREAK_CHAR", withBlankPage[1].visibleText.text)
|
||||
assertEquals("", withBlankPage[2].visibleText.text)
|
||||
assertEquals(3, withBlankPage[2].globalStartIndex)
|
||||
assertEquals(3, withBlankPage[2].globalEndIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editable rich text hides trailing structural page break`() {
|
||||
val text = AnnotatedString("Body$SHARED_PDF_PAGE_BREAK_CHAR")
|
||||
|
||||
val editable = text.withoutTrailingSharedPdfPageBreak()
|
||||
|
||||
assertEquals("Body", editable.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank page insertion uses one page break at explicit rich text boundaries`() {
|
||||
val text = "Page 1$SHARED_PDF_PAGE_BREAK_CHARPage 2"
|
||||
val insertionIndex = "Page 1$SHARED_PDF_PAGE_BREAK_CHAR".length
|
||||
|
||||
assertEquals(1, sharedPdfRichTextBlankInsertBreakCount(text, insertionIndex))
|
||||
assertEquals(1, sharedPdfRichTextBlankInsertBreakCount("Page 1", "Page 1".length))
|
||||
assertEquals(1, sharedPdfRichTextBlankInsertBreakCount("Page 1", 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank page insertion uses two page breaks only for measured text boundaries with following content`() {
|
||||
val text = "Page 1Page 2"
|
||||
val insertionIndex = "Page 1".length
|
||||
|
||||
assertEquals(2, sharedPdfRichTextBlankInsertBreakCount(text, insertionIndex))
|
||||
assertEquals(
|
||||
insertionIndex,
|
||||
sharedPdfRichTextInsertionIndexForPage(
|
||||
insertPageIndex = 1,
|
||||
pageLayouts = listOf(
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 0,
|
||||
visibleText = AnnotatedString("Page 1"),
|
||||
globalStartIndex = 0,
|
||||
globalEndIndex = insertionIndex,
|
||||
pageHeightPx = 1_000f
|
||||
),
|
||||
SharedPdfRichPageLayout(
|
||||
pageIndex = 1,
|
||||
visibleText = AnnotatedString("Page 2"),
|
||||
globalStartIndex = insertionIndex,
|
||||
globalEndIndex = text.length,
|
||||
pageHeightPx = 1_000f
|
||||
)
|
||||
),
|
||||
textLength = text.length
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
package org.dueattendant149.bookreader.shared.pdf
|
||||
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedPdfTextAnnotationsTest {
|
||||
|
||||
@Test
|
||||
fun `createAnnotation applies Android-style text config`() {
|
||||
val style = SharedPdfTextStyleConfig(
|
||||
colorArgb = 0xFF123456.toInt(),
|
||||
backgroundColorArgb = 0x8CFFEB3B.toInt(),
|
||||
fontSize = 20f,
|
||||
isBold = true,
|
||||
isItalic = true,
|
||||
isUnderline = true,
|
||||
isStrikeThrough = true,
|
||||
fontPath = "asset:fonts/lora.ttf",
|
||||
fontName = "Lora"
|
||||
)
|
||||
|
||||
val annotation = SharedPdfTextAnnotationDefaults.createAnnotation(
|
||||
id = "text-1",
|
||||
pageIndex = 3,
|
||||
anchor = PdfPagePoint(0.8f, 0.92f, 42L),
|
||||
canvasSize = IntSize(1_000, 1_400),
|
||||
text = " Styled note ",
|
||||
style = style,
|
||||
createdAt = 99L
|
||||
)
|
||||
|
||||
assertEquals(PdfAnnotationKind.TEXT, annotation.kind)
|
||||
assertEquals(PdfInkTool.TEXT, annotation.tool)
|
||||
assertEquals("Styled note", annotation.text)
|
||||
assertEquals(style.copy(pageRelativeFontSize = 0.04f), annotation.sharedPdfTextStyle())
|
||||
assertEquals(0.04f, annotation.pageRelativeFontSize ?: 0f, 0.0001f)
|
||||
assertEquals(99L, annotation.createdAt)
|
||||
assertTrue(annotation.bounds!!.left >= 0f)
|
||||
assertTrue(annotation.bounds.right <= 1f)
|
||||
assertTrue(annotation.bounds.top >= 0f)
|
||||
assertTrue(annotation.bounds.bottom <= 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withSharedPdfTextStyle replaces all style fields only`() {
|
||||
val original = SharedPdfAnnotation(
|
||||
id = "text-2",
|
||||
pageIndex = 1,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
tool = PdfInkTool.TEXT,
|
||||
bounds = PdfPageBounds(0.1f, 0.2f, 0.5f, 0.3f),
|
||||
text = "Keep me",
|
||||
colorArgb = 0xFF000000.toInt(),
|
||||
backgroundArgb = 0x00000000,
|
||||
fontSize = 16f,
|
||||
createdAt = 5L
|
||||
)
|
||||
val style = SharedPdfTextStyleConfig(
|
||||
colorArgb = 0xFFFF0000.toInt(),
|
||||
backgroundColorArgb = 0x8C64B5F6.toInt(),
|
||||
fontSize = 24f,
|
||||
isBold = true,
|
||||
fontName = "Roboto Mono",
|
||||
fontPath = "asset:fonts/roboto_mono.ttf"
|
||||
)
|
||||
|
||||
val updated = original.withSharedPdfTextStyle(style)
|
||||
|
||||
assertEquals("text-2", updated.id)
|
||||
assertEquals("Keep me", updated.text)
|
||||
assertEquals(original.bounds, updated.bounds)
|
||||
assertEquals(5L, updated.createdAt)
|
||||
assertEquals(style.copy(pageRelativeFontSize = 0.048f), updated.sharedPdfTextStyle())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page relative font size drives Android-compatible text rendering size`() {
|
||||
val canvasSize = IntSize(1_000, 1_500)
|
||||
val style = SharedPdfTextStyleConfig(fontSize = 20f, pageRelativeFontSize = 0.03f)
|
||||
|
||||
assertEquals(45f, style.sharedPdfTextFontSizePx(canvasSize), 0.0001f)
|
||||
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "text-android-size",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.TEXT,
|
||||
bounds = PdfPageBounds(0.1f, 0.1f, 0.4f, 0.2f),
|
||||
text = "Sized like Android",
|
||||
colorArgb = 0xFF000000.toInt(),
|
||||
fontSize = 20f,
|
||||
pageRelativeFontSize = 0.03f
|
||||
)
|
||||
|
||||
assertEquals(45f, annotation.sharedPdfTextFontSizePx(canvasSize), 0.0001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text bounds grow for wrapped content and stay on page`() {
|
||||
val style = SharedPdfTextStyleConfig(fontSize = 18f)
|
||||
val shortBounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = PdfPagePoint(0.1f, 0.1f),
|
||||
canvasSize = IntSize(800, 1_200),
|
||||
text = "Short",
|
||||
style = style
|
||||
)
|
||||
val longBounds = SharedPdfTextAnnotationDefaults.boundsForPlacedText(
|
||||
anchor = PdfPagePoint(0.92f, 0.96f),
|
||||
canvasSize = IntSize(800, 1_200),
|
||||
text = "This is a much longer text annotation that should wrap across multiple lines.",
|
||||
style = style
|
||||
)
|
||||
|
||||
assertTrue(longBounds.bottom - longBounds.top > shortBounds.bottom - shortBounds.top)
|
||||
assertTrue(longBounds.right <= 1f)
|
||||
assertTrue(longBounds.bottom <= 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draft starts empty at click location and commits as text annotation`() {
|
||||
val style = SharedPdfTextStyleConfig(
|
||||
colorArgb = 0xFF4A148C.toInt(),
|
||||
backgroundColorArgb = 0x8CFFEB3B.toInt(),
|
||||
fontSize = 18f,
|
||||
isBold = true
|
||||
)
|
||||
val draft = SharedPdfTextAnnotationDefaults.createDraft(
|
||||
id = "text-draft",
|
||||
pageIndex = 2,
|
||||
anchor = PdfPagePoint(0.2f, 0.3f, 7L),
|
||||
canvasSize = IntSize(1_000, 1_400),
|
||||
style = style,
|
||||
createdAt = 7L
|
||||
).withText(" Inline note ", IntSize(1_000, 1_400))
|
||||
|
||||
val annotation = draft.toAnnotation()
|
||||
|
||||
assertEquals(PdfAnnotationKind.TEXT, annotation.kind)
|
||||
assertEquals(PdfInkTool.TEXT, annotation.tool)
|
||||
assertEquals("Inline note", annotation.text)
|
||||
assertEquals(style.copy(pageRelativeFontSize = 0.036f), annotation.sharedPdfTextStyle())
|
||||
assertEquals(draft.bounds, annotation.bounds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draft reflows when text or style changes`() {
|
||||
val canvasSize = IntSize(800, 1_200)
|
||||
val draft = SharedPdfTextAnnotationDefaults.createDraft(
|
||||
id = "text-draft-2",
|
||||
pageIndex = 0,
|
||||
anchor = PdfPagePoint(0.82f, 0.9f),
|
||||
canvasSize = canvasSize,
|
||||
style = SharedPdfTextStyleConfig(fontSize = 14f),
|
||||
createdAt = 11L
|
||||
)
|
||||
val expanded = draft.withText(
|
||||
"A longer inline text annotation that wraps across more than one row.",
|
||||
canvasSize
|
||||
)
|
||||
val restyled = expanded.withStyle(expanded.style.copy(fontSize = 24f), canvasSize)
|
||||
|
||||
assertTrue(expanded.bounds.bottom - expanded.bounds.top > draft.bounds.bottom - draft.bounds.top)
|
||||
assertTrue(restyled.bounds.bottom - restyled.bounds.top > expanded.bounds.bottom - expanded.bounds.top)
|
||||
assertTrue(restyled.bounds.right <= 1f)
|
||||
assertTrue(restyled.bounds.bottom <= 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manually sized draft preserves bounds while typing and styling`() {
|
||||
val canvasSize = IntSize(800, 1_200)
|
||||
val resizedBounds = PdfPageBounds(0.2f, 0.3f, 0.7f, 0.48f)
|
||||
val draft = SharedPdfTextAnnotationDefaults.createDraft(
|
||||
id = "text-draft-3",
|
||||
pageIndex = 0,
|
||||
anchor = PdfPagePoint(0.2f, 0.3f),
|
||||
canvasSize = canvasSize,
|
||||
style = SharedPdfTextStyleConfig(fontSize = 14f),
|
||||
createdAt = 12L
|
||||
).withBounds(resizedBounds)
|
||||
|
||||
val typed = draft.withText("Manual size should stay fixed", canvasSize)
|
||||
val styled = typed.withStyle(typed.style.copy(fontSize = 24f), canvasSize)
|
||||
|
||||
assertEquals(resizedBounds, typed.bounds)
|
||||
assertEquals(resizedBounds, styled.bounds)
|
||||
assertTrue(styled.isManuallySized)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resize handle updates normalized bounds and keeps box on page`() {
|
||||
val resized = PdfPageBounds(0.2f, 0.2f, 0.5f, 0.4f).resizedBy(
|
||||
handle = SharedPdfTextResizeHandle.BOTTOM_RIGHT,
|
||||
deltaXPx = 160f,
|
||||
deltaYPx = 120f,
|
||||
canvasSize = IntSize(1_000, 1_000)
|
||||
)
|
||||
val clamped = resized.resizedBy(
|
||||
handle = SharedPdfTextResizeHandle.TOP_LEFT,
|
||||
deltaXPx = -1_000f,
|
||||
deltaYPx = -1_000f,
|
||||
canvasSize = IntSize(1_000, 1_000)
|
||||
)
|
||||
|
||||
assertTrue(abs(resized.right - 0.66f) < 0.001f)
|
||||
assertTrue(abs(resized.bottom - 0.52f) < 0.001f)
|
||||
assertEquals(0f, clamped.left)
|
||||
assertEquals(0f, clamped.top)
|
||||
assertTrue(clamped.right <= 1f)
|
||||
assertTrue(clamped.bottom <= 1f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move keeps text box size and clamps to page`() {
|
||||
val moved = PdfPageBounds(0.2f, 0.3f, 0.5f, 0.45f).movedBy(
|
||||
deltaXPx = 100f,
|
||||
deltaYPx = -120f,
|
||||
canvasSize = IntSize(1_000, 1_000)
|
||||
)
|
||||
val clamped = moved.movedBy(
|
||||
deltaXPx = 1_000f,
|
||||
deltaYPx = 1_000f,
|
||||
canvasSize = IntSize(1_000, 1_000)
|
||||
)
|
||||
|
||||
assertTrue(abs((moved.right - moved.left) - 0.3f) < 0.001f)
|
||||
assertTrue(abs((moved.bottom - moved.top) - 0.15f) < 0.001f)
|
||||
assertTrue(abs(moved.left - 0.3f) < 0.001f)
|
||||
assertTrue(abs(moved.top - 0.18f) < 0.001f)
|
||||
assertTrue(abs(clamped.left - 0.7f) < 0.001f)
|
||||
assertTrue(abs(clamped.top - 0.85f) < 0.001f)
|
||||
assertEquals(1f, clamped.right)
|
||||
assertEquals(1f, clamped.bottom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizeTextDraft trims and normalizes line endings`() {
|
||||
assertEquals(
|
||||
"Line one\nLine two",
|
||||
SharedPdfTextAnnotationDefaults.normalizeTextDraft(" \r\nLine one\r\nLine two\n ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,581 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||
import org.dueattendant149.bookreader.paginatedreader.CssStyle
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertSame
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderEngineTest {
|
||||
|
||||
@Test
|
||||
fun `createSession restores page and valid bookmarks`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = longBook()
|
||||
val restored = engine.createSession(
|
||||
book = book,
|
||||
initialPageIndex = 2,
|
||||
bookmarks = listOf(
|
||||
ReaderBookmark("keep", pageIndex = 1, chapterTitle = "One", preview = "Valid"),
|
||||
ReaderBookmark("drop", pageIndex = 200, chapterTitle = "One", preview = "Invalid")
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(2, restored.reader.currentPageIndex)
|
||||
assertEquals(listOf("keep"), restored.bookmarks.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSession reuses paginated pages for the same book and settings`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = longBook()
|
||||
|
||||
val first = engine.createSession(book)
|
||||
val second = engine.createSession(book)
|
||||
|
||||
assertSame(first.reader.pages, second.reader.pages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible locator and bookmarks prefer android style cfi when semantic blocks provide it`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
SharedEpubBook(
|
||||
id = "semantic",
|
||||
fileName = "semantic.epub",
|
||||
title = "Semantic",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Alpha beta",
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "Alpha beta",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2/2",
|
||||
startCharOffsetInSource = 0
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val bookmarked = engine.toggleBookmark(session)
|
||||
|
||||
assertEquals("/4/2/2:0", session.navigationLocator?.cfi)
|
||||
assertEquals("/4/2/2:0", bookmarked.bookmarks.single().locator.cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visual settings update does not repaginate or move current page`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.goToPage(engine.createSession(longBook()), 1)
|
||||
val oldPages = session.reader.pages
|
||||
val oldPageIndex = session.reader.currentPageIndex
|
||||
|
||||
val updated = engine.updateSettings(
|
||||
session,
|
||||
session.reader.settings.copy(
|
||||
darkMode = true,
|
||||
themeId = "night",
|
||||
backgroundColorArgb = 0xFF101010L,
|
||||
textColorArgb = 0xFFEFEFEFL,
|
||||
textureId = "paper",
|
||||
textureAlpha = 0.25f
|
||||
)
|
||||
)
|
||||
|
||||
assertSame(oldPages, updated.reader.pages)
|
||||
assertEquals(oldPageIndex, updated.reader.currentPageIndex)
|
||||
assertEquals("night", updated.reader.settings.themeId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical page navigation uses scroll page locator for webview slider sync`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = longBook(),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
|
||||
val moved = engine.goToPage(session, 1)
|
||||
|
||||
assertEquals(1, moved.reader.currentPageIndex)
|
||||
assertEquals("desktop-scroll-page:1", moved.navigationLocator?.cfi)
|
||||
assertEquals(1, moved.navigationLocator?.pageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible page sync stores stable vertical locator cfi instead of scroll metrics`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = longBook(),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
|
||||
)
|
||||
val page = session.reader.pages[1]
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = page.chapterIndex,
|
||||
pageIndex = page.pageIndex,
|
||||
startOffset = page.startOffset + 24,
|
||||
endOffset = page.startOffset + 24,
|
||||
cfi = "desktop-scroll:120:800:desktop:${page.chapterIndex}:${page.startOffset + 24}:${page.startOffset + 24}"
|
||||
)
|
||||
|
||||
val synced = engine.syncVisiblePage(session, page.pageIndex, locator)
|
||||
|
||||
assertEquals("desktop:${page.chapterIndex}:${page.startOffset + 24}:${page.startOffset + 24}", synced.navigationLocator?.cfi)
|
||||
assertEquals(locator.startOffset, synced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createSession restores precise locator ahead of fallback page index`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = longBook()
|
||||
val base = engine.createSession(book)
|
||||
val targetPage = base.reader.pages.getOrNull(2) ?: error("Expected multiple pages")
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = targetPage.chapterIndex,
|
||||
pageIndex = targetPage.pageIndex,
|
||||
startOffset = targetPage.startOffset + 12,
|
||||
endOffset = targetPage.startOffset + 12,
|
||||
cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 12}:${targetPage.startOffset + 12}"
|
||||
)
|
||||
|
||||
val restored = engine.createSession(
|
||||
book = book,
|
||||
initialPageIndex = 0,
|
||||
initialLocator = locator
|
||||
)
|
||||
|
||||
assertEquals(targetPage.pageIndex, restored.navigationLocator?.pageIndex)
|
||||
assertEquals(targetPage.pageIndex, restored.reader.currentPageIndex)
|
||||
assertEquals(locator.startOffset, restored.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layout settings update keeps precise visible locator across reading modes`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook())
|
||||
val targetPage = session.reader.pages.getOrNull(1) ?: error("Expected multiple pages")
|
||||
val visibleLocator = ReaderLocator(
|
||||
chapterIndex = targetPage.chapterIndex,
|
||||
pageIndex = targetPage.pageIndex,
|
||||
startOffset = targetPage.startOffset + 40,
|
||||
endOffset = targetPage.startOffset + 40,
|
||||
textQuote = "visible text",
|
||||
cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 40}:${targetPage.startOffset + 40}"
|
||||
)
|
||||
val synced = engine.syncVisiblePage(session, targetPage.pageIndex, visibleLocator)
|
||||
|
||||
val updated = engine.updateSettings(
|
||||
synced,
|
||||
synced.reader.settings.copy(
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
fontSize = synced.reader.settings.fontSize + 4
|
||||
)
|
||||
)
|
||||
|
||||
val page = updated.reader.currentPage ?: error("Expected current page")
|
||||
assertEquals(visibleLocator.startOffset, updated.navigationLocator?.startOffset)
|
||||
assertTrue(visibleLocator.startOffset!! in page.startOffset..page.endOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page spread keeps right page locator while normalizing visible spread start`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook())
|
||||
val targetPage = session.reader.pages.getOrNull(3) ?: error("Expected multiple pages")
|
||||
val locator = ReaderLocator(
|
||||
chapterIndex = targetPage.chapterIndex,
|
||||
pageIndex = targetPage.pageIndex,
|
||||
startOffset = targetPage.startOffset + 20,
|
||||
endOffset = targetPage.startOffset + 20,
|
||||
cfi = "desktop:${targetPage.chapterIndex}:${targetPage.startOffset + 20}:${targetPage.startOffset + 20}"
|
||||
)
|
||||
val synced = engine.syncVisiblePage(session, targetPage.pageIndex, locator)
|
||||
|
||||
val updated = engine.updateSettings(
|
||||
synced,
|
||||
synced.reader.settings.copy(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(targetPage.pageIndex - 1, updated.reader.currentPageIndex)
|
||||
assertEquals(targetPage.pageIndex, updated.navigationLocator?.pageIndex)
|
||||
assertEquals(locator.startOffset, updated.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search returns every match on a page`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Alpha beta alpha gamma ALPHA."
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val searched = engine.search(session, "alpha")
|
||||
|
||||
assertEquals(3, searched.searchResults.size)
|
||||
assertEquals(listOf(0, 11, 23), searched.searchResults.map { it.matchIndex })
|
||||
assertTrue(searched.searchResults.all { it.pageIndex == 0 })
|
||||
assertEquals(-1, searched.activeSearchResultIndex)
|
||||
|
||||
val secondMatch = engine.goToSearchResult(searched, 1)
|
||||
|
||||
assertEquals(1, secondMatch.activeSearchResultIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLink returns external target for web urls`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook())
|
||||
|
||||
val target = engine.resolveLink(session, "https://example.com/page", sourceChapterIndex = 0)
|
||||
|
||||
assertTrue(target is ReaderLinkTarget.External)
|
||||
target as ReaderLinkTarget.External
|
||||
assertEquals("https://example.com/page", target.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLink normalizes scheme-less web links`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(longBook())
|
||||
|
||||
val target = engine.resolveLink(session, "www.example.com/page", sourceChapterIndex = 0)
|
||||
|
||||
assertTrue(target is ReaderLinkTarget.External)
|
||||
target as ReaderLinkTarget.External
|
||||
assertEquals("https://www.example.com/page", target.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLink maps relative epub href to target chapter locator`() {
|
||||
val engine = ReaderEngine()
|
||||
val targetText = "Intro target paragraph"
|
||||
val session = engine.createSession(
|
||||
SharedEpubBook(
|
||||
id = "links",
|
||||
fileName = "links.epub",
|
||||
title = "Links",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = "Source chapter",
|
||||
baseHref = "Text/one.xhtml"
|
||||
),
|
||||
SharedEpubChapter(
|
||||
id = "two",
|
||||
title = "Two",
|
||||
plainText = targetText,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = targetText,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "target",
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 6
|
||||
)
|
||||
),
|
||||
baseHref = "Text/two.xhtml"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val target = engine.resolveLink(session, "two.xhtml?unused=1#target", sourceChapterIndex = 0)
|
||||
|
||||
assertTrue(target is ReaderLinkTarget.Internal)
|
||||
target as ReaderLinkTarget.Internal
|
||||
assertEquals(1, target.locator.chapterIndex)
|
||||
assertEquals(6, target.locator.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveLink maps intercepted about blank fragment to source chapter locator`() {
|
||||
val engine = ReaderEngine()
|
||||
val text = "Source target paragraph"
|
||||
val session = engine.createSession(
|
||||
SharedEpubBook(
|
||||
id = "links",
|
||||
fileName = "links.epub",
|
||||
title = "Links",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = text,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = text,
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "spot",
|
||||
cfi = null,
|
||||
startCharOffsetInSource = 7
|
||||
)
|
||||
),
|
||||
baseHref = "Text/one.xhtml"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val target = engine.resolveLink(session, "about:blank#spot", sourceChapterIndex = 0)
|
||||
|
||||
assertTrue(target is ReaderLinkTarget.Internal)
|
||||
target as ReaderLinkTarget.Internal
|
||||
assertEquals(0, target.locator.chapterIndex)
|
||||
assertEquals(7, target.locator.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jump navigation records locator history and can step back and forward`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(multiChapterBook())
|
||||
|
||||
val second = engine.jumpToChapter(session, 1)
|
||||
val third = engine.jumpToChapter(second, 2)
|
||||
val back = engine.jumpBack(third)
|
||||
val forward = engine.jumpForward(back)
|
||||
|
||||
assertEquals(1, third.jumpHistory.backLocator?.chapterIndex)
|
||||
assertEquals(1, back.reader.currentPage?.chapterIndex)
|
||||
assertEquals(0, back.jumpHistory.backLocator?.chapterIndex)
|
||||
assertEquals(2, back.jumpHistory.forwardLocator?.chapterIndex)
|
||||
assertEquals(2, forward.reader.currentPage?.chapterIndex)
|
||||
assertTrue(engine.clearJumpHistory(forward).jumpHistory.locators.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginated mode does not record or use jump history`() {
|
||||
val engine = ReaderEngine()
|
||||
val session = engine.createSession(
|
||||
book = multiChapterBook(),
|
||||
settings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED)
|
||||
)
|
||||
|
||||
val jumped = engine.jumpToChapter(session, 1)
|
||||
val verticalWithHistory = engine.jumpToChapter(engine.createSession(multiChapterBook()), 1)
|
||||
val switchedToPaginated = engine.updateSettings(
|
||||
verticalWithHistory,
|
||||
verticalWithHistory.reader.settings.copy(readingMode = ReaderReadingMode.PAGINATED)
|
||||
)
|
||||
val withLegacyHistory = jumped.copy(
|
||||
jumpHistory = ReaderJumpHistory()
|
||||
.record(
|
||||
currentLocator = ReaderLocator(chapterIndex = 0, cfi = "desktop:0:0:0"),
|
||||
targetLocator = ReaderLocator(chapterIndex = 1, cfi = "desktop:1:0:0"),
|
||||
chapterCount = 3
|
||||
)
|
||||
)
|
||||
val back = engine.jumpBack(withLegacyHistory)
|
||||
|
||||
assertTrue(jumped.jumpHistory.locators.isEmpty())
|
||||
assertTrue(switchedToPaginated.jumpHistory.locators.isEmpty())
|
||||
assertEquals(jumped.reader.currentPageIndex, back.reader.currentPageIndex)
|
||||
assertTrue(back.jumpHistory.locators.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages uses captured reflow anchor when no newer navigation happened`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = manualRangeBook()
|
||||
val oldPages = listOf(
|
||||
ReaderPage(0, 0, "One", "first", 0, 100),
|
||||
ReaderPage(1, 0, "One", "second", 100, 200)
|
||||
)
|
||||
val newPages = listOf(
|
||||
ReaderPage(0, 0, "One", "first expanded", 0, 140),
|
||||
ReaderPage(1, 0, "One", "second shifted", 140, 260)
|
||||
)
|
||||
val session = engine.createSession(book).copy(
|
||||
reader = PaginatedReaderState(book, oldPages, currentPageIndex = 1),
|
||||
navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 0, startOffset = 20, endOffset = 20),
|
||||
navigationRequestId = 4L
|
||||
)
|
||||
val reflowAnchor = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 160, endOffset = 160)
|
||||
|
||||
val replaced = engine.replacePages(
|
||||
state = session,
|
||||
pages = newPages,
|
||||
reflowAnchor = reflowAnchor,
|
||||
navigationRequestIdAtReflowStart = 4L
|
||||
)
|
||||
|
||||
assertEquals(1, replaced.reader.currentPageIndex)
|
||||
assertEquals(1, replaced.navigationLocator?.pageIndex)
|
||||
assertEquals(160, replaced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages resolves page start anchors to the page after a touching boundary`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = manualRangeBook()
|
||||
val pages = listOf(
|
||||
ReaderPage(0, 0, "One", "first", 0, 100),
|
||||
ReaderPage(1, 0, "One", "second", 100, 200),
|
||||
ReaderPage(2, 0, "One", "third", 200, 300)
|
||||
)
|
||||
val session = engine.createSession(book).copy(
|
||||
reader = PaginatedReaderState(book, pages, currentPageIndex = 1),
|
||||
navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 100, endOffset = 100),
|
||||
navigationRequestId = 8L
|
||||
)
|
||||
|
||||
val replaced = engine.replacePages(
|
||||
state = session,
|
||||
pages = pages,
|
||||
reflowAnchor = session.navigationLocator,
|
||||
navigationRequestIdAtReflowStart = 8L
|
||||
)
|
||||
|
||||
assertEquals(1, replaced.reader.currentPageIndex)
|
||||
assertEquals(1, replaced.navigationLocator?.pageIndex)
|
||||
assertEquals(100, replaced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages resolves android block locator after measured pagination`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = manualRangeBook()
|
||||
val initialSession = engine.createSession(
|
||||
book = book,
|
||||
initialLocator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
blockIndex = 42,
|
||||
charOffset = 160,
|
||||
cfi = "android-locator:0:42:160"
|
||||
)
|
||||
)
|
||||
val measuredPages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "first",
|
||||
startOffset = 0,
|
||||
endOffset = 100,
|
||||
semanticBlocks = listOf(SemanticParagraph("first", emptyList(), CssStyle(), null, null, 0, 7))
|
||||
),
|
||||
ReaderPage(
|
||||
pageIndex = 1,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "target",
|
||||
startOffset = 150,
|
||||
endOffset = 210,
|
||||
semanticBlocks = listOf(SemanticParagraph("target", emptyList(), CssStyle(), null, null, 150, 42))
|
||||
)
|
||||
)
|
||||
|
||||
val replaced = engine.replacePages(
|
||||
state = initialSession,
|
||||
pages = measuredPages,
|
||||
reflowAnchor = initialSession.navigationLocator,
|
||||
navigationRequestIdAtReflowStart = initialSession.navigationRequestId
|
||||
)
|
||||
|
||||
assertEquals(1, replaced.reader.currentPageIndex)
|
||||
assertEquals(1, replaced.navigationLocator?.pageIndex)
|
||||
assertEquals(42, replaced.navigationLocator?.blockIndex)
|
||||
assertEquals(160, replaced.navigationLocator?.charOffset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacePages lets newer explicit navigation override reflow anchor`() {
|
||||
val engine = ReaderEngine()
|
||||
val book = manualRangeBook()
|
||||
val oldPages = listOf(
|
||||
ReaderPage(0, 0, "One", "first", 0, 100),
|
||||
ReaderPage(1, 0, "One", "second", 100, 200)
|
||||
)
|
||||
val newPages = listOf(
|
||||
ReaderPage(0, 0, "One", "first expanded", 0, 140),
|
||||
ReaderPage(1, 0, "One", "second shifted", 140, 260)
|
||||
)
|
||||
val session = engine.createSession(book).copy(
|
||||
reader = PaginatedReaderState(book, oldPages, currentPageIndex = 0),
|
||||
navigationLocator = ReaderLocator(chapterIndex = 0, pageIndex = 0, startOffset = 20, endOffset = 20),
|
||||
navigationRequestId = 5L
|
||||
)
|
||||
val staleReflowAnchor = ReaderLocator(chapterIndex = 0, pageIndex = 1, startOffset = 160, endOffset = 160)
|
||||
|
||||
val replaced = engine.replacePages(
|
||||
state = session,
|
||||
pages = newPages,
|
||||
reflowAnchor = staleReflowAnchor,
|
||||
navigationRequestIdAtReflowStart = 4L
|
||||
)
|
||||
|
||||
assertEquals(0, replaced.reader.currentPageIndex)
|
||||
assertEquals(0, replaced.navigationLocator?.pageIndex)
|
||||
assertEquals(20, replaced.navigationLocator?.startOffset)
|
||||
}
|
||||
|
||||
private fun longBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "long",
|
||||
fileName = "long.epub",
|
||||
title = "Long",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = List(280) { "This paragraph gives the paginator enough text to create several pages." }
|
||||
.joinToString("\n\n")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun multiChapterBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "multi",
|
||||
fileName = "multi.epub",
|
||||
title = "Multi",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(id = "one", title = "One", plainText = "First chapter text."),
|
||||
SharedEpubChapter(id = "two", title = "Two", plainText = "Second chapter text."),
|
||||
SharedEpubChapter(id = "three", title = "Three", plainText = "Third chapter text.")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun manualRangeBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "manual",
|
||||
fileName = "manual.epub",
|
||||
title = "Manual",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "one",
|
||||
title = "One",
|
||||
plainText = List(300) { "x" }.joinToString("")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,108 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import org.dueattendant149.bookreader.paginatedreader.CssStyle
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticFlexContainer
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticImage
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ReaderImageModelsTest {
|
||||
|
||||
@Test
|
||||
fun `reader image references keep chapter order and page locators`() {
|
||||
val style = CssStyle()
|
||||
val image = SemanticImage(
|
||||
path = "data:image/png;base64,abc",
|
||||
altText = "Cover art",
|
||||
intrinsicWidth = 320f,
|
||||
intrinsicHeight = 240f,
|
||||
style = style,
|
||||
elementId = "cover",
|
||||
cfi = "/4/2",
|
||||
blockIndex = 2
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter-one",
|
||||
title = "One",
|
||||
plainText = "Before after",
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph("Before", emptyList(), style, null, "/4/1", startCharOffsetInSource = 0, blockIndex = 1),
|
||||
image,
|
||||
SemanticParagraph("after", emptyList(), style, null, "/4/3", startCharOffsetInSource = 7, blockIndex = 3)
|
||||
),
|
||||
baseHref = "one.xhtml"
|
||||
)
|
||||
)
|
||||
)
|
||||
val pages = listOf(
|
||||
ReaderPage(
|
||||
pageIndex = 4,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "One",
|
||||
text = "Before after",
|
||||
startOffset = 0,
|
||||
endOffset = 12,
|
||||
semanticBlocks = book.chapters.first().semanticBlocks
|
||||
)
|
||||
)
|
||||
|
||||
val references = book.readerImageReferences(pages)
|
||||
|
||||
assertEquals(1, references.size)
|
||||
assertEquals("Cover art", references.first().displayTitle)
|
||||
assertEquals("320x240", references.first().dimensionLabel)
|
||||
assertEquals(4, references.first().locator.pageIndex)
|
||||
assertNull(references.first().locator.startOffset)
|
||||
assertEquals("/4/2", references.first().locator.cfi)
|
||||
assertEquals("Cover art.png", references.first().suggestedDownloadFileName())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader image references include nested images`() {
|
||||
val style = CssStyle()
|
||||
val nestedImage = SemanticImage(
|
||||
path = "OPS/images/chart.webp",
|
||||
altText = null,
|
||||
intrinsicWidth = null,
|
||||
intrinsicHeight = null,
|
||||
style = style,
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
blockIndex = 9
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter-one",
|
||||
title = "One",
|
||||
plainText = "",
|
||||
semanticBlocks = listOf(
|
||||
SemanticFlexContainer(
|
||||
children = listOf(nestedImage),
|
||||
style = style,
|
||||
elementId = null,
|
||||
cfi = null,
|
||||
blockIndex = 8
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val references = book.readerImageReferences()
|
||||
|
||||
assertEquals(1, references.size)
|
||||
assertEquals("chart", references.first().displayTitle)
|
||||
assertEquals("chart.webp", references.first().suggestedDownloadFileName())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderJumpHistoryTest {
|
||||
|
||||
@Test
|
||||
fun `records explicit locator jumps and exposes back and forward locators`() {
|
||||
val start = locator(chapter = 0, cfi = "start")
|
||||
val middle = locator(chapter = 1, cfi = "middle")
|
||||
val end = locator(chapter = 2, cfi = "end")
|
||||
|
||||
val recorded = ReaderJumpHistory()
|
||||
.record(currentLocator = start, targetLocator = middle, chapterCount = 4)
|
||||
.record(currentLocator = middle, targetLocator = end, chapterCount = 4)
|
||||
|
||||
val steppedBack = recorded.stepBack()
|
||||
val branched = steppedBack.record(
|
||||
currentLocator = middle,
|
||||
targetLocator = locator(chapter = 3, cfi = "appendix"),
|
||||
chapterCount = 4
|
||||
)
|
||||
|
||||
assertEquals(listOf(start, middle, end), recorded.locators)
|
||||
assertEquals(middle, recorded.backLocator)
|
||||
assertEquals(null, recorded.forwardLocator)
|
||||
assertEquals(start, steppedBack.backLocator)
|
||||
assertEquals(end, steppedBack.forwardLocator)
|
||||
assertEquals(listOf(start, middle, locator(chapter = 3, cfi = "appendix")), branched.locators)
|
||||
assertEquals(middle, branched.backLocator)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores invalid and duplicate jumps prunes chapters and caps entries`() {
|
||||
val unchanged = ReaderJumpHistory()
|
||||
.record(currentLocator = locator(chapter = 0, cfi = "same"), targetLocator = locator(chapter = 0, cfi = "same"), chapterCount = 3)
|
||||
.record(currentLocator = locator(chapter = 0, cfi = "ok"), targetLocator = locator(chapter = 99, cfi = "bad"), chapterCount = 3)
|
||||
|
||||
val pruned = ReaderJumpHistory(
|
||||
locators = listOf(
|
||||
locator(chapter = 0, cfi = "start"),
|
||||
locator(chapter = 3, cfi = "drop"),
|
||||
locator(chapter = 1, cfi = "keep")
|
||||
),
|
||||
cursor = 2
|
||||
).pruned(chapterCount = 2)
|
||||
|
||||
val capped = (0 until 40).fold(ReaderJumpHistory(maxEntries = 5)) { history, index ->
|
||||
history.record(
|
||||
currentLocator = locator(chapter = 0, cfi = "spot-$index"),
|
||||
targetLocator = locator(chapter = 0, cfi = "spot-${index + 1}"),
|
||||
chapterCount = 1
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(unchanged.locators.isEmpty())
|
||||
assertTrue(
|
||||
locator(chapter = 0, cfi = "stable").copy(pageIndex = 12)
|
||||
.hasSameJumpLocation(locator(chapter = 0, cfi = "stable").copy(pageIndex = 48))
|
||||
)
|
||||
assertEquals(listOf(locator(chapter = 0, cfi = "start"), locator(chapter = 1, cfi = "keep")), pruned.locators)
|
||||
assertEquals(1, pruned.cursor)
|
||||
assertEquals((36..40).map { locator(chapter = 0, cfi = "spot-$it") }, capped.locators)
|
||||
assertEquals(4, capped.cursor)
|
||||
}
|
||||
|
||||
private fun locator(chapter: Int, cfi: String): ReaderLocator {
|
||||
return ReaderLocator(chapterIndex = chapter, cfi = cfi)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package org.dueattendant149.bookreader.shared.reader
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderSpreadLayoutTest {
|
||||
|
||||
@Test
|
||||
fun `single page mode keeps direct page indexes`() {
|
||||
val settings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.SINGLE)
|
||||
|
||||
assertEquals(3, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals("4", ReaderSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page mode normalizes direct jumps to the spread start`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
assertEquals(2, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(2, 3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals("3-4", ReaderSpreadLayout.pageRangeLabel(3, pageCount = 10, settings = settings))
|
||||
assertEquals(2, ReaderSpreadLayout.sliderPositionForPage(3, pageCount = 10, settings = settings))
|
||||
assertEquals(3, ReaderSpreadLayout.pageNumberForSliderPosition(2, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `right to left pagination reverses only the displayed spread order`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE,
|
||||
rightToLeftPagination = true
|
||||
)
|
||||
|
||||
assertEquals(listOf(2, 3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3, 2), ReaderSpreadLayout.visiblePageIndicesForDisplay(3, pageCount = 10, settings = settings))
|
||||
assertEquals(4, ReaderSpreadLayout.nextPageIndex(2, pageCount = 10, settings = settings))
|
||||
assertEquals(0, ReaderSpreadLayout.previousPageIndex(2, pageCount = 10, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page mode advances by spread and clamps odd final page`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
assertEquals(3, ReaderSpreadLayout.sliderStepCount(pageCount = 5, settings = settings))
|
||||
assertEquals(2, ReaderSpreadLayout.nextPageIndex(0, pageCount = 5, settings = settings))
|
||||
assertEquals(4, ReaderSpreadLayout.nextPageIndex(2, pageCount = 5, settings = settings))
|
||||
assertEquals(listOf(4), ReaderSpreadLayout.visiblePageIndices(4, pageCount = 5, settings = settings))
|
||||
assertEquals(5, ReaderSpreadLayout.pageNumberForSliderPosition(3, pageCount = 5, settings = settings))
|
||||
assertFalse(ReaderSpreadLayout.canGoNext(4, pageCount = 5, settings = settings))
|
||||
assertTrue(ReaderSpreadLayout.canGoNext(2, pageCount = 5, settings = settings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two page progress uses the visible spread end`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.PAGINATED,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
val state = PaginatedReaderState(
|
||||
book = SharedEpubBook("book", "book.epub", "Book", chapters = emptyList()),
|
||||
pages = (0 until 4).map { index ->
|
||||
ReaderPage(index, chapterIndex = 0, chapterTitle = "One", text = "$index", startOffset = index, endOffset = index + 1)
|
||||
},
|
||||
currentPageIndex = 2,
|
||||
settings = settings
|
||||
)
|
||||
|
||||
assertEquals(100f, state.progress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spread mode is ignored in vertical reading`() {
|
||||
val settings = ReaderSettings(
|
||||
readingMode = ReaderReadingMode.VERTICAL,
|
||||
pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE
|
||||
)
|
||||
|
||||
assertEquals(3, ReaderSpreadLayout.normalizePageIndex(3, pageCount = 10, settings = settings))
|
||||
assertEquals(listOf(3), ReaderSpreadLayout.visiblePageIndices(3, pageCount = 10, settings = settings))
|
||||
assertEquals(1, ReaderSpreadLayout.pageStep(settings))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import org.dueattendant149.bookreader.shared.BookItem
|
||||
import org.dueattendant149.bookreader.shared.FileType
|
||||
import org.dueattendant149.bookreader.shared.LibraryFilters
|
||||
import org.dueattendant149.bookreader.shared.ReadStatusFilter
|
||||
import org.dueattendant149.bookreader.shared.ReaderPlatform
|
||||
import org.dueattendant149.bookreader.shared.SharedFeaturePolicy
|
||||
import org.dueattendant149.bookreader.shared.SharedFileCapabilities
|
||||
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
|
||||
import org.dueattendant149.bookreader.shared.Shelf
|
||||
import org.dueattendant149.bookreader.shared.ShelfType
|
||||
import org.dueattendant149.bookreader.shared.SyncedFolder
|
||||
import org.dueattendant149.bookreader.shared.Tag
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NonReaderLayoutModelsTest {
|
||||
|
||||
@Test
|
||||
fun `android library keeps the simple top level organization tabs`() {
|
||||
val visibleTabs = visibleNonReaderLibraryTabs(ReaderPlatform.ANDROID)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS
|
||||
),
|
||||
visibleTabs
|
||||
)
|
||||
assertFalse(NonReaderLibraryTab.SMART_SHELVES in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.TAGS in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.UNREAD in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.IN_PROGRESS in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.COMPLETED in visibleTabs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop library includes organization and reading status tabs`() {
|
||||
val visibleTabs = visibleNonReaderLibraryTabs(ReaderPlatform.DESKTOP)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
NonReaderLibraryTab.BOOKS,
|
||||
NonReaderLibraryTab.SHELVES,
|
||||
NonReaderLibraryTab.FOLDERS,
|
||||
NonReaderLibraryTab.UNREAD,
|
||||
NonReaderLibraryTab.IN_PROGRESS,
|
||||
NonReaderLibraryTab.COMPLETED
|
||||
),
|
||||
visibleTabs
|
||||
)
|
||||
assertFalse(NonReaderLibraryTab.SMART_SHELVES in visibleTabs)
|
||||
assertFalse(NonReaderLibraryTab.TAGS in visibleTabs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop shelves tab exposes primary new shelf action only on desktop`() {
|
||||
assertEquals(
|
||||
listOf(NonReaderLibraryPrimaryAction.NEW_SHELF),
|
||||
primaryLibraryActionsForTab(NonReaderLibraryTab.SHELVES, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<NonReaderLibraryPrimaryAction>(),
|
||||
primaryLibraryActionsForTab(NonReaderLibraryTab.SHELVES, ReaderPlatform.ANDROID)
|
||||
)
|
||||
assertEquals(
|
||||
emptyList<NonReaderLibraryPrimaryAction>(),
|
||||
primaryLibraryActionsForTab(NonReaderLibraryTab.BOOKS, ReaderPlatform.DESKTOP)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `book overflow exposes platform save and share actions`() {
|
||||
assertEquals(
|
||||
setOf(
|
||||
NonReaderBookOverflowAction.ADD_TO_SHELF,
|
||||
NonReaderBookOverflowAction.SAVE_ORIGINAL
|
||||
),
|
||||
bookOverflowActionsForPlatform(ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
setOf(
|
||||
NonReaderBookOverflowAction.SAVE_ORIGINAL,
|
||||
NonReaderBookOverflowAction.SHARE_ORIGINAL
|
||||
),
|
||||
bookOverflowActionsForPlatform(ReaderPlatform.ANDROID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop library command bar uses inline layout only on wide panes`() {
|
||||
assertEquals(
|
||||
LibraryCommandBarLayout.STACKED,
|
||||
libraryCommandBarLayoutForWidth(widthDp = 979f, platform = ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
LibraryCommandBarLayout.INLINE,
|
||||
libraryCommandBarLayoutForWidth(widthDp = 980f, platform = ReaderPlatform.DESKTOP)
|
||||
)
|
||||
assertEquals(
|
||||
LibraryCommandBarLayout.STACKED,
|
||||
libraryCommandBarLayoutForWidth(widthDp = 1200f, platform = ReaderPlatform.ANDROID)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop library filter file type groups include every shared readable format`() {
|
||||
val groupedTypes = nonReaderLibraryFileTypeGroups().flatMap { it.fileTypes }
|
||||
|
||||
assertEquals(
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP),
|
||||
groupedTypes.toSet()
|
||||
)
|
||||
assertEquals(groupedTypes.size, groupedTypes.toSet().size)
|
||||
assertTrue(FileType.DOCX in groupedTypes)
|
||||
assertTrue(FileType.FODT in groupedTypes)
|
||||
assertTrue(FileType.PPTX in groupedTypes)
|
||||
assertTrue(
|
||||
nonReaderLibraryFileTypeGroups()
|
||||
.any { it.title == "Comics" && FileType.CBR in it.fileTypes && FileType.CB7 in it.fileTypes && FileType.CBT in it.fileTypes }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home layout separates active tab pinned and recent books`() {
|
||||
val activeTab = book("tab", title = "Open Tab", progress = 12f)
|
||||
val inProgress = book("continue", title = "Continue", progress = 40f)
|
||||
val pinned = book("pinned", title = "Pinned")
|
||||
val recent = book("recent", title = "Recent")
|
||||
|
||||
val layout = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(activeTab, inProgress, pinned, recent),
|
||||
recentBooks = listOf(inProgress, pinned, recent),
|
||||
openTabs = listOf(activeTab),
|
||||
openTabIds = listOf(activeTab.id),
|
||||
activeTabBookId = activeTab.id,
|
||||
isTabsEnabled = true,
|
||||
pinnedHomeBookIds = setOf(pinned.id),
|
||||
selectedBookIds = setOf(recent.id)
|
||||
).toNonReaderHomeLayoutModel()
|
||||
|
||||
assertEquals(activeTab.id, layout.continueBook?.id)
|
||||
assertEquals(listOf(activeTab.id), layout.activeTabs.map { it.id })
|
||||
assertEquals(listOf(pinned.id), layout.pinnedBooks.map { it.id })
|
||||
assertEquals(listOf(inProgress.id, recent.id), layout.recentBooks.map { it.id })
|
||||
assertEquals(listOf(recent.id), layout.selectedBooks.map { it.id })
|
||||
assertTrue(layout.isContextualModeActive)
|
||||
assertFalse(layout.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home layout ignores open tabs when tabs are disabled`() {
|
||||
val activeTab = book("tab", title = "Open Tab", progress = 12f)
|
||||
|
||||
val layout = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(activeTab),
|
||||
openTabs = listOf(activeTab),
|
||||
openTabIds = listOf(activeTab.id),
|
||||
activeTabBookId = activeTab.id,
|
||||
isTabsEnabled = false
|
||||
).toNonReaderHomeLayoutModel()
|
||||
|
||||
assertEquals(null, layout.continueBook)
|
||||
assertTrue(layout.activeTabs.isEmpty())
|
||||
assertTrue(layout.isEmpty)
|
||||
assertFalse(layout.isLibraryEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library organization counts shelves tags folders status and filters`() {
|
||||
val favorite = Tag("favorite", "Favorite")
|
||||
val unread = book("unread", type = FileType.EPUB, progress = 0f)
|
||||
val inProgress = book("progress", type = FileType.PDF, progress = 50f, tags = listOf(favorite), sourceFolder = "/sync")
|
||||
val complete = book("complete", type = FileType.CBZ, progress = 100f, path = "opds-pse://stream")
|
||||
|
||||
val organization = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(unread, inProgress, complete),
|
||||
allTags = listOf(favorite),
|
||||
syncedFolders = listOf(SyncedFolder("/sync", "Sync", lastScanTime = 1L)),
|
||||
shelves = listOf(
|
||||
Shelf("manual", "Manual", ShelfType.MANUAL, listOf(unread)),
|
||||
Shelf("series", "Series", ShelfType.SERIES, listOf(inProgress)),
|
||||
Shelf("smart", "Smart", ShelfType.SMART, listOf(complete)),
|
||||
Shelf("tag_favorite", "Favorite", ShelfType.TAG, listOf(inProgress)),
|
||||
Shelf("folder_root", "Sync", ShelfType.FOLDER, listOf(inProgress)),
|
||||
Shelf("folder_child", "Nested", ShelfType.FOLDER, listOf(inProgress), parentShelfId = "folder_root")
|
||||
),
|
||||
libraryFilters = LibraryFilters(
|
||||
fileTypes = setOf(FileType.PDF),
|
||||
sourceFolders = setOf("/sync"),
|
||||
readStatus = ReadStatusFilter.IN_PROGRESS,
|
||||
tagIds = setOf(favorite.id)
|
||||
)
|
||||
).toNonReaderLibraryOrganizationModel()
|
||||
|
||||
assertEquals(3, organization.allBooksCount)
|
||||
assertEquals(2, organization.shelfCount)
|
||||
assertEquals(1, organization.smartShelfCount)
|
||||
assertEquals(1, organization.tagCount)
|
||||
assertEquals(1, organization.folderCount)
|
||||
assertEquals(1, organization.unreadCount)
|
||||
assertEquals(1, organization.inProgressCount)
|
||||
assertEquals(1, organization.completedCount)
|
||||
assertEquals(4, organization.activeFilterCount)
|
||||
assertEquals(listOf(FileType.PDF, FileType.EPUB, FileType.CBZ), organization.availableFileTypes)
|
||||
assertTrue(organization.hasInAppBooks)
|
||||
assertTrue(organization.hasOpdsStreams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library organization falls back to book tags and synced folders`() {
|
||||
val favorite = Tag("favorite", "Favorite")
|
||||
val tagged = book("tagged", tags = listOf(favorite), sourceFolder = "/sync")
|
||||
|
||||
val organization = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(tagged),
|
||||
syncedFolders = listOf(SyncedFolder("/sync", "Sync", lastScanTime = 1L))
|
||||
).toNonReaderLibraryOrganizationModel()
|
||||
|
||||
assertEquals(1, organization.tagCount)
|
||||
assertEquals(1, organization.folderCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop status tabs filter books while android hidden statuses fall back`() {
|
||||
val unread = book("unread", type = FileType.EPUB, progress = 0f)
|
||||
val inProgress = book("progress", type = FileType.PDF, progress = 44f)
|
||||
val complete = book("complete", type = FileType.CBZ, progress = 100f)
|
||||
val state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(unread, inProgress, complete),
|
||||
libraryBooks = listOf(unread, inProgress, complete)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("unread"),
|
||||
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.UNREAD, ReaderPlatform.DESKTOP).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("progress"),
|
||||
state.visibleBooksForLibrarySelection(NonReaderLibraryTab.IN_PROGRESS, ReaderPlatform.DESKTOP).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("complete"),
|
||||
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.COMPLETED, ReaderPlatform.DESKTOP).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("unread", "progress", "complete"),
|
||||
state.booksForNonReaderLibraryTab(NonReaderLibraryTab.UNREAD, ReaderPlatform.ANDROID).map { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library visible selection follows folder shelf navigation`() {
|
||||
val rootBook = book("root", sourceFolder = "/sync")
|
||||
val childBook = book("child", sourceFolder = "/sync")
|
||||
val rootShelf = Shelf(
|
||||
id = "folder_/sync",
|
||||
name = "Sync",
|
||||
type = ShelfType.FOLDER,
|
||||
books = listOf(rootBook, childBook),
|
||||
directBooks = listOf(rootBook),
|
||||
childShelfIds = listOf("folder_/sync::Nested")
|
||||
)
|
||||
val childShelf = Shelf(
|
||||
id = "folder_/sync::Nested",
|
||||
name = "Nested",
|
||||
type = ShelfType.FOLDER,
|
||||
books = listOf(childBook),
|
||||
directBooks = listOf(childBook),
|
||||
parentShelfId = rootShelf.id,
|
||||
depth = 1
|
||||
)
|
||||
|
||||
val rootState = SharedReaderScreenState(
|
||||
shelves = listOf(rootShelf, childShelf),
|
||||
libraryBooks = listOf(rootBook, childBook)
|
||||
)
|
||||
val childState = rootState.copy(viewingShelfId = childShelf.id)
|
||||
|
||||
assertEquals(
|
||||
listOf("root", "child"),
|
||||
rootState.visibleBooksForLibrarySelection(NonReaderLibraryTab.FOLDERS).map { it.id }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("child"),
|
||||
childState.visibleBooksForLibrarySelection(NonReaderLibraryTab.FOLDERS).map { it.id }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library organization does not expose unknown as an available file type`() {
|
||||
val organization = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(
|
||||
book("known", type = FileType.PDF),
|
||||
book("unknown", type = FileType.UNKNOWN)
|
||||
)
|
||||
).toNonReaderLibraryOrganizationModel()
|
||||
|
||||
assertEquals(listOf(FileType.PDF), organization.availableFileTypes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell model keeps account in primary navigation and exposes more actions`() {
|
||||
val model = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.CUSTOM_FONTS,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(SharedAppTab.LIBRARY, SharedAppTab.CATALOGS, SharedAppTab.PRO),
|
||||
model.primaryTabs
|
||||
)
|
||||
assertEquals(listOf(SharedAppToolAction.AI_SETTINGS), model.primaryActions)
|
||||
assertEquals(SharedAppTab.LIBRARY, model.selectedPrimaryTab)
|
||||
assertTrue(SharedAppToolAction.SETTINGS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.APP_THEME in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.AI_SETTINGS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.SUPPORT in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.IMPORT_FILES in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.IMPORT_FOLDER in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.SYNC in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.PRO in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.TABS_TOGGLE in model.toolActions)
|
||||
assertTrue(model.showPrimaryNavigation)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedAppMoreGroup.PREFERENCES,
|
||||
SharedAppMoreGroup.HELP
|
||||
),
|
||||
model.moreSections.map { it.group }
|
||||
)
|
||||
|
||||
val accountModel = sharedAppShellModel(SharedAppTab.PRO, aiSettingsAvailable = true)
|
||||
assertEquals(SharedAppTab.PRO, accountModel.selectedPrimaryTab)
|
||||
|
||||
val withoutAi = sharedAppShellModel(SharedAppTab.SHELVES, aiSettingsAvailable = false)
|
||||
assertEquals(SharedAppTab.LIBRARY, withoutAi.selectedPrimaryTab)
|
||||
assertEquals(emptyList(), withoutAi.primaryActions)
|
||||
assertFalse(SharedAppToolAction.AI_SETTINGS in withoutAi.toolActions)
|
||||
|
||||
val byokModel = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.LIBRARY,
|
||||
aiSettingsAvailable = true,
|
||||
featurePolicy = SharedFeaturePolicy.OssOnline
|
||||
)
|
||||
assertEquals(emptyList(), byokModel.primaryActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell model groups more menu preferences and help only`() {
|
||||
val model = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.LIBRARY,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.LIBRARY })
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.ACCOUNT })
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedAppToolAction.SETTINGS,
|
||||
SharedAppToolAction.APP_THEME,
|
||||
SharedAppToolAction.AI_SETTINGS,
|
||||
SharedAppToolAction.CUSTOM_FONTS
|
||||
),
|
||||
model.moreSections.single { it.group == SharedAppMoreGroup.PREFERENCES }.actions
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedAppToolAction.HELP_FEEDBACK,
|
||||
SharedAppToolAction.SUPPORT,
|
||||
SharedAppToolAction.ABOUT
|
||||
),
|
||||
model.moreSections.single { it.group == SharedAppMoreGroup.HELP }.actions
|
||||
)
|
||||
|
||||
val legacyActions = sharedAppMoreSections(
|
||||
listOf(
|
||||
SharedAppToolAction.IMPORT_FILES,
|
||||
SharedAppToolAction.PRO,
|
||||
SharedAppToolAction.SETTINGS,
|
||||
SharedAppToolAction.TABS_TOGGLE,
|
||||
SharedAppToolAction.ABOUT
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
listOf(SharedAppMoreGroup.PREFERENCES, SharedAppMoreGroup.HELP),
|
||||
legacyActions.map { it.group }
|
||||
)
|
||||
assertEquals(
|
||||
listOf(SharedAppToolAction.SETTINGS),
|
||||
legacyActions.single { it.group == SharedAppMoreGroup.PREFERENCES }.actions
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shell model hides primary navigation while reading`() {
|
||||
val readerModel = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.READER,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
val libraryModel = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.LIBRARY,
|
||||
aiSettingsAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(readerModel.showPrimaryNavigation)
|
||||
assertTrue(libraryModel.showPrimaryNavigation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline shell model hides network backed navigation and tools`() {
|
||||
val model = sharedAppShellModel(
|
||||
selectedTab = SharedAppTab.CATALOGS,
|
||||
aiSettingsAvailable = true,
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline
|
||||
)
|
||||
|
||||
assertEquals(listOf(SharedAppTab.LIBRARY), model.primaryTabs)
|
||||
assertEquals(emptyList(), model.primaryActions)
|
||||
assertEquals(SharedAppTab.LIBRARY, model.selectedPrimaryTab)
|
||||
assertFalse(SharedAppToolAction.AI_SETTINGS in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.HELP_FEEDBACK in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.SUPPORT in model.toolActions)
|
||||
assertFalse(SharedAppToolAction.PRO in model.toolActions)
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.ACCOUNT })
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.LIBRARY })
|
||||
assertFalse(model.moreSections.any { it.group == SharedAppMoreGroup.HELP && SharedAppToolAction.SUPPORT in it.actions })
|
||||
assertTrue(SharedAppToolAction.SETTINGS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.CUSTOM_FONTS in model.toolActions)
|
||||
assertTrue(SharedAppToolAction.ABOUT in model.toolActions)
|
||||
assertTrue(model.showPrimaryNavigation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sidebar sync toggle is visible only for signed in account builds and follows pro gating`() {
|
||||
assertEquals(
|
||||
SharedSidebarSyncToggleModel(visible = false, enabled = false, checked = false),
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = false,
|
||||
accountAvailable = true,
|
||||
syncAvailable = true,
|
||||
isProUser = true,
|
||||
isSyncEnabled = true
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
SharedSidebarSyncToggleModel(visible = true, enabled = true, checked = true),
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = true,
|
||||
accountAvailable = true,
|
||||
syncAvailable = true,
|
||||
isProUser = true,
|
||||
isSyncEnabled = true
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
SharedSidebarSyncToggleModel(visible = true, enabled = false, checked = true),
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = true,
|
||||
accountAvailable = true,
|
||||
syncAvailable = true,
|
||||
isProUser = false,
|
||||
isSyncEnabled = true
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
sharedSidebarSyncToggleModel(
|
||||
isSignedIn = true,
|
||||
accountAvailable = false,
|
||||
syncAvailable = true,
|
||||
isProUser = true,
|
||||
isSyncEnabled = true,
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline
|
||||
).visible
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collection cover stack uses Android cover order and limit`() {
|
||||
val books = listOf(
|
||||
book("one", coverImagePath = "/covers/one.png"),
|
||||
book("two", coverImagePath = "/covers/two.png"),
|
||||
book("three", coverImagePath = "/covers/three.png"),
|
||||
book("four", coverImagePath = "/covers/four.png"),
|
||||
book("five", coverImagePath = "/covers/five.png")
|
||||
)
|
||||
|
||||
val coverBooks = collectionCoverStackBooks(
|
||||
Shelf("manual", "Manual", ShelfType.MANUAL, books)
|
||||
)
|
||||
|
||||
assertEquals(listOf("four", "three", "two", "one"), coverBooks.map { it.id })
|
||||
assertEquals(
|
||||
listOf("/covers/four.png", "/covers/three.png", "/covers/two.png", "/covers/one.png"),
|
||||
coverBooks.map { it.coverImagePath }
|
||||
)
|
||||
assertTrue(collectionCoverStackBooks(Shelf("empty", "Empty", ShelfType.FOLDER, emptyList())).isEmpty())
|
||||
}
|
||||
|
||||
private fun book(
|
||||
id: String,
|
||||
title: String = id,
|
||||
type: FileType = FileType.EPUB,
|
||||
progress: Float? = null,
|
||||
tags: List<Tag> = emptyList(),
|
||||
sourceFolder: String? = null,
|
||||
path: String? = "/books/$id.epub",
|
||||
coverImagePath: String? = null
|
||||
) = BookItem(
|
||||
id = id,
|
||||
path = path,
|
||||
type = type,
|
||||
displayName = "$id.epub",
|
||||
timestamp = 1L,
|
||||
coverImagePath = coverImagePath,
|
||||
title = title,
|
||||
progressPercentage = progress,
|
||||
tags = tags,
|
||||
sourceFolder = sourceFolder
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ReaderMinimalSliderTest {
|
||||
@Test
|
||||
fun markerFractionMapsValueIntoRange() {
|
||||
assertEquals(
|
||||
0.5f,
|
||||
readerMinimalSliderMarkerFraction(
|
||||
markerValue = 5f,
|
||||
valueRange = 0f..10f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerFractionClampsOutsideRange() {
|
||||
assertEquals(
|
||||
0f,
|
||||
readerMinimalSliderMarkerFraction(
|
||||
markerValue = -4f,
|
||||
valueRange = 1f..9f
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
1f,
|
||||
readerMinimalSliderMarkerFraction(
|
||||
markerValue = 12f,
|
||||
valueRange = 1f..9f
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerFractionIsAbsentForMissingOrEmptyRange() {
|
||||
assertNull(readerMinimalSliderMarkerFraction(null, 0f..10f))
|
||||
assertNull(readerMinimalSliderMarkerFraction(4f, 5f..5f))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,593 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import org.dueattendant149.bookreader.shared.PdfDisplayMode
|
||||
import org.dueattendant149.bookreader.shared.ReaderAutoScrollState
|
||||
import org.dueattendant149.bookreader.shared.ReaderCloudTtsState
|
||||
import org.dueattendant149.bookreader.shared.ReaderExtrasState
|
||||
import org.dueattendant149.bookreader.shared.ReaderTool
|
||||
import org.dueattendant149.bookreader.shared.ReaderToolbarPreferences
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfReaderState
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderEngine
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReaderWorkspaceModelsTest {
|
||||
|
||||
@Test
|
||||
fun `epub workspace maps shared toolbar preferences without toolbar tab`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.THEME.id, ReaderTool.FORMAT.id, ReaderTool.BOOKMARK.id),
|
||||
bottomToolIds = setOf(ReaderTool.SLIDER.id, ReaderTool.SEARCH.id)
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(ReaderWorkspaceKind.EPUB, model.kind)
|
||||
assertEquals(
|
||||
listOf(
|
||||
ReaderWorkspaceLeftSection.CONTENTS,
|
||||
ReaderWorkspaceLeftSection.NOTES,
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS,
|
||||
ReaderWorkspaceLeftSection.IMAGES
|
||||
),
|
||||
model.leftSections
|
||||
)
|
||||
assertFalse(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertFalse(ReaderWorkspaceTopAction.BOOKMARK in model.topActions)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.SEARCH in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.FULL_SCREEN in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
assertTrue(ReaderWorkspaceBottomAction.PAGE_SLIDER in model.bottomActions)
|
||||
assertFalse(model.panelDefaults.leftOpen)
|
||||
assertFalse(model.panelDefaults.inspectorOpen)
|
||||
assertTrue(model.chrome.preferAutoHide)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chrome model is forced visible for active reader states`() {
|
||||
val model = readerWorkspaceChromeModel(
|
||||
preferAutoHide = false,
|
||||
searchActive = true,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = true,
|
||||
annotationEditing = true,
|
||||
richTextEditing = true,
|
||||
loading = true,
|
||||
errorMessage = "Failed",
|
||||
autoScroll = ReaderAutoScrollState(enabled = true),
|
||||
ttsBusy = true
|
||||
)
|
||||
|
||||
assertFalse(model.preferAutoHide)
|
||||
assertTrue(model.forceVisible)
|
||||
assertEquals(
|
||||
setOf("search", "inspector", "annotation", "rich-text", "loading", "error", "auto-scroll"),
|
||||
model.forceVisibleReasons
|
||||
)
|
||||
assertEquals(setOf("tts"), model.revealVisibleReasons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tap toggles chrome when it is not locked or forced`() {
|
||||
assertTrue(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = false,
|
||||
lockedVisible = false,
|
||||
forcedVisible = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = true,
|
||||
lockedVisible = false,
|
||||
forcedVisible = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader tap closes inspector and reveals chrome when panel suppresses bars`() {
|
||||
assertTrue(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = false,
|
||||
lockedVisible = false,
|
||||
forcedVisible = false,
|
||||
rightPanelClosedByTap = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen = true,
|
||||
hasInspectorSections = true,
|
||||
closeRightPanelOnReaderTap = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen = true,
|
||||
hasInspectorSections = false,
|
||||
closeRightPanelOnReaderTap = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldCloseRightPanelAfterReaderTap(
|
||||
rightPanelOpen = true,
|
||||
hasInspectorSections = true,
|
||||
closeRightPanelOnReaderTap = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active tts reveals chrome without locking reader tap toggle`() {
|
||||
val model = readerWorkspaceChromeModel(
|
||||
preferAutoHide = true,
|
||||
searchActive = false,
|
||||
leftPanelOpen = false,
|
||||
inspectorOpen = false,
|
||||
annotationEditing = false,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
autoScroll = ReaderAutoScrollState(),
|
||||
ttsBusy = true
|
||||
)
|
||||
|
||||
assertFalse(model.forceVisible)
|
||||
assertEquals(emptySet(), model.forceVisibleReasons)
|
||||
assertEquals(setOf("tts"), model.revealVisibleReasons)
|
||||
assertFalse(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = true,
|
||||
lockedVisible = false,
|
||||
forcedVisible = model.forceVisible
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `locked or forced reader chrome stays visible after reader taps`() {
|
||||
assertTrue(
|
||||
readerWorkspaceChromeVisible(
|
||||
requestedVisible = false,
|
||||
lockedVisible = true,
|
||||
forcedVisible = false
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
readerWorkspaceChromeVisibleAfterReaderTap(
|
||||
requestedVisible = false,
|
||||
lockedVisible = false,
|
||||
forcedVisible = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `left reader panel is drawn with chrome while preserving its toggle state`() {
|
||||
assertFalse(
|
||||
readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen = true,
|
||||
chromeVisible = false,
|
||||
hasNavigationSections = true
|
||||
)
|
||||
)
|
||||
assertTrue(
|
||||
readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen = true,
|
||||
chromeVisible = true,
|
||||
hasNavigationSections = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceLeftPanelVisible(
|
||||
toggledOpen = false,
|
||||
chromeVisible = true,
|
||||
hasNavigationSections = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader focus is restored after closing the final workspace panel`() {
|
||||
assertTrue(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelClose(
|
||||
closingPanelOpen = true,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelClose(
|
||||
closingPanelOpen = false,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelClose(
|
||||
closingPanelOpen = true,
|
||||
otherPanelOpen = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader focus is restored when an open sidebar is hidden with chrome`() {
|
||||
assertTrue(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
|
||||
wasPanelVisible = true,
|
||||
isPanelVisible = false,
|
||||
panelOpen = true,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
|
||||
wasPanelVisible = true,
|
||||
isPanelVisible = true,
|
||||
panelOpen = true,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
|
||||
wasPanelVisible = true,
|
||||
isPanelVisible = false,
|
||||
panelOpen = true,
|
||||
otherPanelOpen = true
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
readerWorkspaceShouldRestoreFocusAfterPanelVisibilityChange(
|
||||
wasPanelVisible = true,
|
||||
isPanelVisible = false,
|
||||
panelOpen = false,
|
||||
otherPanelOpen = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace exposes visual options through tools popup`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
.filterNot { it == ReaderTool.VISUAL_OPTIONS }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace maps reading mode into appearance popup instead of tools inspector`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
.filterNot { it == ReaderTool.READING_MODE }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false
|
||||
)
|
||||
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace ignores external lookup in inspector`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
.filterNot { it == ReaderTool.DICTIONARY }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epub workspace exposes tools popup for desktop app theme controls`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries.mapTo(mutableSetOf()) { it.id }
|
||||
)
|
||||
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false,
|
||||
appThemeControlsAvailable = true
|
||||
)
|
||||
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar quick actions preserve visibility order and bottom placement`() {
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf(ReaderTool.BOOKMARK.id),
|
||||
toolOrder = listOf(
|
||||
ReaderTool.SEARCH,
|
||||
ReaderTool.AI_FEATURES,
|
||||
ReaderTool.THEME,
|
||||
ReaderTool.BOOKMARK
|
||||
) + ReaderTool.entries,
|
||||
bottomToolIds = setOf(ReaderTool.SEARCH.id, ReaderTool.AI_FEATURES.id)
|
||||
)
|
||||
|
||||
val topTools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = false,
|
||||
aiAvailable = true
|
||||
)
|
||||
val bottomToolsWithoutAi = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = false
|
||||
)
|
||||
val bottomToolsWithAi = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(ReaderTool.THEME, topTools.first())
|
||||
assertEquals(listOf(ReaderTool.SEARCH), bottomToolsWithoutAi)
|
||||
assertEquals(listOf(ReaderTool.SEARCH), bottomToolsWithAi)
|
||||
assertFalse(ReaderTool.BOOKMARK in topTools)
|
||||
assertFalse(ReaderTool.BOOKMARK in bottomToolsWithAi)
|
||||
assertFalse(ReaderTool.AI_FEATURES in bottomToolsWithAi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toolbar quick actions hide online tools when unavailable`() {
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
toolOrder = listOf(
|
||||
ReaderTool.DICTIONARY,
|
||||
ReaderTool.SEARCH,
|
||||
ReaderTool.AI_FEATURES,
|
||||
ReaderTool.TTS_CONTROLS
|
||||
) + ReaderTool.entries,
|
||||
bottomToolIds = setOf(
|
||||
ReaderTool.DICTIONARY.id,
|
||||
ReaderTool.SEARCH.id,
|
||||
ReaderTool.AI_FEATURES.id,
|
||||
ReaderTool.TTS_CONTROLS.id
|
||||
)
|
||||
)
|
||||
|
||||
val tools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false
|
||||
)
|
||||
|
||||
assertEquals(listOf(ReaderTool.SEARCH), tools)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tts controls use top read aloud action instead of toolbar quick action`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
toolOrder = listOf(ReaderTool.TTS_CONTROLS) + ReaderTool.entries,
|
||||
bottomToolIds = setOf(ReaderTool.TTS_CONTROLS.id)
|
||||
)
|
||||
|
||||
val tools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = true,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = true,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(ReaderTool.TTS_CONTROLS in tools)
|
||||
assertTrue(ReaderWorkspaceTopAction.READ_ALOUD in model.topActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ai features use top hub action instead of toolbar quick action`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = ReaderTool.entries
|
||||
.filterNot { it == ReaderTool.AI_FEATURES }
|
||||
.mapTo(mutableSetOf()) { it.id },
|
||||
toolOrder = listOf(ReaderTool.AI_FEATURES) + ReaderTool.entries,
|
||||
bottomToolIds = setOf(ReaderTool.AI_FEATURES.id)
|
||||
)
|
||||
|
||||
val tools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = true,
|
||||
aiAvailable = true,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = true,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = true
|
||||
)
|
||||
|
||||
assertFalse(ReaderTool.AI_FEATURES in tools)
|
||||
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
assertFalse(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retired auto scroll preferences are ignored for desktop reader tools`() {
|
||||
val session = ReaderEngine().createSession(readerFixtureBook())
|
||||
val preferences = ReaderToolbarPreferences(
|
||||
hiddenToolIds = setOf("auto_scroll"),
|
||||
toolOrder = ReaderTool.entries
|
||||
)
|
||||
|
||||
val tools = readerWorkspaceQuickActionTools(
|
||||
toolbarPreferences = preferences,
|
||||
bottom = false,
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false
|
||||
)
|
||||
val model = epubReaderWorkspaceModel(
|
||||
session = session,
|
||||
toolbarPreferences = preferences,
|
||||
extrasState = ReaderExtrasState(autoScroll = ReaderAutoScrollState(enabled = true)),
|
||||
aiAvailable = false,
|
||||
cloudTtsAvailable = false,
|
||||
externalLookupAvailable = false
|
||||
)
|
||||
|
||||
assertNull(ReaderTool.fromId("auto_scroll"))
|
||||
assertFalse("auto_scroll" in preferences.sanitized().hiddenToolIds)
|
||||
assertFalse(tools.any { it.id == "auto_scroll" })
|
||||
assertFalse(ReaderWorkspaceTopAction.TOOLS in model.topActions)
|
||||
assertFalse("auto-scroll" in model.chrome.forceVisibleReasons)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf workspace defaults to reading first while keeping annotation tools in inspector`() {
|
||||
val model = pdfReaderWorkspaceModel(
|
||||
state = SharedPdfReaderState.initial(pageCount = 4),
|
||||
displayMode = PdfDisplayMode.PAGINATION,
|
||||
hasContents = true,
|
||||
hasBookmarks = true,
|
||||
hasAnnotations = true,
|
||||
hasEmbeddedComments = true,
|
||||
searchActive = false,
|
||||
annotationEditing = false,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = null,
|
||||
extrasState = ReaderExtrasState(),
|
||||
aiAvailable = true
|
||||
)
|
||||
|
||||
assertEquals(ReaderWorkspaceKind.PDF, model.kind)
|
||||
assertNull(model.defaultPdfInteractionMode)
|
||||
assertEquals(
|
||||
listOf(
|
||||
ReaderWorkspaceLeftSection.CONTENTS,
|
||||
ReaderWorkspaceLeftSection.NOTES,
|
||||
ReaderWorkspaceLeftSection.BOOKMARKS,
|
||||
ReaderWorkspaceLeftSection.PAGES
|
||||
),
|
||||
model.leftSections
|
||||
)
|
||||
assertFalse(ReaderWorkspaceLeftSection.SEARCH in model.leftSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.APPEARANCE in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.TOOLS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.AI_TTS in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceInspectorSection.TOOLBAR in model.inspectorSections)
|
||||
assertTrue(ReaderWorkspaceTopAction.BOOKMARK in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.FULL_SCREEN in model.topActions)
|
||||
assertTrue(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
assertFalse(model.panelDefaults.leftOpen)
|
||||
assertFalse(model.panelDefaults.inspectorOpen)
|
||||
assertTrue(model.chrome.preferAutoHide)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf workspace forces chrome for search editing errors and reveals tts`() {
|
||||
val model = pdfReaderWorkspaceModel(
|
||||
state = SharedPdfReaderState.initial(pageCount = 4).copy(searchQuery = "needle"),
|
||||
displayMode = PdfDisplayMode.VERTICAL_SCROLL,
|
||||
hasContents = false,
|
||||
hasBookmarks = false,
|
||||
hasAnnotations = false,
|
||||
hasEmbeddedComments = false,
|
||||
searchActive = false,
|
||||
annotationEditing = true,
|
||||
richTextEditing = false,
|
||||
loading = false,
|
||||
errorMessage = "Problem",
|
||||
extrasState = ReaderExtrasState(
|
||||
autoScroll = ReaderAutoScrollState(enabled = true),
|
||||
cloudTts = ReaderCloudTtsState(isPlaying = true)
|
||||
),
|
||||
aiAvailable = false
|
||||
)
|
||||
|
||||
assertTrue(model.chrome.forceVisible)
|
||||
assertTrue(model.chrome.preferAutoHide)
|
||||
assertTrue("search" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("annotation" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("error" in model.chrome.forceVisibleReasons)
|
||||
assertFalse("auto-scroll" in model.chrome.forceVisibleReasons)
|
||||
assertFalse("tts" in model.chrome.forceVisibleReasons)
|
||||
assertTrue("tts" in model.chrome.revealVisibleReasons)
|
||||
assertFalse(ReaderWorkspaceTopAction.AI in model.topActions)
|
||||
}
|
||||
|
||||
private fun readerFixtureBook(): SharedEpubBook {
|
||||
return SharedEpubBook(
|
||||
id = "reader_fixture",
|
||||
fileName = "Reader Fixture.epub",
|
||||
title = "Reader Fixture",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "intro",
|
||||
title = "Intro",
|
||||
plainText = "A short reader fixture for workspace model tests."
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SharedAppThemeColorMathTest {
|
||||
|
||||
@Test
|
||||
fun `rgb color converts to expected hsv components`() {
|
||||
val hsv = Color(0xFFFF0000).toSharedHsvColor()
|
||||
|
||||
assertClose(0f, hsv.hue)
|
||||
assertClose(1f, hsv.saturation)
|
||||
assertClose(1f, hsv.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hsv color converts back to compose rgb color`() {
|
||||
val color = SharedHsvColor(hue = 120f, saturation = 1f, value = 1f).toComposeColor()
|
||||
|
||||
assertEquals(Color(0xFF00FF00).toArgb(), color.toArgb())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hex parser accepts android style six digit colors`() {
|
||||
val color = "#006C4C".toSharedHexColorOrNull()
|
||||
|
||||
assertEquals(Color(0xFF006C4C).toArgb(), color?.toArgb())
|
||||
assertEquals("#006C4C", color?.toSharedHexString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hex parser rejects incomplete and invalid colors`() {
|
||||
assertNull("006C4".toSharedHexColorOrNull())
|
||||
assertNull("#006C4Z".toSharedHexColorOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rgb hsv conversion round trips common custom theme colors`() {
|
||||
val original = Color(0xFF2D6A4F)
|
||||
val roundTripped = original.toSharedHsvColor().toComposeColor()
|
||||
|
||||
assertTrue(abs(original.red - roundTripped.red) < 0.01f)
|
||||
assertTrue(abs(original.green - roundTripped.green) < 0.01f)
|
||||
assertTrue(abs(original.blue - roundTripped.blue) < 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hsv wheel maps center to no saturation and right edge to red`() {
|
||||
val center = sharedHsvWheelSelection(
|
||||
offsetX = 50f,
|
||||
offsetY = 50f,
|
||||
width = 100f,
|
||||
height = 100f
|
||||
)
|
||||
val rightEdge = sharedHsvWheelSelection(
|
||||
offsetX = 100f,
|
||||
offsetY = 50f,
|
||||
width = 100f,
|
||||
height = 100f
|
||||
)
|
||||
|
||||
assertClose(0f, center.saturation)
|
||||
assertClose(0f, rightEdge.hue)
|
||||
assertClose(1f, rightEdge.saturation)
|
||||
}
|
||||
|
||||
private fun assertClose(expected: Float, actual: Float) {
|
||||
assertTrue(abs(expected - actual) < 0.01f, "Expected $expected but was $actual")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import org.dueattendant149.bookreader.paginatedreader.CssStyle
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph
|
||||
import org.dueattendant149.bookreader.shared.HighlightColor
|
||||
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||
import org.dueattendant149.bookreader.shared.UserHighlight
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPage
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class SharedNativePaginatedReaderInteractionTest {
|
||||
@Test
|
||||
fun `word selection trims punctuation around long press range`() {
|
||||
val range = sharedNativeReaderTrimmedWordRange(
|
||||
text = "\"Reader,\" she said.",
|
||||
start = 0,
|
||||
end = 9
|
||||
)
|
||||
|
||||
assertNotNull(range)
|
||||
assertEquals(1, range.start)
|
||||
assertEquals(7, range.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `word selection ignores punctuation only range`() {
|
||||
val range = sharedNativeReaderTrimmedWordRange(
|
||||
text = "...",
|
||||
start = 0,
|
||||
end = 3
|
||||
)
|
||||
|
||||
assertNull(range)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selection gesture key ignores paint-only annotated string changes`() {
|
||||
val plain = AnnotatedString("Alpha beta")
|
||||
val selected = buildAnnotatedString {
|
||||
append("Alpha beta")
|
||||
addStyle(SpanStyle(background = Color.Blue), start = 0, end = 5)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
sharedNativeReaderSelectionGestureKey("0:1:0", plain),
|
||||
sharedNativeReaderSelectionGestureKey("0:1:0", selected)
|
||||
)
|
||||
assertNotEquals(
|
||||
sharedNativeReaderSelectionGestureKey("0:1:0", plain),
|
||||
sharedNativeReaderSelectionGestureKey("0:1:0", AnnotatedString("Alpha beta gamma"))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight for native selection keeps desktop locator offsets`() {
|
||||
val selection = SharedNativeReaderTextSelection(
|
||||
chapterIndex = 2,
|
||||
pageIndex = 7,
|
||||
startOffset = 120,
|
||||
endOffset = 136,
|
||||
text = "selected passage"
|
||||
)
|
||||
|
||||
val highlight = sharedNativeReaderHighlightForSelection(selection, HighlightColor.YELLOW)
|
||||
|
||||
assertEquals("desktop:2:120:136", highlight.cfi)
|
||||
assertEquals(2, highlight.chapterIndex)
|
||||
assertEquals(7, highlight.locator.pageIndex)
|
||||
assertEquals(120, highlight.locator.startOffset)
|
||||
assertEquals(136, highlight.locator.endOffset)
|
||||
assertEquals("selected passage", highlight.locator.textQuote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `highlight for block selection keeps android style cfi and locator offsets`() {
|
||||
val selection = SharedNativeReaderTextSelection(
|
||||
chapterIndex = 1,
|
||||
pageIndex = 4,
|
||||
startOffset = 105,
|
||||
endOffset = 220,
|
||||
text = "selected across blocks",
|
||||
startPageIndex = 4,
|
||||
endPageIndex = 4,
|
||||
startBlockIndex = 8,
|
||||
endBlockIndex = 10,
|
||||
startBlockCharOffset = 100,
|
||||
endBlockCharOffset = 200,
|
||||
startLocalOffset = 5,
|
||||
endLocalOffset = 20,
|
||||
startBaseCfi = "/4/2/8",
|
||||
endBaseCfi = "/4/2/10"
|
||||
)
|
||||
|
||||
val highlight = sharedNativeReaderHighlightForSelection(selection, HighlightColor.GREEN)
|
||||
|
||||
assertEquals("/4/2/8:5|/4/2/10:20", highlight.cfi)
|
||||
assertEquals(1, highlight.chapterIndex)
|
||||
assertEquals(4, highlight.locator.pageIndex)
|
||||
assertEquals(105, highlight.locator.startOffset)
|
||||
assertEquals(220, highlight.locator.endOffset)
|
||||
assertEquals(8, highlight.locator.blockIndex)
|
||||
assertEquals(105, highlight.locator.charOffset)
|
||||
assertEquals("selected across blocks", highlight.locator.textQuote)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native paginated keeps cfi highlights visible only on anchored page`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:3|/4/2:8",
|
||||
text = "alpha",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 2
|
||||
)
|
||||
val page = ReaderPage(
|
||||
pageIndex = 20,
|
||||
chapterIndex = 2,
|
||||
chapterTitle = "Chapter",
|
||||
text = "alpha beta",
|
||||
startOffset = 100,
|
||||
endOffset = 110,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "alpha beta",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 100,
|
||||
blockIndex = 7
|
||||
)
|
||||
)
|
||||
)
|
||||
val unrelatedPage = ReaderPage(
|
||||
pageIndex = 21,
|
||||
chapterIndex = 2,
|
||||
chapterTitle = "Chapter",
|
||||
text = "alpha beta",
|
||||
startOffset = 200,
|
||||
endOffset = 210,
|
||||
semanticBlocks = listOf(
|
||||
SemanticParagraph(
|
||||
text = "alpha beta",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = null,
|
||||
cfi = "/4/4",
|
||||
startCharOffsetInSource = 200,
|
||||
blockIndex = 8
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val visible = sharedNativeVisibleHighlightsForPage(listOf(highlight), page)
|
||||
val unrelatedVisible = sharedNativeVisibleHighlightsForPage(listOf(highlight), unrelatedPage)
|
||||
|
||||
assertEquals(listOf(highlight), visible)
|
||||
assertEquals(emptyList(), unrelatedVisible)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping prefers locator offsets before cfi offsets`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:0|/4/2:5",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 8,
|
||||
endOffset = 14,
|
||||
textQuote = "target",
|
||||
cfi = "/4/2:0|/4/2:5"
|
||||
)
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
textStartOffset = 0,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping can use source cfi when locator offsets miss block range`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:8|/4/2:14",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 108,
|
||||
endOffset = 114,
|
||||
textQuote = "target",
|
||||
cfi = "/4/2:8|/4/2:14"
|
||||
)
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
textStartOffset = 300,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping ignores block local offsets on sibling cfi blocks`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:8|/4/2:14",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 8,
|
||||
endOffset = 14,
|
||||
blockIndex = 42,
|
||||
charOffset = 8,
|
||||
textQuote = "target",
|
||||
cfi = "/4/2:8|/4/2:14"
|
||||
)
|
||||
)
|
||||
|
||||
val selectedRange = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
blockIndex = 42,
|
||||
blockCharOffset = 0,
|
||||
textStartOffset = 0,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
val siblingRange = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/4",
|
||||
blockIndex = 43,
|
||||
blockCharOffset = 0,
|
||||
textStartOffset = 0,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, selectedRange?.start)
|
||||
assertEquals(14, selectedRange?.end)
|
||||
assertNull(siblingRange)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping can use android style block locator`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "android-locator:0:42:108",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
blockIndex = 42,
|
||||
charOffset = 108,
|
||||
textQuote = "target",
|
||||
cfi = "android-locator:0:42:108"
|
||||
)
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
blockIndex = 42,
|
||||
blockCharOffset = 100,
|
||||
textStartOffset = 100,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping prefers block locator before overlapping offsets`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "android-locator:0:42:108",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0,
|
||||
locator = ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
startOffset = 0,
|
||||
endOffset = 6,
|
||||
blockIndex = 42,
|
||||
charOffset = 108,
|
||||
textQuote = "target",
|
||||
cfi = "android-locator:0:42:108"
|
||||
)
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
blockIndex = 42,
|
||||
blockCharOffset = 100,
|
||||
textStartOffset = 0,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping treats source cfi offsets as block local`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:8|/4/2:14",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
textStartOffset = 100,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native highlight mapping still accepts legacy absolute cfi offsets`() {
|
||||
val highlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "/4/2:108|/4/2:114",
|
||||
text = "target",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 0
|
||||
)
|
||||
|
||||
val range = sharedNativeHighlightRangeForBlock(
|
||||
highlight = highlight,
|
||||
blockCfi = "/4/2",
|
||||
textStartOffset = 100,
|
||||
textLength = "prefix target suffix".length,
|
||||
text = "prefix target suffix"
|
||||
)
|
||||
|
||||
assertEquals(8, range?.start)
|
||||
assertEquals(14, range?.end)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.dueattendant149.bookreader.paginatedreader.BlockStyle
|
||||
import org.dueattendant149.bookreader.paginatedreader.BorderStyle
|
||||
import org.dueattendant149.bookreader.paginatedreader.CssStyle
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticImage
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticMath
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticParagraph
|
||||
import org.dueattendant149.bookreader.paginatedreader.SemanticWrappingBlock
|
||||
import org.dueattendant149.bookreader.shared.ReaderLocator
|
||||
import org.dueattendant149.bookreader.shared.reader.ReaderPage
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubBook
|
||||
import org.dueattendant149.bookreader.shared.reader.SharedEpubChapter
|
||||
import org.dueattendant149.bookreader.shared.reader.resolveSharedReaderFontFeatureSettings
|
||||
import org.dueattendant149.bookreader.shared.reader.resolveSharedReaderTextAlign
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class SharedNativeVerticalReaderFlowTest {
|
||||
@Test
|
||||
fun `shared native visibility hides css hidden blocks`() {
|
||||
assertEquals(true, BlockStyle(visibility = "hidden").isSharedNativeVisibilityHidden())
|
||||
assertEquals(false, BlockStyle(visibility = "visible").isSharedNativeVisibilityHidden())
|
||||
assertEquals(false, BlockStyle().isSharedNativeVisibilityHidden())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared native background image extracts usable image paths`() {
|
||||
assertEquals(
|
||||
"images/paper.png",
|
||||
BlockStyle(backgroundImage = """url("images/paper.png")""").sharedNativeBackgroundImagePath()
|
||||
)
|
||||
assertEquals(
|
||||
"images/paper.png",
|
||||
BlockStyle(backgroundImage = """url( "images/paper.png" )""").sharedNativeBackgroundImagePath()
|
||||
)
|
||||
assertEquals(
|
||||
"data:image/png;base64,abc",
|
||||
BlockStyle(backgroundImage = "data:image/png;base64,abc").sharedNativeBackgroundImagePath()
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
BlockStyle(backgroundImage = "NONE").sharedNativeBackgroundImagePath()
|
||||
)
|
||||
assertEquals(
|
||||
null,
|
||||
BlockStyle(backgroundImage = "linear-gradient(red, blue)").sharedNativeBackgroundImagePath()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared native background image keeps fit and position styles`() {
|
||||
val image = BlockStyle(
|
||||
backgroundImage = """url("images/paper.png")""",
|
||||
objectFit = "cover",
|
||||
objectPosition = "left top",
|
||||
filter = "invert(100%)"
|
||||
).toSharedNativeBackgroundImage(blockIndex = 9)
|
||||
|
||||
assertNotNull(image)
|
||||
assertEquals("images/paper.png", image.path)
|
||||
assertEquals("", image.altText)
|
||||
assertEquals("cover", image.style.blockStyle.objectFit)
|
||||
assertEquals("left top", image.style.blockStyle.objectPosition)
|
||||
assertEquals("invert(100%)", image.style.blockStyle.filter)
|
||||
assertEquals(9, image.blockIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared native link style makes links visible`() {
|
||||
val style = sharedNativeReaderLinkSpanStyle(
|
||||
isDarkTheme = false,
|
||||
themeBackgroundColor = Color.White,
|
||||
themeTextColor = Color.Black
|
||||
)
|
||||
|
||||
assertEquals(true, style.color.isSpecified)
|
||||
assertEquals(true, style.background.isSpecified)
|
||||
assertEquals(true, style.textDecoration?.contains(TextDecoration.Underline))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `css justify downgrades unless shared setting explicitly forces alignment`() {
|
||||
assertEquals(
|
||||
TextAlign.Left,
|
||||
resolveSharedReaderTextAlign(
|
||||
cssTextAlign = TextAlign.Justify,
|
||||
fallbackTextAlign = TextAlign.Start
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
TextAlign.Justify,
|
||||
resolveSharedReaderTextAlign(
|
||||
cssTextAlign = TextAlign.Justify,
|
||||
fallbackTextAlign = TextAlign.Justify
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
TextAlign.Right,
|
||||
resolveSharedReaderTextAlign(
|
||||
cssTextAlign = TextAlign.Center,
|
||||
fallbackTextAlign = TextAlign.Right
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `css numeric font variants map to shared native font features`() {
|
||||
assertEquals(
|
||||
""""tnum" on, "zero" on""",
|
||||
resolveSharedReaderFontFeatureSettings(
|
||||
existingSettings = null,
|
||||
fontVariantNumeric = "tabular-nums slashed-zero"
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
""""smcp" on, "onum" on, "pnum" on""",
|
||||
resolveSharedReaderFontFeatureSettings(
|
||||
existingSettings = """"smcp" on""",
|
||||
fontVariantNumeric = "oldstyle-nums proportional-nums"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `semantic images and styles stay in native vertical flow`() {
|
||||
val paragraphStyle = CssStyle(
|
||||
blockStyle = BlockStyle(
|
||||
backgroundColor = Color(0xFFEFEFEF),
|
||||
borderTop = BorderStyle(width = 1.dp, color = Color.Red)
|
||||
)
|
||||
)
|
||||
val paragraph = SemanticParagraph(
|
||||
text = "Styled paragraph",
|
||||
spans = emptyList(),
|
||||
style = paragraphStyle,
|
||||
elementId = "p1",
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 1
|
||||
)
|
||||
val imageStyle = CssStyle(
|
||||
blockStyle = BlockStyle(
|
||||
width = 120.dp,
|
||||
height = 80.dp,
|
||||
objectFit = "cover"
|
||||
)
|
||||
)
|
||||
val image = SemanticImage(
|
||||
path = "data:image/png;base64,iVBORw0KGgo=",
|
||||
altText = "cover",
|
||||
intrinsicWidth = 120f,
|
||||
intrinsicHeight = 80f,
|
||||
style = imageStyle,
|
||||
elementId = "img1",
|
||||
cfi = "/4/4",
|
||||
blockIndex = 2
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = "Chapter",
|
||||
plainText = "Styled paragraph",
|
||||
semanticBlocks = listOf(paragraph, image)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
|
||||
|
||||
assertEquals(
|
||||
listOf(SharedNativeVerticalFlowItemKind.BLOCK, SharedNativeVerticalFlowItemKind.BLOCK),
|
||||
items.map { it.kind }
|
||||
)
|
||||
val imageItem = items.single { it.block is SemanticImage }
|
||||
val flowImage = assertIs<SemanticImage>(imageItem.block)
|
||||
assertEquals("data:image/png;base64,iVBORw0KGgo=", flowImage.path)
|
||||
assertEquals("cover", flowImage.style.blockStyle.objectFit)
|
||||
assertEquals(2, imageItem.page.semanticBlocks.single().blockIndex)
|
||||
val paragraphItem = assertNotNull(items.first().block)
|
||||
assertEquals(Color(0xFFEFEFEF), paragraphItem.style.blockStyle.backgroundColor)
|
||||
assertEquals(Color.Red, paragraphItem.style.blockStyle.borderTop?.color)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shared native vertical restore prefers block locator before compat page`() {
|
||||
val first = SemanticParagraph(
|
||||
text = "First paragraph",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "p1",
|
||||
cfi = "/4/2",
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 1
|
||||
)
|
||||
val second = SemanticParagraph(
|
||||
text = "Second paragraph",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "p2",
|
||||
cfi = "/4/4",
|
||||
startCharOffsetInSource = 16,
|
||||
blockIndex = 2
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = "Chapter",
|
||||
plainText = "First paragraph\nSecond paragraph",
|
||||
semanticBlocks = listOf(first, second)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
|
||||
val restoredIndex = items.sharedNativeVerticalItemIndexForLocator(
|
||||
ReaderLocator(
|
||||
chapterIndex = 0,
|
||||
pageIndex = 0,
|
||||
startOffset = 16,
|
||||
endOffset = 32,
|
||||
blockIndex = 2,
|
||||
charOffset = 16,
|
||||
cfi = "/4/4:0"
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(1, restoredIndex)
|
||||
assertEquals(2, items[restoredIndex!!].block?.blockIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `svg math blocks stay in native vertical flow`() {
|
||||
val math = SemanticMath(
|
||||
svgContent = """<svg width="24" height="12" viewBox="0 0 24 12"><text>x</text></svg>""",
|
||||
altText = "Equation",
|
||||
svgWidth = "24",
|
||||
svgHeight = "12",
|
||||
svgViewBox = "0 0 24 12",
|
||||
isFromMathJax = false,
|
||||
style = CssStyle(),
|
||||
elementId = "eq1",
|
||||
cfi = "/4/6",
|
||||
blockIndex = 3
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = "Chapter",
|
||||
plainText = "Equation",
|
||||
semanticBlocks = listOf(math)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
|
||||
|
||||
assertEquals(1, items.size)
|
||||
assertEquals(SharedNativeVerticalFlowItemKind.BLOCK, items.single().kind)
|
||||
val flowMath = assertIs<SemanticMath>(items.single().block)
|
||||
assertEquals("""<svg width="24" height="12" viewBox="0 0 24 12"><text>x</text></svg>""", flowMath.svgContent)
|
||||
assertEquals(3, items.single().page.semanticBlocks.single().blockIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `floated image wrapping blocks stay grouped in native vertical flow`() {
|
||||
val image = SemanticImage(
|
||||
path = "cover.png",
|
||||
altText = "Cover",
|
||||
intrinsicWidth = 120f,
|
||||
intrinsicHeight = 180f,
|
||||
style = CssStyle(blockStyle = BlockStyle(float = "left")),
|
||||
elementId = "img1",
|
||||
cfi = "/4/2",
|
||||
blockIndex = 4
|
||||
)
|
||||
val firstParagraph = SemanticParagraph(
|
||||
text = "Wrapped first",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "p1",
|
||||
cfi = "/4/4",
|
||||
startCharOffsetInSource = 0,
|
||||
blockIndex = 5
|
||||
)
|
||||
val secondParagraph = SemanticParagraph(
|
||||
text = "Wrapped second",
|
||||
spans = emptyList(),
|
||||
style = CssStyle(),
|
||||
elementId = "p2",
|
||||
cfi = "/4/6",
|
||||
startCharOffsetInSource = 14,
|
||||
blockIndex = 6
|
||||
)
|
||||
val wrappingBlock = SemanticWrappingBlock(
|
||||
floatedImage = image,
|
||||
paragraphsToWrap = listOf(firstParagraph, secondParagraph),
|
||||
style = CssStyle(),
|
||||
elementId = "wrap1",
|
||||
cfi = "/4/2",
|
||||
blockIndex = 7
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.epub",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = "Chapter",
|
||||
plainText = "Wrapped first\nWrapped second",
|
||||
semanticBlocks = listOf(wrappingBlock)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val items = buildSharedNativeVerticalFlowItems(book, pages = emptyList())
|
||||
|
||||
assertEquals(1, items.size)
|
||||
assertEquals(SharedNativeVerticalFlowItemKind.BLOCK, items.single().kind)
|
||||
val flowWrappingBlock = assertIs<SemanticWrappingBlock>(items.single().block)
|
||||
assertEquals("cover.png", flowWrappingBlock.floatedImage.path)
|
||||
assertEquals(listOf(5, 6), flowWrappingBlock.paragraphsToWrap.map { it.blockIndex })
|
||||
assertEquals(listOf(7), items.single().page.semanticBlocks.map { it.blockIndex })
|
||||
assertEquals("Wrapped first\nWrapped second", items.single().page.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plain text pages are used when a chapter has no semantic blocks`() {
|
||||
val page = ReaderPage(
|
||||
pageIndex = 0,
|
||||
chapterIndex = 0,
|
||||
chapterTitle = "Chapter",
|
||||
text = "Plain text",
|
||||
startOffset = 0,
|
||||
endOffset = 10
|
||||
)
|
||||
val book = SharedEpubBook(
|
||||
id = "book",
|
||||
fileName = "book.txt",
|
||||
title = "Book",
|
||||
chapters = listOf(
|
||||
SharedEpubChapter(
|
||||
id = "chapter_0",
|
||||
title = "Chapter",
|
||||
plainText = "Plain text"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val items = buildSharedNativeVerticalFlowItems(book, pages = listOf(page))
|
||||
|
||||
assertEquals(1, items.size)
|
||||
assertEquals(SharedNativeVerticalFlowItemKind.TEXT_PAGE, items.single().kind)
|
||||
assertEquals(page, items.single().page)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import org.dueattendant149.bookreader.shared.pdf.PdfAnnotationKind
|
||||
import org.dueattendant149.bookreader.shared.pdf.PdfInkTool
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAndroidHighlightColors
|
||||
import org.dueattendant149.bookreader.shared.pdf.SharedPdfAnnotation
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedPdfAnnotationUiTest {
|
||||
@Test
|
||||
fun `text highlight annotations render with readable highlighter blending`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
colorArgb = Color.Yellow.copy(alpha = 0.9f).toArgb()
|
||||
)
|
||||
|
||||
val style = sharedPdfHighlightAnnotationOverlayStyle(annotation)
|
||||
|
||||
assertEquals(BlendMode.Multiply, style.blendMode)
|
||||
assertEquals(SharedPdfAndroidHighlightColors.RenderAlpha, style.color.alpha)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text highlight annotations preserve lower custom opacity`() {
|
||||
val annotation = SharedPdfAnnotation(
|
||||
id = "highlight-1",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
tool = PdfInkTool.HIGHLIGHTER,
|
||||
colorArgb = Color.Yellow.copy(alpha = 0.18f).toArgb()
|
||||
)
|
||||
|
||||
val style = sharedPdfHighlightAnnotationOverlayStyle(annotation)
|
||||
|
||||
assertEquals(0.18f, style.color.alpha, 0.005f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interaction dock keeps reading modes before markup actions`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedPdfInteractionDockItem.PAN,
|
||||
SharedPdfInteractionDockItem.SELECT_TEXT,
|
||||
SharedPdfInteractionDockItem.PEN,
|
||||
SharedPdfInteractionDockItem.HIGHLIGHTER,
|
||||
SharedPdfInteractionDockItem.TEXT_NOTE,
|
||||
SharedPdfInteractionDockItem.ERASER,
|
||||
SharedPdfInteractionDockItem.UNDO,
|
||||
SharedPdfInteractionDockItem.REDO,
|
||||
SharedPdfInteractionDockItem.CLEAR_PAGE
|
||||
),
|
||||
sharedPdfInteractionDockItems()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interaction dock only exposes available markup groups`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
SharedPdfInteractionDockItem.PAN,
|
||||
SharedPdfInteractionDockItem.SELECT_TEXT,
|
||||
SharedPdfInteractionDockItem.TEXT_NOTE,
|
||||
SharedPdfInteractionDockItem.UNDO,
|
||||
SharedPdfInteractionDockItem.REDO,
|
||||
SharedPdfInteractionDockItem.CLEAR_PAGE
|
||||
),
|
||||
sharedPdfInteractionDockItems(tools = listOf(PdfInkTool.TEXT))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool settings palette matches highlighter colors by rgb`() {
|
||||
val paletteColor = Color(0xFFFFEB3B).copy(alpha = 0.55f).toArgb()
|
||||
val selectedColor = Color(0xFFFFEB3B).copy(alpha = 0.25f).toArgb()
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
sharedPdfSettingsSelectedPaletteIndex(
|
||||
activePalette = listOf(paletteColor),
|
||||
selectedColor = selectedColor,
|
||||
matchRgbOnly = true
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
-1,
|
||||
sharedPdfSettingsSelectedPaletteIndex(
|
||||
activePalette = listOf(paletteColor),
|
||||
selectedColor = selectedColor,
|
||||
matchRgbOnly = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool settings slider display percent clamps like android popup`() {
|
||||
val range = 0.01f..0.06f
|
||||
|
||||
assertEquals(1, sharedPdfSettingsDisplayPercent(0.0f, range))
|
||||
assertEquals(50, sharedPdfSettingsDisplayPercent(0.035f, range))
|
||||
assertEquals(100, sharedPdfSettingsDisplayPercent(0.10f, range))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ink preview reveal progress clamps to animation range`() {
|
||||
assertEquals(0f, sharedPdfInkPreviewRevealProgress(-0.5f))
|
||||
assertEquals(0.45f, sharedPdfInkPreviewRevealProgress(0.45f))
|
||||
assertEquals(1f, sharedPdfInkPreviewRevealProgress(1.5f))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedReaderModalSizingTest {
|
||||
|
||||
@Test
|
||||
fun `reader popup width is capped on wide surfaces`() {
|
||||
assertEquals(SharedReaderPopupDefaultMaxWidth, sharedReaderPopupWidth(1200.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader popup width stays usable on narrow surfaces`() {
|
||||
assertEquals(320.dp, sharedReaderPopupWidth(500.dp))
|
||||
assertEquals(280.dp, sharedReaderPopupWidth(280.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedSelectionMenuPlacementTest {
|
||||
@Test
|
||||
fun `places menu above selection when there is room`() {
|
||||
val result = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(width = 800, height = 600),
|
||||
popup = SharedSelectionMenuSize(width = 240, height = 120),
|
||||
selection = SharedSelectionMenuRect(left = 300f, top = 300f, right = 360f, bottom = 330f),
|
||||
marginPx = 16f,
|
||||
gapPx = 12f
|
||||
)
|
||||
|
||||
assertEquals(SharedSelectionMenuPlacement.ABOVE, result.placement)
|
||||
assertEquals(210, result.x)
|
||||
assertEquals(168, result.y)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `places menu below selection when above is blocked`() {
|
||||
val result = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(width = 800, height = 600),
|
||||
popup = SharedSelectionMenuSize(width = 240, height = 120),
|
||||
selection = SharedSelectionMenuRect(left = 300f, top = 40f, right = 360f, bottom = 70f),
|
||||
marginPx = 16f,
|
||||
gapPx = 12f
|
||||
)
|
||||
|
||||
assertEquals(SharedSelectionMenuPlacement.BELOW, result.placement)
|
||||
assertEquals(210, result.x)
|
||||
assertEquals(82, result.y)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `places menu on wider side in short landscape viewport`() {
|
||||
val result = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(width = 800, height = 320),
|
||||
popup = SharedSelectionMenuSize(width = 240, height = 180),
|
||||
selection = SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f),
|
||||
marginPx = 16f,
|
||||
gapPx = 12f
|
||||
)
|
||||
|
||||
assertEquals(SharedSelectionMenuPlacement.RIGHT, result.placement)
|
||||
assertEquals(392, result.x)
|
||||
assertEquals(65, result.y)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps menu off selected text when a valid side placement exists`() {
|
||||
val result = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(width = 800, height = 320),
|
||||
popup = SharedSelectionMenuSize(width = 240, height = 180),
|
||||
selection = SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f),
|
||||
marginPx = 16f,
|
||||
gapPx = 12f
|
||||
)
|
||||
|
||||
assertEquals(0f, result.rect(width = 240, height = 180).overlapAreaWith(
|
||||
SharedSelectionMenuRect(left = 300f, top = 120f, right = 380f, bottom = 190f)
|
||||
))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back predictably when selection consumes the viewport`() {
|
||||
val result = sharedSelectionMenuPlacement(
|
||||
viewport = SharedSelectionMenuViewport(width = 320, height = 220),
|
||||
popup = SharedSelectionMenuSize(width = 260, height = 180),
|
||||
selection = SharedSelectionMenuRect(left = 10f, top = 20f, right = 310f, bottom = 200f),
|
||||
marginPx = 16f,
|
||||
gapPx = 12f
|
||||
)
|
||||
|
||||
assertEquals(SharedSelectionMenuPlacement.FALLBACK, result.placement)
|
||||
assertEquals(30, result.x)
|
||||
assertEquals(16, result.y)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SharedSelectionMenuPlacementResult.rect(
|
||||
width: Int,
|
||||
height: Int
|
||||
): SharedSelectionMenuRect {
|
||||
return SharedSelectionMenuRect(
|
||||
left = x.toFloat(),
|
||||
top = y.toFloat(),
|
||||
right = x + width.toFloat(),
|
||||
bottom = y + height.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
private fun SharedSelectionMenuRect.overlapAreaWith(other: SharedSelectionMenuRect): Float {
|
||||
val overlapWidth = minOf(right, other.right) - maxOf(left, other.left)
|
||||
val overlapHeight = minOf(bottom, other.bottom) - maxOf(top, other.top)
|
||||
return overlapWidth.coerceAtLeast(0f) * overlapHeight.coerceAtLeast(0f)
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package org.dueattendant149.bookreader.shared.ui
|
||||
|
||||
import org.dueattendant149.bookreader.shared.SharedText
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SharedStringsTest {
|
||||
@Test
|
||||
fun formatsAndroidIndexedPlaceholders() {
|
||||
val formatted = formatAndroidString(
|
||||
"Remove \"%1\$s\" and its %2\$d books?",
|
||||
listOf("Downloads", 3)
|
||||
)
|
||||
|
||||
assertEquals("Remove \"Downloads\" and its 3 books?", formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesEscapedPercentLiterals() {
|
||||
val formatted = formatAndroidString("Preparing %1\$d%%", listOf(42))
|
||||
|
||||
assertEquals("Preparing 42%", formatted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvesQuantityStringsBeforeFallingBack() {
|
||||
val resolver = SharedStringResolver(
|
||||
resolveQuantity = { name, quantity ->
|
||||
when {
|
||||
name == "book_count" && quantity == 1 -> "%1\$d localized book"
|
||||
name == "book_count" -> "%1\$d localized books"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"2 localized books",
|
||||
resolver.quantityString("book_count", 2, "%1\$d book", "%1\$d books", 2)
|
||||
)
|
||||
assertEquals(
|
||||
"1 fallback book",
|
||||
resolver.quantityString("missing_count", 1, "%1\$d fallback book", "%1\$d fallback books", 1)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvesSharedTextResources() {
|
||||
val resolver = SharedStringResolver(
|
||||
resolve = { name -> if (name == "banner_shelf_created") "Localized shelf %1\$s" else null },
|
||||
resolveQuantity = { name, quantity ->
|
||||
if (name == "banner_books_added_to_shelf" && quantity > 1) "%1\$d localized books" else null
|
||||
}
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Localized shelf Favorites",
|
||||
resolver.sharedText(SharedText.string("banner_shelf_created", "Created shelf %1\$s", "Favorites"))
|
||||
)
|
||||
assertEquals(
|
||||
"2 localized books",
|
||||
resolver.sharedText(
|
||||
SharedText.quantity(
|
||||
"banner_books_added_to_shelf",
|
||||
2,
|
||||
"%1\$d fallback book",
|
||||
"%1\$d fallback books",
|
||||
2
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue