Просмотр исходного кода

refactor(R2.2): 搜索结果定位的纯逻辑摘出 ReadBookViewModel 并补测

章内搜索域和前三个域形态不同,按实际边界分两半处理,本提交只做能干净
摘出的那一半。ReadBookViewModel 5018 → 4906 行。

- searchResultPositions / findSearchResultMatch / findSearchTextPoint
  共 115 行是纯函数(只吃 TextChapter + SearchResult + query,不碰任何
  UI 状态),摘成 ReadSearchResultLocator。VM 保留 3 行转发入口,
  ReadBookController 的调用点零改动。file-private 的 SearchTextPoint
  随之搬走。
- 补 ReadSearchResultLocatorTest(8 条)。这段逻辑原来埋在 VM 里没法测:
  它先信 SearchResult.queryIndexInChapter 直达下标,校验不过才退回
  「数到第 N 次出现」的扫描。直达下标来自搜索时那一版正文,被替换规则或
  AI 净化改写后就失效——校验分支要是坏了,搜索跳转会静默跳到错的位置。
  已注入回归反证(把 if (directMatch) 改成 if (true))确认会变红。

未做(有意留下):搜索的**状态**没有摘成 delegate。isShowingSearchResult
/ searchResultList / searchResultIndex 被 ReadBookMenuBar、
ReadBookSearchBar、ReadBookController 返回键、ReadBookScreen 返回键、
MainNavGraph 五处直读——它是阅读器 chrome 的状态,不是弹层自包含状态,
和前三个域的边界不可比。强行摘会把 4 个 state 参数穿进 chrome 的重组热
路径,收益为负。留待 R2.3 Track B 端游时连同 chrome 状态一起处理。

验证:compileAppDebugKotlin + verifyConfigArchitecture + 全量单测
(426 passed) + assembleAppDebug + lintAppDebug 全绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
0fe3727f18

+ 2 - 117
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -109,7 +109,6 @@ import io.legado.app.model.translation.TranslationManager
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.page.entities.TextChapter
-import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
@@ -3225,120 +3224,12 @@ class ReadBookViewModel(
         reverseContent()
     }
 
