PyClickerRPA 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/PKG-INFO +27 -2
  2. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/PyClickerRPA.egg-info/PKG-INFO +27 -2
  3. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/PyClickerRPA.egg-info/SOURCES.txt +6 -0
  4. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/PyClickerRPA.egg-info/entry_points.txt +1 -0
  5. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/README.md +25 -0
  6. pyclickerrpa-0.5.0/autoclicker/diagnose.py +179 -0
  7. pyclickerrpa-0.5.0/autoclicker/gui/lint_dialog.py +53 -0
  8. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/main_window.py +112 -2
  9. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/qt_runner.py +6 -1
  10. pyclickerrpa-0.5.0/autoclicker/lint.py +163 -0
  11. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/visual.py +11 -4
  12. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/pyproject.toml +3 -2
  13. pyclickerrpa-0.5.0/tests/test_diagnose.py +198 -0
  14. pyclickerrpa-0.5.0/tests/test_diagnose_gui.py +97 -0
  15. pyclickerrpa-0.5.0/tests/test_lint.py +155 -0
  16. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/LICENSE +0 -0
  17. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
  18. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/PyClickerRPA.egg-info/requires.txt +0 -0
  19. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/PyClickerRPA.egg-info/top_level.txt +0 -0
  20. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/__init__.py +0 -0
  21. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/adb.py +0 -0
  22. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/app.py +0 -0
  23. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/capture.py +0 -0
  24. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/clicker.py +0 -0
  25. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/dpi.py +0 -0
  26. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/expr.py +0 -0
  27. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/__init__.py +0 -0
  28. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/flow_connection.py +0 -0
  29. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/flow_editor.py +0 -0
  30. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/flow_node.py +0 -0
  31. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/hotkeys.py +0 -0
  32. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/monitor.py +0 -0
  33. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/preview_label.py +0 -0
  34. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/recorder.py +0 -0
  35. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/scheduler_widget.py +0 -0
  36. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/snippet_library.py +0 -0
  37. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/task_widget.py +0 -0
  38. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/gui/testkit_widget.py +0 -0
  39. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/matcher.py +0 -0
  40. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/models.py +0 -0
  41. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/ocr.py +0 -0
  42. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/run.py +0 -0
  43. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/runner.py +0 -0
  44. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/templatize.py +0 -0
  45. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/__init__.py +0 -0
  46. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/__main__.py +0 -0
  47. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/assertions.py +0 -0
  48. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/case.py +0 -0
  49. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/loader.py +0 -0
  50. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/pytest_plugin.py +0 -0
  51. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/report.py +0 -0
  52. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/testkit/runner.py +0 -0
  53. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/__init__.py +0 -0
  54. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/__main__.py +0 -0
  55. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/run_manager.py +0 -0
  56. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/scheduler.py +0 -0
  57. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/serialize.py +0 -0
  58. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/server.py +0 -0
  59. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/static/app.js +0 -0
  60. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/static/index.html +0 -0
  61. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/web/static/style.css +0 -0
  62. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/autoclicker/windows.py +0 -0
  63. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/setup.cfg +0 -0
  64. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_adb.py +0 -0
  65. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_flow_branch.py +0 -0
  66. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_matcher.py +0 -0
  67. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_models.py +0 -0
  68. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_recorder_gui.py +0 -0
  69. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_runner.py +0 -0
  70. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_templatize.py +0 -0
  71. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_testkit.py +0 -0
  72. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_testkit_gui.py +0 -0
  73. {pyclickerrpa-0.4.0 → pyclickerrpa-0.5.0}/tests/test_web.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.4.0
4
- Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, a smart recorder that records images instead of coordinates, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
3
+ Version: 0.5.0
4
+ Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, a smart recorder that records images instead of coordinates, task health checks and failure attribution, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
5
5
  Author: Equinox
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Equinox/PyClickerRPA
@@ -66,6 +66,7 @@ pip install "PyClickerRPA[web]" # FastAPI + uvicorn — web console
66
66
  autoclicker # desktop GUI (flow editor / list editor / test tab)
67
67
  autoclicker-run task.json --window "Notepad" # headless run against a window
68
68
  autoclicker-run task.json --adb # run against an Android device (adb)
