Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

View file

@ -0,0 +1,336 @@
// CssParserTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CssParserTest {
@Test
fun parseColor_handlesNamedColorsCorrectly() {
assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red)
assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black)
assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent)
}
@Test
fun parseColor_handles3DigitHexCodes() {
assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles6DigitHexCodes() {
assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles8DigitHexCodes() {
assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC))
}
@Test
fun parseColor_handlesRgbFunction() {
assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
}
@Test
fun parseColor_handlesRgbaFunction() {
assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
}
@Test
fun parseColor_returnsNullForInvalidInput() {
assertThat(CssParser.parseColor("not a color")).isNull()
assertThat(CssParser.parseColor("#12345")).isNull()
assertThat(CssParser.parseColor("rgb(1,2)")).isNull()
}
private val dummyConstraints = androidx.compose.ui.unit.Constraints()
private val baseFontSize = 16f
private val density = 1f
@Test
fun parse_handlesSimpleRule() {
val css = "p { color: red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val rules = result.rules.byTag["p"]
assertThat(rules).hasSize(1)
assertThat(rules?.first()?.style?.spanStyle?.color).isEqualTo(Color.Red)
}
@Test
fun parse_handlesMultipleSelectors() {
val css = "h1, h2, h3 { font-weight: bold; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag["h1"]).hasSize(1)
assertThat(result.rules.byTag["h2"]).hasSize(1)
assertThat(result.rules.byTag["h3"]).hasSize(1)
assertThat(result.rules.byTag["h1"]?.first()?.style?.spanStyle?.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(result.rules.byTag["h2"]?.first()?.style?.spanStyle?.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(result.rules.byTag["h3"]?.first()?.style?.spanStyle?.fontWeight).isEqualTo(FontWeight.Bold)
}
@Test
fun parse_handlesImportantRules() {
val css = "p { color: red !important; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val importantRule = result.rules.byTag["p"]?.find { it.selector.specificity >= 10000 }
assertThat(importantRule).isNotNull()
assertThat(importantRule!!.style.spanStyle.color).isEqualTo(Color.Red)
val normalRule = result.rules.byTag["p"]?.find { it.selector.specificity < 10000 }
assertThat(normalRule).isNull()
}
@Test
fun parse_createsBothNormalAndImportantRulesWhenMixed() {
val css = "p { color: blue; background-color: white !important; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val rules = result.rules.byTag["p"]
assertThat(rules).hasSize(2)
val importantRule = rules?.find { it.selector.specificity >= 10000 }
assertThat(importantRule).isNotNull()
assertThat(importantRule!!.style.blockStyle.backgroundColor).isEqualTo(Color.White)
assertThat(importantRule.style.spanStyle.color.isSpecified).isFalse()
val normalRule = rules.find { it.selector.specificity < 10000 }
assertThat(normalRule).isNotNull()
assertThat(normalRule!!.style.spanStyle.color).isEqualTo(Color.Blue)
assertThat(normalRule.style.blockStyle.backgroundColor.isSpecified).isFalse()
}
@Test
fun parse_extractsFontFaceRulesAndResolvesPath() {
val css = """
@font-face {
font-family: "MyCustomFont";
src: url("../fonts/myfont.ttf");
font-weight: bold;
}
p { color: black; }
""".trimIndent()
val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag).containsKey("p")
assertThat(result.fontFaces).hasSize(1)
val fontFace = result.fontFaces.first()
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf")
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
}
@Test
fun parse_handlesFontFaceWithDataUri() {
val dataUri = "data:font/truetype;base64,AAEAAA..."
val css = """
@font-face {
font-family: 'MyDataFont';
src: url('$dataUri');
}
""".trimIndent()
val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
assertThat(result.fontFaces.first().src).isEqualTo(dataUri)
}
@Test
fun parse_sanitizesPseudoClassesFromSelectors() {
val css = "a:hover, p::first-line, button:focus { color: red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag.keys).containsExactly("a", "p", "button")
}
@Test
fun parse_calculatesSpecificityCorrectly() {
val css = """
#myId { color: red; } /* 100 */
p.myClass { color: green; } /* 11 */
p { color: blue; } /* 1 */
div p { color: yellow; } /* 2 */
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val idRule = result.rules.byId["myId"]?.first()
val classRule = result.rules.otherComplex.find { it.selector.selector == "p.myClass" }
val elementRule = result.rules.byTag["p"]?.first()
val descendantRule = result.rules.otherComplex.find { it.selector.selector == "div p" }
assertThat(idRule?.selector?.specificity).isEqualTo(100)
assertThat(classRule?.selector?.specificity).isEqualTo(11)
assertThat(elementRule?.selector?.specificity).isEqualTo(1)
assertThat(descendantRule?.selector?.specificity).isEqualTo(2)
}
@Test
fun parse_ignoresComments() {
val css = """
/* This is a comment */
p {
color: /* another comment */ blue; /* block comment */
}
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val rules = result.rules.byTag["p"]
assertThat(rules).hasSize(1)
assertThat(rules?.first()?.style?.spanStyle?.color).isEqualTo(Color.Blue)
}
@Test
fun parse_handlesBorderShorthand() {
val css = "div { border: 2px solid red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
assertThat(style?.border).isNotNull()
assertThat(style?.border?.width).isEqualTo(2.dp)
assertThat(style?.border?.style).isEqualTo("solid")
assertThat(style?.border?.color).isEqualTo(Color.Red)
}
@Test
fun parse_handlesMarginAndPaddingShorthand() {
val css = "p { margin: 10px 20px; padding: 1em 2em 3em 4em; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style?.blockStyle
assertThat(style?.margin?.top).isEqualTo(10.dp)
assertThat(style?.margin?.right).isEqualTo(20.dp)
assertThat(style?.margin?.bottom).isEqualTo(10.dp)
assertThat(style?.margin?.left).isEqualTo(20.dp)
assertThat(style?.padding?.top).isEqualTo(16.dp) // 1em
assertThat(style?.padding?.right).isEqualTo(32.dp) // 2em
assertThat(style?.padding?.bottom).isEqualTo(48.dp) // 3em
assertThat(style?.padding?.left).isEqualTo(64.dp) // 4em
}
@Test
fun parse_handlesFontSizeWithEmUnits() {
val css = "p { font-size: 1.2em; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.fontSize?.isEm).isTrue()
assertThat(style?.fontSize?.value).isEqualTo(1.2f)
}
@Test
fun parse_optimizationCategorizesRulesCorrectly() {
val css = """
p { color: blue; }
.myClass { color: green; }
#myId { color: red; }
div > p { color: yellow; }
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag).containsKey("p")
assertThat(result.rules.byClass).containsKey("myClass")
assertThat(result.rules.byId).containsKey("myId")
assertThat(result.rules.otherComplex).hasSize(1)
assertThat(result.rules.otherComplex.first().selector.selector).isEqualTo("div > p")
}
@Test
fun parse_mediaQueryAppliesDarkThemeRules() {
val css = """
p { color: black; }
@media (prefers-color-scheme: dark) {
p { color: white; }
}
""".trimIndent()
val lightResult = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(lightResult.rules.byTag["p"]?.first()?.style?.spanStyle?.color).isEqualTo(Color.Black)
val darkResult = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = true)
assertThat(darkResult.rules.byTag["p"]?.last()?.style?.spanStyle?.color).isEqualTo(Color.White)
}
@Test
fun parse_fontFaceSelectsPreferredSourceFormat() {
val css = """
@font-face {
font-family: "MyFont";
src: url("font.woff2") format("woff2"),
url("font.otf") format("opentype"),
url("font.ttf") format("truetype");
}
""".trimIndent()
val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf")
}
@Test
fun parse_propertiesHandlesVariousUnitsAndValues() {
val css = """
p {
font-size: 150%;
text-transform: uppercase;
text-decoration: underline;
text-align: center;
page-break-inside: avoid;
margin: 0 auto;
}
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.fontSize).isEqualTo(1.5.em)
assertThat(style?.textTransform).isEqualTo("uppercase")
assertThat(style?.spanStyle?.textDecoration).isEqualTo(TextDecoration.Underline)
assertThat(style?.paragraphStyle?.textAlign).isEqualTo(TextAlign.Center)
assertThat(style?.blockStyle?.pageBreakInsideAvoid).isTrue()
assertThat(style?.blockStyle?.horizontalAlign).isEqualTo("center")
}
@Test
fun parse_themeAdaptationAdaptsColorsCorrectlyForDarkTheme() {
val css = "p { color: #111; background-color: #EEE; }" // very dark text, very light bg
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = true)
val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.spanStyle?.color).isEqualTo(Color.White.copy(alpha = 0.87f))
assertThat(style?.blockStyle?.backgroundColor).isEqualTo(Color.Transparent)
}
@Test
fun parse_dataUriWithSemicolonParsesCorrectly() {
val dataUri = "data:font/opentype;base64,d09GMgABAAAAAAPs...;something=else"
val css = """
@font-face {
font-family: 'MyDataFont';
src: url('$dataUri');
}
p { color: red; }
""".trimIndent()
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
assertThat(result.fontFaces.first().src).isEqualTo(dataUri)
assertThat(result.rules.byTag).containsKey("p")
}
@Test
fun parse_textEmphasisParsesCorrectly() {
val css = "p { -epub-text-emphasis-style: filled dot; -epub-text-emphasis-color: red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val emphasis = result.rules.byTag["p"]?.first()?.style?.textEmphasis
assertThat(emphasis).isNotNull()
assertThat(emphasis?.style).isEqualTo("dot")
assertThat(emphasis?.fill).isEqualTo("filled")
assertThat(emphasis?.color).isEqualTo(Color.Red)
}
@Test
fun parse_lineHeightClampsSmallEmValues() {
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style
assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em)
}
}

