Browse Source

fix: 收掉 main 上的 CI 红灯并修正 Web 产物自动提交触发器 (#2281)

6 项 CI 失败全部修复:API 26/27 装饰颜色崩溃(NoSuchMethodError)、AnalyzeRule JS 对象条目快捷路径按 mode 分发(书源校验误判失效)、BookInfoScreen stringResource、ReadSettings 字段数 111→112、ReadBookViewModel 验收线 2743→2746、docs 7 个死链;另修 web.yml 的 push 分支(master→main)与 contents: write 权限。本地验证:1098 tests / 0 failures、lint 0 errors、verifyConfigArchitecture 通过、vitepress build 通过。
Kudomaga 1 month ago
parent
commit
476eaa2326

+ 7 - 1
.github/workflows/web.yml

@@ -3,7 +3,8 @@ name: Build Web
 on:
   push:
     branches:
-      - master
+      # 本仓库默认分支是 main;原写 master 导致下面的「自动提交产物」步骤从未触发过。
+      - main
     paths:
       - '**/modules/web/**'
   pull_request:
@@ -11,6 +12,11 @@ on:
       - '**/modules/web/**'
   workflow_dispatch:
 
+# git-auto-commit-action 要往 main 提交 app/src/main/assets/web/vue/**,需要写权限;
+# 缺这段时 GITHUB_TOKEN 默认只读,push changes 会 403(与 auto-release.yml 的写法一致)。
+permissions:
+  contents: write
+
 
 jobs:
   build:

+ 5 - 2
app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderHtmlSourceResolver.kt

@@ -71,13 +71,16 @@ class AndroidReaderHtmlSourceResolver(
                     when (span) {
                         is QuoteSpan -> add(ReaderParagraphDecoration(
                             ReaderParagraphDecorationKind.QUOTE,
-                            span.color,
+                            // QuoteSpan/BulletSpan 的 getColor() 是 API 28 才加入的方法:
+                            // minSdk 26/27 上读 span.color 会在运行期抛 NoSuchMethodError,
+                            // 带 <blockquote>/<ul><li> 的章节会整章崩掉,颜色回落 null。
+                            if (Build.VERSION.SDK_INT >= 28) span.color else null,
                             if (Build.VERSION.SDK_INT >= 28) span.stripeWidth.toFloat() else 2f,
                             leadingOffset,
                         ))
                         is BulletSpan -> add(ReaderParagraphDecoration(
                             ReaderParagraphDecorationKind.BULLET,
-                            span.color.takeUnless { it == 0 },
+                            if (Build.VERSION.SDK_INT >= 28) span.color.takeUnless { it == 0 } else null,
                             if (Build.VERSION.SDK_INT >= 28) span.bulletRadius.toFloat() else 4f,
                             leadingOffset,
                         ))

+ 18 - 9
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt

@@ -211,12 +211,17 @@ class AnalyzeRule(
                 val sourceRule = ruleList.first()
                 putRule(sourceRule.putMap)
                 sourceRule.makeUpRule(result)
-                result = if (sourceRule.getParamSize() > 1) {
+                result = when {
+                    // 快捷路径同样要按 mode 分发(与下方通用路径一致):`@js:` 书源列表
+                    // 规则产出的 JS 对象条目上,`$.x` / `@js:xxx` 若被当字面量键名处理会
+                    // 返回空,BookList 丢掉空书名条目 → 书源校验把搜索/发现误判为失效。
+                    sourceRule.mode == Mode.Js -> evalJS(sourceRule.rule, result)
+                    sourceRule.mode == Mode.Json ->
+                        getAnalyzeByJSonPath(result).getStringList(sourceRule.rule)
                     // get {{}}
-                    sourceRule.rule
-                } else {
+                    sourceRule.getParamSize() > 1 -> sourceRule.rule
                     // 键值直接访问
-                    result[sourceRule.rule]
+                    else -> result[sourceRule.rule]
                 }
                 result?.let {
                     if (sourceRule.replaceRegex.isNotEmpty() && it is List<*>) {
@@ -310,14 +315,18 @@ class AnalyzeRule(
                 val sourceRule = ruleList.first()
                 putRule(sourceRule.putMap)
                 sourceRule.makeUpRule(result)
-                result = if (sourceRule.getParamSize() > 1) {
+                result = when {
+                    // 与 getStringList 同理:JS 对象条目必须按 mode 分发,否则 `$.name`
+                    // 与 `@js:xxx` 都会被当字面量键名而返回空串。
+                    sourceRule.mode == Mode.Js -> evalJS(sourceRule.rule, result)
+                    sourceRule.mode == Mode.Json ->
+                        getAnalyzeByJSonPath(result).getString(sourceRule.rule)
                     // get {{}}
-                    sourceRule.rule
-                } else {
+                    sourceRule.getParamSize() > 1 -> sourceRule.rule
                     // 键值直接访问
-                    result[sourceRule.rule]?.toString()
+                    else -> result[sourceRule.rule]?.toString()
                 }?.let {
-                    replaceRegex(it, sourceRule)
+                    replaceRegex(it.toString(), sourceRule)
                 }
             } else if (result is LinkedTreeMap<*, *>) {
                 result = result[ruleList.first().rule]?.toString()

+ 6 - 2
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -263,11 +263,15 @@ private fun BookInfoScreenContent(
     val snackbarHostState = remember { SnackbarHostState() }
     val scope = rememberCoroutineScope()
     val context = LocalContext.current
+    // 资源串在 composable 作用域内解析:LocalContext.current.getString 不感知配置变化,
+    // 语言/字体缩放切换后可能拿到过期值(LocalContextGetResourceValueCall)。
+    val jumpToAnotherAppMessage = stringResource(R.string.jump_to_another_app)
+    val confirmLabel = stringResource(R.string.confirm)
     val jumpToAnotherApp: (Uri) -> Unit = { uri ->
         scope.launch {
             val result = snackbarHostState.showSnackbar(
-                message = context.getString(R.string.jump_to_another_app),
-                actionLabel = context.getString(R.string.confirm),
+                message = jumpToAnotherAppMessage,
+                actionLabel = confirmLabel,
             )
             if (result == SnackbarResult.ActionPerformed) {
                 context.openUrl(uri)

+ 2 - 2
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -14,11 +14,11 @@ import kotlin.reflect.full.primaryConstructor
 class ReadSettingsMappingTest {
 
     @Test
-    fun `gateway 持久化映射覆盖 ReadSettings 全部 111 个字段`() {
+    fun `gateway 持久化映射覆盖 ReadSettings 全部 112 个字段`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(111, actualKeys.size)
+        assertEquals(112, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
     }
 

+ 39 - 0
app/src/test/java/io/legado/app/feature/reader/platform/AndroidReaderHtmlSourceResolverApi26Test.kt

@@ -0,0 +1,39 @@
+package io.legado.app.feature.reader.platform
+
+import android.app.Application
+import io.legado.app.feature.reader.core.layout.ReaderParagraphDecorationKind
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+/**
+ * API 26/27 回归:`QuoteSpan.getColor()` / `BulletSpan.getColor()` 是 API 28 才有的方法,
+ * 直接读 `span.color` 会在 minSdk 26 的设备上抛 `NoSuchMethodError`——
+ * 带 `<blockquote>` 或 `<ul><li>` 的章节会整章崩掉。颜色在低版本上回落 `null`。
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26])
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class AndroidReaderHtmlSourceResolverApi26Test {
+
+    private val html = "<blockquote>引用</blockquote><ul><li>条目</li></ul>"
+
+    @Test
+    fun quoteAndBulletDecorationsResolveOnApi26() {
+        val paragraphs = AndroidReaderHtmlSourceResolver(20f, 2f).resolve(html, 0)
+        val decorations = paragraphs.flatMap { it.decorations }
+        val kinds = decorations.map { it.kind }.toSet()
+
+        assertTrue("缺少 QUOTE 装饰:$kinds", ReaderParagraphDecorationKind.QUOTE in kinds)
+        assertTrue("缺少 BULLET 装饰:$kinds", ReaderParagraphDecorationKind.BULLET in kinds)
+        decorations.forEach {
+            assertTrue(
+                "API 26 上颜色应回落 null(读 span.color 会 NoSuchMethodError),实际 ${it.colorArgb}",
+                it.colorArgb == null,
+            )
+        }
+    }
+}

+ 25 - 16
app/src/test/java/io/legado/app/model/analyzeRule/AnalyzeRuleFastPathReproTest.kt

@@ -1,18 +1,26 @@
 package io.legado.app.model.analyzeRule
 
+import android.app.Application
 import com.script.rhino.RhinoScriptEngine
 import org.junit.Assert.assertEquals
+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
 
 /**
- * 复现:书源校验时,`@js:` 书源列表规则产生的 JS 对象条目(Rhino NativeObject)
- * 在 AnalyzeRule.getString/getStringList 的“单规则快捷路径”上被错误处理。
+ * 回归锁定:书源校验时,`@js:` 书源列表规则产生的 JS 对象条目(Rhino NativeObject)
+ * 在 AnalyzeRule.getString/getStringList 的“单规则快捷路径”上必须按 mode 分发。
  *
- * 上游 legado(legadoteam/legado,commit 7dc8d809c / 6eea19643 修复后)在 NativeObject
- * 快捷路径中保留了 Mode.Js / Mode.Json 分支:
+ * 修复前该快捷路径只做 `result[sourceRule.rule]`(把 `$.name` / `@js:xxx` 当字面量键名),
+ * `$.name`(Mode.Json)与 `@js:xxx`(Mode.Js)在 JS 对象条目上返回空字符串、`$.authors`
+ * 返回 null。BookList.getSearchItem 会丢弃书名为空的条目 → 搜索/发现结果为空 →
+ * `BookSourceCheckRepository.checkSource` 判定“搜索失效/发现失效” → 书源被判为失效。
+ *
+ * 修复后两个快捷路径与通用路径一致(上游 legado 7dc8d809c / 6eea19643 同款):
  *
  *   result = when {
  *       sourceRule.mode == Mode.Js    -> evalJS(sourceRule.rule, result)
@@ -21,22 +29,23 @@ import org.robolectric.annotation.Config
  *       else -> result[sourceRule.rule]
  *   }
  *
- * 本仓库(legado-with-MD3)在 AnalyzeRule.kt 中把该快捷路径简化成了:
- *
- *   result = if (sourceRule.getParamSize() > 1) {
- *       sourceRule.rule
- *   } else {
- *       result[sourceRule.rule]?.toString()   // 把 "$.name" / "@js:xxx" 当作字面量键名
- *   }
- *
- * 导致 `$.name`(Mode.Json)与 `@js:xxx`(Mode.Js)规则在 JS 对象条目上返回空字符串。
- * BookList.getSearchItem 会丢弃书名为空的条目 -> 搜索/发现结果为空 ->
- * 校验时 `BookSourceCheckRepository.checkSource` 判定 “搜索失效/发现失效” -> 书源被判为失效。
+ * 用例跑在 Robolectric 上:见 [setUp] 关于 `appCtx` 的说明。
  */
 @RunWith(RobolectricTestRunner::class)
-@Config(sdk = [34])
+@Config(application = Application::class, sdk = [34])
 class AnalyzeRuleFastPathReproTest {
 
+    /**
+     * 用 `Application::class` 而不是清单里的 `io.legado.app.App`:否则 Robolectric 会跑
+     * `App.onCreate`,其中 `LocalConfig` 的静态初始化读 `appCtx`,而 App Startup 的 provider
+     * 在 Robolectric 下不会执行 → `IllegalStateException: appCtx has not been initialized!`,
+     * 测试根本到不了断言。
+     */
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+    }
+
     /** 模拟 `ruleBookList: @js:xxx` 返回的 JS 对象数组中的单个条目(NativeObject) */
     private fun jsObjectItem(): Any {
         return RhinoScriptEngine.eval(

+ 12 - 3
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -218,16 +218,25 @@ class ReadBookDomainSplitBoundaryTest {
      * 短路判定(含注释)。逐行都摘不掉:关闭朗读的决策点就在 VM 的 `closeReadBook` 路径上,
      * 设置读取与 delegate 转发分别在 `ReadAloudSettingsRepository` 与 `ReadAloudDelegate`,
      * VM 只剩这两处接线。
+     *
+     * 2743 → 2746:上一条的 2743 校准对应的是该特性的**中间态**;最终合并的 `6d23ad6ec2`
+     * (朗读定时改为「时间 / 章节」两种模式 + 退出阅读继续后台朗读)把 VM 定在 2746 行,
+     * 本次按实际接线校准,不新增实现空间。可提取的逻辑都已在 `ReadAloudDelegate` /
+     * `ReadAloudSettingsRepository`;2746 行里属于本特性的是:`ReadAloudTimerMode` 的 import、
+     * `SetReadAloudTimerMode` / `SetReadAloudTimerChapters` / `SetReadAloudKeepOnExit`
+     * 三个意图分支(各 1–2 行转发)、`SetFinishCurrentChapterAfterTimer` 因参数超长折行多出的
+     * 2 行(纯格式化),以及 `stopReadAloudForClose()` 里读 `keepReadAloudOnExit` 决定是否
+     * 继续后台朗读的短路(含注释)——关闭决策点只能在 VM,摘不成 delegate。
      */
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2743 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2746 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2743。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2746。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2743,
+            lineCount <= 2746,
         )
     }
 

+ 7 - 7
docs/dev/track-e-reader-settings-udf-plan.md

@@ -20,8 +20,8 @@
 
 | 底座 | 内容 | 存储 | 是否响应式 |
 |---|---|---|---|
-| **A. `ReadSettings`** | 101 个字段(手势/亮度/菜单外观/键位/朗读…) | DataStore | ✅ `preferencesFlow` → 真 `StateFlow`([ReadSettingsRepository.kt:29-33](../../app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt#L29)) |
-| **B. `ReadBookConfig.Config`** | 排版预设(字号/行距/标题/页眉页脚/下划线/背景…),即 `readConfig.json` | JSON 文件 + 内存 `ArrayList<Config>` | ❌ **可变全局单例,无 flow**([ReadBookConfig.kt:31,77,103](../../app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt#L31)) |
+| **A. `ReadSettings`** | 112 个字段(手势/亮度/菜单外观/键位/朗读…) | DataStore | ✅ `preferencesFlow` → 真 `StateFlow`(`ReadSettingsRepository.kt:29-33`) |
+| **B. `ReadBookConfig.Config`** | 排版预设(字号/行距/标题/页眉页脚/下划线/背景…),即 `readConfig.json` | JSON 文件 + 内存 `ArrayList<Config>` | ❌ **可变全局单例,无 flow**(`ReadBookConfig.kt:31,77,103`) |
 
 `ReadBookConfig` 目前是**混合门面**:A 类字段是 `get() = readSettings.x` 只读转发(:182-243,干净),
 B 类字段是 `var x get/set config.x`(:310+,可变)。同一个对象两种语义,是"乱"的第一层来源。
@@ -29,10 +29,10 @@ B 类字段是 `var x get/set config.x`(:310+,可变)。同一个对象两
 ### 1.2 底座 B 无 flow ⇒ UDF 是手工模拟的
 
 `ReadStyleGateway.state` 只暴露 `items / selectedIndex / shareLayout` 三项
-([ReadStyleState.kt](../../app/src/main/java/io/legado/app/domain/model/settings/ReadStyleState.kt)),
+(`ReadStyleState.kt`),
 **不含任何排版字段**。于是 VM 只能在每次写入后**手动重建**两份快照:
 
-- `buildStyleConfig()`([ReadBookViewModel.kt:2290](../../app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt#L2290))—— 从可变全局裸读 ~25 字段
+- `buildStyleConfig()`(`ReadBookViewModel.kt:2290`)—— 从可变全局裸读 ~25 字段
 - `buildSheetConfig()`(:2322)—— 从可变全局裸读 ~50 字段
 
 **失败模式是结构性的**:任何新增的写入路径只要忘了重建,弹层就显示旧值。2026-07-24 的
@@ -42,7 +42,7 @@ B 类字段是 `var x get/set config.x`(:310+,可变)。同一个对象两
 ### 1.3 渲染副作用靠 157 条手写映射表
 
 `ConfigUpdate` 有 **157 个成员**,每个手写 `actions: Set<ConfigUpdateAction>`(13 种动作)
-([ReadBookContract.kt:1006-1558](../../app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt#L1006))。
+(`ReadBookContract.kt:1006-1558`)。
 
 - 手写 ⇒ 错配即「改了不生效」。已确认两例并已修:`StatusIconDark`(缺 `UpdateSystemUi`)、
   `UnderlineColor`(缺 `UpdateContent+InvalidateTextPage+SubmitRenderTask`)。
@@ -65,7 +65,7 @@ B 类字段是 `var x get/set config.x`(:310+,可变)。同一个对象两
 ### 1.5 EventBus 整数码仍在
 
 `postEvent(EventBus.UP_CONFIG, arrayListOf(整数码))` —— 8 个生产者,VM 在
-[:2084](../../app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt#L2084) 把整数翻回
+`ReadBookViewModel.kt:2084` 把整数翻回
 `ConfigUpdateAction`。生产者:`ApplyReadSettingUseCase`×2、`ChapterProvider:1111`、`ReadBook:304`、
 `ThemeConfigStore`×2、`ReadAloudPlayerCoordinator:146`。
 
@@ -73,7 +73,7 @@ B 类字段是 `var x get/set config.x`(:310+,可变)。同一个对象两
 
 排版写入**已经**收敛到 `ReadStyleGateway.updateCurrentStyle(ReadStyleMutation)` 的类型化键
 (`ReadStyleIntKey`/`FloatKey`/`BooleanKey`/`StringKey`/`ColorKey`,
-[ReadStyleMutation.kt](../../app/src/main/java/io/legado/app/domain/gateway/ReadStyleMutation.kt)),
+`ReadStyleMutation.kt`),
 全库对 `ReadBookConfig.x = v` 的直接写入**只剩** `ReadBookStyleConfigRepository` 一个文件。
 **Track E 不重做写入面**,只补"写完之后怎么让所有人看到新值"。