Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue