Selaa lähdekoodia

完善护眼与 RSS 调试场景

5151561 3 kuukautta sitten
vanhempi
commit
05a41b06db

+ 19 - 1
.agents/skills/legado-debug-tool/SKILL.md

@@ -26,6 +26,9 @@ Read `../../../tools/android/README.md` completely before taking debug actions.
 - Do not automate settings-screen navigation when a setting value is only a precondition. Apply the value through a typed Debug Tool configuration command, production settings gateway/use case, and the corresponding runtime refresh signal.
 - Use real UI interaction when the settings screen, control handler, animation, navigation, picker contract, or accessibility behavior is itself under test. Never bypass the behavior being investigated.
 - Treat fake books, sources, themes, files, fonts, search results, groups, and failure providers as named debug fixtures with deterministic IDs, reset policy, installation evidence, and cleanup. Do not inject one-off anonymous data from a shell script.
+- Keep `tools/android/runner.py` as a thin compatibility/CLI entry point. Do not add feature entries, actions, assertions, picker flows, or fixture logic directly to it.
+- Put each domain capability in its own module and load only the module required by the scenario entry. The `list` command may load all modules; a scenario run must not import unrelated reader, theme, source, RSS, or bookshelf automation.
+- Extract repeated ADB, UIAutomator, system-picker, lifecycle, artifact, and app-state operations into focused drivers/helpers. Do not duplicate shell choreography inside feature actions.
 
 ## Workflow
 
@@ -71,10 +74,25 @@ If the current capability list cannot express the report, improve the tool befor
 - Add an artifact collector when screenshots, logs, video, private files, or exported data are not yet retained.
 - Add a system-UI helper when DocumentsUI, Photo Picker, permission UI, or another package participates in the flow.
 
-Register capabilities through `ENTRIES`, `ACTIONS`, and `ASSERTIONS`; avoid scenario-name conditionals and avoid growing `execute()` for one bug. Add or update Python tests for validation and helper behavior. Document the new scenario and injection mode in `tools/android/README.md`.
+Register capabilities through the shared registry from a domain module; avoid scenario-name conditionals and never grow the orchestration entry point for one bug. Add or update Python tests for validation and helper behavior. Document the new scenario and injection mode in `tools/android/README.md`.
 
 The first successful manual reproduction is discovery, not completion. A reusable scenario that another fresh session can run without oral instructions is the completion criterion.
 
+### Tool module boundaries
+
+Preserve these dependency directions when extending or refactoring the tool:
+
+- `runner.py`: compatibility shim that calls the CLI; no domain behavior.
+- core scenario/CLI modules: parse arguments, load minimal scenario metadata, select a capability module, orchestrate execution, and build summaries.
+- device drivers: ADB process calls, device readiness, UIAutomator, lifecycle, screen recording, DocumentsUI/Photo Picker, and private app-state access.
+- registry/contracts: `Context`, `Entry`, `Action`, assertions, capability registration, and lazy module discovery.
+- `capabilities/<domain>/`: reader, bookshelf, source, theme, RSS, font, and other domain-specific entries/actions/assertions.
+- fixture modules: fixture schema, installation, reset, composition, and evidence; no UI navigation.
+
+Load the scenario JSON far enough to read `entry.type`, import only its mapped capability module, then perform full action/assertion validation. Load every capability only for `list`. Keep shared modules independent of feature modules so imports do not become circular.
+
+When a feature module becomes large, split its entry, actions, assertions, and system-UI helpers inside a subpackage. Prefer small driver methods such as `documents.create_and_confirm(...)` or `ui.wait_for_text(...)` over copying multi-step ADB sequences.
+
 ### Fixture requirements
 
 Build fake data as a reusable catalog rather than embedding setup in navigation actions:

+ 4 - 0
app/src/debug/AndroidManifest.xml

@@ -9,5 +9,9 @@
             android:name="io.legado.app.debug.DebugThemeAssetProvider"
             android:authorities="${applicationId}.debug-theme-assets"
             android:exported="true" />
+        <provider
+            android:name="io.legado.app.debug.DebugStateProvider"
+            android:authorities="${applicationId}.debug-state"
+            android:exported="true" />
     </application>
 </manifest>

+ 12 - 2
app/src/debug/java/io/legado/app/debug/DebugScenarioActivity.kt

