PyClickerRPA 0.3.0__tar.gz → 0.3.1__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.
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PKG-INFO +1 -1
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/PKG-INFO +1 -1
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/SOURCES.txt +1 -0
- pyclickerrpa-0.3.1/autoclicker/expr.py +94 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/flow_editor.py +60 -3
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/main_window.py +95 -4
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/qt_runner.py +16 -1
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/task_widget.py +73 -14
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/runner.py +64 -1
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/pyproject.toml +1 -1
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/LICENSE +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/entry_points.txt +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/requires.txt +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/PyClickerRPA.egg-info/top_level.txt +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/README.md +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/__init__.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/adb.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/app.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/capture.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/clicker.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/dpi.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/__init__.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/flow_connection.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/flow_node.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/hotkeys.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/monitor.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/preview_label.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/recorder.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/scheduler_widget.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/snippet_library.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/gui/testkit_widget.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/matcher.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/models.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/ocr.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/run.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/__init__.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/__main__.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/assertions.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/case.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/loader.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/pytest_plugin.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/report.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/runner.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/testkit/visual.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/__init__.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/__main__.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/run_manager.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/scheduler.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/serialize.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/server.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/static/app.js +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/static/index.html +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/web/static/style.css +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/autoclicker/windows.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/setup.cfg +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_adb.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_flow_branch.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_matcher.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_models.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_runner.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_testkit.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_testkit_gui.py +0 -0
- {pyclickerrpa-0.3.0 → pyclickerrpa-0.3.1}/tests/test_web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyClickerRPA
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, recorder, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
|
|
5
5
|
Author: Equinox
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyClickerRPA
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, recorder, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
|
|
5
5
|
Author: Equinox
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Safe arithmetic/string expression evaluator for the SetVar step.
|
|
2
|
+
|
|
3
|
+
`SetVar` values starting with ``=`` are evaluated here (e.g. ``=count + 1``,
|
|
4
|
+
``=max(a, b) * 2``, ``=upper(name)``). Variable names resolve against the
|
|
5
|
+
runner's variable dict — numeric-looking values become numbers, everything
|
|
6
|
+
else stays a string; unknown names default to 0.
|
|
7
|
+
|
|
8
|
+
Security: this never calls the builtin ``eval``. It walks a whitelisted AST
|
|
9
|
+
and rejects attribute access, subscripting, comprehensions, and any call to a
|
|
10
|
+
function outside a fixed safe set — so ``__import__(...)`` / ``().__class__``
|
|
11
|
+
style escapes raise ``ValueError`` instead of executing.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import operator
|
|
17
|
+
|
|
18
|
+
_BIN_OPS = {
|
|
19
|
+
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
|
20
|
+
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
|
21
|
+
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
|
22
|
+
}
|
|
23
|
+
_UNARY_OPS = {ast.UAdd: operator.pos, ast.USub: operator.neg, ast.Not: operator.not_}
|
|
24
|
+
_CMP_OPS = {
|
|
25
|
+
ast.Eq: operator.eq, ast.NotEq: operator.ne, ast.Lt: operator.lt,
|
|
26
|
+
ast.LtE: operator.le, ast.Gt: operator.gt, ast.GtE: operator.ge,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_FUNCS = {
|
|
30
|
+
"int": int, "float": float, "str": str, "round": round, "abs": abs,
|
|
31
|
+
"min": min, "max": max, "len": len,
|
|
32
|
+
"upper": lambda s: str(s).upper(), "lower": lambda s: str(s).lower(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _coerce(value: str):
|
|
37
|
+
"""A variable value string → number when it looks numeric, else the string."""
|
|
38
|
+
try:
|
|
39
|
+
f = float(value)
|
|
40
|
+
return int(f) if f.is_integer() else f
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _eval(node, variables):
|
|
46
|
+
if isinstance(node, ast.Expression):
|
|
47
|
+
return _eval(node.body, variables)
|
|
48
|
+
if isinstance(node, ast.Constant): # numbers, strings, True/False/None
|
|
49
|
+
return node.value
|
|
50
|
+
if isinstance(node, ast.Name):
|
|
51
|
+
return _coerce(variables.get(node.id, "0"))
|
|
52
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
|
|
53
|
+
return _BIN_OPS[type(node.op)](_eval(node.left, variables), _eval(node.right, variables))
|
|
54
|
+
if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS:
|
|
55
|
+
return _UNARY_OPS[type(node.op)](_eval(node.operand, variables))
|
|
56
|
+
if isinstance(node, ast.BoolOp):
|
|
57
|
+
vals = [_eval(v, variables) for v in node.values]
|
|
58
|
+
result = vals[0]
|
|
59
|
+
for v in vals[1:]:
|
|
60
|
+
result = (result and v) if isinstance(node.op, ast.And) else (result or v)
|
|
61
|
+
return result
|
|
62
|
+
if isinstance(node, ast.Compare):
|
|
63
|
+
left = _eval(node.left, variables)
|
|
64
|
+
for op, comparator in zip(node.ops, node.comparators):
|
|
65
|
+
if type(op) not in _CMP_OPS:
|
|
66
|
+
raise ValueError(f"不支持的比较运算符: {type(op).__name__}")
|
|
67
|
+
right = _eval(comparator, variables)
|
|
68
|
+
if not _CMP_OPS[type(op)](left, right):
|
|
69
|
+
return False
|
|
70
|
+
left = right
|
|
71
|
+
return True
|
|
72
|
+
if isinstance(node, ast.Call):
|
|
73
|
+
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCS:
|
|
74
|
+
raise ValueError("只允许调用: " + ", ".join(sorted(_FUNCS)))
|
|
75
|
+
if node.keywords:
|
|
76
|
+
raise ValueError("函数不支持关键字参数")
|
|
77
|
+
args = [_eval(a, variables) for a in node.args]
|
|
78
|
+
return _FUNCS[node.func.id](*args)
|
|
79
|
+
raise ValueError(f"表达式包含不允许的语法: {type(node).__name__}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def safe_eval(expr: str, variables: dict) -> str:
|
|
83
|
+
"""Evaluate `expr` against `variables`, returning a string result.
|
|
84
|
+
|
|
85
|
+
Integer-valued floats render without a trailing ``.0`` so counters stay clean.
|
|
86
|
+
Raises ``ValueError`` (or SyntaxError) on anything outside the whitelist.
|
|
87
|
+
"""
|
|
88
|
+
tree = ast.parse(expr, mode="eval")
|
|
89
|
+
result = _eval(tree, variables)
|
|
90
|
+
if isinstance(result, float) and result.is_integer():
|
|
91
|
+
return str(int(result))
|
|
92
|
+
if isinstance(result, bool):
|
|
93
|
+
return "1" if result else "0"
|
|
94
|
+
return str(result)
|
|
@@ -7,10 +7,11 @@ recreates loop/do-until back-edges.
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import copy
|
|
10
11
|
from collections import deque
|
|
11
12
|
|
|
12
|
-
from PySide6.QtCore import QLineF, Qt, Signal
|
|
13
|
-
from PySide6.QtGui import QColor, QPainter, QPen
|
|
13
|
+
from PySide6.QtCore import QLineF, QPointF, Qt, Signal
|
|
14
|
+
from PySide6.QtGui import QColor, QKeySequence, QPainter, QPen
|
|
14
15
|
from PySide6.QtWidgets import (
|
|
15
16
|
QDialog, QGraphicsScene, QGraphicsView, QInputDialog, QMenu,
|
|
16
17
|
)
|
|
@@ -52,7 +53,7 @@ class FlowEditor(QGraphicsView):
|
|
|
52
53
|
self._scene.setSceneRect(-2000, -2000, 4000, 4000)
|
|
53
54
|
self.setScene(self._scene)
|
|
54
55
|
self.setRenderHint(QPainter.Antialiasing)
|
|
55
|
-
self.setDragMode(QGraphicsView.
|
|
56
|
+
self.setDragMode(QGraphicsView.RubberBandDrag)
|
|
56
57
|
self.setBackgroundBrush(QColor(30, 30, 30))
|
|
57
58
|
self.setMinimumSize(400, 300)
|
|
58
59
|
|
|
@@ -64,6 +65,7 @@ class FlowEditor(QGraphicsView):
|
|
|
64
65
|
|
|
65
66
|
self.nodes: list[FlowNode] = []
|
|
66
67
|
self.connections: list[FlowConnection] = []
|
|
68
|
+
self._clip: dict | None = None
|
|
67
69
|
self._dragging = False
|
|
68
70
|
self._drag_start_port: FlowPort | None = None
|
|
69
71
|
self._drag_line = None
|
|
@@ -165,11 +167,61 @@ class FlowEditor(QGraphicsView):
|
|
|
165
167
|
self.scale(factor, factor)
|
|
166
168
|
|
|
167
169
|
def keyPressEvent(self, event):
|
|
170
|
+
if event.matches(QKeySequence.Copy):
|
|
171
|
+
self._copy_selected()
|
|
172
|
+
return
|
|
173
|
+
if event.matches(QKeySequence.Paste):
|
|
174
|
+
self._paste_clip()
|
|
175
|
+
return
|
|
168
176
|
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
|
|
169
177
|
self._delete_selected()
|
|
170
178
|
return
|
|
171
179
|
super().keyPressEvent(event)
|
|
172
180
|
|
|
181
|
+
# ---- copy / paste selected nodes ----
|
|
182
|
+
def selected_nodes(self) -> list[FlowNode]:
|
|
183
|
+
return [n for n in self.nodes if n.isSelected()]
|
|
184
|
+
|
|
185
|
+
def get_selected_steps(self) -> list[Step]:
|
|
186
|
+
nodes = sorted(self.selected_nodes(), key=lambda n: (n.pos().y(), n.pos().x()))
|
|
187
|
+
return [copy.deepcopy(n.step()) for n in nodes]
|
|
188
|
+
|
|
189
|
+
def _copy_selected(self):
|
|
190
|
+
nodes = self.selected_nodes()
|
|
191
|
+
if not nodes:
|
|
192
|
+
return
|
|
193
|
+
idx = {n: i for i, n in enumerate(nodes)}
|
|
194
|
+
edges = []
|
|
195
|
+
for conn in self.connections:
|
|
196
|
+
fn = conn.from_port.parentItem()
|
|
197
|
+
tn = conn.to_port.parentItem()
|
|
198
|
+
if fn in idx and tn in idx:
|
|
199
|
+
# 记录出边走的是「真」还是「假」分支(条件节点有 false_port)
|
|
200
|
+
branch = "false" if conn.from_port is fn.false_port else "true"
|
|
201
|
+
edges.append((idx[fn], idx[tn], branch, conn.repeat_count))
|
|
202
|
+
self._clip = {
|
|
203
|
+
"steps": [copy.deepcopy(n.step()) for n in nodes],
|
|
204
|
+
"pos": [(n.pos().x(), n.pos().y()) for n in nodes],
|
|
205
|
+
"edges": edges,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def _paste_clip(self):
|
|
209
|
+
if not self._clip or not self._clip["steps"]:
|
|
210
|
+
return
|
|
211
|
+
new_nodes = []
|
|
212
|
+
for step, (px, py) in zip(self._clip["steps"], self._clip["pos"]):
|
|
213
|
+
new_nodes.append(self.add_node(copy.deepcopy(step), QPointF(px + 40, py + 40)))
|
|
214
|
+
for fi, ti, branch, rc in self._clip["edges"]:
|
|
215
|
+
src = new_nodes[fi].false_port if branch == "false" else new_nodes[fi].output_port
|
|
216
|
+
if src is None: # 终止节点没有输出口
|
|
217
|
+
continue
|
|
218
|
+
conn = self.connect_nodes(src, new_nodes[ti].input_port)
|
|
219
|
+
if conn:
|
|
220
|
+
conn.set_repeat_count(rc)
|
|
221
|
+
self._scene.clearSelection()
|
|
222
|
+
for n in new_nodes:
|
|
223
|
+
n.setSelected(True)
|
|
224
|
+
|
|
173
225
|
def contextMenuEvent(self, event):
|
|
174
226
|
menu = QMenu(self)
|
|
175
227
|
scene_pos = self.mapToScene(event.pos())
|
|
@@ -183,6 +235,11 @@ class FlowEditor(QGraphicsView):
|
|
|
183
235
|
menu.addAction("设置重复次数", lambda: self._set_repeat(conn))
|
|
184
236
|
if node or conn:
|
|
185
237
|
menu.addAction("删除", self._delete_selected)
|
|
238
|
+
if self.selected_nodes():
|
|
239
|
+
menu.addAction("复制选中节点 (Ctrl+C)", self._copy_selected)
|
|
240
|
+
if self._clip and self._clip["steps"]:
|
|
241
|
+
menu.addAction("粘贴 (Ctrl+V)", self._paste_clip)
|
|
242
|
+
if node or conn or self.selected_nodes() or self._clip:
|
|
186
243
|
menu.addSeparator()
|
|
187
244
|
|
|
188
245
|
add_menu = menu.addMenu("添加节点")
|
|
@@ -67,6 +67,7 @@ class MainWindow(QMainWindow):
|
|
|
67
67
|
self._runner.matchFound.connect(
|
|
68
68
|
lambda x, y, c: self._append_log(f" → 命中 ({x},{y}) {c:.3f}"))
|
|
69
69
|
self._runner.runFinished.connect(self._on_finished)
|
|
70
|
+
self._runner.pausedAt.connect(self._on_paused)
|
|
70
71
|
|
|
71
72
|
self._recorder = Recorder(self)
|
|
72
73
|
self._recorder.stepRecorded.connect(self._on_step_recorded)
|
|
@@ -236,6 +237,14 @@ class MainWindow(QMainWindow):
|
|
|
236
237
|
self.stop_btn = QPushButton("■ 停止")
|
|
237
238
|
self.stop_btn.clicked.connect(self.stop_task)
|
|
238
239
|
self.stop_btn.setEnabled(False)
|
|
240
|
+
self.debug_btn = QPushButton("🐞 调试")
|
|
241
|
+
self.debug_btn.clicked.connect(self.debug_run)
|
|
242
|
+
self.step_btn = QPushButton("单步")
|
|
243
|
+
self.step_btn.clicked.connect(lambda: self._runner.debug_step())
|
|
244
|
+
self.step_btn.setEnabled(False)
|
|
245
|
+
self.continue_btn = QPushButton("继续")
|
|
246
|
+
self.continue_btn.clicked.connect(lambda: self._runner.debug_continue())
|
|
247
|
+
self.continue_btn.setEnabled(False)
|
|
239
248
|
bar.addWidget(QLabel("目标窗口"))
|
|
240
249
|
bar.addWidget(self.window_combo, 1)
|
|
241
250
|
bar.addWidget(refresh_btn)
|
|
@@ -245,6 +254,9 @@ class MainWindow(QMainWindow):
|
|
|
245
254
|
bar.addWidget(self.run_btn)
|
|
246
255
|
bar.addWidget(self.pause_btn)
|
|
247
256
|
bar.addWidget(self.stop_btn)
|
|
257
|
+
bar.addWidget(self.debug_btn)
|
|
258
|
+
bar.addWidget(self.step_btn)
|
|
259
|
+
bar.addWidget(self.continue_btn)
|
|
248
260
|
root.addLayout(bar)
|
|
249
261
|
|
|
250
262
|
splitter = QSplitter(Qt.Horizontal)
|
|
@@ -327,6 +339,9 @@ class MainWindow(QMainWindow):
|
|
|
327
339
|
e = self.menuBar().addMenu("编辑")
|
|
328
340
|
e.addAction(QAction("选中步骤存为片段…", self, triggered=self.save_snippet))
|
|
329
341
|
e.addSeparator()
|
|
342
|
+
e.addAction(QAction("导出选中步骤为文件(.json)…", self, triggered=self.export_steps_file))
|
|
343
|
+
e.addAction(QAction("从文件导入步骤(.json)…", self, triggered=self.import_steps_file))
|
|
344
|
+
e.addSeparator()
|
|
330
345
|
e.addAction(QAction("复制步骤到剪贴板(JSON)", self, triggered=self.copy_steps_json))
|
|
331
346
|
e.addAction(QAction("从剪贴板粘贴步骤(JSON)", self, triggered=self.paste_steps_json))
|
|
332
347
|
|
|
@@ -402,6 +417,29 @@ class MainWindow(QMainWindow):
|
|
|
402
417
|
self._runner.start(task, target_hwnd=self._target_hwnd(),
|
|
403
418
|
screenshot_on_fail=True, screenshot_dir=".failures")
|
|
404
419
|
|
|
420
|
+
def debug_run(self):
|
|
421
|
+
if self._runner.is_running():
|
|
422
|
+
return
|
|
423
|
+
self.tabs.setCurrentWidget(self.task_widget)
|
|
424
|
+
task = self._current_task()
|
|
425
|
+
if not task.steps:
|
|
426
|
+
self._append_log("任务为空")
|
|
427
|
+
return
|
|
428
|
+
self.log.clear()
|
|
429
|
+
self._clear_vars()
|
|
430
|
+
self.preview.clear_markers()
|
|
431
|
+
self._set_running(True)
|
|
432
|
+
self._append_log("调试模式:停在第 1 步,用「单步」/「继续」推进")
|
|
433
|
+
self._runner.start(task, target_hwnd=self._target_hwnd(),
|
|
434
|
+
screenshot_on_fail=True, screenshot_dir=".failures",
|
|
435
|
+
breakpoints=self.task_widget.breakpoints(), step_mode=True)
|
|
436
|
+
|
|
437
|
+
def _on_paused(self, index: int, desc: str):
|
|
438
|
+
self._append_log(f"⏸ 暂停于 [{index + 1}] {desc}")
|
|
439
|
+
self.task_widget.set_running_row(index)
|
|
440
|
+
self.step_btn.setEnabled(True)
|
|
441
|
+
self.continue_btn.setEnabled(True)
|
|
442
|
+
|
|
405
443
|
def stop_task(self):
|
|
406
444
|
self._runner.stop()
|
|
407
445
|
|
|
@@ -415,16 +453,20 @@ class MainWindow(QMainWindow):
|
|
|
415
453
|
|
|
416
454
|
def _set_running(self, running: bool):
|
|
417
455
|
self.run_btn.setEnabled(not running)
|
|
456
|
+
self.debug_btn.setEnabled(not running)
|
|
418
457
|
self.stop_btn.setEnabled(running)
|
|
419
458
|
self.pause_btn.setEnabled(running)
|
|
420
459
|
if not running:
|
|
421
460
|
self.pause_btn.setText("⏸ 暂停")
|
|
461
|
+
self.step_btn.setEnabled(False)
|
|
462
|
+
self.continue_btn.setEnabled(False)
|
|
422
463
|
|
|
423
464
|
def _on_step_started(self, index: int, desc: str):
|
|
424
465
|
self._append_log(f"[{index + 1}] {desc}")
|
|
425
466
|
|
|
426
467
|
def _on_finished(self):
|
|
427
468
|
self._set_running(False)
|
|
469
|
+
self.task_widget.clear_running_row()
|
|
428
470
|
|
|
429
471
|
def _append_log(self, msg: str):
|
|
430
472
|
self.log.append(msg)
|
|
@@ -503,10 +545,16 @@ class MainWindow(QMainWindow):
|
|
|
503
545
|
self.tabs.setCurrentWidget(self.task_widget)
|
|
504
546
|
self._append_log(f"插入片段: {len(steps)} 步")
|
|
505
547
|
|
|
548
|
+
def _selected_steps(self) -> list[Step]:
|
|
549
|
+
"""Selected steps from whichever editor tab is active."""
|
|
550
|
+
if self.tabs.currentWidget() is self.flow_editor:
|
|
551
|
+
return self.flow_editor.get_selected_steps()
|
|
552
|
+
return self.task_widget.get_selected_steps()
|
|
553
|
+
|
|
506
554
|
def save_snippet(self):
|
|
507
|
-
steps = self.
|
|
555
|
+
steps = self._selected_steps()
|
|
508
556
|
if not steps:
|
|
509
|
-
self._append_log("
|
|
557
|
+
self._append_log("请先在「步骤列表」或「流程图」中选中要保存的步骤")
|
|
510
558
|
return
|
|
511
559
|
name, ok = QInputDialog.getText(self, "存为片段", "片段名称:")
|
|
512
560
|
if ok and name:
|
|
@@ -516,13 +564,56 @@ class MainWindow(QMainWindow):
|
|
|
516
564
|
# ---- step clipboard (JSON) ----
|
|
517
565
|
def copy_steps_json(self):
|
|
518
566
|
import json
|
|
519
|
-
steps = self.
|
|
567
|
+
steps = self._selected_steps()
|
|
520
568
|
if not steps:
|
|
521
|
-
self._append_log("
|
|
569
|
+
self._append_log("请先在「步骤列表」或「流程图」中选中要复制的步骤")
|
|
522
570
|
return
|
|
523
571
|
QGuiApplication.clipboard().setText(json.dumps([s.to_json() for s in steps], ensure_ascii=False, indent=2))
|
|
524
572
|
self._append_log(f"已复制 {len(steps)} 步到剪贴板(JSON)")
|
|
525
573
|
|
|
574
|
+
def export_steps_file(self):
|
|
575
|
+
import json
|
|
576
|
+
steps = self._selected_steps()
|
|
577
|
+
if not steps:
|
|
578
|
+
self._append_log("请先在「步骤列表」或「流程图」中选中要导出的步骤")
|
|
579
|
+
return
|
|
580
|
+
path, _ = QFileDialog.getSaveFileName(self, "导出选中步骤", "", "步骤文件 (*.json)")
|
|
581
|
+
if not path:
|
|
582
|
+
return
|
|
583
|
+
if not path.lower().endswith(".json"):
|
|
584
|
+
path += ".json"
|
|
585
|
+
try:
|
|
586
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
587
|
+
json.dump([s.to_json() for s in steps], f, ensure_ascii=False, indent=4)
|
|
588
|
+
except OSError as ex:
|
|
589
|
+
QMessageBox.warning(self, "导出失败", str(ex))
|
|
590
|
+
return
|
|
591
|
+
self._append_log(f"已导出 {len(steps)} 步到: {path}")
|
|
592
|
+
|
|
593
|
+
def import_steps_file(self):
|
|
594
|
+
import json
|
|
595
|
+
path, _ = QFileDialog.getOpenFileName(self, "导入步骤", "", "步骤文件 (*.json)")
|
|
596
|
+
if not path:
|
|
597
|
+
return
|
|
598
|
+
try:
|
|
599
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
600
|
+
data = json.load(f)
|
|
601
|
+
except (OSError, json.JSONDecodeError) as ex:
|
|
602
|
+
QMessageBox.warning(self, "导入失败", str(ex))
|
|
603
|
+
return
|
|
604
|
+
if isinstance(data, dict) and "steps" in data: # accept a full task file too
|
|
605
|
+
data = data["steps"]
|
|
606
|
+
if not isinstance(data, list):
|
|
607
|
+
QMessageBox.warning(self, "导入失败", "文件内容既不是步骤数组也不是任务")
|
|
608
|
+
return
|
|
609
|
+
steps = [Step.from_json(o) for o in data]
|
|
610
|
+
if not steps:
|
|
611
|
+
self._append_log("文件里没有步骤")
|
|
612
|
+
return
|
|
613
|
+
self.task_widget.insert_steps(steps)
|
|
614
|
+
self.tabs.setCurrentWidget(self.task_widget)
|
|
615
|
+
self._append_log(f"已从文件导入 {len(steps)} 步: {path}")
|
|
616
|
+
|
|
526
617
|
def paste_steps_json(self):
|
|
527
618
|
import json
|
|
528
619
|
text = QGuiApplication.clipboard().text()
|
|
@@ -32,6 +32,9 @@ class _SignalCallbacks(RunnerCallbacks):
|
|
|
32
32
|
def var_changed(self, name, value):
|
|
33
33
|
self._o.varChanged.emit(name, value)
|
|
34
34
|
|
|
35
|
+
def paused(self, index, description):
|
|
36
|
+
self._o.pausedAt.emit(index, description)
|
|
37
|
+
|
|
35
38
|
def finished(self):
|
|
36
39
|
self._o.runFinished.emit()
|
|
37
40
|
|
|
@@ -42,19 +45,31 @@ class QtRunner(QObject):
|
|
|
42
45
|
matchFound = Signal(int, int, float)
|
|
43
46
|
logMessage = Signal(str)
|
|
44
47
|
varChanged = Signal(str, str)
|
|
48
|
+
pausedAt = Signal(int, str)
|
|
45
49
|
runFinished = Signal()
|
|
46
50
|
|
|
47
51
|
def __init__(self, parent=None) -> None:
|
|
48
52
|
super().__init__(parent)
|
|
49
53
|
self._runner: TaskRunner | None = None
|
|
50
54
|
|
|
51
|
-
def start(self, task: Task, target_hwnd=None, screenshot_on_fail=False, screenshot_dir=None
|
|
55
|
+
def start(self, task: Task, target_hwnd=None, screenshot_on_fail=False, screenshot_dir=None,
|
|
56
|
+
breakpoints=None, step_mode=False) -> None:
|
|
52
57
|
self._runner = TaskRunner(task, target_hwnd=target_hwnd, callbacks=_SignalCallbacks(self))
|
|
53
58
|
self._runner.screenshot_on_fail = screenshot_on_fail
|
|
54
59
|
if screenshot_dir:
|
|
55
60
|
self._runner.screenshot_dir = screenshot_dir
|
|
61
|
+
self._runner.set_breakpoints(breakpoints)
|
|
62
|
+
self._runner.enable_step_mode(step_mode)
|
|
56
63
|
self._runner.start()
|
|
57
64
|
|
|
65
|
+
def debug_step(self) -> None:
|
|
66
|
+
if self._runner:
|
|
67
|
+
self._runner.debug_step()
|
|
68
|
+
|
|
69
|
+
def debug_continue(self) -> None:
|
|
70
|
+
if self._runner:
|
|
71
|
+
self._runner.debug_continue()
|
|
72
|
+
|
|
58
73
|
def stop(self) -> None:
|
|
59
74
|
if self._runner:
|
|
60
75
|
self._runner.stop()
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import copy
|
|
11
11
|
|
|
12
12
|
from PySide6.QtCore import Qt, Signal
|
|
13
|
+
from PySide6.QtGui import QColor, QKeySequence, QShortcut
|
|
13
14
|
from PySide6.QtWidgets import (
|
|
14
15
|
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog,
|
|
15
16
|
QFormLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
|
@@ -73,6 +74,14 @@ _ACTION_TYPES = {StepType.ClickImage, StepType.ClickText, StepType.ClickPosition
|
|
|
73
74
|
StepType.MergeImage, StepType.ClickAll}
|
|
74
75
|
_UNTIL_TYPES = {StepType.DoUntil, StepType.WhileCondition}
|
|
75
76
|
|
|
77
|
+
# 变量比较运算符 (显示标签, 存储 token)。前 6 个 label==token 兼容旧任务。
|
|
78
|
+
_COMPARE_OPS = [
|
|
79
|
+
("==", "=="), ("!=", "!="), (">", ">"), ("<", "<"), (">=", ">="), ("<=", "<="),
|
|
80
|
+
("介于 [a,b]", "between"), ("不介于 [a,b]", "not_between"),
|
|
81
|
+
("包含", "contains"), ("不包含", "not_contains"),
|
|
82
|
+
("开头是", "startswith"), ("结尾是", "endswith"),
|
|
83
|
+
]
|
|
84
|
+
|
|
76
85
|
|
|
77
86
|
class ImageListEditor(QWidget):
|
|
78
87
|
"""Edit one-or-more template image paths."""
|
|
@@ -278,16 +287,20 @@ class StepEditorDialog(QDialog):
|
|
|
278
287
|
self.form.addRow("子任务路径", self._line("subTaskPath", s.subTaskPath))
|
|
279
288
|
if t == StepType.SetVar:
|
|
280
289
|
self.form.addRow("变量名", self._line("varName", s.varName))
|
|
281
|
-
self.form.addRow("值 (
|
|
290
|
+
self.form.addRow("值 (=表达式 / ++ / -- / 字面量)", self._line("varValue", s.varValue))
|
|
282
291
|
if t in (StepType.IfVar, StepType.WhileCondition):
|
|
283
292
|
self.form.addRow("变量名", self._line("varName", s.varName))
|
|
284
293
|
op = QComboBox()
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
294
|
+
for label, token in _COMPARE_OPS:
|
|
295
|
+
op.addItem(label, token)
|
|
296
|
+
idx = op.findData(s.compareOp)
|
|
297
|
+
if idx < 0 and s.compareOp: # legacy op not in list → keep it
|
|
298
|
+
op.addItem(s.compareOp, s.compareOp)
|
|
299
|
+
idx = op.count() - 1
|
|
300
|
+
op.setCurrentIndex(max(0, idx))
|
|
288
301
|
self.w["compareOp"] = op
|
|
289
302
|
self.form.addRow("比较", op)
|
|
290
|
-
self.form.addRow("比较值", self._line("compareValue", s.compareValue))
|
|
303
|
+
self.form.addRow("比较值 (区间填 a,b)", self._line("compareValue", s.compareValue))
|
|
291
304
|
if t == StepType.KeyPress:
|
|
292
305
|
self.form.addRow("按键", self._line("keyName", s.keyName))
|
|
293
306
|
if t == StepType.Hotkey:
|
|
@@ -394,7 +407,7 @@ class StepEditorDialog(QDialog):
|
|
|
394
407
|
if "script" in w:
|
|
395
408
|
s.text = w["script"].toPlainText()
|
|
396
409
|
if "compareOp" in w:
|
|
397
|
-
s.compareOp = w["compareOp"].currentText()
|
|
410
|
+
s.compareOp = w["compareOp"].currentData() or w["compareOp"].currentText()
|
|
398
411
|
if "readmode" in w:
|
|
399
412
|
s.varValue = w["readmode"].currentData()
|
|
400
413
|
if "use_var" in w:
|
|
@@ -427,6 +440,8 @@ class TaskWidget(QWidget):
|
|
|
427
440
|
super().__init__(parent)
|
|
428
441
|
self._task = Task()
|
|
429
442
|
self._clipboard: list[Step] = []
|
|
443
|
+
self._breakpoints: set[int] = set()
|
|
444
|
+
self._running_row: int = -1
|
|
430
445
|
|
|
431
446
|
lay = QVBoxLayout(self)
|
|
432
447
|
top = QHBoxLayout()
|
|
@@ -440,30 +455,63 @@ class TaskWidget(QWidget):
|
|
|
440
455
|
lay.addLayout(top)
|
|
441
456
|
|
|
442
457
|
self.list = QListWidget()
|
|
458
|
+
self.list.setSelectionMode(QListWidget.ExtendedSelection)
|
|
443
459
|
self.list.itemDoubleClicked.connect(self._edit_item)
|
|
444
460
|
lay.addWidget(self.list, 1)
|
|
445
461
|
|
|
446
462
|
row = QHBoxLayout()
|
|
447
463
|
for text, slot in [("↑", self._move_up), ("↓", self._move_down),
|
|
448
464
|
("删除", self._remove), ("复制", self._copy), ("粘贴", self._paste),
|
|
449
|
-
("试跑选中步", self._test_step)]:
|
|
465
|
+
("断点", self._toggle_breakpoint), ("试跑选中步", self._test_step)]:
|
|
450
466
|
b = QPushButton(text)
|
|
451
467
|
b.clicked.connect(slot)
|
|
452
468
|
row.addWidget(b)
|
|
453
469
|
lay.addLayout(row)
|
|
454
470
|
|
|
471
|
+
for seq, slot in [(QKeySequence.Copy, self._copy), (QKeySequence.Paste, self._paste)]:
|
|
472
|
+
sc = QShortcut(seq, self)
|
|
473
|
+
sc.setContext(Qt.WidgetWithChildrenShortcut)
|
|
474
|
+
sc.activated.connect(slot)
|
|
475
|
+
|
|
455
476
|
# -- task <-> ui ----
|
|
456
477
|
def get_task(self) -> Task:
|
|
457
478
|
return self._task
|
|
458
479
|
|
|
459
480
|
def set_task(self, task: Task):
|
|
460
481
|
self._task = task
|
|
482
|
+
self._breakpoints.clear() # step indices change with the task
|
|
483
|
+
self._running_row = -1
|
|
461
484
|
self._refresh()
|
|
462
485
|
|
|
463
486
|
def _refresh(self):
|
|
464
487
|
self.list.clear()
|
|
465
488
|
for i, step in enumerate(self._task.steps):
|
|
466
|
-
|
|
489
|
+
mark = "● " if i in self._breakpoints else ""
|
|
490
|
+
item = QListWidgetItem(f"{mark}{i + 1}. {step.display_name()}")
|
|
491
|
+
if i == self._running_row:
|
|
492
|
+
item.setBackground(QColor(60, 90, 40))
|
|
493
|
+
self.list.addItem(item)
|
|
494
|
+
|
|
495
|
+
# -- step debugging ----
|
|
496
|
+
def _toggle_breakpoint(self):
|
|
497
|
+
rows = [self.list.row(it) for it in self.list.selectedItems()] or [self._selected_row()]
|
|
498
|
+
for r in rows:
|
|
499
|
+
if 0 <= r < len(self._task.steps):
|
|
500
|
+
self._breakpoints.discard(r) if r in self._breakpoints else self._breakpoints.add(r)
|
|
501
|
+
self._refresh()
|
|
502
|
+
|
|
503
|
+
def breakpoints(self) -> set[int]:
|
|
504
|
+
return set(self._breakpoints)
|
|
505
|
+
|
|
506
|
+
def set_running_row(self, i: int):
|
|
507
|
+
self._running_row = i
|
|
508
|
+
self._refresh()
|
|
509
|
+
if 0 <= i < self.list.count():
|
|
510
|
+
self.list.setCurrentRow(i)
|
|
511
|
+
|
|
512
|
+
def clear_running_row(self):
|
|
513
|
+
self._running_row = -1
|
|
514
|
+
self._refresh()
|
|
467
515
|
|
|
468
516
|
def get_selected_steps(self) -> list[Step]:
|
|
469
517
|
rows = sorted(self.list.row(it) for it in self.list.selectedItems())
|
|
@@ -487,6 +535,7 @@ class TaskWidget(QWidget):
|
|
|
487
535
|
at = row + 1 if row >= 0 else len(self._task.steps)
|
|
488
536
|
for i, s in enumerate(steps):
|
|
489
537
|
self._task.steps.insert(at + i, copy.deepcopy(s))
|
|
538
|
+
self._breakpoints.clear() # indices shift
|
|
490
539
|
self._refresh()
|
|
491
540
|
self.taskChanged.emit()
|
|
492
541
|
|
|
@@ -519,9 +568,16 @@ class TaskWidget(QWidget):
|
|
|
519
568
|
self.taskChanged.emit()
|
|
520
569
|
|
|
521
570
|
def _remove(self):
|
|
522
|
-
|
|
523
|
-
if
|
|
524
|
-
|
|
571
|
+
rows = sorted((self.list.row(it) for it in self.list.selectedItems()), reverse=True)
|
|
572
|
+
if not rows:
|
|
573
|
+
rows = [self._selected_row()]
|
|
574
|
+
removed = False
|
|
575
|
+
for row in rows:
|
|
576
|
+
if 0 <= row < len(self._task.steps):
|
|
577
|
+
del self._task.steps[row]
|
|
578
|
+
removed = True
|
|
579
|
+
if removed:
|
|
580
|
+
self._breakpoints.clear() # indices shift
|
|
525
581
|
self._refresh()
|
|
526
582
|
self.taskChanged.emit()
|
|
527
583
|
|
|
@@ -529,6 +585,7 @@ class TaskWidget(QWidget):
|
|
|
529
585
|
row = self._selected_row()
|
|
530
586
|
if row > 0:
|
|
531
587
|
self._task.steps[row - 1], self._task.steps[row] = self._task.steps[row], self._task.steps[row - 1]
|
|
588
|
+
self._breakpoints.clear()
|
|
532
589
|
self._refresh()
|
|
533
590
|
self.list.setCurrentRow(row - 1)
|
|
534
591
|
self.taskChanged.emit()
|
|
@@ -537,14 +594,15 @@ class TaskWidget(QWidget):
|
|
|
537
594
|
row = self._selected_row()
|
|
538
595
|
if 0 <= row < len(self._task.steps) - 1:
|
|
539
596
|
self._task.steps[row + 1], self._task.steps[row] = self._task.steps[row], self._task.steps[row + 1]
|
|
597
|
+
self._breakpoints.clear()
|
|
540
598
|
self._refresh()
|
|
541
599
|
self.list.setCurrentRow(row + 1)
|
|
542
600
|
self.taskChanged.emit()
|
|
543
601
|
|
|
544
602
|
def _copy(self):
|
|
545
|
-
|
|
546
|
-
if
|
|
547
|
-
self._clipboard =
|
|
603
|
+
steps = self.get_selected_steps()
|
|
604
|
+
if steps:
|
|
605
|
+
self._clipboard = steps
|
|
548
606
|
|
|
549
607
|
def _test_step(self):
|
|
550
608
|
step = self.step_at(self._selected_row())
|
|
@@ -558,6 +616,7 @@ class TaskWidget(QWidget):
|
|
|
558
616
|
insert_at = row + 1 if row >= 0 else len(self._task.steps)
|
|
559
617
|
for i, s in enumerate(self._clipboard):
|
|
560
618
|
self._task.steps.insert(insert_at + i, copy.deepcopy(s))
|
|
619
|
+
self._breakpoints.clear() # indices shift
|
|
561
620
|
self._refresh()
|
|
562
621
|
self.taskChanged.emit()
|
|
563
622
|
|
|
@@ -31,6 +31,7 @@ class RunnerCallbacks:
|
|
|
31
31
|
def match_found(self, x: int, y: int, confidence: float) -> None: ...
|
|
32
32
|
def log(self, msg: str) -> None: ...
|
|
33
33
|
def var_changed(self, name: str, value: str) -> None: ...
|
|
34
|
+
def paused(self, index: int, description: str) -> None: ...
|
|
34
35
|
def finished(self) -> None: ...
|
|
35
36
|
|
|
36
37
|
|
|
@@ -61,6 +62,11 @@ class TaskRunner(threading.Thread):
|
|
|
61
62
|
|
|
62
63
|
self._running = False
|
|
63
64
|
self._paused = False
|
|
65
|
+
# Step-debugging: pause before a step when in step-mode or on a breakpoint;
|
|
66
|
+
# _advance is released by debug_step()/debug_continue().
|
|
67
|
+
self._breakpoints: set[int] = set()
|
|
68
|
+
self._step_mode = False
|
|
69
|
+
self._advance = threading.Event()
|
|
64
70
|
self._loop_counters: dict[int, int] = {}
|
|
65
71
|
self._vars: dict[str, str] = {}
|
|
66
72
|
self._last_condition = False
|
|
@@ -85,6 +91,23 @@ class TaskRunner(threading.Thread):
|
|
|
85
91
|
def resume(self) -> None:
|
|
86
92
|
self._paused = False
|
|
87
93
|
|
|
94
|
+
# ---- step debugging ----
|
|
95
|
+
def set_breakpoints(self, indices) -> None:
|
|
96
|
+
self._breakpoints = set(indices or ())
|
|
97
|
+
|
|
98
|
+
def enable_step_mode(self, on: bool) -> None:
|
|
99
|
+
self._step_mode = bool(on)
|
|
100
|
+
|
|
101
|
+
def debug_step(self) -> None:
|
|
102
|
+
"""Advance one step, then pause again at the next step."""
|
|
103
|
+
self._step_mode = True
|
|
104
|
+
self._advance.set()
|
|
105
|
+
|
|
106
|
+
def debug_continue(self) -> None:
|
|
107
|
+
"""Leave step-mode; run until the next breakpoint (or the end)."""
|
|
108
|
+
self._step_mode = False
|
|
109
|
+
self._advance.set()
|
|
110
|
+
|
|
88
111
|
def get_var(self, name: str) -> str:
|
|
89
112
|
return self._vars.get(name, "")
|
|
90
113
|
|
|
@@ -167,6 +190,7 @@ class TaskRunner(threading.Thread):
|
|
|
167
190
|
|
|
168
191
|
def run(self) -> None:
|
|
169
192
|
self._running = True
|
|
193
|
+
self._advance.clear()
|
|
170
194
|
self._loop_counters.clear()
|
|
171
195
|
self._vars.clear()
|
|
172
196
|
self._vars.update({str(k): str(v) for k, v in self._initial_vars.items()})
|
|
@@ -200,6 +224,15 @@ class TaskRunner(threading.Thread):
|
|
|
200
224
|
i = skip
|
|
201
225
|
continue
|
|
202
226
|
|
|
227
|
+
if self._running and (self._step_mode or i in self._breakpoints):
|
|
228
|
+
self.cb.paused(i, log_prefix + step.display_name())
|
|
229
|
+
self._advance.clear()
|
|
230
|
+
while self._running and not self._advance.is_set():
|
|
231
|
+
time.sleep(0.05)
|
|
232
|
+
self._advance.clear()
|
|
233
|
+
if not self._running:
|
|
234
|
+
break
|
|
235
|
+
|
|
203
236
|
self.cb.step_started(i, log_prefix + step.display_name())
|
|
204
237
|
self._snap_timeline(i, log_prefix + step.display_name())
|
|
205
238
|
result = self._execute_with_repeat(i)
|
|
@@ -329,7 +362,30 @@ class TaskRunner(threading.Thread):
|
|
|
329
362
|
self.cb.log(f" 重试 {step.retry.count} 次后仍失败")
|
|
330
363
|
return StepResult.Fail
|
|
331
364
|
|
|
332
|
-
|
|
365
|
+
@staticmethod
|
|
366
|
+
def _compare(val: str, op: str, cmp: str) -> bool:
|
|
367
|
+
# Range ops: cmp is "a,b" or "a~b" (inclusive)
|
|
368
|
+
if op in ("between", "not_between"):
|
|
369
|
+
parts = cmp.replace(",", ",").replace("~", ",").split(",")
|
|
370
|
+
try:
|
|
371
|
+
x = float(val)
|
|
372
|
+
lo, hi = float(parts[0]), float(parts[1])
|
|
373
|
+
except (ValueError, IndexError):
|
|
374
|
+
return False
|
|
375
|
+
if lo > hi:
|
|
376
|
+
lo, hi = hi, lo
|
|
377
|
+
inside = lo <= x <= hi
|
|
378
|
+
return inside if op == "between" else not inside
|
|
379
|
+
# String ops (compare raw strings, no numeric coercion)
|
|
380
|
+
if op == "contains":
|
|
381
|
+
return cmp in val
|
|
382
|
+
if op == "not_contains":
|
|
383
|
+
return cmp not in val
|
|
384
|
+
if op == "startswith":
|
|
385
|
+
return val.startswith(cmp)
|
|
386
|
+
if op == "endswith":
|
|
387
|
+
return val.endswith(cmp)
|
|
388
|
+
# Numeric-first ordering ops, string fallback for ==/!=
|
|
333
389
|
try:
|
|
334
390
|
a, b = float(val), float(cmp)
|
|
335
391
|
except ValueError:
|
|
@@ -485,6 +541,13 @@ class TaskRunner(threading.Thread):
|
|
|
485
541
|
value = str(int(self._vars.get(step.varName, "0")) + 1)
|
|
486
542
|
elif value.startswith("--"):
|
|
487
543
|
value = str(int(self._vars.get(step.varName, "0")) - 1)
|
|
544
|
+
elif value.startswith("="):
|
|
545
|
+
from autoclicker import expr
|
|
546
|
+
try:
|
|
547
|
+
value = expr.safe_eval(value[1:], self._vars)
|
|
548
|
+
except Exception as e: # noqa: BLE001 - bad expr is a recoverable step failure
|
|
549
|
+
self.cb.log(f" 表达式错误: {e}")
|
|
550
|
+
return StepResult.Fail
|
|
488
551
|
self._vars[step.varName] = value
|
|
489
552
|
self.cb.var_changed(step.varName, value)
|
|
490
553
|
self.cb.log(f" 变量 {step.varName} = {value}")
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "PyClickerRPA"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, recorder, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|