View file

@ -0,0 +1,375 @@
// HtmlParserTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class HtmlParserTest {
// region Test Setup
private val defaultTextStyle = TextStyle.Default.copy(fontSize = 16.sp, color = Color.Black)
private val defaultDensity = Density(density = 1f, fontScale = 1f)
private val defaultConstraints = Constraints(maxWidth = 1000)
private val defaultChapterPath = "OEBPS/chapter1.xhtml"
private val defaultExtractionPath = InstrumentationRegistry.getInstrumentation().targetContext.cacheDir.absolutePath + "/epub_test/"
private fun parse(
html: String,
cssRules: OptimizedCssRules? = null,
mathSvgCache: Map<String, String> = emptyMap()
): List<SemanticBlock> {
val userAgentRules = CssParser.parse(
cssContent = UserAgentStylesheet.default,
cssPath = null,
baseFontSizeSp = defaultTextStyle.fontSize.value,
density = defaultDensity.density,
constraints = defaultConstraints,
isDarkTheme = false // This is for CSS parsing, not the semantic parser
).rules
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
return htmlToSemanticBlocks(
html = "<body>$html</body>", // Wrap in body to match real usage
cssRules = allRules, // Use the combined list of rules
textStyle = defaultTextStyle,
chapterAbsPath = defaultChapterPath,
extractionBasePath = defaultExtractionPath,
density = defaultDensity,
fontFamilyMap = emptyMap(),
constraints = defaultConstraints,
mathSvgCache = mathSvgCache
)
}
@Test
fun htmlToSemanticBlocks_simpleParagraphTag_createsSemanticParagraph() {
val blocks = parse("<p>Hello World</p>")
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticParagraph::class.java)
val pBlock = block as SemanticParagraph
assertThat(pBlock.text).isEqualTo("Hello World")
}
@Test
fun htmlToSemanticBlocks_headerTag_createsSemanticHeaderWithCorrectLevel() {
val blocks = parse("<h2>Chapter 2</h2>")
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticHeader::class.java)
val hBlock = block as SemanticHeader
assertThat(hBlock.text).isEqualTo("Chapter 2")
assertThat(hBlock.level).isEqualTo(2)
}
@Test
fun htmlToSemanticBlocks_nestedTag_inheritsStyleFromParent() {
val blocks = parse("<div style=\"color: #FF0000;\"><p>This text should be red.</p></div>")
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This text should be red.")
assertThat(pBlock.style.spanStyle.color).isEqualTo(Color.Red)
}
@Test
fun htmlToSemanticBlocks_inlineStyle_overridesCssRule() {
val css = "p { color: red; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p style=\"color: green;\">I am green.</p>", cssRules = cssRules)
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
val blockStyle = pBlock.style.spanStyle
assertThat(blockStyle.color).isEqualTo(Color.Green)
}
@Test
fun htmlToSemanticBlocks_elementWithDisplayNone_isNotIncludedInOutput() {
val blocks = parse("<p>Visible</p><p style=\"display: none;\">Invisible</p>")
assertThat(blocks).hasSize(1)
assertThat((blocks.first() as SemanticParagraph).text).isEqualTo("Visible")
}
@Test
fun htmlToSemanticBlocks_imageWithNonExistentPath_producesNoBlock() {
// This tests the negative path where resolveImagePath returns null
val blocks = parse("<img src=\"non/existent/path.jpg\" />")
assertThat(blocks).isEmpty()
}
@Test
fun htmlToSemanticBlocks_unorderedList_createsSemanticList() {
val blocks = parse("<ul><li>Item 1</li><li>Item 2</li></ul>")
assertThat(blocks).hasSize(1)
val listBlock = blocks.first() as SemanticList
assertThat(listBlock.isOrdered).isFalse()
assertThat(listBlock.items).hasSize(2)
val item1 = listBlock.items[0]
val item2 = listBlock.items[1]
assertThat(item1.text).isEqualTo("Item 1")
assertThat(item2.text).isEqualTo("Item 2")
}
@Test
fun htmlToSemanticBlocks_orderedListWithCssType_createsCorrectSemanticList() {
val css = "ol { list-style-type: lower-roman; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<ol><li>Item 1</li><li>Item 2</li></ol>", cssRules = cssRules)
assertThat(blocks).hasSize(1)
val listBlock = blocks.first() as SemanticList
assertThat(listBlock.isOrdered).isTrue()
assertThat(listBlock.style.blockStyle.listStyleType).isEqualTo("lower-roman")
assertThat(listBlock.items).hasSize(2)
}
@Test
fun htmlToSemanticBlocks_table_createsSemanticTableWithCorrectStructure() {
val html = """
<table>
<tr>
<th>Header 1</th>
<th style="text-align: right;">Header 2</th>
</tr>
<tr>
<td>Data A</td>
<td>Data B</td>
</tr>
</table>
""".trimIndent()
val blocks = parse(html)
assertThat(blocks).hasSize(1)
val tableBlock = blocks.first() as SemanticTable
assertThat(tableBlock.rows).hasSize(2)
// Verify Header Row
val headerRow = tableBlock.rows[0]
assertThat(headerRow).hasSize(2)
assertThat(headerRow[0].isHeader).isTrue()
assertThat((headerRow[0].content.first() as SemanticParagraph).text).isEqualTo("Header 1")
assertThat(headerRow[1].isHeader).isTrue()
assertThat((headerRow[1].content.first() as SemanticParagraph).text).isEqualTo("Header 2")
assertThat(headerRow[1].style.paragraphStyle.textAlign).isEqualTo(TextAlign.End)
// Verify Data Row
val dataRow = tableBlock.rows[1]
assertThat(dataRow).hasSize(2)
assertThat(dataRow[0].isHeader).isFalse()
assertThat((dataRow[0].content.first() as SemanticParagraph).text).isEqualTo("Data A")
assertThat(dataRow[1].isHeader).isFalse()
assertThat((dataRow[1].content.first() as SemanticParagraph).text).isEqualTo("Data B")
}
@Test
fun htmlToSemanticBlocks_textTransformations_areAppliedCorrectly() {
val blocks = parse("<p style=\"text-transform: uppercase;\">hello world</p>")
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
// The transformation is applied during text building
assertThat(pBlock.text).isEqualTo("HELLO WORLD")
}
@Test
fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() {
val html = "<p>This is <b>bold</b> and <i>italic</i> text.</p>"
val blocks = parse(html)
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
// Find the range for "bold" and check its style
val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
assertThat(boldRange).isNotNull()
assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
// Find the range for "italic" and check its style
val italicRange =
pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
assertThat(italicRange).isNotNull()
assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
}
@Test
fun htmlToSemanticBlocks_imageWithExistingPath_createsSemanticImageWithCorrectPath() {
// SETUP
val imageRelativeSrc = "../images/test.jpg"
val chapterParentDir = File(defaultChapterPath).parent ?: ""
val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
imageFile.parentFile?.mkdirs()
imageFile.createNewFile()
imageFile.deleteOnExit()
// ACTION
val blocks = parse("<img src=\"$imageRelativeSrc\" alt=\"A test image\" />")
// ASSERT
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticImage::class.java)
val imageBlock = block as SemanticImage
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
assertThat(imageBlock.altText).isEqualTo("A test image")
}
@Test
fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() {
val css = "p::before { content: \"Note: \"; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<p>This is a test.</p>", cssRules = cssRules)
// The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
assertThat(blocks).hasSize(1)
val pBlock = blocks[0] as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is a test.")
}
@Test
fun htmlToSemanticBlocks_hrWithPseudoElement_ignoresPseudoElement() {
val css = "hr.fancy::after { content: ''; display: block; border-bottom: 2px solid blue; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("<hr class=\"fancy\" />", cssRules = cssRules)
// The pseudo-element is ignored, so only the spacer from <hr> is created.
assertThat(blocks).hasSize(1)
assertThat(blocks[0]).isInstanceOf(SemanticSpacer::class.java)
}
@Test
fun htmlToSemanticBlocks_inlineSvg_createsSemanticMathWithCorrectContent() {
val svg = """
<svg width="100" height="100">
<title>My SVG</title>
<circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
<text x="50" y="50" fill="red">Hello</text>
</svg>
""".trimIndent()
val blocks = parse(svg)
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticMath::class.java)
val mathBlock = block as SemanticMath
assertThat(mathBlock.altText).isEqualTo("My SVG")
// The parser now passes the SVG content through as-is.
assertThat(mathBlock.svgContent).contains("""<text x="50" y="50" fill="red">Hello</text>""")
}
@Test
fun htmlToSemanticBlocks_imgTagWithSvgSource_createsSemanticMath() {
// SETUP
val svgContent = """<svg width="10" height="10"><rect width="10" height="10" /></svg>"""
val svgRelativeSrc = "images/test.svg"
val chapterParentDir = File(defaultChapterPath).parent ?: ""
val svgFile = File(File(defaultExtractionPath, chapterParentDir), svgRelativeSrc).canonicalFile
svgFile.parentFile?.mkdirs()
svgFile.writeText(svgContent)
svgFile.deleteOnExit()
// ACTION
val blocks = parse("<img src=\"$svgRelativeSrc\" />")
// ASSERT
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticMath::class.java)
val mathBlock = block as SemanticMath
assertThat(mathBlock.svgContent).contains("<rect")
}
@Test
fun htmlToSemanticBlocks_mathPlaceholder_createsSemanticMathFromCache() {
val svgContent = "<svg><text>E=mc^2</text></svg>"
val cache = mapOf("math-123" to svgContent)
val blocks = parse(
html = """<math-placeholder id="math-123" alttext="An equation"></math-placeholder>""",
mathSvgCache = cache
)
assertThat(blocks).hasSize(1)
val block = blocks.first() as SemanticMath
assertThat(block.svgContent).isEqualTo(svgContent)
assertThat(block.altText).isEqualTo("An equation")
assertThat(block.isFromMathJax).isTrue()
}
@Test
fun htmlToSemanticBlocks_displayFlex_createsSemanticFlexContainer() {
val html = """
<div style="display: flex;">
<p>One</p>
<p>Two</p>
</div>
""".trimIndent()
val blocks = parse(html)
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticFlexContainer::class.java)
val flexBlock = block as SemanticFlexContainer
assertThat(flexBlock.children).hasSize(2)
assertThat(flexBlock.children[0]).isInstanceOf(SemanticParagraph::class.java)
assertThat((flexBlock.children[0] as SemanticParagraph).text).isEqualTo("One")
}
@Test
fun htmlToSemanticBlocks_brTagInParagraph_createsNewlineCharacter() {
val blocks = parse("<p>Line one.<br>Line two.</p>")
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("Line one.\nLine two.")
}
@Test
fun htmlToSemanticBlocks_imageWithRootRelativePath_resolvesCorrectly() {
// SETUP
val imageRootRelativeSrc = "images/test.jpg"
val imageFile = File(defaultExtractionPath, imageRootRelativeSrc).canonicalFile
imageFile.parentFile?.mkdirs()
imageFile.createNewFile()
imageFile.deleteOnExit()
// ACTION
val blocks = parse("<img src=\"$imageRootRelativeSrc\" alt=\"A test image\" />")
// ASSERT
assertThat(blocks).hasSize(1)
val block = blocks.first()
assertThat(block).isInstanceOf(SemanticImage::class.java)
val imageBlock = block as SemanticImage
assertThat(imageBlock.path).isEqualTo(imageFile.absolutePath)
}
}

