diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/SummaryService.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/SummaryService.kt index 065f237..5ea5f81 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/SummaryService.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/SummaryService.kt @@ -21,7 +21,11 @@ class SummaryService @Inject constructor( @ApplicationContext private val context: Context ) { - private val TAG = "SummaryService" + companion object { + private const val TAG = "SummaryService" + private val WHITESPACE_REGEX = Regex("\\s+") + } + private var modelFile: File? = null private var isInitialized = false private var isInitializing = false @@ -79,7 +83,7 @@ class SummaryService @Inject constructor( val selectedContent = selectKeyContent(content, maxWords = 300) val prompt = buildPrompt(chapterTitle, selectedContent) - Log.d(TAG, "Generating summary (${selectedContent.split(Regex("\\s+")).size} words, ~${(selectedContent.length + prompt.length) / 4 + 200} tokens)") + Log.d(TAG, "Generating summary (${selectedContent.split(WHITESPACE_REGEX).size} words, ~${(selectedContent.length + prompt.length) / 4 + 200} tokens)") generateWithRetry(prompt, selectedContent, content, onProgress) }.onFailure { e -> @@ -151,7 +155,7 @@ class SummaryService @Inject constructor( private fun selectKeyContent(content: List, maxWords: Int): String { if (content.isEmpty()) return "" - val wordsPerParagraph = content.map { it.split(Regex("\\s+")) } + val wordsPerParagraph = content.map { it.split(WHITESPACE_REGEX) } val totalWords = wordsPerParagraph.sumOf { it.size } if (totalWords <= maxWords) return content.joinToString("\n\n") diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt index 943a0bf..4326d0a 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt @@ -13,6 +13,11 @@ abstract class BaseJsoupSource( protected open val preferencesManager: PreferencesManager? = null, protected open val okHttpClient: okhttp3.OkHttpClient? = null ) : NovelSource { + + companion object { + private val MULTIPLE_SLASHES_REGEX = Regex("/+") + } + protected open val userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" protected open val timeout = 15000L @@ -73,6 +78,6 @@ abstract class BaseJsoupSource( path.startsWith("//") -> "https:$path" path.startsWith("/") -> "$baseUrl$path" else -> if (path.startsWith(baseUrl)) path else "$baseUrl/$path" - }.replace(Regex("/+"), "/").replace("https:/", "https://").replace("http:/", "http://") + }.replace(MULTIPLE_SLASHES_REGEX, "/").replace("https:/", "https://").replace("http:/", "http://") } } diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt index 1af76c1..f209c2e 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt @@ -14,6 +14,10 @@ class MangaBatSource @Inject constructor( override val name = "MangaBat" override val baseUrl = "https://www.mangabats.com" + companion object { + private val SUMMARY_REGEX = Regex(".*summary: ", RegexOption.IGNORE_CASE) + } + override suspend fun getPopularNovels(page: Int, tags: List): List = io { val url = if (tags.isNotEmpty()) { val tagSlug = tags.first().lowercase().replace(" ", "-") @@ -89,7 +93,7 @@ class MangaBatSource @Inject constructor( val author = extractAuthor(document) val summary = document.select("#contentBox, .panel-story-info-description, .story-info-description") .first()?.text()?.replace("Description :", "") - ?.replace(Regex(".*summary: ", RegexOption.IGNORE_CASE), "")?.trim() + ?.replace(SUMMARY_REGEX, "")?.trim() val coverUrl = extractCoverUrl(document) diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt index f14b89a..35fd58c 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt @@ -21,14 +21,25 @@ class NovelFireSource @Inject constructor( override val name = "NovelFire" override val baseUrl = "https://novelfire.net" + companion object { + private val BRACKET_NUMBER_REGEX = Regex("^\\[\\d+\\]\\s*") + private val R_NUMBER_REGEX = Regex("^R\\s*\\d+(\\.\\d+)?\\s*") + private val RANK_PREFIX_REGEX = Regex("^Rank\\s*\\d+\\s*", RegexOption.IGNORE_CASE) + private val RANK_REGEX = Regex("RANK\\s+(\\d+)", RegexOption.IGNORE_CASE) + private val RATING_REGEX = Regex("Average score is\\s+([0-9.]+)", RegexOption.IGNORE_CASE) + private val CHAPTERS_COUNT_REGEX = Regex("(\\d+)\\s*Chapters", RegexOption.IGNORE_CASE) + private val TIME_AGO_REGEX = Regex("\\d+\\s+(year|month|day|hour|minute|second)s?\\s+ago.*$") + private val LEADING_NUM_REGEX = Regex("^(\\d+)\\s+(Chapter\\s+\\1.*)") + } + private fun cleanNovelTitle(title: String): String { var clean = title // Remove [123] at start - clean = clean.replace(Regex("^\\[\\d+\\]\\s*"), "") + clean = clean.replace(BRACKET_NUMBER_REGEX, "") // Remove R 14.8 or R 123 at start - clean = clean.replace(Regex("^R\\s*\\d+(\\.\\d+)?\\s*"), "") + clean = clean.replace(R_NUMBER_REGEX, "") // Remove Rank 123 at start - clean = clean.replace(Regex("^Rank\\s*\\d+\\s*", RegexOption.IGNORE_CASE), "") + clean = clean.replace(RANK_PREFIX_REGEX, "") return clean.trim() } @@ -155,8 +166,8 @@ class NovelFireSource @Inject constructor( val infoText = document.text() val chapterCount = extractChapterCount(infoText) - val rank = Regex("RANK\\s+(\\d+)", RegexOption.IGNORE_CASE).find(infoText)?.groupValues?.get(1) - val rating = Regex("Average score is\\s+([0-9.]+)", RegexOption.IGNORE_CASE).find(infoText)?.groupValues?.get(1) + val rank = RANK_REGEX.find(infoText)?.groupValues?.get(1) + val rating = RATING_REGEX.find(infoText)?.groupValues?.get(1) val chaptersUrl = getChaptersUrl(url, document) val firstPageDoc = runCatching { getDocument(chaptersUrl) }.getOrDefault(document) @@ -195,8 +206,7 @@ class NovelFireSource @Inject constructor( } private fun extractChapterCount(infoText: String): Int { - return Regex("(\\d+)\\s*Chapters", RegexOption.IGNORE_CASE) - .find(infoText)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + return CHAPTERS_COUNT_REGEX.find(infoText)?.groupValues?.get(1)?.toIntOrNull() ?: 0 } private fun getChaptersUrl(url: String, document: org.jsoup.nodes.Document): String { @@ -217,9 +227,8 @@ class NovelFireSource @Inject constructor( element.select(".chapter-title").text().ifBlank { element.text() } } - var cleanTitle = rawTitle.replace(Regex("\\d+\\s+(year|month|day|hour|minute|second)s?\\s+ago.*$"), "").trim() - val leadingNumRegex = Regex("^(\\d+)\\s+(Chapter\\s+\\1.*)") - leadingNumRegex.find(cleanTitle)?.let { match -> + var cleanTitle = rawTitle.replace(TIME_AGO_REGEX, "").trim() + LEADING_NUM_REGEX.find(cleanTitle)?.let { match -> cleanTitle = match.groupValues[2] } diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt index 0cfcb98..3b97002 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt @@ -33,6 +33,10 @@ class ReaderViewModel @Inject constructor( private val preferencesManager: PreferencesManager ) : BaseViewModel(ReaderUiState()) { + companion object { + private val DOUBLE_NEWLINE_REGEX = Regex("""\n\s*\n""") + } + // Current library item ID being read private var currentLibraryItemId: String? = null @@ -613,7 +617,7 @@ class ReaderViewModel @Inject constructor( if (textBuffer.isEmpty()) return val joined = textBuffer.joinToString("\n\n") val formatted = TextUtils.formatChapterText(joined) - val parts = formatted.split(Regex("""\n\s*\n""")).map { it.trim() }.filter { it.isNotBlank() } + val parts = formatted.split(DOUBLE_NEWLINE_REGEX).map { it.trim() }.filter { it.isNotBlank() } parts.forEach { p -> formattedElements.add(ContentElement.Text(p)) } textBuffer.clear() } diff --git a/app/src/test/java/io/aatricks/novelscraper/data/repository/source/NovelFireSourceBenchmarkTest.kt b/app/src/test/java/io/aatricks/novelscraper/data/repository/source/NovelFireSourceBenchmarkTest.kt new file mode 100644 index 0000000..d74e290 --- /dev/null +++ b/app/src/test/java/io/aatricks/novelscraper/data/repository/source/NovelFireSourceBenchmarkTest.kt @@ -0,0 +1,76 @@ +package io.aatricks.novelscraper.data.repository.source + +import org.junit.Test +import kotlin.system.measureTimeMillis + +class NovelFireSourceBenchmarkTest { + + private fun cleanNovelTitleOriginal(title: String): String { + var clean = title + // Remove [123] at start + clean = clean.replace(Regex("^\\[\\d+\\]\\s*"), "") + // Remove R 14.8 or R 123 at start + clean = clean.replace(Regex("^R\\s*\\d+(\\.\\d+)?\\s*"), "") + // Remove Rank 123 at start + clean = clean.replace(Regex("^Rank\\s*\\d+\\s*", RegexOption.IGNORE_CASE), "") + return clean.trim() + } + + companion object { + private val BRACKET_NUMBER_REGEX = Regex("^\\[\\d+\\]\\s*") + private val R_NUMBER_REGEX = Regex("^R\\s*\\d+(\\.\\d+)?\\s*") + private val RANK_PREFIX_REGEX = Regex("^Rank\\s*\\d+\\s*", RegexOption.IGNORE_CASE) + } + + private fun cleanNovelTitleOptimized(title: String): String { + var clean = title + // Remove [123] at start + clean = clean.replace(BRACKET_NUMBER_REGEX, "") + // Remove R 14.8 or R 123 at start + clean = clean.replace(R_NUMBER_REGEX, "") + // Remove Rank 123 at start + clean = clean.replace(RANK_PREFIX_REGEX, "") + return clean.trim() + } + + @Test + fun benchmarkCleanNovelTitle() { + val titles = listOf( + "[123] Some Novel Name", + "R 14.8 Another Novel Name", + "Rank 42 A Third Novel Name", + "Just a regular name", + "Rank 123 [123] Double trouble name", + "Nothing to replace here 123" + ) + + // Warmup + for (i in 0 until 1000) { + for (title in titles) { + cleanNovelTitleOriginal(title) + cleanNovelTitleOptimized(title) + } + } + + val iterations = 100_000 + val timeOriginal = measureTimeMillis { + for (i in 0 until iterations) { + for (title in titles) { + cleanNovelTitleOriginal(title) + } + } + } + + val timeOptimized = measureTimeMillis { + for (i in 0 until iterations) { + for (title in titles) { + cleanNovelTitleOptimized(title) + } + } + } + + println("Baseline - Time to run $iterations iterations: $timeOriginal ms") + println("Optimized - Time to run $iterations iterations: $timeOptimized ms") + println("Improvement: ${timeOriginal - timeOptimized} ms") + } +} diff --git a/benchmark_output.txt b/benchmark_output.txt new file mode 100644 index 0000000..f9b3947 --- /dev/null +++ b/benchmark_output.txt @@ -0,0 +1,50 @@ +> Task :app:preBuild UP-TO-DATE +> Task :app:preDebugBuild UP-TO-DATE +> Task :app:checkKotlinGradlePluginConfigurationErrors SKIPPED +> Task :app:checkDebugAarMetadata UP-TO-DATE +> Task :app:processDebugNavigationResources UP-TO-DATE +> Task :app:compileDebugNavigationResources UP-TO-DATE +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:mapDebugSourceSetPaths UP-TO-DATE +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:mergeDebugResources UP-TO-DATE +> Task :app:packageDebugResources UP-TO-DATE +> Task :app:parseDebugLocalResources UP-TO-DATE +> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE +> Task :app:extractDeepLinksDebug UP-TO-DATE +> Task :app:processDebugMainManifest UP-TO-DATE +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:processDebugManifestForPackage UP-TO-DATE +> Task :app:processDebugResources UP-TO-DATE +> Task :app:kspDebugKotlin UP-TO-DATE +> Task :app:compileDebugKotlin UP-TO-DATE +> Task :app:javaPreCompileDebug UP-TO-DATE +> Task :app:compileDebugJavaWithJavac UP-TO-DATE +> Task :app:hiltAggregateDepsDebug UP-TO-DATE +> Task :app:hiltJavaCompileDebug +> Task :app:preDebugUnitTestBuild UP-TO-DATE +> Task :app:processDebugJavaRes +> Task :app:javaPreCompileDebugUnitTest UP-TO-DATE +> Task :app:bundleDebugClassesToCompileJar +> Task :app:transformDebugClassesWithAsm +> Task :app:bundleDebugClassesToRuntimeJar +> Task :app:kspDebugUnitTestKotlin + +> Task :app:compileDebugUnitTestKotlin +w: file:///app/app/src/test/java/io/aatricks/novelscraper/data/repository/ContentRepositoryEpubTest.kt:43:19 'fun createTempDir(prefix: String = ..., suffix: String? = ..., directory: File? = ...): File' is deprecated. Avoid creating temporary directories in the default temp location with this function due to too wide permissions on the newly created directory. Use kotlin.io.path.createTempDirectory instead. + +> Task :app:compileDebugUnitTestJavaWithJavac NO-SOURCE +> Task :app:hiltAggregateDepsDebugUnitTest +> Task :app:hiltJavaCompileDebugUnitTest NO-SOURCE +> Task :app:processDebugUnitTestJavaRes +> Task :app:transformDebugUnitTestClassesWithAsm + +> Task :app:testDebugUnitTest + +NovelFireSourceBenchmarkTest > benchmarkCleanNovelTitle STANDARD_OUT + Baseline - Time to run 100000 iterations: 1806 ms + +NovelFireSourceBenchmarkTest > benchmarkCleanNovelTitle PASSED + +BUILD SUCCESSFUL in 37s +32 actionable tasks: 11 executed, 21 up-to-date diff --git a/benchmark_output_2.txt b/benchmark_output_2.txt new file mode 100644 index 0000000..c151399 --- /dev/null +++ b/benchmark_output_2.txt @@ -0,0 +1,49 @@ +> Task :app:preBuild UP-TO-DATE +> Task :app:preDebugBuild UP-TO-DATE +> Task :app:checkKotlinGradlePluginConfigurationErrors SKIPPED +> Task :app:checkDebugAarMetadata UP-TO-DATE +> Task :app:processDebugNavigationResources UP-TO-DATE +> Task :app:compileDebugNavigationResources UP-TO-DATE +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:mapDebugSourceSetPaths UP-TO-DATE +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:mergeDebugResources UP-TO-DATE +> Task :app:packageDebugResources UP-TO-DATE +> Task :app:parseDebugLocalResources UP-TO-DATE +> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE +> Task :app:extractDeepLinksDebug UP-TO-DATE +> Task :app:processDebugMainManifest UP-TO-DATE +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:processDebugManifestForPackage UP-TO-DATE +> Task :app:processDebugResources UP-TO-DATE +> Task :app:javaPreCompileDebug UP-TO-DATE +> Task :app:preDebugUnitTestBuild UP-TO-DATE +> Task :app:javaPreCompileDebugUnitTest UP-TO-DATE +> Task :app:kspDebugKotlin +> Task :app:compileDebugKotlin +> Task :app:compileDebugJavaWithJavac +> Task :app:hiltAggregateDepsDebug UP-TO-DATE +> Task :app:hiltJavaCompileDebug +> Task :app:processDebugJavaRes +> Task :app:bundleDebugClassesToCompileJar +> Task :app:transformDebugClassesWithAsm +> Task :app:bundleDebugClassesToRuntimeJar +> Task :app:kspDebugUnitTestKotlin +> Task :app:compileDebugUnitTestKotlin +> Task :app:compileDebugUnitTestJavaWithJavac NO-SOURCE +> Task :app:hiltAggregateDepsDebugUnitTest UP-TO-DATE +> Task :app:hiltJavaCompileDebugUnitTest NO-SOURCE +> Task :app:processDebugUnitTestJavaRes UP-TO-DATE +> Task :app:transformDebugUnitTestClassesWithAsm + +> Task :app:testDebugUnitTest + +NovelFireSourceBenchmarkTest > benchmarkCleanNovelTitle STANDARD_OUT + Baseline - Time to run 100000 iterations: 1788 ms + Optimized - Time to run 100000 iterations: 315 ms + Improvement: 1473 ms + +NovelFireSourceBenchmarkTest > benchmarkCleanNovelTitle PASSED + +BUILD SUCCESSFUL in 26s +32 actionable tasks: 12 executed, 20 up-to-date diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..e22438f --- /dev/null +++ b/test_output.txt @@ -0,0 +1,193 @@ +> Task :app:preBuild UP-TO-DATE +> Task :app:preDebugBuild UP-TO-DATE +> Task :app:checkKotlinGradlePluginConfigurationErrors SKIPPED +> Task :app:checkDebugAarMetadata UP-TO-DATE +> Task :app:processDebugNavigationResources UP-TO-DATE +> Task :app:compileDebugNavigationResources UP-TO-DATE +> Task :app:generateDebugResValues UP-TO-DATE +> Task :app:mapDebugSourceSetPaths UP-TO-DATE +> Task :app:generateDebugResources UP-TO-DATE +> Task :app:mergeDebugResources UP-TO-DATE +> Task :app:packageDebugResources UP-TO-DATE +> Task :app:parseDebugLocalResources UP-TO-DATE +> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE +> Task :app:extractDeepLinksDebug UP-TO-DATE +> Task :app:processDebugMainManifest UP-TO-DATE +> Task :app:processDebugManifest UP-TO-DATE +> Task :app:processDebugManifestForPackage UP-TO-DATE +> Task :app:processDebugResources UP-TO-DATE +> Task :app:kspDebugKotlin UP-TO-DATE +> Task :app:compileDebugKotlin UP-TO-DATE +> Task :app:javaPreCompileDebug UP-TO-DATE +> Task :app:compileDebugJavaWithJavac UP-TO-DATE +> Task :app:hiltAggregateDepsDebug UP-TO-DATE +> Task :app:hiltJavaCompileDebug UP-TO-DATE +> Task :app:transformDebugClassesWithAsm UP-TO-DATE +> Task :app:bundleDebugClassesToRuntimeJar UP-TO-DATE +> Task :app:preDebugUnitTestBuild UP-TO-DATE +> Task :app:processDebugJavaRes UP-TO-DATE +> Task :app:bundleDebugClassesToCompileJar UP-TO-DATE +> Task :app:kspDebugUnitTestKotlin UP-TO-DATE +> Task :app:compileDebugUnitTestKotlin UP-TO-DATE +> Task :app:javaPreCompileDebugUnitTest UP-TO-DATE +> Task :app:compileDebugUnitTestJavaWithJavac NO-SOURCE +> Task :app:hiltAggregateDepsDebugUnitTest UP-TO-DATE +> Task :app:hiltJavaCompileDebugUnitTest NO-SOURCE +> Task :app:processDebugUnitTestJavaRes UP-TO-DATE +> Task :app:transformDebugUnitTestClassesWithAsm UP-TO-DATE +OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended + +> Task :app:testDebugUnitTest + +ExampleUnitTest > addition_isCorrect PASSED + +ContentRepositoryBenchmarkTest > benchmarkBackgroundCacheImages STANDARD_ERROR + WARNING: A Java agent has been loaded dynamically (/home/jules/.gradle/caches/modules-2/files-2.1/net.bytebuddy/byte-buddy-agent/1.14.15/b4cc9d88d5de562bf75b3a54ab436ad01b3f72d/byte-buddy-agent-1.14.15.jar) + WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning + WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information + WARNING: Dynamic loading of agents will be disallowed by default in a future release + +ContentRepositoryBenchmarkTest > benchmarkBackgroundCacheImages STANDARD_OUT + Starting benchmark with 5000 images... + Benchmark completed in 889ms + +ContentRepositoryBenchmarkTest > benchmarkBackgroundCacheImages PASSED + +ContentRepositoryConcurrencyTest > testBackgroundCacheImagesConcurrency STANDARD_OUT + Max concurrent requests: 3 + +ContentRepositoryConcurrencyTest > testBackgroundCacheImagesConcurrency PASSED + +ContentRepositoryEpubTest > testLoadEpubChapterContentUri PASSED + +ContentRepositoryEpubTest > testLoadEpubChapterLocalFile PASSED + +ContentRepositoryTest > testAdRemoval PASSED + +ContentRepositoryTest > testMangaBatLastImageRemoval PASSED + +ContentRepositoryTest > testMangaDetection PASSED + +ContentRepositoryTest > testParseHtmlDocument STANDARD_OUT + Paragraphs: [This is paragraph one., This is paragraph two, which is split across two tags., This is paragraph three., Chapter 525: The Battle Some text with a line break., Dialogue line one., Dialogue line two.] + +ContentRepositoryTest > testParseHtmlDocument PASSED + +ContentRepositoryUrlTest > verifyChapterUrlLogic PASSED + +ExploreRepositoryTest > testNovelFireSourceScraping PASSED + +LibraryRepositoryBenchmarkTest > benchmarkRefreshLibraryUpdates STANDARD_OUT + BENCHMARK_RESULT: 116ms for 100 novels (50 recent, 50 old) + +LibraryRepositoryBenchmarkTest > benchmarkRefreshLibraryUpdates PASSED + +LibraryRepositoryTest > testAddItem PASSED + +LibraryRepositoryTest > testUpdateProgress PASSED + +LibraryRepositoryTest > testSelection PASSED + +LibraryRepositoryTest > testMarkAsCurrentlyReadingClearsUpdates PASSED + +LibraryRepositoryTest > testRemoveItem PASSED + +LibraryRepositoryTest > testMarkAsCurrentlyReadingClearsUpdatesNoBaseTitle PASSED + +LibraryRepositoryUpdateTest > testUpdateNovelInfo_ReturnsFalseOnFailure PASSED + +LibraryRepositoryUpdateTest > testRefreshLibraryUpdates_batches_updates PASSED + +LibraryRepositoryUpdateTest > testRefreshLibraryUpdates_partial_failure PASSED + +LibraryRepositoryUpdateTest > testUpdateNovelInfo_CallsDaoUpdate PASSED + +LibraryRepositoryUpdateTest > testRefreshLibraryUpdates_skips_old_novels PASSED + +LibraryRepositoryUpdateTest > testRefreshLibraryUpdates_includes_old_but_currently_reading_novels PASSED + +BaseJsoupSourceSecurityTest > connect does NOT set global hostname verifier PASSED + +MangaBatSourceTest > testParsePopularNovels PASSED + +MangaBatSourceTest > testParseNovelDetails PASSED + +NovelFireSourceBenchmarkTest > benchmarkCleanNovelTitle STANDARD_OUT + Baseline - Time to run 100000 iterations: 1897 ms + Optimized - Time to run 100000 iterations: 349 ms + Improvement: 1548 ms + +NovelFireSourceBenchmarkTest > benchmarkCleanNovelTitle PASSED + +NovelFireSourceConcurrencyTest > testLoadAdditionalChapterPagesConcurrency STANDARD_OUT + Max concurrency: 3 + Total requests: 11 + +NovelFireSourceConcurrencyTest > testLoadAdditionalChapterPagesConcurrency PASSED + +NovelFireSourceTest > testParsePopularNovels PASSED + +NovelFireSourceTest > testParseNovelDetails PASSED + +ReaderScreenPrefetchTest > prefetchImages enqueues requests only for new indices PASSED + +LibraryViewModelTest > initial state is correct PASSED + +ReaderViewModelNavigationTest > loadContent populates fullChapterList from library when source list is empty PASSED + +ReaderViewModelSecurityTest > requestOpenFile updates state correctly PASSED + +ReaderViewModelSecurityTest > dismissFileConfirmation clears state PASSED + +ReaderViewModelTest > updateReadingProgress ignores placeholder content PASSED + +ReaderViewModelTest > updateScrollPosition saves progress after delay PASSED + +ReaderViewModelTest > toggleReadingMode updates repository PASSED + +ReaderViewModelTest > initial state is correct PASSED + +ReaderViewModelTest > loadContent saves current progress before loading new PASSED + +SafeRedirectInterceptorTest > intercept limits redirects PASSED + +SafeRedirectInterceptorTest > intercept passes through normal response PASSED + +SafeRedirectInterceptorTest > intercept follows safe redirect STANDARD_OUT + DEBUG: Request URL: http://8.8.8.8/start + DEBUG: Request URL: http://8.8.8.8/target + +SafeRedirectInterceptorTest > intercept follows safe redirect PASSED + +SafeRedirectInterceptorTest > intercept blocks unsafe redirect to localhost PASSED + +TextUtilsTest > testUrlNavigation PASSED + +TextUtilsTest > testWordCountAndReadingTime PASSED + +TextUtilsTest > testExtractBaseTitle PASSED + +TextUtilsTest > testFormatChapterText PASSED + +TextUtilsTest > testExtractChapterLabel PASSED + +TextUtilsTest > testCleanHtmlEntities PASSED + +TextUtilsTest > testExtractChapterNumber PASSED + +TextUtilsTest > testRemovePageNumbers PASSED + +TextUtilsTest > testExtractTitleFromUrl PASSED + +UrlSecurityTest > isSafeUrl rejects non-http schemes PASSED + +UrlSecurityTest > isSafeUrl rejects private addresses PASSED + +UrlSecurityTest > isSafeUrl rejects loopback addresses PASSED + +UrlSecurityTest > isSafeUrl accepts public addresses PASSED + +WebViewUtilsTest > configureCloudflareWebView sets secure mixed content mode PASSED + +BUILD SUCCESSFUL in 13s +32 actionable tasks: 1 executed, 31 up-to-date