+    /** 纯定位逻辑见 [ReadSearchResultLocator];这里保留转发入口供 ReadBookController 调用。 */
     fun searchResultPositions(
         textChapter: TextChapter,
         searchResult: SearchResult,
         query: String = _uiState.value.searchContentQuery,
-    ): Array<Int> {
-        val pages = textChapter.pages
-        val content = textChapter.getContent()
-        if (pages.isEmpty()) return arrayOf(-1, 0, 0, 0, 0, 0)
-
-        val match = findSearchResultMatch(content, searchResult, query)
-            ?: return arrayOf(-1, 0, 0, 0, 0, 0)
-        val contentPosition = match.first
-        val queryLength = match.second
-        if (contentPosition < 0 || queryLength <= 0) {
-            return arrayOf(-1, 0, 0, 0, 0, 0)
-        }
-
-        val start = findSearchTextPoint(pages, contentPosition, preferPreviousLine = false)
-            ?: return arrayOf(-1, 0, 0, 0, 0, 0)
-        val end = findSearchTextPoint(
-            pages,
-            contentPosition + queryLength - 1,
-            preferPreviousLine = true
-        ) ?: start
-        return arrayOf(
-            start.pageIndex,
-            start.lineIndex,
-            start.charIndex,
-            end.pageIndex - start.pageIndex,
-            end.lineIndex,
-            end.charIndex
-        )
-    }
-
-    private fun findSearchResultMatch(
-        content: String,
-        searchResult: SearchResult,
-        query: String,
-    ): Pair<Int, Int>? {
-        if (query.isEmpty()) return null
-        val directLength = if (searchResult.matchLength > 0) searchResult.matchLength else query.length
-        val directIndex = searchResult.queryIndexInChapter
-        if (directIndex >= 0 && directIndex + directLength <= content.length) {
-            val directMatch = if (searchResult.isRegex) {
-                runCatching {
-                    Regex(query).matches(content.substring(directIndex, directIndex + directLength))
-                }.getOrDefault(false)
-            } else {
-                content.regionMatches(
-                    directIndex,
-                    query,
-                    0,
-                    query.length,
-                    ignoreCase = false
-                )
-            }
-            if (directMatch) {
-                return directIndex to directLength
-            }
-        }
-        if (searchResult.isRegex) {
-            return runCatching {
-                Regex(query).findAll(content)
-                    .drop(searchResult.resultCountWithinChapter)
-                    .firstOrNull()
-                    ?.let { it.range.first to it.value.length }
-            }.getOrNull()
-        }
-
-        var count = 0
-        var index = content.indexOf(query)
-        while (count != searchResult.resultCountWithinChapter && index >= 0) {
-            index = content.indexOf(query, index + query.length)
-            count += 1
-        }
-        return index.takeIf { it >= 0 }?.let { it to query.length }
-    }
-
-    private fun findSearchTextPoint(
-        pages: List<TextPage>,
-        contentPosition: Int,
-        preferPreviousLine: Boolean,
-    ): SearchTextPoint? {
-        var fallback: SearchTextPoint? = null
-        pages.forEachIndexed { pageIndex, page ->
-            page.lines.forEachIndexed { lineIndex, line ->
-                if (line.columns.isEmpty()) return@forEachIndexed
-                val lineStart = line.chapterPosition
-                val lineEndExclusive = lineStart + line.charSize
-                if (contentPosition in lineStart until lineEndExclusive) {
-                    return SearchTextPoint(
-                        pageIndex = pageIndex,
-                        lineIndex = lineIndex,
-                        charIndex = (contentPosition - lineStart).coerceIn(0, line.columns.lastIndex)
-                    )
-                }
-                if (preferPreviousLine && line.isParagraphEnd && contentPosition == lineEndExclusive) {
-                    return SearchTextPoint(
-                        pageIndex = pageIndex,
-                        lineIndex = lineIndex,
-                        charIndex = line.columns.lastIndex
-                    )
-                }
-                if (contentPosition >= lineEndExclusive) {
-                    fallback = SearchTextPoint(
-                        pageIndex = pageIndex,
-                        lineIndex = lineIndex,
-                        charIndex = line.columns.lastIndex
-                    )
-                }
-            }
-        }
-        return fallback
-    }
+    ): Array<Int> = ReadSearchResultLocator.positions(textChapter, searchResult, query)
 
     /**
      * Compute the search result position and emit [ReadBookEffect.NavigateToSearchResult]
@@ -5038,12 +4929,6 @@ private const val BRIGHT_LUX_THRESHOLD = 100f
 private const val LIGHT_LUMINANCE_THRESHOLD = 0.35
 private const val REMINDER_COOLDOWN_MS = 10 * 60 * 1000L
 
-private data class SearchTextPoint(
-    val pageIndex: Int,
-    val lineIndex: Int,
-    val charIndex: Int,
-)
-
 private fun Int.coerceSearchResultIndex(resultSize: Int): Int {
     return if (resultSize <= 0) 0 else coerceIn(0, resultSize - 1)
 }

+ 144 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadSearchResultLocator.kt

@@ -0,0 +1,144 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.ui.book.read.page.entities.TextChapter
+import io.legado.app.ui.book.read.page.entities.TextPage
+import io.legado.app.ui.book.searchContent.SearchResult
+
+/**
+ * 把一条 [SearchResult] 定位到当前章排版结果里的 (页, 行, 列)。
+ *
+ * 这段是纯函数——只吃 (TextChapter, SearchResult, query),不碰任何 UI 状态,
+ * 所以从 `ReadBookViewModel` 摘出来独立成对象,[findMatch] 可以直接写单测覆盖。
+ * `ReadBookViewModel.searchResultPositions` 保留为转发入口,供 `ReadBookController` 调用。
+ */
+internal object ReadSearchResultLocator {
+
+    /** @return `[pageIndex, lineIndex, charIndex, pageSpan, endLineIndex, endCharIndex]`;定位失败首位为 -1。 */
+    fun positions(
+        textChapter: TextChapter,
+        searchResult: SearchResult,
+        query: String,
+    ): Array<Int> {
+        val pages = textChapter.pages
+        val content = textChapter.getContent()
+        if (pages.isEmpty()) return NOT_FOUND
+
+        val match = findMatch(content, searchResult, query) ?: return NOT_FOUND
+        val contentPosition = match.first
+        val queryLength = match.second
+        if (contentPosition < 0 || queryLength <= 0) {
+            return NOT_FOUND
+        }
+
+        val start = findTextPoint(pages, contentPosition, preferPreviousLine = false)
+            ?: return NOT_FOUND
+        val end = findTextPoint(
+            pages,
+            contentPosition + queryLength - 1,
+            preferPreviousLine = true
+        ) ?: start
+        return arrayOf(
+            start.pageIndex,
+            start.lineIndex,
+            start.charIndex,
+            end.pageIndex - start.pageIndex,
+            end.lineIndex,
+            end.charIndex
+        )
+    }
+
+    /**
+     * 在章节正文里找到这条结果对应的 `位置 to 长度`。
+     *
+     * 先信 [SearchResult.queryIndexInChapter] 这个直达下标(校验后才用),校验不过再退回
+     * 「数到第 [SearchResult.resultCountWithinChapter] 次出现」的扫描。
+     */
+    internal fun findMatch(
+        content: String,
+        searchResult: SearchResult,
+        query: String,
+    ): Pair<Int, Int>? {
+        if (query.isEmpty()) return null
+        val directLength = if (searchResult.matchLength > 0) searchResult.matchLength else query.length
+        val directIndex = searchResult.queryIndexInChapter
+        if (directIndex >= 0 && directIndex + directLength <= content.length) {
+            val directMatch = if (searchResult.isRegex) {
+                runCatching {
+                    Regex(query).matches(content.substring(directIndex, directIndex + directLength))
+                }.getOrDefault(false)
+            } else {
+                content.regionMatches(
+                    directIndex,
+                    query,
+                    0,
+                    query.length,
+                    ignoreCase = false
+                )
+            }
+            if (directMatch) {
+                return directIndex to directLength
+            }
+        }
+        if (searchResult.isRegex) {
+            return runCatching {
+                Regex(query).findAll(content)
+                    .drop(searchResult.resultCountWithinChapter)
+                    .firstOrNull()
+                    ?.let { it.range.first to it.value.length }
+            }.getOrNull()
+        }
+
+        var count = 0
+        var index = content.indexOf(query)
+        while (count != searchResult.resultCountWithinChapter && index >= 0) {
+            index = content.indexOf(query, index + query.length)
+            count += 1
+        }
+        return index.takeIf { it >= 0 }?.let { it to query.length }
+    }
+
+    private fun findTextPoint(
+        pages: List<TextPage>,
+        contentPosition: Int,
+        preferPreviousLine: Boolean,
+    ): SearchTextPoint? {
+        var fallback: SearchTextPoint? = null
+        pages.forEachIndexed { pageIndex, page ->
+            page.lines.forEachIndexed { lineIndex, line ->
+                if (line.columns.isEmpty()) return@forEachIndexed
+                val lineStart = line.chapterPosition
+                val lineEndExclusive = lineStart + line.charSize
+                if (contentPosition in lineStart until lineEndExclusive) {
+                    return SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = (contentPosition - lineStart).coerceIn(0, line.columns.lastIndex)
+                    )
+                }
+                if (preferPreviousLine && line.isParagraphEnd && contentPosition == lineEndExclusive) {
+                    return SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = line.columns.lastIndex
+                    )
+                }
+                if (contentPosition >= lineEndExclusive) {
+                    fallback = SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = line.columns.lastIndex
+                    )
+                }
+            }
+        }
+        return fallback
+    }
+
+    private val NOT_FOUND get() = arrayOf(-1, 0, 0, 0, 0, 0)
+
+    private data class SearchTextPoint(
+        val pageIndex: Int,
+        val lineIndex: Int,
+        val charIndex: Int,
+    )
+}