69
+ autoclicker-lint task.json # health-check a task (exit code = error count)
69
70
  autoclicker-testkit suite.json --report out.html --junit out.xml # run a test suite
70
71
  autoclicker-web --suites-dir examples # web console at http://127.0.0.1:8000
71
72
  ```
@@ -120,6 +121,30 @@ like* instead of where they *were*:
120
121
  Anything that can't be made robust falls back to the plain coordinate/fixed-wait
121
122
  step and says why in the log.
122
123
 
124
+ ## Diagnostics
125
+
126
+ **Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
127
+ statically scans a task and separates *will definitely break* from *works but
128
+ fragile*:
129
+
130
+ - **errors** — missing template / sub-task file, image step with no template,
131
+ loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
132
+ for confirmation before running a task with errors; the CLI exit code is the
133
+ error count.
134
+ - **warnings** — steps that depend on absolute coordinates, long fixed waits,
135
+ a match threshold low enough to hit the wrong thing, templates too small to be
136
+ unique. These are exactly the things the smart recorder now avoids, so the
137
+ check doubles as a "what should I re-record?" list.
138
+
139
+ **Failure attribution.** A run captures a screenshot per step; the first
140
+ successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
141
+ run fails, the frames are compared step by step and the report names the step
142
+ where the screen **first diverged** from the successful run — which is usually
143
+ *earlier* than the step that failed (step 7 can't find its button because a popup
144
+ appeared at step 4). You get the diverging step, the per-step changed-pixel
145
+ ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
146
+ that points at timing/flakiness rather than a UI change.
147
+
123
148
  ## Visual test framework (`autoclicker.testkit`)
124
149
 
125
150
  Turn automation into tests:
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.4.0
4
- Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, a smart recorder that records images instead of coordinates, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
3
+ Version: 0.5.0
4
+ Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, a smart recorder that records images instead of coordinates, task health checks and failure attribution, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
5
5
  Author: Equinox
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Equinox/PyClickerRPA
@@ -66,6 +66,7 @@ pip install "PyClickerRPA[web]" # FastAPI + uvicorn — web console
66
66
  autoclicker # desktop GUI (flow editor / list editor / test tab)
67
67
  autoclicker-run task.json --window "Notepad" # headless run against a window
68
68
  autoclicker-run task.json --adb # run against an Android device (adb)
69
+ autoclicker-lint task.json # health-check a task (exit code = error count)
69
70
  autoclicker-testkit suite.json --report out.html --junit out.xml # run a test suite
70
71
  autoclicker-web --suites-dir examples # web console at http://127.0.0.1:8000
71
72
  ```
@@ -120,6 +121,30 @@ like* instead of where they *were*:
120
121
  Anything that can't be made robust falls back to the plain coordinate/fixed-wait
121
122
  step and says why in the log.
122
123
 
124
+ ## Diagnostics
125
+
126
+ **Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
127
+ statically scans a task and separates *will definitely break* from *works but
128
+ fragile*:
129
+
130
+ - **errors** — missing template / sub-task file, image step with no template,
131
+ loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
132
+ for confirmation before running a task with errors; the CLI exit code is the
133
+ error count.
134
+ - **warnings** — steps that depend on absolute coordinates, long fixed waits,
135
+ a match threshold low enough to hit the wrong thing, templates too small to be
136
+ unique. These are exactly the things the smart recorder now avoids, so the
137
+ check doubles as a "what should I re-record?" list.
138
+
139
+ **Failure attribution.** A run captures a screenshot per step; the first
140
+ successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
141
+ run fails, the frames are compared step by step and the report names the step
142
+ where the screen **first diverged** from the successful run — which is usually
143
+ *earlier* than the step that failed (step 7 can't find its button because a popup
144
+ appeared at step 4). You get the diverging step, the per-step changed-pixel
145
+ ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
146
+ that points at timing/flakiness rather than a UI change.
147
+
123
148
  ## Visual test framework (`autoclicker.testkit`)
124
149
 
125
150
  Turn automation into tests:
@@ -12,8 +12,10 @@ autoclicker/adb.py
12
12
  autoclicker/app.py
13
13
  autoclicker/capture.py
14
14
  autoclicker/clicker.py
