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

test(D1c): 补阅读器翻页边界与换章的第一批测试

项目有 robolectric 却零阅读器核心测试(审计 F-04「结构上不可测」)。D2 把取页器解耦
干净之后,它的依赖只剩两个接口,用假实现就能断言「哪个命令被下达了」——比构造 ReadView
便宜得多,覆盖的又正是真机上最难复现的分支:滚动模式翻到章尾而下一章还没加载(必须
原地不动)、上一章排版没完成时不许后退、章内翻页与跨章翻页走不同命令。20 条。

打开 isIncludeAndroidResources:项目此前没开,Robolectric 下取任何 R.string 都是
Resources$NotFoundException(阅读器在构造期就读——TextPageFactory 的 keepSwipeTip、
TextPage 的默认 text/title、ReadView 的无障碍动作名),整条阅读器测试线起不来。
副作用是 AppLocaleRepositoryTest 挂 6 条:它的 @Config 缺了其余测试类都有的
application = Application::class,开资源后 Robolectric 才解析 manifest、于是实例化
真的 App,App.onCreate 要拉 Koin/DB/Cronet。补上即恢复——等于恢复开关前的实际行为。

页对象用占位而非真 TextPage:TextPage 的属性初始化器一路牵出
CanvasRecorderFactory → ReadConfig → Koin,以及 ChapterProvider.<clinit> →
ReadBookConfig → ReadStyleConfigStore → ReadStyleRepository → Room。而翻页边界只用到
pages.size(pageSize / isLastIndex / isLastIndexCurrent),从不碰页对象本身,故按 JVM
泛型擦除往 private textPages 里塞占位。将来真要读页内容会抛 ClassCastException,
响亮不静默——届时该做的是给渲染实体解开构造期的全局依赖,而不是在测试里把那条链拉起来。
选页逻辑(curPage/nextPage)因此仍测不了。

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

+ 10 - 0
app/build.gradle.kts

@@ -154,6 +154,16 @@ android {
         baseline = file("lint-baseline.xml")
         checkDependencies = true
     }