View file

@ -0,0 +1,35 @@
// MainDispatcherRule.kt
package com.aryan.reader.paginatedreader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* A JUnit TestRule that sets the Main dispatcher to a TestDispatcher for the duration of a test.
* This allows tests to execute coroutines on the Main dispatcher without needing a real Android environment.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestRule {
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
Dispatchers.setMain(testDispatcher)
try {
base.evaluate()
} finally {
Dispatchers.resetMain()
}
}
}
}
}

View file

@ -0,0 +1,120 @@
// PaginatedReaderDataTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PaginatedReaderDataTest {
@Test
fun cssStyle_mergeCorrectlyCombinesStyles() {
val baseStyle = CssStyle(
spanStyle = SpanStyle(color = Color.Black, fontWeight = FontWeight.Normal, fontSize = 16.sp),
paragraphStyle = ParagraphStyle(textAlign = TextAlign.Start),
fontFamilies = listOf("serif"),
display = "block"
)
val overrideStyle = CssStyle(
spanStyle = SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic),
paragraphStyle = ParagraphStyle(textAlign = TextAlign.Center),
fontFamilies = listOf("sans-serif"),
textTransform = "uppercase"
)
val merged = baseStyle.merge(overrideStyle)
// Overridden properties
assertThat(merged.spanStyle.color).isEqualTo(Color.Red)
assertThat(merged.spanStyle.fontStyle).isEqualTo(FontStyle.Italic)
assertThat(merged.paragraphStyle.textAlign).isEqualTo(TextAlign.Center)
assertThat(merged.fontFamilies).containsExactly("sans-serif")
assertThat(merged.textTransform).isEqualTo("uppercase")
// Inherited properties
assertThat(merged.spanStyle.fontWeight).isEqualTo(FontWeight.Normal)
assertThat(merged.spanStyle.fontSize).isEqualTo(16.sp)
assertThat(merged.display).isEqualTo("block")
}
@Test
fun cssStyle_mergeWithEmptyOverrideDoesNotChangeBase() {
val baseStyle = CssStyle(
spanStyle = SpanStyle(color = Color.Black, fontWeight = FontWeight.Normal),
fontFamilies = listOf("serif")
)
val overrideStyle = CssStyle()
val merged = baseStyle.merge(overrideStyle)
assertThat(merged).isEqualTo(baseStyle)
}
@Test
fun blockStyle_mergeUsesOverrideProperties() {
val baseStyle = BlockStyle(
padding = BoxBorders(top = 10.dp, left = 10.dp),
margin = BoxBorders(top = 5.dp, bottom = 5.dp),
width = 100.dp,
backgroundColor = Color.White
)
val overrideStyle = BlockStyle(
padding = BoxBorders(top = 5.dp, right = 5.dp),
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
width = 200.dp,
backgroundColor = Color.Black,
border = BorderStyle(width = 1.dp, color = Color.Red)
)
val merged = baseStyle.merge(overrideStyle)
// Padding should be from override, not additive
assertThat(merged.padding.top).isEqualTo(5.dp)
assertThat(merged.padding.left).isEqualTo(10.dp) // from base
assertThat(merged.padding.right).isEqualTo(5.dp)
assertThat(merged.padding.bottom).isEqualTo(0.dp) // from base
// Margin should be from override
assertThat(merged.margin.top).isEqualTo(5.dp) // from base
assertThat(merged.margin.bottom).isEqualTo(10.dp)
assertThat(merged.margin.left).isEqualTo(10.dp)
assertThat(merged.margin.right).isEqualTo(0.dp) // from base
// Other properties
assertThat(merged.width).isEqualTo(200.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
assertThat(merged.border).isNotNull()
assertThat(merged.border?.width).isEqualTo(1.dp)
}
@Test
fun blockStyle_mergeWithEmptyOverrideDoesNotChangeBase() {
val baseStyle = BlockStyle(
padding = BoxBorders(10.dp, 10.dp, 10.dp, 10.dp),
margin = BoxBorders(5.dp, 5.dp, 5.dp, 5.dp),
width = 100.dp,
backgroundColor = Color.White
)
val overrideStyle = BlockStyle()
val merged = baseStyle.merge(overrideStyle)
assertThat(merged.padding.top).isEqualTo(10.dp)
assertThat(merged.margin.top).isEqualTo(5.dp)
assertThat(merged.width).isEqualTo(100.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.White)
assertThat(merged.border).isNull()
}
}