15
+ autoclicker/diagnose.py
15
16
  autoclicker/dpi.py
16
17
  autoclicker/expr.py
18
+ autoclicker/lint.py
17
19
  autoclicker/matcher.py
18
20
  autoclicker/models.py
19
21
  autoclicker/ocr.py
@@ -26,6 +28,7 @@ autoclicker/gui/flow_connection.py
26
28
  autoclicker/gui/flow_editor.py
27
29
  autoclicker/gui/flow_node.py
28
30
  autoclicker/gui/hotkeys.py
31
+ autoclicker/gui/lint_dialog.py
29
32
  autoclicker/gui/main_window.py
30
33
  autoclicker/gui/monitor.py
31
34
  autoclicker/gui/preview_label.py
@@ -54,7 +57,10 @@ autoclicker/web/static/app.js
54
57
  autoclicker/web/static/index.html
55
58
  autoclicker/web/static/style.css
56
59
  tests/test_adb.py
60
+ tests/test_diagnose.py
61
+ tests/test_diagnose_gui.py
57
62
  tests/test_flow_branch.py
63
+ tests/test_lint.py
58
64
  tests/test_matcher.py
59
65
  tests/test_models.py
60
66
  tests/test_recorder_gui.py
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ autoclicker-lint = autoclicker.lint:main
2
3
  autoclicker-run = autoclicker.run:main
3
4
  autoclicker-testkit = autoclicker.testkit.__main__:main
4
5
  autoclicker-web = autoclicker.web.__main__:main
@@ -28,6 +28,7 @@ pip install "PyClickerRPA[web]" # FastAPI + uvicorn — web console
28
28
  autoclicker # desktop GUI (flow editor / list editor / test tab)
29
29
  autoclicker-run task.json --window "Notepad" # headless run against a window
30
30
  autoclicker-run task.json --adb # run against an Android device (adb)
31
+ autoclicker-lint task.json # health-check a task (exit code = error count)
31
32
  autoclicker-testkit suite.json --report out.html --junit out.xml # run a test suite
32
33
  autoclicker-web --suites-dir examples # web console at http://127.0.0.1:8000
