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

test(Track E · E0): 阅读设置的三条防回归不变式

产品代码零改动。三个测试类 / 8 条断言,沿用本仓既有的双向棘轮惯例
(新增违规报红 / 基线修好了不下调也报红)。

- ConfigUpdateActionsInvariantTest:反射遍历并实例化 ConfigUpdate 全部 157 个
  成员,断言 actions 非空或在 NO_RENDER_EFFECT 白名单(52 条,E1 后删除);
  反向断言白名单无失效条目。封死 UnderlineColor/StatusIconDark 那类错配复发。
- ReaderConfigSnapshotInvariantTest:反射取 ReadSheetConfigUiState(50 字段) /
  ReadBookStyleConfig(25 字段) 的构造参数名,源码扫描 buildSheetConfig /
  buildStyleConfig 的具名实参,断言无遗漏。E2 改成纯函数后应改写为实例断言。
- ReadSettingsGatewayCoverageTest:行为性判定——逐字段变异 ReadSettings 观察
  toGatewayPrefMap 输出是否随之变化,把「走不通 update{} 的 56 个字段」冻成基线。

变异验证:四条断言均实测可红(白名单少一条/多一条、给两个 state 类各加一个
未接线字段),报错直接给出字段名与修法。全量单测 285 绿。

同时更正计划书 §1.7:45/101 不是现存 bug 而是 ReadSettingsGateway.update 的
KDoc 明确记录的设计(其余字段走遗留 setter),4 个 update{} 调用点写的都是
已在 map 内的字段;真实风险是 latent,故用基线棘轮而非凑满 101。
5151561 3 месяцев назад
Родитель
Сommit
b40a7fb100

+ 156 - 0
app/src/test/java/io/legado/app/data/repository/ReadSettingsGatewayCoverageTest.kt