View file

@ -0,0 +1,304 @@
// PaginatedReaderViewModelTest.kt
package com.aryan.reader.paginatedreader
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.SearchResult
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
private class FakePaginator(
initiallyLoading: Boolean,
initialPageCount: Int,
initialGeneration: Int
) : IPaginator {
override var isLoading by mutableStateOf(initiallyLoading)
override var totalPageCount by mutableIntStateOf(initialPageCount)
override var generation by mutableIntStateOf(initialGeneration)
override val pageShiftRequest: Flow<Int> = emptyFlow()
var lastNavigatedHref: String? = null
var lastNavigatedChapter: String? = null
override fun getPageContent(pageIndex: Int): Page? = null
override fun getChapterPathForPage(pageIndex: Int): String? = null
override fun getPlainTextForChapter(chapterIndex: Int): String? = null
override fun navigateToHref(
currentChapterAbsPath: String,
href: String,
onNavigationComplete: (pageIndex: Int) -> Unit
) {
lastNavigatedChapter = currentChapterAbsPath
lastNavigatedHref = href
}
override fun findPageForSearchResult(
result: SearchResult,
onResult: (Int) -> Unit
) = Unit
// Add stubs for the other missing interface members
override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (Int) -> Unit) = Unit
override fun findPageForCfiAndOffset(
chapterIndex: Int,
cfi: String,
charOffset: Int
): Int? {
return null
}
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
override fun getCfiForPage(pageIndex: Int): String? = null
override fun onUserScrolledTo(pageIndex: Int) = Unit
}
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class PaginatedReaderViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: PaginatedReaderViewModel
private lateinit var fakePaginator: FakePaginator
@Before
fun setUp() {
viewModel = PaginatedReaderViewModel()
fakePaginator = FakePaginator(
initiallyLoading = true,
initialPageCount = 0,
initialGeneration = 0
)
viewModel.setPaginatorForTest(fakePaginator)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun uiState_reflectsPaginatorInitialState() = runTest {
val initialState = viewModel.uiState.value
assertThat(initialState.isLoading).isTrue()
assertThat(initialState.totalPageCount).isEqualTo(0)
assertThat(initialState.generation).isEqualTo(0)
}
@Test
fun uiState_updatesWhenPaginatorIsLoadingChanges() = runTest {
assertThat(viewModel.uiState.value.isLoading).isTrue()
fakePaginator.isLoading = false
Snapshot.sendApplyNotifications()
advanceUntilIdle()
assertThat(viewModel.uiState.value.isLoading).isFalse()
}
@Test
fun uiState_updatesWhenPaginatorTotalPageCountChanges() = runTest {
assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(0)
fakePaginator.totalPageCount = 123
Snapshot.sendApplyNotifications()
advanceUntilIdle()
assertThat(viewModel.uiState.value.totalPageCount).isEqualTo(123)
}
@Test
fun uiState_updatesWhenPaginatorGenerationChanges() = runTest {
assertThat(viewModel.uiState.value.generation).isEqualTo(0)
fakePaginator.generation = 5
Snapshot.sendApplyNotifications()
advanceUntilIdle()
assertThat(viewModel.uiState.value.generation).isEqualTo(5)
}
@Test
fun onLinkClick_callsPaginatorNavigateToHrefWithCorrectArguments() {
val currentChapter = "chapter1.xhtml"
val href = "#section2"
viewModel.onLinkClick(currentChapter, href) {}
assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
}
@Test
fun initialize_createsARealPaginatorAndUpdateState() = runTest {
// Arrange
val viewModel = PaginatedReaderViewModel() // Create a fresh ViewModel
val context = ApplicationProvider.getApplicationContext<Context>()
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
val textStyle = TextStyle.Default
val density = Density(1f)
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
val testBook = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Test Author",
language = "en",
coverImage = null,
chapters = listOf(
EpubChapter(
chapterId = "ch1",
title = "Chapter 1",
htmlFilePath = "ch1.html",
absPath = "/ops/ch1.html",
htmlContent = "<p>Some content</p>",
plainTextContent = "Some content"
)
),
css = mapOf("/ops/style.css" to "p {color: red;}"),
extractionBasePath = ""
)
// Mock dependencies for BookPaginator
val mockDao = mockk<BookCacheDao>(relaxed = true)
coEvery { mockDao.getProcessedBook(any()) } returns null // Simulate cache miss
val mockDb = mockk<BookCacheDatabase>()
every { mockDb.bookCacheDao() } returns mockDao
mockkObject(BookCacheDatabase.Companion)
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
mockkObject(BookProcessingWorker.Companion)
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
// Pre-condition check
assertThat(viewModel.uiState.value.isLoading).isTrue()
assertThat(viewModel.paginator).isNull()
// Act
viewModel.initialize(
book = testBook,
textMeasurer = textMeasurer,
textConstraints = constraints,
textStyle = textStyle,
density = density,
isDarkTheme = false,
context = context,
initialChapterToPaginate = 0,
mathMLRenderer = mathMLRenderer
)
advanceUntilIdle() // Allow coroutines to complete
// Assert
assertThat(viewModel.paginator).isInstanceOf(BookPaginator::class.java)
assertThat(viewModel.uiState.value.isLoading).isFalse()
assertThat(viewModel.uiState.value.totalPageCount).isGreaterThan(0)
}
@Test
fun initialize_isIdempotent() = runTest {
// Arrange
val viewModel = PaginatedReaderViewModel()
val context = ApplicationProvider.getApplicationContext<Context>()
val textMeasurer = mockk<TextMeasurer>(relaxed = true)
val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
val textStyle = TextStyle.Default
val density = Density(1f)
val mathMLRenderer = mockk<MathMLRenderer>(relaxed = true)
val testBook = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Test Author",
language = "en",
coverImage = null,
chapters = listOf(
EpubChapter(
chapterId = "ch1",
title = "Chapter 1",
htmlFilePath = "ch1.html",
absPath = "/ops/ch1.html",
htmlContent = "<p>Some content</p>",
plainTextContent = "Some content"
)
),
css = mapOf("/ops/style.css" to "p {color: red;}"),
extractionBasePath = ""
)
// Mock dependencies
val mockDao = mockk<BookCacheDao>(relaxed = true)
coEvery { mockDao.getProcessedBook(any()) } returns null
val mockDb = mockk<BookCacheDatabase>()
every { mockDb.bookCacheDao() } returns mockDao
mockkObject(BookCacheDatabase.Companion)
every { BookCacheDatabase.getDatabase(any()) } returns mockDb
mockkObject(BookProcessingWorker.Companion)
every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
// Act
viewModel.initialize(
book = testBook,
textMeasurer = textMeasurer,
textConstraints = constraints,
textStyle = textStyle,
density = density,
isDarkTheme = false,
context = context,
initialChapterToPaginate = 0,
mathMLRenderer = mathMLRenderer
)
advanceUntilIdle()
val firstPaginator = viewModel.paginator
assertThat(firstPaginator).isNotNull()
// Act again
viewModel.initialize(
book = testBook,
textMeasurer = textMeasurer,
textConstraints = constraints,
textStyle = textStyle,
density = density,
isDarkTheme = false,
context = context,
initialChapterToPaginate = 0,
mathMLRenderer = mathMLRenderer
)
advanceUntilIdle()
// Assert
val secondPaginator = viewModel.paginator
assertThat(secondPaginator).isSameInstanceAs(firstPaginator)
}
}

