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:
Atte149 2026-06-24 10:36:09 +03:00
parent ab9a2dcfeb
commit 0d9439e8c3
323 changed files with 74 additions and 50598 deletions

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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())
}
}

View file

@ -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()
)
}
}

View file

@ -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
)
}
}

View file

@ -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"
)
)
)
}
}

View file

@ -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()
)
}
}

View file

@ -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,
)
}
}

View file

@ -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)
}
}

View file

@ -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")
)
}
}

View file

@ -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)
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}
}

View file

@ -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 }
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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 }
}

View file

@ -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 }
}

View file

@ -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)
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}
}

View file

@ -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))
)
}
}

View file

@ -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
}
}

View file

@ -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()
)
}
}

View file

@ -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
)
}
}

View file

@ -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))
}
}

View file

@ -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 }
)
}
}

View file

@ -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))
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -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
)
)
}
}

View file

@ -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
)
)
}
}

View file

@ -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 ")
)
}
}

View file

@ -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("")
)
)
)
}
}

View file

@ -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())
}
}

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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
)
}

View file

@ -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))
}
}

View file

@ -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."
)
)
)
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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))
}
}

View file

@ -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)
}

View file

@ -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
)
)
)
}
}