+ 120 - 0
app/src/test/java/io/legado/app/ui/book/read/ReadSearchResultLocatorTest.kt

@@ -0,0 +1,120 @@
+package io.legado.app.ui.book.read
+
+import io.legado.app.ui.book.searchContent.SearchResult
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * R2.2 —— [ReadSearchResultLocator.findMatch] 的行为覆盖。
+ *
+ * 这段逻辑原来埋在 `ReadBookViewModel` 里没法测。它有两条路径:
+ * 先信 `queryIndexInChapter` 直达下标,校验不过再退回「数到第 N 次出现」的扫描。
+ * 直达下标来自搜索时那一版正文,正文被替换规则/AI 净化改写后就会失效——
+ * 校验分支失效的话,搜索跳转会静默跳到错的位置。
+ */
+class ReadSearchResultLocatorTest {
+
+    private fun result(
+        indexInChapter: Int = -1,
+        countWithinChapter: Int = 0,
+        matchLength: Int = 0,
+        isRegex: Boolean = false,
+    ) = SearchResult(
+        query = "",
+        queryIndexInChapter = indexInChapter,
+        resultCountWithinChapter = countWithinChapter,
+        matchLength = matchLength,
+        isRegex = isRegex,
+    )
+
+    @Test
+    fun `直达下标命中时直接采用`() {
+        val content = "海边有座灯塔,灯塔很亮。"
+        val match = ReadSearchResultLocator.findMatch(
+            content = content,
+            searchResult = result(indexInChapter = content.indexOf("灯塔", 7), matchLength = 2),
+            query = "灯塔",
+        )
+        assertEquals(7 to 2, match)
+    }
+
+    @Test
+    fun `直达下标失效时退回按出现次数扫描`() {
+        val content = "海边有座灯塔,灯塔很亮。"
+        // 正文被改写过,旧下标 99 已越界,必须退回扫描第 1 次出现(0-based)
+        val match = ReadSearchResultLocator.findMatch(
+            content = content,
+            searchResult = result(indexInChapter = 99, countWithinChapter = 1),
+            query = "灯塔",
+        )
+        assertEquals(7 to 2, match)
+    }
+
+    @Test
+    fun `直达下标指向了别的字时不被采用`() {
+        val content = "海边有座灯塔,灯塔很亮。"
+        // 下标 0 处是「海边」不是「灯塔」,regionMatches 校验应失败并退回扫描第 0 次出现
+        val match = ReadSearchResultLocator.findMatch(
+            content = content,
+            searchResult = result(indexInChapter = 0, matchLength = 2, countWithinChapter = 0),
+            query = "灯塔",
+        )
+        assertEquals(4 to 2, match)
+    }
+
+    @Test
+    fun `空查询返回 null`() {
+        assertNull(
+            ReadSearchResultLocator.findMatch(
+                content = "海边有座灯塔。",
+                searchResult = result(indexInChapter = 0),
+                query = "",
+            )
+        )
+    }
+
+    @Test
+    fun `正文里找不到时返回 null`() {
+        assertNull(
+            ReadSearchResultLocator.findMatch(
+                content = "海边有座灯塔。",
+                searchResult = result(indexInChapter = -1),
+                query = "潜水艇",
+            )
+        )
+    }
+
+    @Test
+    fun `正则匹配返回实际匹配长度而非查询串长度`() {
+        val match = ReadSearchResultLocator.findMatch(
+            content = "第1章 起点\n第22章 终点",
+            searchResult = result(indexInChapter = -1, countWithinChapter = 1, isRegex = true),
+            query = """第\d+章""",
+        )
+        // 第 1 次(0-based)出现是「第22章」,长度 4 而不是 pattern 的长度
+        assertEquals(7 to 4, match)
+    }
+
+    @Test
+    fun `非法正则不抛异常而是返回 null`() {
+        assertNull(
+            ReadSearchResultLocator.findMatch(
+                content = "海边有座灯塔。",
+                searchResult = result(indexInChapter = -1, isRegex = true),
+                query = "[未闭合",
+            )
+        )
+    }
+
+    @Test
+    fun `matchLength 为 0 时回退成查询串长度`() {
+        val content = "海边有座灯塔。"
+        val match = ReadSearchResultLocator.findMatch(
+            content = content,
+            searchResult = result(indexInChapter = 4, matchLength = 0),
+            query = "灯塔",
+        )
+        assertEquals(4 to 2, match)
+    }
+}