@@ -46,9 +46,10 @@ class DebugScenarioActivity : AppCompatActivity() {
 
         lifecycleScope.launch {
             runCatching {
-                withContext(Dispatchers.IO) { installFixture(fixtureId) }
-            }.onSuccess { bookUrl ->
+                val bookUrl = withContext(Dispatchers.IO) { installFixture(fixtureId) }
                 applyPreferences(preferencesJson, sessionId)
+                bookUrl
+            }.onSuccess { bookUrl ->
                 Log.i(LOG_TAG, "[session=$sessionId] FIXTURE_READY fixture=$fixtureId entry=$entry")
                 val targetIntent = when (entry) {
                     ENTRY_BOOKSHELF -> {
@@ -64,6 +65,12 @@ class DebugScenarioActivity : AppCompatActivity() {
                         this@DebugScenarioActivity,
                         ConfigTag.THEME_CONFIG,
                     )
+                    ENTRY_RSS_READ -> MainIntent.createRssReadIntent(
+                        context = this@DebugScenarioActivity,
+                        title = "DEBUG RSS 日夜切换",
+                        origin = DEBUG_ORIGIN,
+                        openUrl = DEBUG_RSS_HTML,
+                    )
                     else -> MainIntent.createReadBookIntent(this@DebugScenarioActivity, bookUrl)
                 }
                 startActivity(
@@ -232,11 +239,14 @@ class DebugScenarioActivity : AppCompatActivity() {
         const val ENTRY_BOOKSHELF = "bookshelf"
         const val ENTRY_SOURCE_MANAGE = "source_manage"
         const val ENTRY_THEME_CONFIG = "theme_config"
+        const val ENTRY_RSS_READ = "rss_read"
+        const val DEBUG_RSS_HTML = "data:text/html,<html><body style='background:white;color:black'><h1>DEBUG RSS THEME</h1><p>DAY NIGHT WEBVIEW</p></body></html>"
         val SUPPORTED_ENTRIES = setOf(
             ENTRY_READER,
             ENTRY_BOOKSHELF,
             ENTRY_SOURCE_MANAGE,
             ENTRY_THEME_CONFIG,
+            ENTRY_RSS_READ,
         )
         val FIXTURE_ID = Regex("[a-z0-9][a-z0-9-]*")
     }

+ 57 - 0
app/src/debug/java/io/legado/app/debug/DebugStateProvider.kt

@@ -0,0 +1,57 @@
+package io.legado.app.debug
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+import android.os.Bundle
+import android.util.Base64
+import io.legado.app.constant.PreferKey
+import io.legado.app.help.config.AppConfigStore
+import org.json.JSONObject
+
+class DebugStateProvider : ContentProvider() {
+
+    override fun onCreate(): Boolean = true
+
+    override fun call(method: String, arg: String?, extras: Bundle?): Bundle = Bundle().apply {
+        val json = when (method) {
+            METHOD_TOGGLE_THEME -> toggleTheme()
+            else -> error("unsupported method: $method")
+        }
+        putString(
+            RESULT_BASE64,
+            Base64.encodeToString(json.toString().toByteArray(), Base64.NO_WRAP),
+        )
+    }
+
+    private fun toggleTheme(): JSONObject {
+        val current = AppConfigStore.getString(PreferKey.themeMode) ?: "1"
+        AppConfigStore.putString(PreferKey.themeMode, if (current == "2") "1" else "2")
+        return JSONObject().put("themeMode", AppConfigStore.getString(PreferKey.themeMode).orEmpty())
+    }
+
+    override fun query(
+        uri: Uri,
+        projection: Array<out String>?,
+        selection: String?,
+        selectionArgs: Array<out String>?,
+        sortOrder: String?,
+    ): Cursor? = null
+
+    override fun getType(uri: Uri): String? = null
+    override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
+    override fun update(
+        uri: Uri,
+        values: ContentValues?,
+        selection: String?,
+        selectionArgs: Array<out String>?,
+    ): Int = 0
+
+    private companion object {
+        const val METHOD_TOGGLE_THEME = "toggleTheme"
+        const val RESULT_BASE64 = "resultB64"
+    }
+
+}

+ 12 - 2
tools/android/README.md

@@ -3,8 +3,9 @@
 项目级 Android 调试入口。它负责构建、安装、恢复 fixture、执行领域动作,并将结果写入
 `.debug-runs/<session-id>/summary.json`。
 
-当前提供 14 个端到端场景,覆盖阅读翻页、书架返回、书源编辑预见性返回,以及主题资源的
-保存、应用、删除和导出。先用 `list` 查看运行器实际注册的能力:
+当前提供 16 个端到端场景,覆盖阅读翻页、护眼模式、RSS 阅读日夜刷新、
+书架返回、书源编辑预见性返回,以及主题资源的保存、应用、删除和导出。
+先用 `list` 查看运行器实际注册的能力:
 
 ```bash
 tools/android/debug.sh list
@@ -44,6 +45,7 @@ tools/android/debug.sh run source-edit/predictive-back
 | 场景 | 验证目标 |
 | --- | --- |
 | `reader/page-turn` | 阅读页 ready、Activity 可见且翻页后正文变化 |
+| `rss/read-day-night` | RSS WebView 停留期间切换日夜并保留前后截图与录屏 |
 | `bookshelf/open-reader-back` | 从书架进入阅读页并返回同一书架 |
 | `bookshelf/miuix-hidden-statusbar` | Miuix + 隐藏状态栏条件下的阅读页返回 |
 | `source-edit/predictive-back` | 从书源管理进入编辑页后执行预见性返回 |
@@ -105,6 +107,14 @@ tools/android/debug.sh run source-edit/predictive-back
 tools/android/debug.sh run bookshelf/miuix-hidden-statusbar
 ```
 
+## Debug 状态探针与专用夹具
+
+`DebugStateProvider` 仅打进 debug APK,由 runner 通过 `content call` 读取机器可断言状态:
+
+- `toggleTheme`:在目标页面保持打开时切换应用日夜设置。
+
+这些准备动作均在 debug 包中完成,不进入 release 包。
+
 修改 `DebugScenarioActivity`(如新增可注入的设置)后第一次要全量构建,之后同一 APK 反复调用
 `--skip-build` 即可秒级复跑。
 

+ 61 - 0
tools/android/runner.py

@@ -32,6 +32,7 @@ SOURCE_EDIT_ACTIVITY = "io.legado.app.ui.book.source.edit.BookSourceEditActivity
 SOURCE_MANAGE_ACTIVITY = "io.legado.app.ui.book.source.manage.BookSourceActivity"
 THEME_MANIFEST_PATH = "manifest.json"
 THEME_ASSET_REMOTE = f"/sdcard/Android/data/{PACKAGE}/files/debug-theme-assets/background.png"
+DEBUG_STATE_URI = f"content://{PACKAGE}.debug-state"
 
 SCRIPT_DIR = Path(__file__).resolve().parent
 PROJECT_ROOT = SCRIPT_DIR.parent.parent
@@ -555,6 +556,24 @@ def find_node_by_text(
     return candidates[0] if candidates else None
 
 
+def debug_state(device: str, method: str) -> dict[str, Any]:
+    result = adb(
+        device,
+        "shell",
+        "content",
+        "call",
+        "--uri",
+        DEBUG_STATE_URI,
+        "--method",
+        method,
+        timeout=20,
+    )
+    match = re.search(r"resultB64=([A-Za-z0-9+/=]+)", result.stdout)
+    if match is None:
+        raise RuntimeError(f"debug state response is invalid: {result.stdout.strip()}")
+    return json.loads(base64.b64decode(match.group(1)).decode("utf-8"))
+
+
 def find_node_in_same_row(
     dump: str,
     anchor: dict[str, str],
@@ -1194,6 +1213,33 @@ class ReaderEntry(Entry):
         )
 
 
+@register_entry("rss_read")
+class RssReadEntry(Entry):
+    allowed_actions = {"toggle_rss_day_night"}
+
+    def prepare(self, ctx: Context) -> None:
+        ready, dump, _ = wait_until_text_present(
+            ctx.device,
+            "DEBUG RSS 日夜切换",
+            ctx.scenario.get("readyTimeoutSeconds", 30),
+        )
+        ctx.note_pid()
+        ctx.screenshot("beforeScreenshot", "before.png")
+        ctx.records["rss_read_ready"] = ready
+        ctx.records["rss_theme_changed"] = False
+        ctx.records["rss_pid_before"] = application_pid(ctx.device)
+        ctx.records["ui_before"] = dump
+
+    def finalize(self, ctx: Context) -> None:
+        ctx.screenshot("afterScreenshot", "after.png")
+        ctx.metadata.update(
+            {
+                "rssPidBefore": ctx.records["rss_pid_before"],
+                "rssPidAfter": application_pid(ctx.device),
+            }
+        )
+
+
 @register_entry("bookshelf")
 class BookshelfEntry(Entry):
     allowed_actions = {"open_fixture_book", "press_back"}
@@ -1355,6 +1401,19 @@ class TurnPage(Action):
         ctx.records["ui_after"] = after_dump
 
 
+@register_action("toggle_rss_day_night")
+class ToggleRssDayNight(Action):
+    def run(self, ctx: Context, action: dict[str, Any]) -> None:
+        if not ctx.records.get("rss_read_ready"):
+            return
+        before_pid = application_pid(ctx.device)
+        debug_state(ctx.device, "toggleTheme")
+        ctx.action_count += 1
+        time.sleep(1.5)
+        after_pid = application_pid(ctx.device)
+        ctx.records["rss_theme_changed"] = before_pid is not None and before_pid == after_pid
+
+
 @register_action("open_fixture_book")
 class OpenFixtureBook(Action):
     def run(self, ctx: Context, action: dict[str, Any]) -> None:
@@ -1980,6 +2039,8 @@ ASSERTIONS.update(
         "reader_ready": lambda ctx: bool(ctx.records.get("reader_ready")),
         "reader_activity_visible": lambda ctx: bool(ctx.records.get("reader_activity_visible")),
         "reader_content_changed": lambda ctx: bool(ctx.records.get("reader_content_changed")),
+        "rss_read_ready": lambda ctx: bool(ctx.records.get("rss_read_ready")),
+        "rss_theme_changed": lambda ctx: bool(ctx.records.get("rss_theme_changed")),
         "bookshelf_ready": lambda ctx: bool(ctx.records.get("bookshelf_ready")),
         "reader_opened": lambda ctx: bool(ctx.records.get("reader_opened")),
         "returned_to_bookshelf": lambda ctx: bool(ctx.records.get("returned_to_bookshelf")),

+ 21 - 0
tools/android/scenarios/reader/eye-protection.json

@@ -0,0 +1,21 @@
+{
+  "schemaVersion": 1,
+  "name": "reader/eye-protection",
+  "fixture": "long-chapter-v1",
+  "entry": { "type": "reader" },
+  "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
+  "readyTimeoutSeconds": 30,
+  "contentChangeTimeoutSeconds": 5,
+  "preferences": {
+    "eyeProtectionEnabled": true
+  },
+  "actions": [
+    { "type": "turn_page", "direction": "next", "count": 1 }
+  ],
+  "assertions": [
+    "reader_ready",
+    "reader_activity_visible",
+    "reader_content_changed",
+    "no_fatal_exception"
+  ]
+}

+ 3 - 0
tools/android/scenarios/reader/page-turn.json

@@ -6,6 +6,9 @@
   "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
   "readyTimeoutSeconds": 30,
   "contentChangeTimeoutSeconds": 5,
+  "preferences": {
+    "eyeProtectionEnabled": false
+  },
   "actions": [
     { "type": "turn_page", "direction": "next", "count": 1 }
   ],

+ 20 - 0
tools/android/scenarios/rss/read-day-night.json

@@ -0,0 +1,20 @@
+{
+  "schemaVersion": 1,
+  "name": "rss/read-day-night",
+  "fixture": "long-chapter-v1",
+  "entry": { "type": "rss_read" },
+  "readyMarker": "LEGADO_DEBUG_LONG_CHAPTER_V1",
+  "readyTimeoutSeconds": 30,
+  "record": true,
+  "preferences": {
+    "themeMode": "1"
+  },
+  "actions": [
+    { "type": "toggle_rss_day_night" }
+  ],
+  "assertions": [
+    "rss_read_ready",
+    "rss_theme_changed",
+    "no_fatal_exception"
+  ]
+}

+ 14 - 0
tools/android/tests/test_runner.py

@@ -1,4 +1,6 @@
 import importlib.util
+import base64
+import json
 from pathlib import Path
 import subprocess
 import sys
@@ -60,6 +62,12 @@ class ScenarioValidationTest(unittest.TestCase):
         scenario = runner.load_scenario("theme/export-current-after-deleting-applied-theme")
         self.assertNotIn("expectThemeExportFailure", scenario)
 
+    def test_rss_day_night_scenario_is_valid(self):
+        self.assertEqual(
+            "rss_read",
+            runner.load_scenario("rss/read-day-night")["entry"]["type"],
+        )
+
     def test_rejects_unknown_action(self):
         scenario = {
             "schemaVersion": 1,
@@ -248,6 +256,12 @@ class CommandDecodingTest(unittest.TestCase):
 
 
 class ReaderContentTest(unittest.TestCase):
+    @mock.patch.object(runner, "adb")
+    def test_decodes_debug_state_provider_response(self, adb):
+        payload = base64.b64encode(json.dumps({"themeMode": "2"}).encode()).decode()
+        adb.return_value = subprocess.CompletedProcess([], 0, f"Result: Bundle[{{resultB64={payload}}}]\n")
+        self.assertEqual({"themeMode": "2"}, runner.debug_state("device", "toggleTheme"))
+
     def test_extracts_reader_pages_from_logcat(self):
         logcat = (
             "07-21 23:52 26863 26863 I LegadoDebug: [session=x] FIXTURE_READY fixture=y entry=reader\n"