View file

@ -0,0 +1,278 @@
// PaginatorTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
class FakeSplittableMeasurementProvider(
private val heights: Map<ContentBlock, Int>,
private val splittableParagraphs: Map<ParagraphBlock, Pair<ParagraphBlock, ParagraphBlock>> = emptyMap(),
private val splittableWrappers: Map<WrappingContentBlock, Pair<WrappingContentBlock, List<ContentBlock>>> = emptyMap()
) : BlockMeasurementProvider {
override suspend fun measure(block: ContentBlock): Int {
// Provide a more helpful error message if a block's height is not defined.
return heights[block] ?: error("No height specified for block: $block")
}
override suspend fun split(block: ParagraphBlock, availableHeight: Int): Pair<ParagraphBlock, ParagraphBlock>? {
val splitPair = splittableParagraphs[block]
if (splitPair != null) {
val part1Height = heights[splitPair.first] ?: 0
// Only return the split pair if the first part actually fits in the available height.
if (part1Height <= availableHeight) {
return splitPair
}
}
return null
}
override suspend fun split(block: WrappingContentBlock, availableHeight: Int): Pair<WrappingContentBlock, List<ContentBlock>>? {
val splitPair = splittableWrappers[block]
if (splitPair != null) {
val part1Height = heights[splitPair.first] ?: 0
if (part1Height <= availableHeight) {
return splitPair
}
}
return null
}
}
@RunWith(AndroidJUnit4::class)
class PaginatorTest {
private val testDensity = Density(density = 1f, fontScale = 1f)
private val pageHeight = 1000
@Test
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
assertThat(pages).isEmpty()
}
@Test
fun paginate_givenBlocksThatFit_createsOnePage() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Block 1"), blockIndex = 0)
val block2 = ParagraphBlock(content = AnnotatedString("Block 2"), blockIndex = 1)
val blocks = listOf(block1, block2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 200, block2 to 300)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
assertThat(pages.first().content).hasSize(2)
}
@Test
fun paginate_givenBlockThatOverflows_createsTwoPages() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Block 1"), blockIndex = 0) // Height: 600
val block2 = ParagraphBlock(content = AnnotatedString("Block 2"), blockIndex = 1) // Height: 500
val blocks = listOf(block1, block2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 600, block2 to 500)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(block2)
}
@Test
fun paginate_correctlySplitsAParagraphBlock() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("First block"), blockIndex = 0)
val originalParagraph = ParagraphBlock(content = AnnotatedString("Long text to be split"), blockIndex = 1)
val part1 = ParagraphBlock(content = AnnotatedString("Long text"), blockIndex = 1)
val part2 = ParagraphBlock(content = AnnotatedString("to be split"), blockIndex = 1)
val blocks = listOf(block1, originalParagraph)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(
block1 to 500,
originalParagraph to 800,
part1 to 450, // Fits in the remaining 500
part2 to 350
),
splittableParagraphs = mapOf(originalParagraph to (part1 to part2))
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
assertThat(pages[1].content).containsExactly(part2)
}
@Test
fun paginate_correctlySplitsAWrappingContentBlock() = runTest {
val image = ImageBlock("image.png", null, 100f, 300f, blockIndex = 0)
val para1 = ParagraphBlock(content = AnnotatedString("Para 1"), blockIndex = 1)
val para2 = ParagraphBlock(content = AnnotatedString("Para 2"), blockIndex = 2)
val originalWrapper = WrappingContentBlock(floatedImage = image, paragraphsToWrap = listOf(para1, para2), blockIndex = 3)
val splitWrapper = WrappingContentBlock(floatedImage = image, paragraphsToWrap = listOf(para1), blockIndex = 3)
val remainingBlocks = listOf(para2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(
originalWrapper to 1500,
splitWrapper to 300,
para2 to 200
),
splittableWrappers = mapOf(originalWrapper to (splitWrapper to remainingBlocks))
)
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(splitWrapper)
assertThat(pages[1].content).containsExactly(para2)
}
@Test
fun paginate_respectsPageBreakInsideAvoid() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("First block"), blockIndex = 0) // Height 800
val unsplittableBlock = ParagraphBlock(
content = AnnotatedString("Can't split me"),
style = BlockStyle(pageBreakInsideAvoid = true),
blockIndex = 1
) // Height 300
val blocks = listOf(block1, unsplittableBlock)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 800, unsplittableBlock to 300)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(unsplittableBlock)
}
@Test
fun paginate_oversizedUnsplittableBlockGetsItsOwnPage() = runTest {
val oversizedBlock = ImageBlock(path = "test.jpg", altText = null, blockIndex = 0) // Height 1200
val blocks = listOf(oversizedBlock)
val measurementProvider = FakeSplittableMeasurementProvider(heights = mapOf(oversizedBlock to 1200))
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
assertThat(pages[0].content).containsExactly(oversizedBlock)
}
@Test
fun paginate_collapsesVerticalMarginsBetweenBlocks() = runTest {
val block1 = ParagraphBlock(
content = AnnotatedString("Block 1"),
style = BlockStyle(margin = BoxBorders(bottom = 50.dp)), // 50px margin
blockIndex = 0
)
val block2 = ParagraphBlock(
content = AnnotatedString("Block 2"),
style = BlockStyle(margin = BoxBorders(top = 80.dp)), // 80px margin
blockIndex = 1
)
val blocks = listOf(block1, block2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 100, block2 to 100)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
val pageContent = pages.first().content
assertThat(pageContent).hasSize(2)
// The paginator logic sets the bottom margin of the previous block to 0
// and sets the top margin of the current block to the collapsed value.
assertThat(pageContent[0].style.margin.bottom).isEqualTo(0.dp)
assertThat(pageContent[1].style.margin.top).isEqualTo(80.dp) // max(50, 80) is 80
}
@Test
fun paginate_preservesTopMarginOfTheFirstBlockOnANewPage() = runTest {
val block1 = ParagraphBlock(
content = AnnotatedString("Block 1"),
style = BlockStyle(margin = BoxBorders(top = 30.dp)),
blockIndex = 0
)
val block2 = ParagraphBlock(content = AnnotatedString("Block 2"), blockIndex = 1)
val blocks = listOf(block1, block2)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(block1 to 980, block2 to 100)
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
// First block on page 1 should have its top margin preserved.
val page1Block1 = pages[0].content.first()
assertThat(page1Block1.style.margin.top).isEqualTo(30.dp)
// First block on page 2 should also have its top margin preserved.
val page2Block1 = pages[1].content.first()
assertThat(page2Block1.style.margin.top).isEqualTo(0.dp) // The default is 0.dp
}
@Test
fun paginate_blockPushedToNextPageWhenNotEnoughSpaceForSplitting() = runTest {
val block1 = ParagraphBlock(content = AnnotatedString("Block 1"), blockIndex = 0)
val splittableBlock = ParagraphBlock(content = AnnotatedString("Splittable"), blockIndex = 1)
val part1 = ParagraphBlock(content = AnnotatedString("Split"), blockIndex = 1)
val part2 = ParagraphBlock(content = AnnotatedString("table"), blockIndex = 1)
val blocks = listOf(block1, splittableBlock)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(
block1 to 960, // Leaves 40px remaining, which is < 50, so no split should occur
splittableBlock to 100,
part1 to 30,
part2 to 70
),
splittableParagraphs = mapOf(splittableBlock to (part1 to part2))
)
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
assertThat(pages[0].content).containsExactly(block1)
assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
}
@Test
fun paginate_doesNotAddEmptyPart1AfterSplitting() = runTest {
val originalBlock = ParagraphBlock(content = AnnotatedString("Some text"), blockIndex = 0)
val part1 = ParagraphBlock(content = AnnotatedString(""), blockIndex = 0) // Empty part 1
val part2 = ParagraphBlock(content = AnnotatedString("Some text"), blockIndex = 0)
val blocks = listOf(originalBlock)
val measurementProvider = FakeSplittableMeasurementProvider(
heights = mapOf(
originalBlock to 200,
part1 to 0,
part2 to 200
),
splittableParagraphs = mapOf(originalBlock to (part1 to part2))
)
// Set page height so that a split is attempted.
val pages = paginate(blocks, 150, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
// The page should be empty because part1 was empty, and the original block was re-added
// to the remaining list. The next page then contains the full block.
assertThat(pages[0].content).containsExactly(part2)
}
}