33
34
  ```
@@ -82,6 +83,30 @@ like* instead of where they *were*:
82
83
  Anything that can't be made robust falls back to the plain coordinate/fixed-wait
83
84
  step and says why in the log.
84
85
 
86
+ ## Diagnostics
87
+
88
+ **Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
89
+ statically scans a task and separates *will definitely break* from *works but
90
+ fragile*:
91
+
92
+ - **errors** — missing template / sub-task file, image step with no template,
93
+ loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
94
+ for confirmation before running a task with errors; the CLI exit code is the
95
+ error count.
96
+ - **warnings** — steps that depend on absolute coordinates, long fixed waits,
97
+ a match threshold low enough to hit the wrong thing, templates too small to be
98
+ unique. These are exactly the things the smart recorder now avoids, so the
99
+ check doubles as a "what should I re-record?" list.
100
+
101
+ **Failure attribution.** A run captures a screenshot per step; the first
102
+ successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
103
+ run fails, the frames are compared step by step and the report names the step
104
+ where the screen **first diverged** from the successful run — which is usually
105
+ *earlier* than the step that failed (step 7 can't find its button because a popup
106
+ appeared at step 4). You get the diverging step, the per-step changed-pixel
107
+ ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
108
+ that points at timing/flakiness rather than a UI change.
109
+
85
110
  ## Visual test framework (`autoclicker.testkit`)
86
111
 
87
112
  Turn automation into tests:
@@ -0,0 +1,179 @@
1
+ """失败归因:拿上一次成功运行的逐步截图当基准,指出画面**第一次**偏离发生在哪一步。
2
+
3
+ 动机:失败的步骤往往不是出问题的步骤。第 7 步找不到图,真正的原因可能是第 4 步弹出了
4
+ 公告框。日志只会告诉你"第 7 步失败",于是排查全靠人肉翻截图。
5
+
6
+ timeline 帧结构沿用 TaskRunner 的 `{"index": int, "desc": str, "png": bytes}`。
7
+ Qt-free。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass, field
15
+
16
+ import cv2
17
+ import numpy as np
18
+
19
+ from autoclicker import matcher
20
+
21
+ BASELINE_ROOT = ".timelines"
22
+ DEFAULT_TOL = 0.02 # 变化像素比例超过这个就算"画面不一样了"
23
+ INDEX_FILE = "index.json"
24
+
25
+
26
+ @dataclass
27
+ class StepDiff:
28
+ index: int
29
+ desc: str
30
+ ratio: float # 变化像素比例;1.0 也可能表示尺寸不一致
31
+ note: str = ""
32
+
33
+ @property
34
+ def diverged(self) -> bool:
35
+ return self.ratio > DEFAULT_TOL
36
+
37
+
38
+ @dataclass
39
+ class Attribution:
40
+ failed_index: int
41
+ diverged_index: int | None # 画面第一次偏离的步骤;None = 没查出偏离
42
+ diffs: list[StepDiff] = field(default_factory=list)
43
+ triptych_png: bytes | None = None # 偏离那一步的 基准|实际|热力图
44
+ message: str = ""
45
+
46
+
47
+ def safe_name(name: str) -> str:
48
+ s = re.sub(r'[<>:"/\\|?*\s]+', "_", (name or "task").strip())
49
+ return s[:60] or "task"
50
+
51
+
52
+ def baseline_dir(task_name: str, root: str = BASELINE_ROOT) -> str:
53
+ return os.path.join(root, safe_name(task_name))
54
+
55
+
56
+ # ─────────────────── 持久化 ───────────────────
57
+
58
+ def save_timeline(timeline: list[dict], dirname: str) -> int:
59
+ os.makedirs(dirname, exist_ok=True)
60
+ meta = []
61
+ for pos, f in enumerate(timeline):
62
+ fname = f"frame_{pos:03d}_step_{int(f['index']):03d}.png"
63
+ with open(os.path.join(dirname, fname), "wb") as fh:
64
+ fh.write(f["png"])
65
+ meta.append({"file": fname, "index": int(f["index"]), "desc": f.get("desc", "")})
66
+ with open(os.path.join(dirname, INDEX_FILE), "w", encoding="utf-8") as fh:
67
+ json.dump(meta, fh, ensure_ascii=False, indent=2)
68
+ return len(meta)
69
+
70
+
71
+ def load_timeline(dirname: str) -> list[dict]:
72
+ idx = os.path.join(dirname, INDEX_FILE)
73
+ if not os.path.isfile(idx):
74
+ return []
75
+ with open(idx, "r", encoding="utf-8") as fh:
76
+ meta = json.load(fh)
77
+ out = []
78
+ for m in meta:
79
+ p = os.path.join(dirname, m["file"])
80
+ if not os.path.isfile(p):
81
+ continue
82
+ with open(p, "rb") as fh:
83
+ out.append({"index": int(m["index"]), "desc": m.get("desc", ""), "png": fh.read()})
84
+ return out
85
+
86
+
87
+ def has_baseline(dirname: str) -> bool:
88
+ return os.path.isfile(os.path.join(dirname, INDEX_FILE))
89
+
90
+
91
+ def save_baseline_if_absent(timeline: list[dict], dirname: str) -> bool:
92
+ """只在还没有基准时写。沿用 visual.compare_region 的"无基准就创建"惯例。
93
+ 返回是否真的写了。"""
94
+ if not timeline or has_baseline(dirname):
95
+ return False
96
+ save_timeline(timeline, dirname)
97
+ return True
98
+
99
+
100
+ # ─────────────────── 比对 ───────────────────
101
+
102
+ def _decode(png: bytes):
103
+ img = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_COLOR)
104
+ return None if img is None or img.size == 0 else img
105
+
106
+
107
+ def _by_step(timeline: list[dict]) -> dict[int, dict]:
108
+ """按步骤下标取**首帧**。循环会让同一步出现多次,比对时只认第一次进入那一帧。"""
109
+ out: dict[int, dict] = {}
110
+ for f in timeline:
111
+ out.setdefault(int(f["index"]), f)
112
+ return out
113
+
114
+
115
+ def compare_timelines(baseline: list[dict], actual: list[dict],
116
+ tol: float = DEFAULT_TOL) -> list[StepDiff]:
117
+ """按步骤下标对齐两条 timeline,逐步算变化像素比例。"""
118
+ base_map = _by_step(baseline)
119
+ diffs: list[StepDiff] = []
120
+ for f in _by_step(actual).values():
121
+ i = int(f["index"])
122
+ desc = f.get("desc", "")
123
+ b = base_map.get(i)
124
+ if b is None:
125
+ diffs.append(StepDiff(i, desc, 0.0, "基准里没有这一步(流程走了不同分支?)"))
126
+ continue
127
+ a_img, b_img = _decode(f["png"]), _decode(b["png"])
128
+ if a_img is None or b_img is None:
129
+ diffs.append(StepDiff(i, desc, 0.0, "截图无法解码"))
130
+ continue
131
+ if a_img.shape != b_img.shape:
132
+ diffs.append(StepDiff(i, desc, 1.0,
133
+ f"画面尺寸变了 基准{b_img.shape[1]}x{b_img.shape[0]} "
134
+ f"vs 实际{a_img.shape[1]}x{a_img.shape[0]}"))
135
+ continue
136
+ diffs.append(StepDiff(i, desc, matcher.diff_ratio(a_img, b_img)))
137
+ diffs.sort(key=lambda d: d.index)
138
+ return diffs
139
+
140
+
141
+ def _triptych_for(baseline: list[dict], actual: list[dict], index: int) -> bytes | None:
142
+ from autoclicker.testkit.visual import heatmap, triptych
143
+ b = _by_step(baseline).get(index)
144
+ a = _by_step(actual).get(index)
145
+ if b is None or a is None:
146
+ return None
147
+ b_img, a_img = _decode(b["png"]), _decode(a["png"])
148
+ if b_img is None or a_img is None:
149
+ return None
150
+ if b_img.shape != a_img.shape:
151
+ return triptych(b_img, a_img, np.zeros_like(a_img))
152
+ per_pixel_max = cv2.absdiff(b_img, a_img).max(axis=2)
153
+ return triptych(b_img, a_img, heatmap(per_pixel_max))
154
+
155
+
156
+ def attribute(baseline: list[dict], actual: list[dict], failed_index: int,
157
+ tol: float = DEFAULT_TOL) -> Attribution:
158
+ """归因。重点在 diverged_index:画面第一次和成功那次不一样是在哪一步。"""
159
+ if not baseline:
160
+ return Attribution(failed_index, None, [], None,
161
+ "没有基准时间线可比对(成功跑一次后会自动建立)")
162
+ diffs = compare_timelines(baseline, actual, tol)
163
+ diverged = next((d for d in diffs if d.ratio > tol), None)
164
+ if diverged is None:
165
+ return Attribution(failed_index, None, diffs, None,
166
+ f"第 {failed_index + 1} 步失败,但逐步画面和成功那次基本一致"
167
+ f"(最大偏离 {max((d.ratio for d in diffs), default=0.0):.4f});"
168
+ f"可能是时序/随机性问题,而不是界面变了")
169
+ png = _triptych_for(baseline, actual, diverged.index)
170
+ if diverged.index < failed_index:
171
+ msg = (f"第 {failed_index + 1} 步失败,但画面从**第 {diverged.index + 1} 步**"
172
+ f"就开始和成功那次不一样了(偏离 {diverged.ratio:.4f})"
173
+ f"{' — ' + diverged.note if diverged.note else ''}。"
174
+ f"先查这一步: {diverged.desc}")
175
+ else:
176
+ msg = (f"第 {failed_index + 1} 步失败,画面偏离也正是从这一步开始"
177
+ f"(偏离 {diverged.ratio:.4f})"
178
+ f"{' — ' + diverged.note if diverged.note else ''}")
179
+ return Attribution(failed_index, diverged.index, diffs, png, msg)
@@ -0,0 +1,53 @@
1
+ """任务体检结果弹窗:按严重程度着色,双击一条直接跳到对应步骤。"""
2
+ from __future__ import annotations
3
+
4
+ from PySide6.QtCore import Qt, Signal
5
+ from PySide6.QtGui import QColor
6
+ from PySide6.QtWidgets import (
7
+ QDialog, QLabel, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout,
8
+ )
9
+
10
+ from autoclicker.lint import ERROR, INFO, WARN, Finding, counts
11
+
12
+ _COLORS = {ERROR: QColor("#c62828"), WARN: QColor("#ef6c00"), INFO: QColor("#546e7a")}
13
+ _ICONS = {ERROR: "✖", WARN: "▲", INFO: "·"}
14
+
15
+
16
+ class LintDialog(QDialog):
17
+ stepActivated = Signal(int) # 双击某条 → 该跳到的步骤下标
18
+
19
+ def __init__(self, findings: list[Finding], step_count: int, parent=None):
20
+ super().__init__(parent)
21
+ self.setWindowTitle("任务体检")
22
+ self.resize(720, 420)
23
+ lay = QVBoxLayout(self)
24
+
25
+ c = counts(findings)
26
+ if not findings:
27
+ summary = f"没查出问题({step_count} 步)。"
28
+ else:
29
+ summary = (f"共 {step_count} 步: {c[ERROR]} 个错误(会导致跑错), "
30
+ f"{c[WARN]} 处脆弱(能跑但不稳), {c[INFO]} 条提示 —— 双击可跳到该步骤")
31
+ lay.addWidget(QLabel(summary))
32
+
33
+ self.list = QListWidget()
34
+ self.list.itemDoubleClicked.connect(self._on_double)
35
+ for level in (ERROR, WARN, INFO):
36
+ for f in findings:
37
+ if f.level != level:
38
+ continue
39
+ where = "整个任务" if f.index < 0 else f"步骤 {f.index + 1}"
40
+ it = QListWidgetItem(f"{_ICONS[level]} {where} {f.message}")
41
+ it.setForeground(_COLORS[level])
42
+ it.setData(Qt.UserRole, f.index)
43
+ self.list.addItem(it)
44
+ lay.addWidget(self.list, 1)
45
+
46
+ close = QPushButton("关闭")
47
+ close.clicked.connect(self.accept)
48
+ lay.addWidget(close)
49
+
50
+ def _on_double(self, item: QListWidgetItem):
51
+ index = item.data(Qt.UserRole)
52
+ if index is not None and index >= 0:
53
+ self.stepActivated.emit(int(index))
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import os
10
10
  import shutil
11
11
  import tempfile
12
+ import time
12
13
  import uuid
13
14
  import zipfile
14
15
 
@@ -64,6 +65,7 @@ class MainWindow(QMainWindow):
64
65
  self._runner = QtRunner(self)
65
66
  self._runner.logMessage.connect(self._append_log)
66
67
  self._runner.stepStarted.connect(self._on_step_started)
68
+ self._runner.stepCompleted.connect(self._on_step_completed)
67
69
  self._runner.matchFound.connect(
68
70
  lambda x, y, c: self._append_log(f" → 命中 ({x},{y}) {c:.3f}"))
69
71
  self._runner.runFinished.connect(self._on_finished)
@@ -83,6 +85,7 @@ class MainWindow(QMainWindow):
83
85
  self._ocr_worker = None
84
86
  self._ocr_offset = (0, 0) # ROI 左上角,用于把框坐标还原到全图
85
87
  self._roi_target = None # (step, apply_fn) 框选 ROI 要写入的目标
88
+ self._first_fail = None # 本次运行第一个失败的步骤下标,用于失败归因
86
89
 
87
90
  self.scheduler.runTaskRequested.connect(self._run_scheduled)
88
91
  self.snippets.insertRequested.connect(self._on_snippet_insert)
@@ -359,6 +362,12 @@ class MainWindow(QMainWindow):
359
362
  e.addAction(QAction("复制步骤到剪贴板(JSON)", self, triggered=self.copy_steps_json))
360
363
  e.addAction(QAction("从剪贴板粘贴步骤(JSON)", self, triggered=self.paste_steps_json))
361
364
 
365
+ d = self.menuBar().addMenu("诊断")
366
+ d.addAction(QAction("任务体检…", self, triggered=self.lint_current_task))
367
+ d.addSeparator()
368
+ d.addAction(QAction("用本次运行更新基准时间线", self, triggered=self.update_baseline))
369
+ d.addAction(QAction("打开基准目录", self, triggered=self.open_baseline_dir))
370
+
362
371
  mon = self.menuBar().addMenu("监视")
363
372
  mon.addAction(QAction("配置并开始监视…", self, triggered=self.monitor_configure))
364
373
  mon.addAction(QAction("停止监视", self, triggered=lambda: self._monitor.stop()))
@@ -424,12 +433,36 @@ class MainWindow(QMainWindow):
424
433
  if not task.steps:
425
434
  self._append_log("任务为空")
426
435
  return
436
+ if not self._lint_gate(task):
437
+ return
427
438
  self.log.clear()
428
439
  self._clear_vars()
429
440
  self.preview.clear_markers()
441
+ self._first_fail = None
430
442
  self._set_running(True)
431
443
  self._runner.start(task, target_hwnd=self._target_hwnd(),
432
- screenshot_on_fail=True, screenshot_dir=".failures")
444
+ screenshot_on_fail=True, screenshot_dir=".failures",
445
+ capture_timeline=True)
446
+
447
+ def _lint_gate(self, task) -> bool:
448
+ """致命问题(模板丢了、跳转越界…)才拦下来问一句;脆弱点只在日志提一行。"""
449
+ from autoclicker.lint import ERROR, counts, lint_task
450
+ findings = lint_task(task, base_dir=self._task_base_dir())
451
+ errors = [f for f in findings if f.level == ERROR]
452
+ c = counts(findings)
453
+ if errors:
454
+ detail = "\n".join("· " + f.format() for f in errors[:8])
455
+ more = f"\n…还有 {len(errors) - 8} 条" if len(errors) > 8 else ""
456
+ ans = QMessageBox.warning(
457
+ self, "体检发现致命问题",
458
+ f"这个任务有 {len(errors)} 个问题会导致跑错:\n\n{detail}{more}\n\n仍然运行吗?",
459
+ QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
460
+ if ans != QMessageBox.Yes:
461
+ self._append_log(f"已取消运行: 体检有 {len(errors)} 个错误(诊断→任务体检 查看)")
462
+ return False
463
+ elif c["warn"]:
464
+ self._append_log(f"提示: {c['warn']} 处脆弱点(诊断→任务体检 查看)")
465
+ return True
433
466
 
434
467
  def debug_run(self):
435
468
  if self._runner.is_running():
@@ -478,9 +511,84 @@ class MainWindow(QMainWindow):
478
511
  def _on_step_started(self, index: int, desc: str):
479
512
  self._append_log(f"[{index + 1}] {desc}")
480
513
 
514
+ def _on_step_completed(self, index: int, ok: bool):
515
+ if not ok and self._first_fail is None:
516
+ self._first_fail = index
517
+
481
518
  def _on_finished(self):
482
519
  self._set_running(False)
483
520
  self.task_widget.clear_running_row()
521
+ self._diagnose_after_run()
522
+
523
+ # ---- 诊断 ----
524
+ def lint_current_task(self):
525
+ from autoclicker.lint import counts, lint_task
526
+ from autoclicker.gui.lint_dialog import LintDialog
527
+ task = self._current_task()
528
+ findings = lint_task(task, base_dir=self._task_base_dir())
529
+ dlg = LintDialog(findings, len(task.steps), self)
530
+ dlg.stepActivated.connect(self._goto_step)
531
+ c = counts(findings)
532
+ self._append_log(f"体检: {c['error']} 个错误, {c['warn']} 处脆弱, {c['info']} 条提示")
533
+ dlg.exec()
534
+
535
+ def _goto_step(self, index: int):
536
+ self.tabs.setCurrentWidget(self.task_widget)
537
+ self.task_widget.set_running_row(index)
538
+
539
+ def _task_base_dir(self) -> str:
540
+ """相对图片路径的解析基准:任务已保存则用它所在目录,否则用当前工作目录
541
+ (和 runner 把路径直接交给 cv2 的行为一致)。"""
542
+ return os.path.dirname(os.path.abspath(self._save_path)) if self._save_path else "."
543
+
544
+ def _baseline_dir(self) -> str:
545
+ from autoclicker import diagnose
546
+ return diagnose.baseline_dir(self._current_task().name)
547
+
548
+ def update_baseline(self):
549
+ from autoclicker import diagnose
550
+ tl = self._runner.timeline()
551
+ if not tl:
552
+ self._append_log("没有可用的时间线(先完整运行一次任务)")
553
+ return
554
+ d = self._baseline_dir()
555
+ n = diagnose.save_timeline(tl, d)
556
+ self._append_log(f"已用本次运行更新基准时间线: {d} ({n} 帧)")
557
+
558
+ def open_baseline_dir(self):
559
+ d = self._baseline_dir()
560
+ if not os.path.isdir(d):
561
+ self._append_log(f"基准目录还不存在: {d}(成功运行一次后会自动建立)")
562
+ return
563
+ os.startfile(d) # noqa: S606 - Windows only tool
564
+
565
+ def _diagnose_after_run(self):
566
+ """成功 → 没基准就建一个;失败 → 拿基准比对,指出画面第一次偏离的步骤。"""
567
+ from autoclicker import diagnose
568
+ tl = self._runner.timeline()
569
+ if not tl:
570
+ return
571
+ d = self._baseline_dir()
572
+ if self._first_fail is None:
573
+ if diagnose.save_baseline_if_absent(tl, d):
574
+ self._append_log(f"已建立基准时间线: {d} ({len(tl)} 帧)")
575
+ return
576
+ baseline = diagnose.load_timeline(d)
577
+ if not baseline:
578
+ self._append_log("失败归因: 还没有基准时间线,成功运行一次后才能比对")
579
+ return
580
+ a = diagnose.attribute(baseline, tl, self._first_fail)
581
+ self._append_log(f"失败归因: {a.message}")
582
+ for sd in a.diffs:
583
+ if sd.diverged or sd.note:
584
+ self._append_log(f" 步骤 {sd.index + 1} 偏离 {sd.ratio:.4f} "
585
+ f"{sd.note} — {sd.desc}")
586
+ if a.triptych_png:
587
+ os.makedirs(".failures", exist_ok=True)
588
+ p = os.path.join(".failures", f"attribution_{int(time.time())}.png")
589
+ with open(p, "wb") as fh:
590
+ fh.write(a.triptych_png)
591
+ self._append_log(f" 对比图(基准|实际|热力图): {p}")
484
592
 
485
593
  def _append_log(self, msg: str):
486
594
  self.log.append(msg)
@@ -552,9 +660,11 @@ class MainWindow(QMainWindow):
552
660
  self._append_log(f"定时触发: {path}")
553
661
  self._clear_vars()
554
662
  self.preview.clear_markers()
663
+ self._first_fail = None
555
664
  self._set_running(True)
556
665
  self._runner.start(task, target_hwnd=self._target_hwnd(),
557
- screenshot_on_fail=True, screenshot_dir=".failures")
666
+ screenshot_on_fail=True, screenshot_dir=".failures",
667
+ capture_timeline=True)
558
668
 
559
669
  def _on_snippet_insert(self, steps):
560
670
  self.task_widget.insert_steps(list(steps))
@@ -53,9 +53,10 @@ class QtRunner(QObject):
53
53
  self._runner: TaskRunner | None = None
54
54
 
55
55
  def start(self, task: Task, target_hwnd=None, screenshot_on_fail=False, screenshot_dir=None,
56
- breakpoints=None, step_mode=False) -> None:
56
+ breakpoints=None, step_mode=False, capture_timeline=False) -> None:
57
57
  self._runner = TaskRunner(task, target_hwnd=target_hwnd, callbacks=_SignalCallbacks(self))
58
58
  self._runner.screenshot_on_fail = screenshot_on_fail
59
+ self._runner.capture_timeline = capture_timeline
59
60
  if screenshot_dir:
60
61
  self._runner.screenshot_dir = screenshot_dir
61
62
  self._runner.set_breakpoints(breakpoints)
@@ -84,3 +85,7 @@ class QtRunner(QObject):
84
85
 
85
86
  def is_running(self) -> bool:
86
87
  return self._runner is not None and self._runner.is_alive()
88
+
89
+ def timeline(self) -> list[dict]:
90
+ """本次运行抓到的逐步截图帧(capture_timeline 开着才有)。供失败归因用。"""
91
+ return list(self._runner.timeline) if self._runner else []