+
+    testOptions {
+        unitTests {
+            // 阅读器核心在构造期就读字符串资源(TextPageFactory 的 keepSwipeTip、
+            // TextPage 的默认 text/title、ReadView 的无障碍动作名)。不打开这个,
+            // Robolectric 下取任何 R.string 都是 Resources$NotFoundException,
+            // 整条阅读器测试线(Track D·D1c)就起不来。
+            isIncludeAndroidResources = true
+        }
+    }
 }
 
 kotlin {

+ 5 - 1
app/src/test/java/io/legado/app/data/repository/AppLocaleRepositoryTest.kt

@@ -1,5 +1,6 @@
 package io.legado.app.data.repository
 
+import android.app.Application
 import androidx.core.os.LocaleListCompat
 import org.junit.Assert.assertEquals
 import org.junit.Test
@@ -7,8 +8,11 @@ import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.annotation.Config
 
+// application = Application::class 与其余 Robolectric 测试一致:不指定的话 Robolectric 会从
+// manifest 取真的 io.legado.app.App,App.onCreate() 要拉 Koin/DB/Cronet,单测里必炸。
+// 开 isIncludeAndroidResources 之前 manifest 不被读取,这里才侥幸没暴露。
 @RunWith(RobolectricTestRunner::class)
-@Config(sdk = [35])
+@Config(application = Application::class, sdk = [35])
 class AppLocaleRepositoryTest {
 
     @Test

+ 362 - 0
app/src/test/java/io/legado/app/ui/book/read/page/provider/TextPageFactoryTest.kt

@@ -0,0 +1,362 @@
+package io.legado.app.ui.book.read.page.provider
+
+import android.app.Application
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.ui.book.read.page.ReaderPageSource
+import io.legado.app.ui.book.read.page.api.DataSource
+import io.legado.app.ui.book.read.page.entities.TextChapter
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import splitties.init.injectAsAppCtx
+
+/**
+ * 阅读器核心第一批测试(Track D·D1c):翻页边界与换章。
+ *
+ * 这些规则以前只能靠真机翻页试出来——`TextPageFactory` 直接调 `ReadBook`,JVM 里构造不出来。
+ * D2 把页位置命令收进 [ReaderPageSource] 之后,取页器的依赖只剩两个接口,可以用假实现断言
+ * 「哪个命令被下达了」,而不必真的动一本书。
+ *
+ * 覆盖的都是**容易回归且真机上难复现**的分支:滚动模式翻到章尾而下一章还没加载、
+ * 上一章排版没完成时不许后退、章内翻页与跨章翻页走不同命令。
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class TextPageFactoryTest {
+
+    private lateinit var dataSource: FakeDataSource
+    private lateinit var pageSource: FakePageSource
+    private lateinit var factory: TextPageFactory
+
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+        dataSource = FakeDataSource()
+        pageSource = FakePageSource()
+        factory = TextPageFactory(dataSource, pageSource)
+    }
+
+    // ── hasPrev / hasNext / hasNextPlus 边界 ──────────────────────────────
+
+    @Test
+    fun `章首且没有上一章时没有上一页`() {
+        dataSource.pageIndex = 0
+        dataSource.hasPrev = false
+
+        assertFalse(factory.hasPrev())
+    }
+
+    @Test
+    fun `章首但有上一章时仍有上一页`() {
+        dataSource.pageIndex = 0
+        dataSource.hasPrev = true
+
+        assertTrue(factory.hasPrev())
+    }
+
+    @Test
+    fun `章内非首页总是有上一页`() {
+        dataSource.pageIndex = 1
+        dataSource.hasPrev = false
+
+        assertTrue(factory.hasPrev())
+    }
+
+    @Test
+    fun `没有当前章也没有下一章时没有下一页`() {
+        dataSource.currentChapter = null
+        dataSource.hasNext = false
+
+        assertFalse(factory.hasNext())
+    }
+
+    @Test
+    fun `章内非末页有下一页`() {
+        dataSource.currentChapter = chapter(pageCount = 3)
+        dataSource.pageIndex = 0
+        dataSource.hasNext = false
+
+        assertTrue(factory.hasNext())
+    }
+
+    @Test
+    fun `排完版的末页且没有下一章时没有下一页`() {
+        dataSource.currentChapter = chapter(pageCount = 3, completed = true)
+        dataSource.pageIndex = 2
+        dataSource.hasNext = false
+
+        assertFalse(factory.hasNext())
+    }
+
+    @Test
+    fun `还没排完版的末页仍算有下一页`() {
+        // isLastIndex 要求 isCompleted——排版还在继续时不能判定「到底了」,
+        // 否则用户会在章节排到一半时被挡住。
+        dataSource.currentChapter = chapter(pageCount = 3, completed = false)
+        dataSource.pageIndex = 2
+        dataSource.hasNext = false
+
+        assertTrue(factory.hasNext())
+    }
+
+    @Test
+    fun `hasNextPlus 以倒数第二页为界`() {
+        dataSource.currentChapter = chapter(pageCount = 5)
+        dataSource.hasNext = false
+
+        dataSource.pageIndex = 2
+        assertTrue(factory.hasNextPlus())
+
+        dataSource.pageIndex = 3
+        assertFalse(factory.hasNextPlus())
+    }
+
+    // ── moveToNext:章内翻页 vs 跨章 ──────────────────────────────────────
+
+    @Test
+    fun `章内前进只改页码不换章`() {
+        dataSource.currentChapter = chapter(pageCount = 3)
+        dataSource.pageIndex = 0
+
+        assertTrue(factory.moveToNext(upContent = true))
+        assertEquals(listOf("setPageIndex(1)"), pageSource.commands)
+    }
+
+    @Test
+    fun `章尾前进下达换章命令`() {
+        dataSource.currentChapter = chapter(pageCount = 3, completed = true)
+        dataSource.pageIndex = 2
+        dataSource.nextChapter = chapter(pageCount = 2)
+        dataSource.hasNext = true
+
+        assertTrue(factory.moveToNext(upContent = true))
+        assertEquals(listOf("moveToNextChapter(true)"), pageSource.commands)
+    }
+
+    @Test
+    fun `滚动模式下一章还没加载出来时章尾不前进`() {
+        // 滚动模式把下一章的内容接在当前章后面渲染,下一章缺席时换章会渲染出空白,
+        // 所以这里必须原地不动。非滚动模式反而可以先换章再等加载。
+        dataSource.isScroll = true
+        dataSource.currentChapter = chapter(pageCount = 3, completed = true)
+        dataSource.pageIndex = 2
+        dataSource.nextChapter = null
+        dataSource.hasNext = true
+
+        assertFalse(factory.moveToNext(upContent = true))
+        assertTrue(pageSource.commands.isEmpty())
+    }
+
+    @Test
+    fun `没有下一页时前进不下达任何命令`() {
+        dataSource.currentChapter = chapter(pageCount = 3, completed = true)
+        dataSource.pageIndex = 2
+        dataSource.hasNext = false
+
+        assertFalse(factory.moveToNext(upContent = true))
+        assertTrue(pageSource.commands.isEmpty())
+    }
+
+    // ── moveToPrev:章内翻页 vs 跨章 ──────────────────────────────────────
+
+    @Test
+    fun `章内后退只改页码不换章`() {
+        dataSource.currentChapter = chapter(pageCount = 3)
+        dataSource.pageIndex = 2
+
+        assertTrue(factory.moveToPrev(upContent = true))
+        assertEquals(listOf("setPageIndex(1)"), pageSource.commands)
+    }
+
+    @Test
+    fun `章首后退下达换章命令`() {
+        dataSource.currentChapter = chapter(pageCount = 3)
+        dataSource.pageIndex = 0
+        dataSource.prevChapter = chapter(pageCount = 2, completed = true)
+        dataSource.hasPrev = true
+
+        assertTrue(factory.moveToPrev(upContent = true))
+        assertEquals(listOf("moveToPrevChapter(true)"), pageSource.commands)
+    }
+
+    @Test
+    fun `上一章排版没完成时章首不后退`() {
+        // 往前翻要落在上一章的**最后一页**,页数没定下来就没法定位,
+        // 所以排版未完成时原地不动等排完。
+        dataSource.currentChapter = chapter(pageCount = 3)
+        dataSource.pageIndex = 0
+        dataSource.prevChapter = chapter(pageCount = 2, completed = false)
+        dataSource.hasPrev = true
+
+        assertFalse(factory.moveToPrev(upContent = true))
+        assertTrue(pageSource.commands.isEmpty())
+    }
+
+    @Test
+    fun `没有上一页时后退不下达任何命令`() {
+        dataSource.currentChapter = chapter(pageCount = 3)
+        dataSource.pageIndex = 0
+        dataSource.hasPrev = false
+
+        assertFalse(factory.moveToPrev(upContent = true))
+        assertTrue(pageSource.commands.isEmpty())
+    }
+
+    // ── upContent 的联动 ────────────────────────────────────────────────
+
+    @Test
+    fun `翻页刷内容时不重置滚动位置`() {
+        dataSource.currentChapter = chapter(pageCount = 3)
+        dataSource.pageIndex = 0
+
+        factory.moveToNext(upContent = true)
+
+        assertEquals(listOf(0 to false), dataSource.upContentCalls)
+    }
+
+    @Test
+    fun `upContent 为 false 时只改位置不刷内容`() {
+        dataSource.currentChapter = chapter(pageCount = 3)
+        dataSource.pageIndex = 0
+
+        factory.moveToNext(upContent = false)
+
+        assertEquals(listOf("setPageIndex(1)"), pageSource.commands)
+        assertTrue(dataSource.upContentCalls.isEmpty())
+    }
+
+    // ── moveToFirst / moveToLast ────────────────────────────────────────
+
+    @Test
+    fun `moveToLast 落在最后一页`() {
+        dataSource.currentChapter = chapter(pageCount = 4)
+
+        factory.moveToLast()
+
+        assertEquals(listOf("setPageIndex(3)"), pageSource.commands)
+    }
+
+    @Test
+    fun `没有页可落时 moveToLast 回到第一页`() {
+        dataSource.currentChapter = chapter(pageCount = 0)
+
+        factory.moveToLast()
+
+        assertEquals(listOf("setPageIndex(0)"), pageSource.commands)
+    }
+
+    @Test
+    fun `没有当前章时 moveToLast 回到第一页`() {
+        dataSource.currentChapter = null
+
+        factory.moveToLast()
+
+        assertEquals(listOf("setPageIndex(0)"), pageSource.commands)
+    }
+
+    @Test
+    fun `moveToFirst 回到第一页`() {
+        factory.moveToFirst()
+
+        assertEquals(listOf("setPageIndex(0)"), pageSource.commands)
+    }
+
+    private companion object {
+
+        /**
+         * 造一个「有 [pageCount] 页」的章节。
+         *
+         * 两处不得不用反射/占位,原因都是**被测逻辑之外的全局依赖**,记在这里省得下次重新踩:
+         *
+         * 1. `TextChapter.textPages` 是 private,只由 `TextChapterLayout` 填充(排版引擎要
+         *    Canvas 和真实度量),单测够不着,所以直接往那个 list 里塞。
+         * 2. 塞进去的**不是真 `TextPage`**:`TextPage` 的属性初始化器会拉起
+         *    `CanvasRecorderFactory → ReadConfig → Koin`、`ChapterProvider.paddingTop →
+         *    ReadBookConfig → ReadStyleConfigStore → ReadStyleRepository → Room`
+         *    一整条链。而本类断言的翻页边界只用到 `pages.size`
+         *    (`pageSize` / `isLastIndex` / `isLastIndexCurrent`),从不碰页对象本身,
+         *    所以按 JVM 泛型擦除塞占位对象即可。
+         *
+         * 将来若有测试真要读页内容,这里会抛 `ClassCastException`——**响亮地**失败,
+         * 不会静默给出错结论。届时该做的是给渲染实体解开构造期的全局依赖(D2/E5 议题),
+         * 而不是在测试里把那条链拉起来。
+         */
+        fun chapter(pageCount: Int, completed: Boolean = true): TextChapter {
+            val chapter = TextChapter(
+                chapter = BookChapter(),
+                position = 0,
+                title = "章节",
+                chaptersSize = 3,
+                sameTitleRemoved = false,
+                isVip = false,
+                isPay = false,
+                effectiveReplaceRules = null,
+            )
+            if (pageCount > 0) {
+                val field = TextChapter::class.java.getDeclaredField("textPages")
+                field.isAccessible = true
+                @Suppress("UNCHECKED_CAST")
+                val pages = field.get(chapter) as ArrayList<Any>
+                repeat(pageCount) { pages.add(PAGE_COUNT_ONLY_PLACEHOLDER) }
+            }
+            chapter.isCompleted = completed
+            return chapter
+        }
+
+        /** 只用来把 `pages.size` 撑起来,见 [chapter] 的说明 */
+        val PAGE_COUNT_ONLY_PLACEHOLDER = Any()
+    }
+
+    private class FakeDataSource(
+        override var pageIndex: Int = 0,
+        override var currentChapter: TextChapter? = null,
+        override var nextChapter: TextChapter? = null,
+        override var prevChapter: TextChapter? = null,
+        override var isScroll: Boolean = false,
+        var hasNext: Boolean = false,
+        var hasPrev: Boolean = false,
+    ) : DataSource {
+
+        val upContentCalls = mutableListOf<Pair<Int, Boolean>>()
+
+        override fun hasNextChapter(): Boolean = hasNext
+
+        override fun hasPrevChapter(): Boolean = hasPrev
+
+        override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
+            upContentCalls += relativePosition to resetPageOffset
+        }
+    }
+
+    private class FakePageSource : ReaderPageSource {
+
+        override var durChapterIndex: Int = 0
+        override var durPageIndex: Int = 0
+        override var simulatedChapterSize: Int = 0
+        override var pageAnim: Int = 0
+        override var msg: String? = null
+
+        /** 下达过的页位置命令,按顺序记录——断言的是「谁被调了」而不是内部状态 */
+        val commands = mutableListOf<String>()
+
+        override fun textChapter(chapterOnDur: Int): TextChapter? = null
+
+        override fun setPageIndex(index: Int) {
+            commands += "setPageIndex($index)"
+        }
+
+        override fun moveToNextChapter(upContent: Boolean) {
+            commands += "moveToNextChapter($upContent)"
+        }
+
+        override fun moveToPrevChapter(upContent: Boolean) {
+            commands += "moveToPrevChapter($upContent)"
+        }
+    }
+}