PyClickerRPA 0.4.0__tar.gz → 0.6.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.
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PKG-INFO +47 -12
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/PKG-INFO +47 -12
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/SOURCES.txt +8 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/entry_points.txt +1 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/README.md +45 -10
- pyclickerrpa-0.6.0/autoclicker/diagnose.py +179 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/flow_editor.py +76 -18
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/flow_node.py +21 -2
- pyclickerrpa-0.6.0/autoclicker/gui/lint_dialog.py +53 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/main_window.py +114 -3
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/qt_runner.py +6 -1
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/task_widget.py +281 -36
- pyclickerrpa-0.6.0/autoclicker/lint.py +203 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/models.py +108 -9
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/runner.py +272 -62
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/visual.py +11 -4
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/pyproject.toml +3 -2
- pyclickerrpa-0.6.0/tests/test_diagnose.py +198 -0
- pyclickerrpa-0.6.0/tests/test_diagnose_gui.py +97 -0
- pyclickerrpa-0.6.0/tests/test_lint.py +200 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_recorder_gui.py +7 -1
- pyclickerrpa-0.6.0/tests/test_structured_flow.py +377 -0
- pyclickerrpa-0.6.0/tests/test_structured_gui.py +223 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/LICENSE +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/requires.txt +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/top_level.txt +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/__init__.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/adb.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/app.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/capture.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/clicker.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/dpi.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/expr.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/__init__.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/flow_connection.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/hotkeys.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/monitor.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/preview_label.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/recorder.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/scheduler_widget.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/snippet_library.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/testkit_widget.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/matcher.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/ocr.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/run.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/templatize.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/__init__.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/__main__.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/assertions.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/case.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/loader.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/pytest_plugin.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/report.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/runner.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/__init__.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/__main__.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/run_manager.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/scheduler.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/serialize.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/server.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/static/app.js +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/static/index.html +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/static/style.css +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/windows.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/setup.cfg +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_adb.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_flow_branch.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_matcher.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_models.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_runner.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_templatize.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_testkit.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_testkit_gui.py +0 -0
- {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.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
|
-
Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor,
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, structured conditions and loops with array iteration, node flow editor, smart recorder, 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
|
|
@@ -44,8 +44,8 @@ ADB) input — then turn those flows into a **visual test framework** with
|
|
|
44
44
|
assertions, visual-regression baselines, HTML/JUnit reports, and a **web console**
|
|
45
45
|
for remote runs.
|
|
46
46
|
|
|
47
|
-
Python/PySide6, started as a port of a Qt/C++ AutoClicker
|
|
48
|
-
|
|
47
|
+
Python/PySide6, started as a port of a Qt/C++ AutoClicker. Legacy `.json` tasks
|
|
48
|
+
remain supported; structured control-flow blocks are a Python-only extension.
|
|
49
49
|
|
|
50
50
|
## Install
|
|
51
51
|
|
|
@@ -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
|
```
|
|
@@ -90,14 +91,24 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
|
|
|
90
91
|
|
|
91
92
|
## Authoring (desktop GUI)
|
|
92
93
|
|
|
93
|
-
Drag-and-drop **node flow graph**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
Drag-and-drop **node flow graph** and an interoperable **step list**, with
|
|
95
|
+
nested **If/Else**, **While**, fixed-count loops, **ForEach** arrays, and
|
|
96
|
+
**Break/Continue** — no step numbers required. Double-click a block to edit its
|
|
97
|
+
body or branches. Legacy condition nodes retain their true/false ports.
|
|
98
|
+
|
|
99
|
+
Select consecutive steps and choose **包进… → 遍历数组** to reuse them for each
|
|
100
|
+
item. Enter a custom JSON array or generate a numeric range, then reference the
|
|
101
|
+
current item in text actions, assignments, and conditions. For example, iterate
|
|
102
|
+
`[1,2,3,4,5,6,7,8,9,10]` with variable `server`, click **`{server}服`**, perform
|
|
103
|
+
the actions, and return to the server-selection screen inside the same body.
|
|
104
|
+
Enable exact text matching so `1服` does not also match `11服`. A failed action
|
|
105
|
+
inside a block stops execution unless its failure policy explicitly skips it.
|
|
106
|
+
|
|
107
|
+
Also includes a snippet library, task packages (`.zip` with nested templates),
|
|
108
|
+
on-preview coordinate/color picking, template crop, single-step test run;
|
|
109
|
+
global hotkeys (F6 run/stop, ESC stop), scheduler, and an image/color
|
|
110
|
+
**monitor/trigger** with tray icon; a **Test** tab to load and run visual test
|
|
111
|
+
suites.
|
|
101
112
|
|
|
102
113
|
### Smart recorder
|
|
103
114
|
|
|
@@ -120,6 +131,30 @@ like* instead of where they *were*:
|
|
|
120
131
|
Anything that can't be made robust falls back to the plain coordinate/fixed-wait
|
|
121
132
|
step and says why in the log.
|
|
122
133
|
|
|
134
|
+
## Diagnostics
|
|
135
|
+
|
|
136
|
+
**Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
|
|
137
|
+
statically scans a task and separates *will definitely break* from *works but
|
|
138
|
+
fragile*:
|
|
139
|
+
|
|
140
|
+
- **errors** — missing template / sub-task file, image step with no template,
|
|
141
|
+
loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
|
|
142
|
+
for confirmation before running a task with errors; the CLI exit code is the
|
|
143
|
+
error count.
|
|
144
|
+
- **warnings** — steps that depend on absolute coordinates, long fixed waits,
|
|
145
|
+
a match threshold low enough to hit the wrong thing, templates too small to be
|
|
146
|
+
unique. These are exactly the things the smart recorder now avoids, so the
|
|
147
|
+
check doubles as a "what should I re-record?" list.
|
|
148
|
+
|
|
149
|
+
**Failure attribution.** A run captures a screenshot per step; the first
|
|
150
|
+
successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
|
|
151
|
+
run fails, the frames are compared step by step and the report names the step
|
|
152
|
+
where the screen **first diverged** from the successful run — which is usually
|
|
153
|
+
*earlier* than the step that failed (step 7 can't find its button because a popup
|
|
154
|
+
appeared at step 4). You get the diverging step, the per-step changed-pixel
|
|
155
|
+
ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
|
|
156
|
+
that points at timing/flakiness rather than a UI change.
|
|
157
|
+
|
|
123
158
|
## Visual test framework (`autoclicker.testkit`)
|
|
124
159
|
|
|
125
160
|
Turn automation into tests:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyClickerRPA
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor,
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, structured conditions and loops with array iteration, node flow editor, smart recorder, 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
|
|
@@ -44,8 +44,8 @@ ADB) input — then turn those flows into a **visual test framework** with
|
|
|
44
44
|
assertions, visual-regression baselines, HTML/JUnit reports, and a **web console**
|
|
45
45
|
for remote runs.
|
|
46
46
|
|
|
47
|
-
Python/PySide6, started as a port of a Qt/C++ AutoClicker
|
|
48
|
-
|
|
47
|
+
Python/PySide6, started as a port of a Qt/C++ AutoClicker. Legacy `.json` tasks
|
|
48
|
+
remain supported; structured control-flow blocks are a Python-only extension.
|
|
49
49
|
|
|
50
50
|
## Install
|
|
51
51
|
|
|
@@ -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
|
```
|
|
@@ -90,14 +91,24 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
|
|
|
90
91
|
|
|
91
92
|
## Authoring (desktop GUI)
|
|
92
93
|
|
|
93
|
-
Drag-and-drop **node flow graph**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
Drag-and-drop **node flow graph** and an interoperable **step list**, with
|
|
95
|
+
nested **If/Else**, **While**, fixed-count loops, **ForEach** arrays, and
|
|
96
|
+
**Break/Continue** — no step numbers required. Double-click a block to edit its
|
|
97
|
+
body or branches. Legacy condition nodes retain their true/false ports.
|
|
98
|
+
|
|
99
|
+
Select consecutive steps and choose **包进… → 遍历数组** to reuse them for each
|
|
100
|
+
item. Enter a custom JSON array or generate a numeric range, then reference the
|
|
101
|
+
current item in text actions, assignments, and conditions. For example, iterate
|
|
102
|
+
`[1,2,3,4,5,6,7,8,9,10]` with variable `server`, click **`{server}服`**, perform
|
|
103
|
+
the actions, and return to the server-selection screen inside the same body.
|
|
104
|
+
Enable exact text matching so `1服` does not also match `11服`. A failed action
|
|
105
|
+
inside a block stops execution unless its failure policy explicitly skips it.
|
|
106
|
+
|
|
107
|
+
Also includes a snippet library, task packages (`.zip` with nested templates),
|
|
108
|
+
on-preview coordinate/color picking, template crop, single-step test run;
|
|
109
|
+
global hotkeys (F6 run/stop, ESC stop), scheduler, and an image/color
|
|
110
|
+
**monitor/trigger** with tray icon; a **Test** tab to load and run visual test
|
|
111
|
+
suites.
|
|
101
112
|
|
|
102
113
|
### Smart recorder
|
|
103
114
|
|
|
@@ -120,6 +131,30 @@ like* instead of where they *were*:
|
|
|
120
131
|
Anything that can't be made robust falls back to the plain coordinate/fixed-wait
|
|
121
132
|
step and says why in the log.
|
|
122
133
|
|
|
134
|
+
## Diagnostics
|
|
135
|
+
|
|
136
|
+
**Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
|
|
137
|
+
statically scans a task and separates *will definitely break* from *works but
|
|
138
|
+
fragile*:
|
|
139
|
+
|
|
140
|
+
- **errors** — missing template / sub-task file, image step with no template,
|
|
141
|
+
loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
|
|
142
|
+
for confirmation before running a task with errors; the CLI exit code is the
|
|
143
|
+
error count.
|
|
144
|
+
- **warnings** — steps that depend on absolute coordinates, long fixed waits,
|
|
145
|
+
a match threshold low enough to hit the wrong thing, templates too small to be
|
|
146
|
+
unique. These are exactly the things the smart recorder now avoids, so the
|
|
147
|
+
check doubles as a "what should I re-record?" list.
|
|
148
|
+
|
|
149
|
+
**Failure attribution.** A run captures a screenshot per step; the first
|
|
150
|
+
successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
|
|
151
|
+
run fails, the frames are compared step by step and the report names the step
|
|
152
|
+
where the screen **first diverged** from the successful run — which is usually
|
|
153
|
+
*earlier* than the step that failed (step 7 can't find its button because a popup
|
|
154
|
+
appeared at step 4). You get the diverging step, the per-step changed-pixel
|
|
155
|
+
ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
|
|
156
|
+
that points at timing/flakiness rather than a UI change.
|
|
157
|
+
|
|
123
158
|
## Visual test framework (`autoclicker.testkit`)
|
|
124
159
|
|
|
125
160
|
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,11 +57,16 @@ 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
|
|
61
67
|
tests/test_runner.py
|
|
68
|
+
tests/test_structured_flow.py
|
|
69
|
+
tests/test_structured_gui.py
|
|
62
70
|
tests/test_templatize.py
|
|
63
71
|
tests/test_testkit.py
|
|
64
72
|
tests/test_testkit_gui.py
|
|
@@ -6,8 +6,8 @@ ADB) input — then turn those flows into a **visual test framework** with
|
|
|
6
6
|
assertions, visual-regression baselines, HTML/JUnit reports, and a **web console**
|
|
7
7
|
for remote runs.
|
|
8
8
|
|
|
9
|
-
Python/PySide6, started as a port of a Qt/C++ AutoClicker
|
|
10
|
-
|
|
9
|
+
Python/PySide6, started as a port of a Qt/C++ AutoClicker. Legacy `.json` tasks
|
|
10
|
+
remain supported; structured control-flow blocks are a Python-only extension.
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -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
|
```
|
|
@@ -52,14 +53,24 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
|
|
|
52
53
|
|
|
53
54
|
## Authoring (desktop GUI)
|
|
54
55
|
|
|
55
|
-
Drag-and-drop **node flow graph**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
Drag-and-drop **node flow graph** and an interoperable **step list**, with
|
|
57
|
+
nested **If/Else**, **While**, fixed-count loops, **ForEach** arrays, and
|
|
58
|
+
**Break/Continue** — no step numbers required. Double-click a block to edit its
|
|
59
|
+
body or branches. Legacy condition nodes retain their true/false ports.
|
|
60
|
+
|
|
61
|
+
Select consecutive steps and choose **包进… → 遍历数组** to reuse them for each
|
|
62
|
+
item. Enter a custom JSON array or generate a numeric range, then reference the
|
|
63
|
+
current item in text actions, assignments, and conditions. For example, iterate
|
|
64
|
+
`[1,2,3,4,5,6,7,8,9,10]` with variable `server`, click **`{server}服`**, perform
|
|
65
|
+
the actions, and return to the server-selection screen inside the same body.
|
|
66
|
+
Enable exact text matching so `1服` does not also match `11服`. A failed action
|
|
67
|
+
inside a block stops execution unless its failure policy explicitly skips it.
|
|
68
|
+
|
|
69
|
+
Also includes a snippet library, task packages (`.zip` with nested templates),
|
|
70
|
+
on-preview coordinate/color picking, template crop, single-step test run;
|
|
71
|
+
global hotkeys (F6 run/stop, ESC stop), scheduler, and an image/color
|
|
72
|
+
**monitor/trigger** with tray icon; a **Test** tab to load and run visual test
|
|
73
|
+
suites.
|
|
63
74
|
|
|
64
75
|
### Smart recorder
|
|
65
76
|
|
|
@@ -82,6 +93,30 @@ like* instead of where they *were*:
|
|
|
82
93
|
Anything that can't be made robust falls back to the plain coordinate/fixed-wait
|
|
83
94
|
step and says why in the log.
|
|
84
95
|
|
|
96
|
+
## Diagnostics
|
|
97
|
+
|
|
98
|
+
**Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
|
|
99
|
+
statically scans a task and separates *will definitely break* from *works but
|
|
100
|
+
fragile*:
|
|
101
|
+
|
|
102
|
+
- **errors** — missing template / sub-task file, image step with no template,
|
|
103
|
+
loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
|
|
104
|
+
for confirmation before running a task with errors; the CLI exit code is the
|
|
105
|
+
error count.
|
|
106
|
+
- **warnings** — steps that depend on absolute coordinates, long fixed waits,
|
|
107
|
+
a match threshold low enough to hit the wrong thing, templates too small to be
|
|
108
|
+
unique. These are exactly the things the smart recorder now avoids, so the
|
|
109
|
+
check doubles as a "what should I re-record?" list.
|
|
110
|
+
|
|
111
|
+
**Failure attribution.** A run captures a screenshot per step; the first
|
|
112
|
+
successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
|
|
113
|
+
run fails, the frames are compared step by step and the report names the step
|
|
114
|
+
where the screen **first diverged** from the successful run — which is usually
|
|
115
|
+
*earlier* than the step that failed (step 7 can't find its button because a popup
|
|
116
|
+
appeared at step 4). You get the diverging step, the per-step changed-pixel
|
|
117
|
+
ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
|
|
118
|
+
that points at timing/flakiness rather than a UI change.
|
|
119
|
+
|
|
85
120
|
## Visual test framework (`autoclicker.testkit`)
|
|
86
121
|
|
|
87
122
|
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)
|
|
@@ -13,10 +13,10 @@ from collections import deque
|
|
|
13
13
|
from PySide6.QtCore import QLineF, QPointF, Qt, Signal
|
|
14
14
|
from PySide6.QtGui import QColor, QKeySequence, QPainter, QPen
|
|
15
15
|
from PySide6.QtWidgets import (
|
|
16
|
-
QDialog, QGraphicsScene, QGraphicsView, QInputDialog, QMenu,
|
|
16
|
+
QDialog, QGraphicsScene, QGraphicsView, QInputDialog, QMenu, QMessageBox,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
-
from autoclicker.models import Step, StepType, Task
|
|
19
|
+
from autoclicker.models import Step, StepType, Task, BLOCK_TYPES, iter_steps
|
|
20
20
|
from autoclicker.gui.flow_node import FlowNode, FlowPort
|
|
21
21
|
from autoclicker.gui.flow_connection import FlowConnection
|
|
22
22
|
|
|
@@ -29,16 +29,13 @@ _ADD_MENU = {
|
|
|
29
29
|
"手势": [("拖拽", StepType.Drag), ("多点拖拽", StepType.MultiDrag),
|
|
30
30
|
("捏合", StepType.Pinch), ("滚轮", StepType.Scroll)],
|
|
31
31
|
"识别/变量": [("读取文字→变量", StepType.ReadText), ("设置变量", StepType.SetVar)],
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
"条件/循环": [("如果 / 否则", StepType.IfBlock), ("遍历数组", StepType.ForEach),
|
|
33
|
+
("重复指定次数", StepType.RepeatBlock), ("条件循环 (While)", StepType.WhileBlock),
|
|
34
|
+
("退出当前循环", StepType.Break), ("进入下一轮", StepType.Continue)],
|
|
35
35
|
"流程": [("等待", StepType.Wait), ("随机等待", StepType.RandomWait),
|
|
36
36
|
("等待画面稳定", StepType.WaitStable),
|
|
37
37
|
("等待图片出现", StepType.WaitImage), ("等待图片消失", StepType.WaitImageGone),
|
|
38
|
-
("
|
|
39
|
-
("跳转", StepType.Goto), ("调用子任务", StepType.CallSub),
|
|
40
|
-
("分组开始", StepType.GroupBegin), ("分组结束", StepType.GroupEnd),
|
|
41
|
-
("结束任务", StepType.Stop)],
|
|
38
|
+
("调用子任务", StepType.CallSub), ("结束任务", StepType.Stop)],
|
|
42
39
|
"窗口/高级": [("刷新/激活窗口", StepType.ActivateWindow), ("合成拖拽", StepType.MergeImage),
|
|
43
40
|
("HTTP 请求", StepType.HttpRequest), ("运行脚本(Python)", StepType.RunScript)],
|
|
44
41
|
}
|
|
@@ -143,14 +140,12 @@ class FlowEditor(QGraphicsView):
|
|
|
143
140
|
self._drag_line = None
|
|
144
141
|
item = self.itemAt(event.position().toPoint())
|
|
145
142
|
if isinstance(item, FlowPort) and item.port_type == FlowPort.INPUT and item is not self._drag_start_port:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if ok:
|
|
153
|
-
conn.set_repeat_count(count)
|
|
143
|
+
from_node = self._drag_start_port.parentItem()
|
|
144
|
+
to_node = item.parentItem()
|
|
145
|
+
if from_node in self._reachable(to_node, set()):
|
|
146
|
+
QMessageBox.information(self, "使用循环块", "无需连接回线:选中需要重复的步骤,右键选择「将选中步骤包进…」。")
|
|
147
|
+
else:
|
|
148
|
+
self.connect_nodes(self._drag_start_port, item)
|
|
154
149
|
self._drag_start_port = None
|
|
155
150
|
return
|
|
156
151
|
super().mouseReleaseEvent(event)
|
|
@@ -237,6 +232,10 @@ class FlowEditor(QGraphicsView):
|
|
|
237
232
|
menu.addAction("删除", self._delete_selected)
|
|
238
233
|
if self.selected_nodes():
|
|
239
234
|
menu.addAction("复制选中节点 (Ctrl+C)", self._copy_selected)
|
|
235
|
+
wrap_menu = menu.addMenu("将选中步骤包进…")
|
|
236
|
+
for label, st in [("遍历数组", StepType.ForEach), ("重复指定次数", StepType.RepeatBlock),
|
|
237
|
+
("条件循环", StepType.WhileBlock), ("如果 / 否则", StepType.IfBlock)]:
|
|
238
|
+
wrap_menu.addAction(label, lambda st=st: self.wrap_selection(st))
|
|
240
239
|
if self._clip and self._clip["steps"]:
|
|
241
240
|
menu.addAction("粘贴 (Ctrl+V)", self._paste_clip)
|
|
242
241
|
if node or conn or self.selected_nodes() or self._clip:
|
|
@@ -246,9 +245,68 @@ class FlowEditor(QGraphicsView):
|
|
|
246
245
|
for group, items in _ADD_MENU.items():
|
|
247
246
|
sub = add_menu.addMenu(group)
|
|
248
247
|
for label, st in items:
|
|
249
|
-
sub.addAction(label, lambda st=st, p=scene_pos: self.
|
|
248
|
+
sub.addAction(label, lambda st=st, p=scene_pos: self._add_configured_node(st, p))
|
|
250
249
|
menu.exec(event.globalPos())
|
|
251
250
|
|
|
251
|
+
def _add_configured_node(self, step_type, pos):
|
|
252
|
+
from autoclicker.gui.task_widget import StepEditorDialog, new_step
|
|
253
|
+
dlg = StepEditorDialog(new_step(step_type), self)
|
|
254
|
+
if dlg.exec() == QDialog.Accepted:
|
|
255
|
+
self.add_node(dlg.result_step(), pos)
|
|
256
|
+
|
|
257
|
+
def wrap_selection(self, step_type):
|
|
258
|
+
from autoclicker.gui.task_widget import StepEditorDialog, new_step
|
|
259
|
+
selected = set(self.selected_nodes())
|
|
260
|
+
if not selected or step_type not in BLOCK_TYPES:
|
|
261
|
+
return
|
|
262
|
+
starts = [n for n in selected if not any(
|
|
263
|
+
c.from_port.parentItem() in selected for c in n.input_port.connections)]
|
|
264
|
+
ordered = []
|
|
265
|
+
node = starts[0] if len(starts) == 1 else None
|
|
266
|
+
while node is not None and node not in ordered:
|
|
267
|
+
ordered.append(node)
|
|
268
|
+
edges = [c for p in node.output_ports() for c in p.connections
|
|
269
|
+
if c.to_port.parentItem() in selected]
|
|
270
|
+
node = edges[0].to_port.parentItem() if len(edges) == 1 else None
|
|
271
|
+
incoming = [c for n in selected for c in n.input_port.connections
|
|
272
|
+
if c.from_port.parentItem() not in selected]
|
|
273
|
+
outgoing = [c for n in selected for p in n.output_ports() for c in p.connections
|
|
274
|
+
if c.to_port.parentItem() not in selected]
|
|
275
|
+
internal = [c for n in selected for p in n.output_ports() for c in p.connections
|
|
276
|
+
if c.to_port.parentItem() in selected]
|
|
277
|
+
linear = (len(ordered) == len(selected) and len(internal) == len(selected) - 1
|
|
278
|
+
and all(c.to_port.parentItem() is ordered[0] for c in incoming)
|
|
279
|
+
and all(c.from_port.parentItem() is ordered[-1] for c in outgoing)
|
|
280
|
+
and all(c.repeat_count == 1 for c in internal + incoming + outgoing)
|
|
281
|
+
and not any(n.is_condition() for n in selected))
|
|
282
|
+
legacy = {StepType.Loop, StepType.Goto, StepType.DoUntil, StepType.WhileCondition,
|
|
283
|
+
StepType.IfImageFound, StepType.IfTextFound, StepType.IfColorMatch,
|
|
284
|
+
StepType.IfImageCount, StepType.IfVar, StepType.IfElse,
|
|
285
|
+
StepType.GroupBegin, StepType.GroupEnd}
|
|
286
|
+
if not linear or any(s.type in legacy or s.failGoto >= 0
|
|
287
|
+
for s in iter_steps([n.step() for n in self.nodes])):
|
|
288
|
+
QMessageBox.warning(self, "无法包进块", "请选择连续、无分叉的步骤;流程中的旧式步骤号跳转须先改为条件/循环块。")
|
|
289
|
+
return
|
|
290
|
+
block = new_step(step_type)
|
|
291
|
+
block.body = [copy.deepcopy(n.step()) for n in ordered]
|
|
292
|
+
dlg = StepEditorDialog(block, self)
|
|
293
|
+
if dlg.exec() != QDialog.Accepted:
|
|
294
|
+
return
|
|
295
|
+
block = dlg.result_step()
|
|
296
|
+
pos = ordered[0].pos()
|
|
297
|
+
sources = [c.from_port for c in incoming]
|
|
298
|
+
targets = [c.to_port for c in outgoing]
|
|
299
|
+
self._scene.clearSelection()
|
|
300
|
+
for n in selected:
|
|
301
|
+
n.setSelected(True)
|
|
302
|
+
self._delete_selected()
|
|
303
|
+
wrapped = self.add_node(block, pos)
|
|
304
|
+
for source in sources:
|
|
305
|
+
self.connect_nodes(source, wrapped.input_port)
|
|
306
|
+
for target in targets:
|
|
307
|
+
self.connect_nodes(wrapped.output_port, target)
|
|
308
|
+
wrapped.setSelected(True)
|
|
309
|
+
|
|
252
310
|
# ---- helpers ----
|
|
253
311
|
def _node_at(self, view_pos) -> FlowNode | None:
|
|
254
312
|
item = self.itemAt(view_pos)
|