@@ -0,0 +1,156 @@
+package io.legado.app.data.repository
+
+import io.legado.app.domain.model.settings.ReadSettings
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import kotlin.reflect.full.memberProperties
+import kotlin.reflect.full.primaryConstructor
+
+/**
+ * Track E · E0 —— `ReadSettingsGateway.update {}` 的持久化覆盖面不变式。
+ *
+ * `ReadSettings` 是 101 字段的**读模型超集**,而 `update {}` 只落盘
+ * `toGatewayPrefMap()` 声明的那 45 个键——这是 `ReadSettingsGateway.update` 的 KDoc
+ * 明确记录的设计:其余字段须走各自的遗留 setter 写入。
+ *
+ * 所以本测试**不要求** 101 == 45(那会推翻既定设计),而是把「哪些字段走不通 `update {}`」
+ * 冻结成基线,形成双向棘轮:
+ * - 新增字段忘了接线 ⇒ 基线变大 ⇒ 红。作者必须显式选择「进 map」还是「加进基线并走 setter」。
+ * - 字段补进了 map 却忘了从基线移除 ⇒ 红。
+ *
+ * 判定方式是行为性的:改一个字段的值,看 `toGatewayPrefMap()` 的输出是否随之变化。
+ */
+class ReadSettingsGatewayCoverageTest {
+
+    @Test
+    fun `update 写不进去的 ReadSettings 字段集合与基线一致`() {
+        val actual = fieldsNotPersistedByUpdate()
+
+        val newlyBroken = (actual - UNPERSISTED_BASELINE).sorted()
+        assertTrue(
+            "以下 ReadSettings 字段无法通过 ReadSettingsGateway.update {} 落盘——" +
+                "在 update {} 里 copy 它们会被静默丢弃:\n" +
+                newlyBroken.joinToString("\n") { "  - $it" } +
+                "\n\n请把它加进 ReadSettingsRepository.toGatewayPrefMap();" +
+                "若确实只走遗留 setter 写入,则加进本测试的 UNPERSISTED_BASELINE。",
+            newlyBroken.isEmpty(),
+        )
+
+        val fixed = (UNPERSISTED_BASELINE - actual).sorted()
+        assertTrue(
+            "以下字段已经能通过 update {} 落盘,请从 UNPERSISTED_BASELINE 移除(基线只能下调):\n" +
+                fixed.joinToString("\n") { "  - $it" },
+            fixed.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `反射确实枚举到了 ReadSettings 的字段`() {
+        val count = ReadSettings::class.primaryConstructor?.parameters?.size ?: 0
+        assertTrue("ReadSettings 只枚举到 $count 个字段,反射可能失效", count > 90)
+    }
+
+    @Test
+    fun `toGatewayPrefMap 的键没有重复`() {
+        val constructor = requireNotNull(ReadSettings::class.primaryConstructor)
+        assertEquals(
+            "toGatewayPrefMap 出现重复的 PreferKey,会让某个字段被另一个覆盖",
+            ReadSettings().toGatewayPrefMap().size,
+            ReadSettings().toGatewayPrefMap().keys.size,
+        )
+        // 顺带确保 map 不是空的(避免下面的行为判定整体假阳)
+        assertTrue(constructor.parameters.isNotEmpty())
+    }
+
+    private fun fieldsNotPersistedByUpdate(): Set<String> {
+        val constructor = requireNotNull(ReadSettings::class.primaryConstructor)
+        val properties = ReadSettings::class.memberProperties.associateBy { it.name }
+        val defaults = ReadSettings()
+        val defaultMap = defaults.toGatewayPrefMap()
+
+        return constructor.parameters.mapNotNullTo(mutableSetOf()) { parameter ->
+            val name = parameter.name ?: return@mapNotNullTo null
+            val property = properties[name] ?: return@mapNotNullTo null
+            val mutated = mutate(name, property.get(defaults))
+            val instance = constructor.callBy(mapOf(parameter to mutated))
+            name.takeIf { instance.toGatewayPrefMap() == defaultMap }
+        }
+    }
+
+    private fun mutate(name: String, value: Any?): Any = when (value) {
+        is Boolean -> !value
+        is Int -> value + 1
+        is Long -> value + 1L
+        is Float -> value + 1f
+        is Double -> value + 1.0
+        is String -> value + "_probe"
+        else -> error("ReadSettings.$name 是未支持的类型 ${value?.let { it::class }},请在本测试补充变异规则")
+    }
+
+    private companion object {
+        /**
+         * 只能通过遗留 setter 写入、走不通 `update {}` 的字段。基线只允许下调。
+         * 主体是阅读菜单/标题栏外观一族(它们在 `ConfigUpdate` 侧也是同一个「无渲染副作用」
+         * 集群,见 `ConfigUpdateActionsInvariantTest`)。
+         */
+        val UNPERSISTED_BASELINE = setOf(
+            "menuAlpha",
+            "expandTextMenu",
+            "showSelectMenuIcon",
+            "autoReadSpeed",
+            "tocUiUseReplace",
+            "tocCountWords",
+            "readStyleSelect",
+            "comicStyleSelect",
+            "shareLayout",
+            "readBarStyleFollowPage",
+            "readBarStyle",
+            "clickActionTL",
+            "clickActionTC",
+            "clickActionTR",
+            "clickActionML",
+            "clickActionMC",
+            "clickActionMR",
+            "clickActionBL",
+            "clickActionBC",
+            "clickActionBR",
+            "readMenuBgColor",
+            "readMenuAccentColor",
+            "readMenuContainerColor",
+            "readMenuBgColorNight",
+            "readMenuAccentColorNight",
+            "readMenuContainerColorNight",
+            "readMenuTextColor",
+            "readMenuTextColorNight",
+            "readMenuColorMode",
+            "readMenuIconShowText",
+            "readMenuIconStyle",
+            "titleBarIconStyle",
+            "readMenuIconItemsPerRow",
+            "readMenuIconRowCount",
+            "readMenuBottomCornerRadius",
+            "readMenuFloatingBottomBar",
+            "readMenuTopBarBlurMode",
+            "readMenuBottomBarBlurMode",
+            "readMenuTopBarLiquidGlassButtons",
+            "readMenuTopBarTitleCapsule",
+            "readMenuBottomBarLiquidGlassButtons",
+            "readMenuTopBarBlurStyle",
+            "readMenuBottomBarBlurStyle",
+            "readMenuBlurRadius",
+            "readMenuBlurColor",
+            "readMenuBlurColorNight",
+            "readMenuPaletteStyle",
+            "readMenuLensRadius",
+            "readMenuBorderWidth",
+            "readMenuBorderColor",
+            "readMenuBorderColorNight",
+            "readMenuCustomIcons",
+            "titleBarCustomIcons",
+            "titleBarIconPosition",
+            "showTitleBarIcons",
+            "chineseConverterType",
+        )
+    }
+}

+ 147 - 0
app/src/test/java/io/legado/app/ui/book/read/ConfigUpdateActionsInvariantTest.kt

@@ -0,0 +1,147 @@
+package io.legado.app.ui.book.read
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import kotlin.reflect.KClass
+import kotlin.reflect.full.primaryConstructor
+
+/**
+ * Track E · E0 —— `ConfigUpdate` 渲染副作用的完备性不变式。
+ *
+ * `ConfigUpdate` 的每个成员都手写一组 [ConfigUpdateAction],描述改这项设置后要驱动哪些渲染
+ * 副作用。手写表的失败模式是「漏填/填错 ⇒ 改了不生效」——已经出过两例(`StatusIconDark`
+ * 缺 `UpdateSystemUi`、`UnderlineColor` 缺 `UpdateContent+InvalidateTextPage+SubmitRenderTask`)。
+ * 本测试把「漏填」从代码评审兜底降级为测试期兜底。
+ *
+ * [NO_RENDER_EFFECT] 是**临时**白名单:这些成员写 DataStore、由 `readPreferences` StateFlow
+ * 反应式生效,不需要命令式渲染副作用。Track E · E1 会把它们迁到独立的 `ReadPreferenceUpdate`
+ * 族——那之后它们在类型上就没有 `actions` 概念,本白名单随之删除。
+ *
+ * 双向棘轮:新增成员漏填 actions 会红;白名单条目补上了 actions 却忘了从白名单移除,也会红。
+ */
+class ConfigUpdateActionsInvariantTest {
+
+    @Test
+    fun `每个 ConfigUpdate 成员都声明了渲染副作用,或在无副作用白名单里`() {
+        val offenders = emptyActionMembers()
+            .filterNot { it in NO_RENDER_EFFECT }
+            .sorted()
+
+        assertTrue(
+            "以下 ConfigUpdate 成员的 actions 为空集,改了不会驱动任何渲染副作用:\n" +
+                offenders.joinToString("\n") { "  - ConfigUpdate.$it" } +
+                "\n\n若它确实只写 DataStore、靠 readPreferences 反应式生效,请加入本测试的 " +
+                "NO_RENDER_EFFECT;否则请补上正确的 ConfigUpdateAction 集。",
+            offenders.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `无副作用白名单里没有失效条目`() {
+        val current = emptyActionMembers().toSet()
+        val stale = (NO_RENDER_EFFECT - current).sorted()
+
+        assertTrue(
+            "以下条目已不再是空 actions(或成员已删除),请从 NO_RENDER_EFFECT 移除:\n" +
+                stale.joinToString("\n") { "  - $it" },
+            stale.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `反射确实枚举到了 ConfigUpdate 的成员`() {
+        // 防止 sealedSubclasses 因运行环境退化成空集,让上面两条断言假绿。
+        val count = ConfigUpdate::class.sealedSubclasses.size
+        assertTrue("ConfigUpdate 只枚举到 $count 个成员,反射可能失效", count > 100)
+    }
+
+    private fun emptyActionMembers(): List<String> =
+        ConfigUpdate::class.sealedSubclasses.mapNotNull { type ->
+            type.simpleName?.takeIf { instantiate(type).actions.isEmpty() }
+        }
+
+    private fun instantiate(type: KClass<out ConfigUpdate>): ConfigUpdate {
+        type.objectInstance?.let { return it }
+        val constructor = type.primaryConstructor
+            ?: error("ConfigUpdate.${type.simpleName} 既不是 object 也没有主构造函数")
+        val args: List<Any> = constructor.parameters.map { parameter ->
+            when (parameter.type.classifier) {
+                Int::class -> 0
+                Long::class -> 0L
+                Float::class -> 0f
+                Double::class -> 0.0
+                Boolean::class -> false
+                String::class -> ""
+                else -> error(
+                    "ConfigUpdate.${type.simpleName} 的参数 ${parameter.name} 是未支持的类型 " +
+                        "${parameter.type},请在本测试补充对应的占位值"
+                )
+            }
+        }
+        return constructor.call(*args.toTypedArray())
+    }
+
+    private companion object {
+        /** 只写 DataStore、由 `readPreferences` StateFlow 反应式生效的成员。E1 后删除。 */
+        val NO_RENDER_EFFECT = setOf(
+            // 阅读菜单外观
+            "MenuTextColor",
+            "MenuTextColorNight",
+            "MenuIconShowText",
+            "MenuIconStyle",
+            "MenuIconItemsPerRow",
+            "MenuIconRowCount",
+            "MenuBottomCornerRadius",
+            "MenuBlurRadius",
+            "MenuBlurAlpha",
+            "MenuBlurColor",
+            "MenuBlurColorNight",
+            "MenuPaletteStyle",
+            "MenuLensRadius",
+            "MenuCustomIcon",
+            "MenuTopBarBlurMode",
+            "MenuTopBarBlurSelection",
+            "MenuTopBarLiquidGlassButtons",
+            "MenuTopBarTitleCapsule",
+            "MenuBottomBarBlurMode",
+            "MenuBottomBarBlurStyle",
+            "MenuBottomBarLiquidGlassButtons",
+            "FloatingBottomBar",
+            "ShowMenuIcon",
+            // 标题栏
+            "TitleBarIconStyle",
+            "TitleBarIconPosition",
+            "TitleBarCustomIcon",
+            "TitleBarCompact",
+            "TitleBarMode",
+            "ShowTitleBarIcons",
+            // 亮度 / 进度条
+            "ShowBrightnessView",
+            "BrightnessVwPos",
+            "BrightnessAuto",
+            "ReadSliderMode",
+            "ProgressBarBehavior",
+            // 手势 / 按键
+            "MouseWheelPage",
+            "VolumeKeyPage",
+            "VolumeKeyPageOnPlay",
+            "KeyPageOnLongPress",
+            "SliderVibrator",
+            "SelectVibrator",
+            "ClickImgWay",
+            "DisableReturnKey",
+            "NoAnimScrollPage",
+            // 文本选择菜单
+            "SelectText",
+            "ExpandTextMenu",
+            "ShowSelectMenuIcon",
+            // 其它纯业务/纯菜单项
+            "StyleName",
+            "DefaultSourceChangeAll",
+            "AutoChangeSource",
+            "AutoSuggestDayNight",
+            "ShowReadTitleAddition",
+            "AutoReadSpeed",
+        )
+    }
+}

+ 95 - 0
app/src/test/java/io/legado/app/ui/book/read/ReaderConfigSnapshotInvariantTest.kt

@@ -0,0 +1,95 @@
+package io.legado.app.ui.book.read
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+import kotlin.reflect.KClass
+import kotlin.reflect.full.primaryConstructor
+
+/**
+ * Track E · E0 —— 排版快照的完备性不变式。
+ *
+ * 排版底座 `ReadBookConfig.Config` 是可变全局、无 flow,所以 `ReadBookViewModel` 只能在每次
+ * 写入后手工重建 [ReadBookStyleConfig] / [ReadSheetConfigUiState] 两份快照。失败模式是
+ * 「给 UiState 加了字段,却忘了在 build 函数里赋值」⇒ 该项永远是默认值,弹层显示旧值。
+ *
+ * 两个 build 函数是 `ReadBookViewModel` 的 private 成员、且 VM 需要大量 Koin 依赖才能构造,
+ * 因此这里做源码扫描而非实例断言。Track E · E2 把它们改成 `ReadStyleSnapshot` 上的纯函数
+ * 之后,本测试应改写为真正的实例断言(构造快照 → 派生 → 逐字段比对)。
+ */
+class ReaderConfigSnapshotInvariantTest {
+
+    @Test
+    fun `buildSheetConfig 覆盖 ReadSheetConfigUiState 的每个字段`() {
+        assertAllFieldsAssigned(
+            stateType = ReadSheetConfigUiState::class,
+            functionSignature = "private fun buildSheetConfig(): ReadSheetConfigUiState",
+        )
+    }
+
+    @Test
+    fun `buildStyleConfig 覆盖 ReadBookStyleConfig 的每个字段`() {
+        assertAllFieldsAssigned(
+            stateType = ReadBookStyleConfig::class,
+            functionSignature = "private fun buildStyleConfig(): ReadBookStyleConfig",
+        )
+    }
+
+    private fun assertAllFieldsAssigned(stateType: KClass<*>, functionSignature: String) {
+        val body = viewModelSource().functionBodyAfter(functionSignature)
+        val assigned = NAMED_ARGUMENT.findAll(body).map { it.groupValues[1] }.toSet()
+        val missing = stateType.primaryConstructor
+            ?.parameters
+            ?.mapNotNull { it.name }
+            ?.filterNot { it in assigned }
+            .orEmpty()
+
+        assertTrue(
+            "${stateType.simpleName} 的以下字段没有在 $functionSignature 里被赋值," +
+                "它们会永远保持默认值:\n" +
+                missing.joinToString("\n") { "  - $it" },
+            missing.isEmpty(),
+        )
+    }
+
+    /** 取签名之后第一个 `(` 起、括号配平为止的文本。 */
+    private fun String.functionBodyAfter(signature: String): String {
+        val signatureIndex = indexOf(signature)
+        require(signatureIndex >= 0) {
+            "在 ReadBookViewModel.kt 里找不到 `$signature`——函数被改名或改签名了,请同步本测试"
+        }
+        val open = indexOf('(', signatureIndex + signature.length)
+        require(open >= 0) { "`$signature` 之后找不到参数列表起始括号" }
+        var depth = 0
+        for (index in open until length) {
+            when (this[index]) {
+                '(' -> depth++
+                ')' -> {
+                    depth--
+                    if (depth == 0) return substring(open + 1, index)
+                }
+            }
+        }
+        error("`$signature` 的括号未配平")
+    }
+
+    private fun viewModelSource(): String =
+        mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
+
+    private companion object {
+        /** 只认行首缩进后的具名实参,避免把嵌套调用里的 `a = b` 也算进来。 */
+        val NAMED_ARGUMENT = Regex("""^\s*([A-Za-z][A-Za-z0-9_]*)\s*=[^=]""", RegexOption.MULTILINE)
+
+        fun mainSourceFile(relativePath: String): File {
+            var directory: File? = File("").absoluteFile
+            while (directory != null) {
+                for (prefix in listOf("src/main/java", "app/src/main/java")) {
+                    val candidate = File(directory, "$prefix/$relativePath")
+                    if (candidate.isFile) return candidate
+                }
+                directory = directory.parentFile
+            }
+            error("从 ${File("").absolutePath} 向上找不到 $relativePath")
+        }
+    }
+}

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

@@ -74,8 +74,13 @@ B 类字段是 `var x get/set config.x`(:310+,可变)。同一个对象两
 
 ### 1.7 附带确认的两个缺陷
 
-- `ReadSettingsRepository.update{}` 的 `toGatewayPrefMap` 只覆盖 **45/101** 字段(:550-596)
-  ⇒ 通用 `update{}` 路径对其余 56 个字段**静默丢写**。(另有 91 个专用 setter 是安全路径。)
+- `ReadSettingsRepository.update{}` 的 `toGatewayPrefMap` 只覆盖 **45/101** 字段(:550-596)。
+  > **更正(E0 落地时核实)**:这**不是现存 bug**,是有意设计——`ReadSettingsGateway.update`
+  > 的 KDoc 明确写了「仅持久化 gateway 实现映射声明的 45 个配置键,其余字段仍须通过对应的
+  > 遗留 Repository setter 写入」。全库只有 4 个 `update{}` 调用点(ThemeConfigViewModel:141、
+  > ReadBookViewModel:265/5237、MangaMenu:284),写的都是**已在 map 内**的字段。
+  > 真实风险是**latent**:将来有人对那 56 个字段之一 `update{ copy(x=…) }` 会被静默丢弃。
+  > 因此 E0 不去凑满 101,而是把「走不通 `update{}` 的字段集合」冻成基线做双向棘轮。
 - 排版引擎 `ChapterProvider`(62 处)/`PageView`(54)/`TextChapterLayout`(11)/`TextLine`(12)
   在排版与绘制时**直接裸读** `ReadBookConfig` ——渲染输入不是参数,是全局。
 
@@ -102,19 +107,30 @@ B 类字段是 `var x get/set config.x`(:310+,可变)。同一个对象两
 
 ## 3. 阶段划分
 
-### E0 —— 防回归地基(先做,成本最低,无行为改动)
+### E0 —— 防回归地基 ✅ 已落地(2026-07-25)
 
-纯新增测试,不改产品代码。
+纯新增测试,产品代码零改动。三个文件、8 条断言,全部采用本仓既有的**双向棘轮**惯例
+(新增违规报红 / 基线修好了不下调也报红)。
 
-1. **actions 完备性测试**:反射遍历 `ConfigUpdate` 全部 157 个成员,断言每个成员的 `actions`
-   要么非空,要么其类名在显式白名单里。新增成员忘了填 actions ⇒ 测试红。
-   > 这条直接封死 `UnderlineColor` 那类错配的复发。
-2. **快照完备性测试**:断言 `ReadSheetConfigUiState` / `ReadBookStyleConfig` 的每个字段都能在
-   `buildSheetConfig()` / `buildStyleConfig()` 里找到赋值(反射比对构造参数名)。
-3. **DataStore 覆盖率测试**:断言 `ReadSettings` 的字段数 == `toGatewayPrefMap` 的键数
-   (当前 101 vs 45,**测试会先红**)。
+1. **`ConfigUpdateActionsInvariantTest`**(`ui/book/read/`)—— 反射遍历 `ConfigUpdate`
+   全部 157 个成员并实例化,断言 `actions` 要么非空、要么类名在 `NO_RENDER_EFFECT`
+   白名单(52 条)里;反向断言白名单无失效条目;外加一条「反射确实枚举到成员」防假绿。
+   > 直接封死 `UnderlineColor` 那类错配的复发。白名单是**临时**的,E1 把这些成员迁到
+   > `ReadPreferenceUpdate` 后整体删除。
+2. **`ReaderConfigSnapshotInvariantTest`**(`ui/book/read/`)—— 反射取
+   `ReadSheetConfigUiState`(50 字段) / `ReadBookStyleConfig`(25 字段) 的构造参数名,
+   源码扫描 `buildSheetConfig()` / `buildStyleConfig()` 的具名实参,断言无遗漏。
+   > 用源码扫描是因为两个函数是 VM 的 private 成员、VM 需大量 Koin 依赖才能构造。
+   > **E2 之后应改写**:那时它们变成 `ReadStyleSnapshot` 上的纯函数,可以直接实例断言。
+3. **`ReadSettingsGatewayCoverageTest`**(`data/repository/`)—— **行为性**判定:逐字段
+   变异 `ReadSettings` 并观察 `toGatewayPrefMap()` 输出是否随之变化,把「走不通
+   `update{}` 的 56 个字段」冻成基线。**不**断言 101 == 45(那会推翻 §1.7 的既定设计)。
 
-**验收**:三条测试进 CI;第 3 条允许先以 `@Ignore` + issue 落地,在 E1 修复后解禁。
+**变异验证**(每条都实测过能变红,非空跑):白名单少一条 → 测试 1 红;白名单多一条 →
+测试 2 红;给 `ReadSheetConfigUiState` 加字段不赋值 → 测试 3 红;给 `ReadSettings`
+加字段不接线 → 测试 4 红。四条报错都直接给出字段名与修法。
+
+**现状**:全量单测 285 条绿(E0 前 277)。
 
 ---
 
@@ -244,8 +260,8 @@ tryEmit 在无订阅者时会丢渲染 effect)。
 
 ```
 E0 ──► E1 ──► E2 ──┬──► E3
- (测试)  (类型)  (核心)  └──► E4
-                          
+ ✅已落地 (类型)  (核心)  └──► E4
+
                     E5 ← 并入 Track D2 评估(不单独做)
 ```
 
@@ -262,7 +278,7 @@ E0/E1 是为了让 E2 之后不再退化。
 
 | 阶段 | 可机器验证的判据 |
 |---|---|
-| E0 | 3 条不变式测试进 CI |
+| E0 | ✅ 3 个测试类 / 8 条断言进 CI,四条变异实测可红 |
 | E1 | `grep 'actions = emptySet()'` == 0;漏填 actions 编译不过 |
 | E2 | VM 中 `ReadBookConfig.` 直读 93 → ≤10;`handleConfigUpdate` 无手动 `styleConfig` 重建;`tryEmit` 归零 |
 | E3 | `sheet/` 下 `ReadBookConfig.` 直读 == 0 |
@@ -286,4 +302,4 @@ E0/E1 是为了让 E2 之后不再退化。
 
 ---
 
-*创建于 2026-07-25。基线数据(行号/计数)截至同日 HEAD。*
+*创建于 2026-07-25,同日完成 E0。基线数据(行号/计数)截至同日 HEAD。*