View file

@ -0,0 +1,115 @@
// StyleUtilsTest.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isUnspecified
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class StyleUtilsTest {
private val baseFontSizeSp = 16f
private val density = 2.0f
private val containerWidthPx = 1000
@Test
fun parseCssSizeToDp_handlesPxValues() {
assertThat(parseCssSizeToDp("100px", baseFontSizeSp, density, containerWidthPx)).isEqualTo(50.dp)
}
@Test
fun parseCssSizeToDp_handlesEmValues() {
assertThat(parseCssSizeToDp("1.5em", baseFontSizeSp, density, containerWidthPx)).isEqualTo(24.dp)
}
@Test
fun parseCssSizeToDp_handlesRemValues() {
assertThat(parseCssSizeToDp("2rem", baseFontSizeSp, density, containerWidthPx)).isEqualTo(32.dp)
}
@Test
fun parseCssSizeToDp_handlesPtValues() {
assertThat(parseCssSizeToDp("12pt", baseFontSizeSp, density, containerWidthPx).value).isWithin(0.01f).of(8.0f)
}
@Test
fun parseCssSizeToDp_handlesPercentageValues() {
// 50% of 1000px = 500px. 500px / 2.0 density = 250dp
assertThat(parseCssSizeToDp("50%", baseFontSizeSp, density, containerWidthPx)).isEqualTo(250.dp)
}
@Test
fun parseCssSizeToDp_returns0ForInvalidInput() {
assertThat(parseCssSizeToDp("invalid", baseFontSizeSp, density, containerWidthPx)).isEqualTo(0.dp)
}
@Test
fun parseCssSizeToDp_handlesZeroDensity() {
assertThat(parseCssSizeToDp("100px", baseFontSizeSp, 0f, containerWidthPx)).isEqualTo(0.dp)
}
@Test
fun parseCssSizeToDp_handlesZeroContainerWidthForPercentage() {
assertThat(parseCssSizeToDp("50%", baseFontSizeSp, density, 0)).isEqualTo(0.dp)
}
@Test
fun parseCssSizeToDp_handlesValuesWithWhitespace() {
assertThat(parseCssSizeToDp(" 1.5em ", baseFontSizeSp, density, containerWidthPx)).isEqualTo(24.dp)
}
@Test
fun parseCssDimensionToTextUnit_handlesPxValues() {
val result = parseCssDimensionToTextUnit("100px", containerWidthPx, density)
assertThat(result.isSp).isTrue()
assertThat(result.value).isWithin(0.01f).of(50f)
}
@Test
fun parseCssDimensionToTextUnit_handlesEmValues() {
val result = parseCssDimensionToTextUnit("1.5em", containerWidthPx, density)
assertThat(result.isEm).isTrue()
assertThat(result.value).isEqualTo(1.5f)
}
@Test
fun parseCssDimensionToTextUnit_handlesRemValues() {
// rem is treated as em
val result = parseCssDimensionToTextUnit("2rem", containerWidthPx, density)
assertThat(result.isEm).isTrue()
assertThat(result.value).isEqualTo(2f)
}
@Test
fun parseCssDimensionToTextUnit_handlesPtValues() {
val result = parseCssDimensionToTextUnit("12pt", containerWidthPx, density)
assertThat(result.isSp).isTrue()
assertThat(result.value).isWithin(0.01f).of(8.0f)
}
@Test
fun parseCssDimensionToTextUnit_handlesPercentageValues() {
val result = parseCssDimensionToTextUnit("50%", containerWidthPx, density)
assertThat(result.isSp).isTrue()
assertThat(result.value).isWithin(0.01f).of(250f)
}
@Test
fun parseCssDimensionToTextUnit_returnsUnspecifiedForInvalidInput() {
assertThat(parseCssDimensionToTextUnit("invalid", containerWidthPx, density).isUnspecified).isTrue()
}
@Test
fun parseCssDimensionToTextUnit_handlesZeroDensity() {
assertThat(parseCssDimensionToTextUnit("100px", containerWidthPx, 0f).isUnspecified).isTrue()
}
@Test
fun parseCssDimensionToTextUnit_handlesZeroContainerWidthForPercentage() {
assertThat(parseCssDimensionToTextUnit("50%", 0, density).isUnspecified).isTrue()
}
}