persona-dsl 26.1.21.44__py3-none-any.whl
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.
- persona_dsl/__init__.py +35 -0
- persona_dsl/components/action.py +10 -0
- persona_dsl/components/base_step.py +251 -0
- persona_dsl/components/combined_step.py +68 -0
- persona_dsl/components/expectation.py +10 -0
- persona_dsl/components/fact.py +10 -0
- persona_dsl/components/goal.py +10 -0
- persona_dsl/components/ops.py +7 -0
- persona_dsl/components/step.py +75 -0
- persona_dsl/expectations/generic/__init__.py +15 -0
- persona_dsl/expectations/generic/contains_item.py +19 -0
- persona_dsl/expectations/generic/contains_the_text.py +15 -0
- persona_dsl/expectations/generic/has_entries.py +21 -0
- persona_dsl/expectations/generic/is_equal.py +24 -0
- persona_dsl/expectations/generic/is_greater_than.py +18 -0
- persona_dsl/expectations/generic/path_equal.py +27 -0
- persona_dsl/expectations/web/__init__.py +5 -0
- persona_dsl/expectations/web/is_displayed.py +13 -0
- persona_dsl/expectations/web/matches_aria_snapshot.py +222 -0
- persona_dsl/expectations/web/matches_screenshot.py +160 -0
- persona_dsl/generators/__init__.py +5 -0
- persona_dsl/generators/api_generator.py +423 -0
- persona_dsl/generators/cli.py +431 -0
- persona_dsl/generators/page_generator.py +1140 -0
- persona_dsl/ops/api/__init__.py +5 -0
- persona_dsl/ops/api/json_as.py +104 -0
- persona_dsl/ops/api/json_response.py +48 -0
- persona_dsl/ops/api/send_request.py +41 -0
- persona_dsl/ops/db/__init__.py +5 -0
- persona_dsl/ops/db/execute_sql.py +22 -0
- persona_dsl/ops/db/fetch_all.py +29 -0
- persona_dsl/ops/db/fetch_one.py +22 -0
- persona_dsl/ops/kafka/__init__.py +4 -0
- persona_dsl/ops/kafka/message_in_topic.py +89 -0
- persona_dsl/ops/kafka/send_message.py +35 -0
- persona_dsl/ops/soap/__init__.py +4 -0
- persona_dsl/ops/soap/call_operation.py +24 -0
- persona_dsl/ops/soap/operation_result.py +24 -0
- persona_dsl/ops/web/__init__.py +37 -0
- persona_dsl/ops/web/aria_snapshot.py +87 -0
- persona_dsl/ops/web/click.py +30 -0
- persona_dsl/ops/web/current_path.py +17 -0
- persona_dsl/ops/web/element_attribute.py +24 -0
- persona_dsl/ops/web/element_is_visible.py +27 -0
- persona_dsl/ops/web/element_text.py +28 -0
- persona_dsl/ops/web/elements_count.py +42 -0
- persona_dsl/ops/web/fill.py +41 -0
- persona_dsl/ops/web/generate_page_object.py +118 -0
- persona_dsl/ops/web/input_value.py +23 -0
- persona_dsl/ops/web/navigate.py +52 -0
- persona_dsl/ops/web/press_key.py +37 -0
- persona_dsl/ops/web/rich_aria_snapshot.py +159 -0
- persona_dsl/ops/web/screenshot.py +68 -0
- persona_dsl/ops/web/table_data.py +43 -0
- persona_dsl/ops/web/wait_for_navigation.py +23 -0
- persona_dsl/pages/__init__.py +133 -0
- persona_dsl/pages/elements.py +998 -0
- persona_dsl/pages/page.py +44 -0
- persona_dsl/pages/virtual_page.py +94 -0
- persona_dsl/persona.py +125 -0
- persona_dsl/pytest_plugin.py +1230 -0
- persona_dsl/runtime/dist/persona_bundle.js +1077 -0
- persona_dsl/skills/__init__.py +7 -0
- persona_dsl/skills/core/base.py +41 -0
- persona_dsl/skills/core/skill_definition.py +30 -0
- persona_dsl/skills/use_api.py +251 -0
- persona_dsl/skills/use_browser.py +78 -0
- persona_dsl/skills/use_database.py +129 -0
- persona_dsl/skills/use_kafka.py +135 -0
- persona_dsl/skills/use_soap.py +66 -0
- persona_dsl/utils/__init__.py +0 -0
- persona_dsl/utils/artifacts.py +22 -0
- persona_dsl/utils/config.py +54 -0
- persona_dsl/utils/data_providers.py +159 -0
- persona_dsl/utils/decorators.py +80 -0
- persona_dsl/utils/metrics.py +69 -0
- persona_dsl/utils/naming.py +14 -0
- persona_dsl/utils/path.py +202 -0
- persona_dsl/utils/retry.py +51 -0
- persona_dsl/utils/taas_integration.py +124 -0
- persona_dsl/utils/waits.py +112 -0
- persona_dsl-26.1.21.44.dist-info/METADATA +233 -0
- persona_dsl-26.1.21.44.dist-info/RECORD +86 -0
- persona_dsl-26.1.21.44.dist-info/WHEEL +5 -0
- persona_dsl-26.1.21.44.dist-info/entry_points.txt +6 -0
- persona_dsl-26.1.21.44.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1140 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from unidecode import unidecode
|
|
6
|
+
|
|
7
|
+
from persona_dsl.pages import (
|
|
8
|
+
Alert,
|
|
9
|
+
AlertDialog,
|
|
10
|
+
Article,
|
|
11
|
+
Banner,
|
|
12
|
+
BlockQuote,
|
|
13
|
+
Button,
|
|
14
|
+
Checkbox,
|
|
15
|
+
Code,
|
|
16
|
+
ColumnHeader,
|
|
17
|
+
ComboBox,
|
|
18
|
+
Complementary,
|
|
19
|
+
ContentInfo,
|
|
20
|
+
Dialog,
|
|
21
|
+
Element,
|
|
22
|
+
Figure,
|
|
23
|
+
Form,
|
|
24
|
+
Grid,
|
|
25
|
+
GridCell,
|
|
26
|
+
Group,
|
|
27
|
+
Heading,
|
|
28
|
+
Image,
|
|
29
|
+
Link,
|
|
30
|
+
ListElement,
|
|
31
|
+
ListBox,
|
|
32
|
+
ListItem,
|
|
33
|
+
Log,
|
|
34
|
+
Main,
|
|
35
|
+
Menu,
|
|
36
|
+
MenuBar,
|
|
37
|
+
MenuItem,
|
|
38
|
+
MenuItemCheckbox,
|
|
39
|
+
MenuItemRadio,
|
|
40
|
+
Meter,
|
|
41
|
+
Navigation,
|
|
42
|
+
Paragraph,
|
|
43
|
+
ProgressBar,
|
|
44
|
+
Radio,
|
|
45
|
+
RadioGroup,
|
|
46
|
+
Region,
|
|
47
|
+
RowHeader,
|
|
48
|
+
ScrollBar,
|
|
49
|
+
Search,
|
|
50
|
+
SearchBox,
|
|
51
|
+
Separator,
|
|
52
|
+
Slider,
|
|
53
|
+
SpinButton,
|
|
54
|
+
Status,
|
|
55
|
+
Strong,
|
|
56
|
+
Switch,
|
|
57
|
+
Tab,
|
|
58
|
+
Table,
|
|
59
|
+
TabList,
|
|
60
|
+
TabPanel,
|
|
61
|
+
TableCell,
|
|
62
|
+
TableRow,
|
|
63
|
+
TextField,
|
|
64
|
+
Timer,
|
|
65
|
+
Toolbar,
|
|
66
|
+
Tooltip,
|
|
67
|
+
Tree,
|
|
68
|
+
TreeItem,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class PageGenerator:
|
|
73
|
+
"""Генератор страниц 2.0 (LCL-34/35)."""
|
|
74
|
+
|
|
75
|
+
_ROLE_TO_CLASS = {
|
|
76
|
+
"alert": Alert,
|
|
77
|
+
"alertdialog": AlertDialog,
|
|
78
|
+
"article": Article,
|
|
79
|
+
"banner": Banner,
|
|
80
|
+
"blockquote": BlockQuote,
|
|
81
|
+
"button": Button,
|
|
82
|
+
"cell": TableCell,
|
|
83
|
+
"checkbox": Checkbox,
|
|
84
|
+
"code": Code,
|
|
85
|
+
"columnheader": ColumnHeader,
|
|
86
|
+
"combobox": ComboBox,
|
|
87
|
+
"complementary": Complementary,
|
|
88
|
+
"contentinfo": ContentInfo,
|
|
89
|
+
"dialog": Dialog,
|
|
90
|
+
"figure": Figure,
|
|
91
|
+
"form": Form,
|
|
92
|
+
"generic": Element,
|
|
93
|
+
"grid": Grid,
|
|
94
|
+
"gridcell": GridCell,
|
|
95
|
+
"group": Group,
|
|
96
|
+
"heading": Heading,
|
|
97
|
+
"img": Image,
|
|
98
|
+
"link": Link,
|
|
99
|
+
"list": ListElement,
|
|
100
|
+
"listbox": ListBox,
|
|
101
|
+
"listitem": ListItem,
|
|
102
|
+
"log": Log,
|
|
103
|
+
"main": Main,
|
|
104
|
+
"menu": Menu,
|
|
105
|
+
"menubar": MenuBar,
|
|
106
|
+
"menuitem": MenuItem,
|
|
107
|
+
"menuitemcheckbox": MenuItemCheckbox,
|
|
108
|
+
"menuitemradio": MenuItemRadio,
|
|
109
|
+
"meter": Meter,
|
|
110
|
+
"navigation": Navigation,
|
|
111
|
+
"paragraph": Paragraph,
|
|
112
|
+
"progressbar": ProgressBar,
|
|
113
|
+
"radio": Radio,
|
|
114
|
+
"radiogroup": RadioGroup,
|
|
115
|
+
"region": Region,
|
|
116
|
+
"row": TableRow,
|
|
117
|
+
"rowheader": RowHeader,
|
|
118
|
+
"scrollbar": ScrollBar,
|
|
119
|
+
"search": Search,
|
|
120
|
+
"searchbox": SearchBox,
|
|
121
|
+
"separator": Separator,
|
|
122
|
+
"slider": Slider,
|
|
123
|
+
"spinbutton": SpinButton,
|
|
124
|
+
"status": Status,
|
|
125
|
+
"strong": Strong,
|
|
126
|
+
"switch": Switch,
|
|
127
|
+
"tab": Tab,
|
|
128
|
+
"table": Table,
|
|
129
|
+
"tablist": TabList,
|
|
130
|
+
"tabpanel": TabPanel,
|
|
131
|
+
"textbox": TextField,
|
|
132
|
+
"timer": Timer,
|
|
133
|
+
"toolbar": Toolbar,
|
|
134
|
+
"tooltip": Tooltip,
|
|
135
|
+
"tree": Tree,
|
|
136
|
+
"treeitem": TreeItem,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_CONTAINER_ROLES = {
|
|
140
|
+
"alert",
|
|
141
|
+
"alertdialog",
|
|
142
|
+
"article",
|
|
143
|
+
"banner",
|
|
144
|
+
"blockquote",
|
|
145
|
+
"complementary",
|
|
146
|
+
"contentinfo",
|
|
147
|
+
"dialog",
|
|
148
|
+
"figure",
|
|
149
|
+
"form",
|
|
150
|
+
"generic",
|
|
151
|
+
"grid",
|
|
152
|
+
"group",
|
|
153
|
+
"heading",
|
|
154
|
+
"list",
|
|
155
|
+
"listitem",
|
|
156
|
+
"main",
|
|
157
|
+
"menu",
|
|
158
|
+
"menubar",
|
|
159
|
+
"navigation",
|
|
160
|
+
"paragraph",
|
|
161
|
+
"radiogroup",
|
|
162
|
+
"region",
|
|
163
|
+
"row",
|
|
164
|
+
"rowgroup",
|
|
165
|
+
"table",
|
|
166
|
+
"tabpanel",
|
|
167
|
+
"toolbar",
|
|
168
|
+
"tree",
|
|
169
|
+
"treeitem",
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_COLLECTION_ITEM_ROLES = {
|
|
173
|
+
"listitem",
|
|
174
|
+
"menuitem",
|
|
175
|
+
"menuitemcheckbox",
|
|
176
|
+
"menuitemradio",
|
|
177
|
+
"tab",
|
|
178
|
+
"treeitem",
|
|
179
|
+
"row",
|
|
180
|
+
"option",
|
|
181
|
+
"link",
|
|
182
|
+
"button", # Расширено для группировки
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
def __init__(self) -> None:
|
|
186
|
+
# Коллекция test_id для одной страницы (для вычисления общего префикса)
|
|
187
|
+
self._seen_test_ids: List[str] = []
|
|
188
|
+
self._test_id_prefix: str | None = None
|
|
189
|
+
|
|
190
|
+
def _get_unique_name(self, base_name: str, used_names: set[str]) -> str:
|
|
191
|
+
"""Генерирует уникальное имя, добавляя суффикс при необходимости."""
|
|
192
|
+
candidate = base_name
|
|
193
|
+
counter = 1
|
|
194
|
+
while candidate in used_names:
|
|
195
|
+
candidate = f"{base_name}_{counter}"
|
|
196
|
+
counter += 1
|
|
197
|
+
return candidate
|
|
198
|
+
|
|
199
|
+
def _sanitize_name(self, name: str) -> str:
|
|
200
|
+
"""Санитизация человекочитаемых имён в идентификаторы Python.
|
|
201
|
+
|
|
202
|
+
Правила:
|
|
203
|
+
* очистка от артефактов снепшота (типа [ref=...]);
|
|
204
|
+
* транслитерация в ASCII;
|
|
205
|
+
* удаление всего, кроме букв/цифр/подчёркиваний и пробелов;
|
|
206
|
+
* схлопывание пробелов в подчёркивание;
|
|
207
|
+
* гарантия корректного идентификатора Python.
|
|
208
|
+
"""
|
|
209
|
+
s = (name or "").strip()
|
|
210
|
+
|
|
211
|
+
# Очистка от старых артефактов, если вдруг попадутся (хотя мы перешли на JSON)
|
|
212
|
+
if "[ref=" in s:
|
|
213
|
+
s = s.split("[ref=")[0].strip()
|
|
214
|
+
|
|
215
|
+
# Базовая логика
|
|
216
|
+
s = unidecode(s)
|
|
217
|
+
s = s.replace("-", "_")
|
|
218
|
+
# Оставляем только буквы, цифры и пробелы/подчёркивания
|
|
219
|
+
s = re.sub(r"[^\w\s]", "", s)
|
|
220
|
+
s = re.sub(r"\s+", "_", s)
|
|
221
|
+
s = s.strip("_")
|
|
222
|
+
|
|
223
|
+
# Truncate logic
|
|
224
|
+
if len(s) > 50:
|
|
225
|
+
s = s[:50]
|
|
226
|
+
if "_" in s:
|
|
227
|
+
s = s.rsplit("_", 1)[0]
|
|
228
|
+
|
|
229
|
+
parts = [p for p in s.split("_") if p]
|
|
230
|
+
# Removed aggressive truncation: if len(parts) > 4: parts = parts[:3]
|
|
231
|
+
s = "_".join(parts)
|
|
232
|
+
|
|
233
|
+
s = s.lower()
|
|
234
|
+
|
|
235
|
+
if not s:
|
|
236
|
+
return "element"
|
|
237
|
+
|
|
238
|
+
if s and not s[0].isalpha() and s[0] != "_":
|
|
239
|
+
s = "_" + s
|
|
240
|
+
|
|
241
|
+
return s
|
|
242
|
+
|
|
243
|
+
def _parse_aria_node_recursive(
|
|
244
|
+
self,
|
|
245
|
+
nodes: List[Dict[str, Any]] | Dict[str, Any],
|
|
246
|
+
used_names: set[str],
|
|
247
|
+
context: Dict[str, Any],
|
|
248
|
+
) -> List[Dict[str, Any]]:
|
|
249
|
+
"""Строит внутреннее дерево элементов из JSON-структуры (от Runtime).
|
|
250
|
+
|
|
251
|
+
Аргумент nodes ожидает список словарей (детей) или один корневой словарь.
|
|
252
|
+
Каждый словарь имеет структуру: {role, name, ref, props, children, ...}.
|
|
253
|
+
"""
|
|
254
|
+
elements: List[Dict[str, Any]] = []
|
|
255
|
+
|
|
256
|
+
# Нормализация входа: работаем всегда со списком узлов
|
|
257
|
+
node_list = nodes if isinstance(nodes, list) else [nodes]
|
|
258
|
+
|
|
259
|
+
for node in node_list:
|
|
260
|
+
if isinstance(node, str):
|
|
261
|
+
# Текстовый узел
|
|
262
|
+
text_val = node.strip()
|
|
263
|
+
if text_val:
|
|
264
|
+
var_name_base = self._sanitize_name(text_val)
|
|
265
|
+
var_name = self._get_unique_name(
|
|
266
|
+
var_name_base or "text", used_names
|
|
267
|
+
)
|
|
268
|
+
used_names.add(var_name)
|
|
269
|
+
elements.append(
|
|
270
|
+
{
|
|
271
|
+
"var_name": var_name,
|
|
272
|
+
"class": Element,
|
|
273
|
+
"role": "generic",
|
|
274
|
+
"aria_name": None,
|
|
275
|
+
"aria_ref": None,
|
|
276
|
+
"test_id": None,
|
|
277
|
+
"placeholder": None,
|
|
278
|
+
"text": text_val,
|
|
279
|
+
"level": None,
|
|
280
|
+
"is_container": False,
|
|
281
|
+
"children": [],
|
|
282
|
+
"accessibility_notes": [],
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
if not isinstance(node, dict):
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
role = node.get("role", "generic")
|
|
291
|
+
aria_name = node.get("name", "")
|
|
292
|
+
aria_ref = node.get("ref")
|
|
293
|
+
props = node.get("props") or {}
|
|
294
|
+
raw_children = node.get("children", [])
|
|
295
|
+
|
|
296
|
+
# Извлекаем пропы
|
|
297
|
+
test_id = props.get("testId") or props.get("test_id")
|
|
298
|
+
if test_id:
|
|
299
|
+
self._seen_test_ids.append(test_id)
|
|
300
|
+
|
|
301
|
+
placeholder = props.get("placeholder")
|
|
302
|
+
text_prop = props.get("text")
|
|
303
|
+
level = node.get("level")
|
|
304
|
+
|
|
305
|
+
# Определяем класс элемента
|
|
306
|
+
element_cls = self._ROLE_TO_CLASS.get(role, Element)
|
|
307
|
+
|
|
308
|
+
# Логика именования (Naming)
|
|
309
|
+
# Приоритет: aria_name -> test_id -> role
|
|
310
|
+
var_name_base = ""
|
|
311
|
+
|
|
312
|
+
if aria_name:
|
|
313
|
+
var_name_base = self._sanitize_name(aria_name)
|
|
314
|
+
|
|
315
|
+
if not var_name_base and test_id:
|
|
316
|
+
normalized_tid = self._normalize_test_id_for_name(test_id)
|
|
317
|
+
var_name_base = self._sanitize_name(normalized_tid)
|
|
318
|
+
|
|
319
|
+
if not var_name_base and text_prop:
|
|
320
|
+
# Попробуем взять имя из текста (props.text)
|
|
321
|
+
var_name_base = self._sanitize_name(text_prop)
|
|
322
|
+
|
|
323
|
+
if not var_name_base:
|
|
324
|
+
var_name_base = self._sanitize_name(role)
|
|
325
|
+
|
|
326
|
+
if role == "listitem" and not aria_name and not test_id:
|
|
327
|
+
var_name_base = "item"
|
|
328
|
+
|
|
329
|
+
var_name = var_name_base or role.lower()
|
|
330
|
+
if role == "list" and aria_name:
|
|
331
|
+
var_name = f"{self._sanitize_name(aria_name)}_list"
|
|
332
|
+
|
|
333
|
+
final_var_name = self._get_unique_name(var_name, used_names)
|
|
334
|
+
used_names.add(final_var_name)
|
|
335
|
+
|
|
336
|
+
# Рекурсивная обработка детей
|
|
337
|
+
child_context = context.copy()
|
|
338
|
+
parsed_children = []
|
|
339
|
+
|
|
340
|
+
if raw_children:
|
|
341
|
+
parsed_children = self._parse_aria_node_recursive(
|
|
342
|
+
raw_children, used_names, child_context
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Формируем элемент
|
|
346
|
+
# Важно: текст теперь может быть отдельным ребенком, если он пришел как строка в children
|
|
347
|
+
|
|
348
|
+
element_data: Dict[str, Any] = {
|
|
349
|
+
"var_name": final_var_name,
|
|
350
|
+
"class": element_cls,
|
|
351
|
+
"role": role,
|
|
352
|
+
"aria_name": aria_name,
|
|
353
|
+
"aria_ref": aria_ref,
|
|
354
|
+
"test_id": test_id,
|
|
355
|
+
"placeholder": placeholder,
|
|
356
|
+
"text": text_prop, # Текст из props
|
|
357
|
+
"level": level,
|
|
358
|
+
"is_container": role in self._CONTAINER_ROLES or bool(parsed_children),
|
|
359
|
+
"children": parsed_children,
|
|
360
|
+
"accessibility_notes": [],
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
elements.append(element_data)
|
|
364
|
+
|
|
365
|
+
return elements
|
|
366
|
+
|
|
367
|
+
def _flatten_tree_recursive(
|
|
368
|
+
self, elements: List[Dict[str, Any]]
|
|
369
|
+
) -> List[Dict[str, Any]]:
|
|
370
|
+
"""Рекурсивно проходит дерево и делает служебные generic-обёртки прозрачными.
|
|
371
|
+
|
|
372
|
+
Правила:
|
|
373
|
+
- Узлы с role="generic" без aria_name/test_id/semantic-флага и без полезного текста
|
|
374
|
+
не становятся отдельными полями PageObject: их дети поднимаются на уровень выше.
|
|
375
|
+
- Семантические контейнеры (is_semantic_group, aria_name, test_id, meaningful text)
|
|
376
|
+
всегда сохраняются.
|
|
377
|
+
"""
|
|
378
|
+
new_elements: List[Dict[str, Any]] = []
|
|
379
|
+
for el in elements:
|
|
380
|
+
# Сначала рекурсивно обрабатываем детей
|
|
381
|
+
if el["children"]:
|
|
382
|
+
el["children"] = self._flatten_tree_recursive(el["children"])
|
|
383
|
+
|
|
384
|
+
role = el.get("role")
|
|
385
|
+
aria_name = (el.get("aria_name") or "").strip()
|
|
386
|
+
test_id = (el.get("test_id") or "").strip()
|
|
387
|
+
text = (el.get("text") or "").strip()
|
|
388
|
+
|
|
389
|
+
# Любой непустой text теперь считаем осмысленным — ничего не выкидываем как артефакт
|
|
390
|
+
has_meaningful_text = bool(text)
|
|
391
|
+
|
|
392
|
+
is_plain_generic = (
|
|
393
|
+
role == "generic"
|
|
394
|
+
and not aria_name
|
|
395
|
+
and not test_id
|
|
396
|
+
and not el.get("is_semantic_group")
|
|
397
|
+
and not has_meaningful_text
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if is_plain_generic:
|
|
401
|
+
# Делаем generic полностью прозрачным: поднимаем всех детей уровнем выше
|
|
402
|
+
new_elements.extend(el["children"])
|
|
403
|
+
else:
|
|
404
|
+
new_elements.append(el)
|
|
405
|
+
|
|
406
|
+
return new_elements
|
|
407
|
+
|
|
408
|
+
def _find_first_heading(
|
|
409
|
+
self, element: Dict[str, Any], depth: int = 0, max_depth: int = 2
|
|
410
|
+
) -> Optional[Dict[str, Any]]:
|
|
411
|
+
"""
|
|
412
|
+
Рекурсивный поиск первого заголовка для именования секции.
|
|
413
|
+
Останавливается, если встречает уже именованную семантическую группу.
|
|
414
|
+
"""
|
|
415
|
+
if depth > max_depth:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
# 1. Ищем среди непосредственных детей
|
|
419
|
+
for child in element["children"]:
|
|
420
|
+
# Если ребенок уже стал семантической группой (в post-order обходе),
|
|
421
|
+
# то его заголовок принадлежит ему, а не нам. Пропускаем.
|
|
422
|
+
if child.get("is_semantic_group") or (
|
|
423
|
+
child.get("aria_name") and child["role"] != "heading"
|
|
424
|
+
):
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
if child["class"] == Heading and child.get("aria_name"):
|
|
428
|
+
return child
|
|
429
|
+
|
|
430
|
+
# 2. Если не нашли, ищем в безымянных generic-детях
|
|
431
|
+
for child in element["children"]:
|
|
432
|
+
# Аналогично: не спускаемся в уже именованные группы
|
|
433
|
+
if child.get("is_semantic_group") or (
|
|
434
|
+
child.get("aria_name") and child["role"] != "heading"
|
|
435
|
+
):
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
if child["role"] == "generic" and not child.get("test_id"):
|
|
439
|
+
found = self._find_first_heading(child, depth + 1, max_depth)
|
|
440
|
+
if found:
|
|
441
|
+
return found
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
def _construct_locator_from_heading(self, heading_el: Dict[str, Any]) -> str:
|
|
445
|
+
"""Создает CSS :has() локатор на основе заголовка."""
|
|
446
|
+
text = heading_el.get("aria_name", "").replace('"', '\\"')
|
|
447
|
+
level = heading_el.get("level")
|
|
448
|
+
|
|
449
|
+
# Если уровень известен, используем h1-h6, иначе роль
|
|
450
|
+
tag = f"h{level}" if level else "[role=heading]"
|
|
451
|
+
|
|
452
|
+
# Используем :has-text для надежности
|
|
453
|
+
return f':has({tag}:has-text("{text}"))'
|
|
454
|
+
|
|
455
|
+
_ROLE_DEFAULT_NAMES = {
|
|
456
|
+
"banner": "header",
|
|
457
|
+
"contentinfo": "footer",
|
|
458
|
+
"navigation": "navigation",
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
def _promote_semantic_wrappers(
|
|
462
|
+
self, elements: List[Dict[str, Any]], used_names: set[str]
|
|
463
|
+
) -> None:
|
|
464
|
+
for el in elements:
|
|
465
|
+
if el["children"]:
|
|
466
|
+
self._promote_semantic_wrappers(el["children"], used_names)
|
|
467
|
+
|
|
468
|
+
if (
|
|
469
|
+
el["role"] in self._CONTAINER_ROLES
|
|
470
|
+
and not el.get("aria_name")
|
|
471
|
+
and not el.get("test_id")
|
|
472
|
+
):
|
|
473
|
+
# Используем улучшенный поиск заголовка
|
|
474
|
+
first_heading = self._find_first_heading(el)
|
|
475
|
+
|
|
476
|
+
if first_heading:
|
|
477
|
+
heading_text = first_heading["aria_name"]
|
|
478
|
+
new_base = self._sanitize_name(heading_text)
|
|
479
|
+
if new_base and new_base != "element":
|
|
480
|
+
suffix = "_section"
|
|
481
|
+
if el["role"] == "form":
|
|
482
|
+
suffix = "_form"
|
|
483
|
+
elif el["role"] == "navigation":
|
|
484
|
+
suffix = "_nav"
|
|
485
|
+
|
|
486
|
+
if not new_base.endswith(suffix) and not new_base.endswith(
|
|
487
|
+
suffix.replace("_", "")
|
|
488
|
+
):
|
|
489
|
+
new_base += suffix
|
|
490
|
+
|
|
491
|
+
new_var_name = self._get_unique_name(new_base, used_names)
|
|
492
|
+
el["var_name"] = new_var_name
|
|
493
|
+
el["is_semantic_group"] = True
|
|
494
|
+
|
|
495
|
+
# Генерируем локатор, чтобы контейнер можно было найти
|
|
496
|
+
if not el.get("locator") and not el.get("test_id"):
|
|
497
|
+
el["locator"] = self._construct_locator_from_heading(
|
|
498
|
+
first_heading
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
used_names.add(new_var_name)
|
|
502
|
+
# Применяем role-based fallback для контейнеров с "плохими" именами
|
|
503
|
+
if (
|
|
504
|
+
el.get("role") in self._ROLE_DEFAULT_NAMES
|
|
505
|
+
and not el.get("aria_name")
|
|
506
|
+
and not el.get("test_id")
|
|
507
|
+
and not el.get("is_semantic_group")
|
|
508
|
+
):
|
|
509
|
+
default_name = self._ROLE_DEFAULT_NAMES[el["role"]]
|
|
510
|
+
base = self._sanitize_name(default_name)
|
|
511
|
+
new_var_name = self._get_unique_name(base, used_names)
|
|
512
|
+
el["var_name"] = new_var_name
|
|
513
|
+
used_names.add(new_var_name)
|
|
514
|
+
|
|
515
|
+
def _build_init_body_ast(
|
|
516
|
+
self,
|
|
517
|
+
elements: List[Dict[str, Any]],
|
|
518
|
+
parent_var: str,
|
|
519
|
+
parent_identity_components: List[str],
|
|
520
|
+
overrides: Dict[str, Dict[str, str]],
|
|
521
|
+
leaf_overrides: Dict[str, List[str]], # Mapped by Signature -> List[names]
|
|
522
|
+
leaf_seen: Dict[str, int],
|
|
523
|
+
) -> List[ast.stmt]:
|
|
524
|
+
nodes: List[ast.stmt] = []
|
|
525
|
+
created_list_attrs: set[str] = set()
|
|
526
|
+
|
|
527
|
+
# LCL-34: Умная группировка
|
|
528
|
+
grouped_indices: Dict[str, List[int]] = {}
|
|
529
|
+
for idx, el in enumerate(elements):
|
|
530
|
+
role = el.get("role")
|
|
531
|
+
if role in self._COLLECTION_ITEM_ROLES:
|
|
532
|
+
# Не группируем интерактивные элементы (ссылки, кнопки), если у них есть осмысленное имя или test_id/text.
|
|
533
|
+
is_interactive = role in ("link", "button")
|
|
534
|
+
has_name = bool(el.get("aria_name"))
|
|
535
|
+
has_tid = bool(el.get("test_id"))
|
|
536
|
+
has_text = bool(el.get("text"))
|
|
537
|
+
|
|
538
|
+
if is_interactive and (has_name or has_tid or has_text):
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
key = f"{role}_{el['class'].__name__}"
|
|
542
|
+
grouped_indices.setdefault(key, []).append(idx)
|
|
543
|
+
|
|
544
|
+
skip_indices: set[int] = set()
|
|
545
|
+
for key, idx_list in grouped_indices.items():
|
|
546
|
+
if len(idx_list) >= 2:
|
|
547
|
+
first_el = elements[idx_list[0]]
|
|
548
|
+
class_name = first_el["class"].__name__
|
|
549
|
+
|
|
550
|
+
base_name = key.split("_")[0]
|
|
551
|
+
|
|
552
|
+
# Универсальные правила именования прототипа
|
|
553
|
+
if base_name in {"generic", "listitem"}:
|
|
554
|
+
base_name = "item"
|
|
555
|
+
elif base_name == "tab":
|
|
556
|
+
base_name = "tab"
|
|
557
|
+
elif base_name == "row":
|
|
558
|
+
base_name = "row"
|
|
559
|
+
|
|
560
|
+
list_base_name = f"{base_name}s"
|
|
561
|
+
|
|
562
|
+
list_attr = list_base_name
|
|
563
|
+
candidate = list_attr
|
|
564
|
+
suffix = 0
|
|
565
|
+
while candidate in created_list_attrs:
|
|
566
|
+
suffix += 1
|
|
567
|
+
candidate = f"{list_attr}_{suffix}"
|
|
568
|
+
list_attr = candidate
|
|
569
|
+
created_list_attrs.add(list_attr)
|
|
570
|
+
|
|
571
|
+
full_var_path = f"{parent_var}.{list_attr}"
|
|
572
|
+
target_node = self._create_attribute_chain(full_var_path)
|
|
573
|
+
parent_ref_node = self._create_attribute_chain(parent_var)
|
|
574
|
+
|
|
575
|
+
proto_keywords = [
|
|
576
|
+
ast.keyword("name", ast.Constant(base_name)),
|
|
577
|
+
ast.keyword("accessible_name", ast.Constant(None)),
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
element_constructor = ast.Call(
|
|
581
|
+
func=ast.Name(id=class_name, ctx=ast.Load()),
|
|
582
|
+
args=[],
|
|
583
|
+
keywords=proto_keywords,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
call_node = ast.Call(
|
|
587
|
+
func=ast.Attribute(
|
|
588
|
+
value=parent_ref_node,
|
|
589
|
+
attr="add_element_list",
|
|
590
|
+
ctx=ast.Load(),
|
|
591
|
+
),
|
|
592
|
+
args=[element_constructor],
|
|
593
|
+
keywords=[ast.keyword("alias", ast.Constant(list_attr))],
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
assign_node = ast.Assign(targets=[target_node], value=call_node)
|
|
597
|
+
nodes.append(assign_node)
|
|
598
|
+
skip_indices.update(idx_list)
|
|
599
|
+
|
|
600
|
+
for idx, el in enumerate(elements):
|
|
601
|
+
if idx in skip_indices:
|
|
602
|
+
continue
|
|
603
|
+
|
|
604
|
+
class_name = el["class"].__name__
|
|
605
|
+
# key = (class_name, el.get("aria_name", ""))
|
|
606
|
+
|
|
607
|
+
# Signature for overrides lookup
|
|
608
|
+
# Note: aria_name here is raw data.
|
|
609
|
+
sig = self._leaf_signature(
|
|
610
|
+
class_name,
|
|
611
|
+
el.get("aria_name", "") or "",
|
|
612
|
+
el.get("text", "") or "",
|
|
613
|
+
el.get("test_id", "") or "",
|
|
614
|
+
el.get("placeholder", "") or "",
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
current_sig_count = leaf_seen.get(sig, 0)
|
|
618
|
+
leaf_seen[sig] = current_sig_count + 1
|
|
619
|
+
|
|
620
|
+
var_name = el["var_name"]
|
|
621
|
+
|
|
622
|
+
# Apply Override if exists
|
|
623
|
+
if sig in leaf_overrides:
|
|
624
|
+
if len(leaf_overrides[sig]) > current_sig_count:
|
|
625
|
+
# Use the existing user-defined name
|
|
626
|
+
var_name = leaf_overrides[sig][current_sig_count]
|
|
627
|
+
|
|
628
|
+
# Also update el["var_name"] so children usage (path building) is correct?
|
|
629
|
+
# Actually var_name is used below for construction.
|
|
630
|
+
# However, if we change var_name here, we must ensure unique check was done or doesn't matter?
|
|
631
|
+
# The user-supplied name is assumed to be valid and unique in the OLD file.
|
|
632
|
+
# But in the NEW generation, we already reserved 'el["var_name"]' in `used_names` during parse.
|
|
633
|
+
# If we swap it, we might collide?
|
|
634
|
+
# Ideally we should push this override into `_parse_aria_node_recursive`, but that's hard.
|
|
635
|
+
# Here we just override the name used in `self.parent.VAR_NAME = ...`.
|
|
636
|
+
# This is safe enough given the user controls it.
|
|
637
|
+
|
|
638
|
+
full_var_path = f"{parent_var}.{var_name}"
|
|
639
|
+
target_node = self._create_attribute_chain(full_var_path)
|
|
640
|
+
parent_ref_node = self._create_attribute_chain(parent_var)
|
|
641
|
+
|
|
642
|
+
keywords = [ast.keyword("name", ast.Constant(var_name))]
|
|
643
|
+
|
|
644
|
+
has_locator_strategy = False
|
|
645
|
+
|
|
646
|
+
if el.get("test_id"):
|
|
647
|
+
keywords.append(ast.keyword("test_id", ast.Constant(el["test_id"])))
|
|
648
|
+
has_locator_strategy = True
|
|
649
|
+
elif el.get("locator"): # Приоритет локатора (например, для секций)
|
|
650
|
+
keywords.append(ast.keyword("locator", ast.Constant(el["locator"])))
|
|
651
|
+
# Если есть локатор, accessible_name может быть не нужен для поиска, но полезен для отладки
|
|
652
|
+
if el.get("aria_name"):
|
|
653
|
+
keywords.append(
|
|
654
|
+
ast.keyword("accessible_name", ast.Constant(el["aria_name"]))
|
|
655
|
+
)
|
|
656
|
+
has_locator_strategy = True
|
|
657
|
+
elif el.get("role") and el.get("aria_name"):
|
|
658
|
+
# aria_name не пустой -> используем его
|
|
659
|
+
keywords.append(
|
|
660
|
+
ast.keyword("accessible_name", ast.Constant(el["aria_name"]))
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# Для generic элемента (Element) нужно явно передать role="generic",
|
|
664
|
+
# чтобы сработала стратегия get_by_role(role, name=...)
|
|
665
|
+
if el["role"] == "generic":
|
|
666
|
+
keywords.append(ast.keyword("role", ast.Constant("generic")))
|
|
667
|
+
|
|
668
|
+
has_locator_strategy = True
|
|
669
|
+
elif el.get("placeholder"):
|
|
670
|
+
keywords.append(
|
|
671
|
+
ast.keyword("placeholder", ast.Constant(el["placeholder"]))
|
|
672
|
+
)
|
|
673
|
+
has_locator_strategy = True
|
|
674
|
+
elif el.get("text"):
|
|
675
|
+
keywords.append(ast.keyword("text", ast.Constant(el["text"])))
|
|
676
|
+
has_locator_strategy = True
|
|
677
|
+
# elif el.get("role") and el.get("role") != "generic":
|
|
678
|
+
# keywords.append(ast.keyword("accessible_name", ast.Constant("")))
|
|
679
|
+
# has_locator_strategy = True
|
|
680
|
+
|
|
681
|
+
if el.get("aria_ref"):
|
|
682
|
+
keywords.append(ast.keyword("aria_ref", ast.Constant(el["aria_ref"])))
|
|
683
|
+
|
|
684
|
+
element_constructor = ast.Call(
|
|
685
|
+
func=ast.Name(id=class_name, ctx=ast.Load()),
|
|
686
|
+
args=[],
|
|
687
|
+
keywords=keywords,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
call_node = ast.Call(
|
|
691
|
+
func=ast.Attribute(
|
|
692
|
+
value=parent_ref_node, attr="add_element", ctx=ast.Load()
|
|
693
|
+
),
|
|
694
|
+
args=[element_constructor],
|
|
695
|
+
keywords=[],
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
# Если элемент не имеет стабильной стратегии поиска и не является контейнером/семантической группой,
|
|
699
|
+
# мы не генерируем для него отдельное предупреждение в коде страницы, чтобы не засорять __init__.
|
|
700
|
+
# Диагностику таких случаев лучше делать на уровне генератора (логирование/ошибка), если потребуется.
|
|
701
|
+
if (
|
|
702
|
+
not has_locator_strategy
|
|
703
|
+
and not el["is_container"]
|
|
704
|
+
and not el.get("is_semantic_group")
|
|
705
|
+
):
|
|
706
|
+
# LCL-38: Помечаем нестабильные локаторы
|
|
707
|
+
nodes.append(
|
|
708
|
+
ast.Expr(value=ast.Constant(value="TODO: Unstable Locator"))
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
assign_node = ast.Assign(targets=[target_node], value=call_node)
|
|
712
|
+
nodes.append(assign_node)
|
|
713
|
+
|
|
714
|
+
if el["children"]:
|
|
715
|
+
child_nodes = self._build_init_body_ast(
|
|
716
|
+
el["children"],
|
|
717
|
+
full_var_path,
|
|
718
|
+
parent_identity_components,
|
|
719
|
+
overrides,
|
|
720
|
+
leaf_overrides,
|
|
721
|
+
leaf_seen,
|
|
722
|
+
)
|
|
723
|
+
nodes.extend(child_nodes)
|
|
724
|
+
return nodes
|
|
725
|
+
|
|
726
|
+
def _identity_component_key(
|
|
727
|
+
self, class_name: str, aria_name: str, index: int
|
|
728
|
+
) -> str:
|
|
729
|
+
return f"{class_name}|{aria_name}|{index}"
|
|
730
|
+
|
|
731
|
+
def _leaf_signature(
|
|
732
|
+
self,
|
|
733
|
+
class_name: str,
|
|
734
|
+
aria_name: str,
|
|
735
|
+
text: str = "",
|
|
736
|
+
test_id: str = "",
|
|
737
|
+
placeholder: str = "",
|
|
738
|
+
) -> str:
|
|
739
|
+
"""
|
|
740
|
+
Creates a signature for keying elements in the map.
|
|
741
|
+
We include all stable identifiers.
|
|
742
|
+
Format: ClassName|acc=...|text=...|tid=...|ph=...
|
|
743
|
+
"""
|
|
744
|
+
# Normalize to handle None/empty consistently
|
|
745
|
+
an = (aria_name or "").strip()
|
|
746
|
+
tx = (text or "").strip()
|
|
747
|
+
ti = (test_id or "").strip()
|
|
748
|
+
ph = (placeholder or "").strip()
|
|
749
|
+
return f"{class_name}|acc={an}|text={tx}|tid={ti}|ph={ph}"
|
|
750
|
+
|
|
751
|
+
def _extract_field_mappings(self, class_node: ast.ClassDef) -> Dict[str, List[str]]:
|
|
752
|
+
"""
|
|
753
|
+
Парсит __init__ существующего класса и строит карту:
|
|
754
|
+
Signature -> List[var_name]
|
|
755
|
+
"""
|
|
756
|
+
mappings: Dict[str, List[str]] = {}
|
|
757
|
+
|
|
758
|
+
init_method = next(
|
|
759
|
+
(
|
|
760
|
+
n
|
|
761
|
+
for n in class_node.body
|
|
762
|
+
if isinstance(n, ast.FunctionDef) and n.name == "__init__"
|
|
763
|
+
),
|
|
764
|
+
None,
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
if init_method:
|
|
768
|
+
for node in init_method.body:
|
|
769
|
+
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
|
770
|
+
if isinstance(node.targets[0], ast.Attribute):
|
|
771
|
+
target = node.targets[0]
|
|
772
|
+
var_name = target.attr
|
|
773
|
+
|
|
774
|
+
inner_call = None
|
|
775
|
+
if isinstance(node.value, ast.Call):
|
|
776
|
+
# self.foo = self.add_element(...)
|
|
777
|
+
if len(node.value.args) > 0:
|
|
778
|
+
arg0 = node.value.args[0]
|
|
779
|
+
if isinstance(arg0, ast.Call):
|
|
780
|
+
inner_call = arg0
|
|
781
|
+
|
|
782
|
+
if inner_call:
|
|
783
|
+
if isinstance(inner_call.func, ast.Name):
|
|
784
|
+
class_name = inner_call.func.id
|
|
785
|
+
elif isinstance(inner_call.func, ast.Attribute):
|
|
786
|
+
# Handle module.Element?
|
|
787
|
+
class_name = inner_call.func.attr
|
|
788
|
+
else:
|
|
789
|
+
class_name = "Unknown"
|
|
790
|
+
|
|
791
|
+
text_val = ""
|
|
792
|
+
test_id_val = ""
|
|
793
|
+
placeholder_val = ""
|
|
794
|
+
acc_name = ""
|
|
795
|
+
|
|
796
|
+
for kw in inner_call.keywords:
|
|
797
|
+
if isinstance(kw.value, ast.Constant):
|
|
798
|
+
val = kw.value.value
|
|
799
|
+
if kw.arg == "accessible_name":
|
|
800
|
+
acc_name = str(val)
|
|
801
|
+
elif kw.arg == "text":
|
|
802
|
+
text_val = str(val)
|
|
803
|
+
elif kw.arg == "test_id":
|
|
804
|
+
test_id_val = str(val)
|
|
805
|
+
elif kw.arg == "placeholder":
|
|
806
|
+
placeholder_val = str(val)
|
|
807
|
+
|
|
808
|
+
sig = self._leaf_signature(
|
|
809
|
+
class_name,
|
|
810
|
+
acc_name,
|
|
811
|
+
text_val,
|
|
812
|
+
test_id_val,
|
|
813
|
+
placeholder_val,
|
|
814
|
+
)
|
|
815
|
+
mappings.setdefault(sig, []).append(var_name)
|
|
816
|
+
|
|
817
|
+
return mappings
|
|
818
|
+
|
|
819
|
+
def _create_attribute_chain(self, path: str) -> ast.expr:
|
|
820
|
+
parts = path.split(".")
|
|
821
|
+
expr: ast.expr = ast.Name(id=parts[0], ctx=ast.Load())
|
|
822
|
+
for part in parts[1:]:
|
|
823
|
+
expr = ast.Attribute(value=expr, attr=part, ctx=ast.Load())
|
|
824
|
+
return expr
|
|
825
|
+
|
|
826
|
+
def _generate_code(
|
|
827
|
+
self,
|
|
828
|
+
elements_tree: List[Dict[str, Any]],
|
|
829
|
+
class_name: str,
|
|
830
|
+
page_path: Optional[str],
|
|
831
|
+
aria_snapshot_path: Optional[str],
|
|
832
|
+
screenshot_path: Optional[str],
|
|
833
|
+
overrides: Dict[str, Dict[str, str]],
|
|
834
|
+
leaf_overrides: Dict[str, List[str]], # Changed type to List[str]
|
|
835
|
+
) -> str:
|
|
836
|
+
all_classes = {el["class"] for el in self._flatten_tree(elements_tree)}
|
|
837
|
+
imports_list = sorted({cls.__name__ for cls in all_classes} | {"Element"})
|
|
838
|
+
import_names = [ast.alias(name="Page")] + [
|
|
839
|
+
ast.alias(name=name) for name in imports_list
|
|
840
|
+
]
|
|
841
|
+
imports_node = ast.ImportFrom(
|
|
842
|
+
module="persona_dsl.pages", names=import_names, level=0
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
class_def = ast.ClassDef(
|
|
846
|
+
name=class_name,
|
|
847
|
+
bases=[ast.Name(id="Page", ctx=ast.Load())],
|
|
848
|
+
keywords=[],
|
|
849
|
+
body=[],
|
|
850
|
+
decorator_list=[],
|
|
851
|
+
type_params=[],
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
init_body: List[ast.stmt] = [
|
|
855
|
+
ast.Expr(
|
|
856
|
+
value=ast.Call(
|
|
857
|
+
func=ast.Attribute(
|
|
858
|
+
value=ast.Call(
|
|
859
|
+
func=ast.Name(id="super", ctx=ast.Load()),
|
|
860
|
+
args=[],
|
|
861
|
+
keywords=[],
|
|
862
|
+
),
|
|
863
|
+
attr="__init__",
|
|
864
|
+
ctx=ast.Load(),
|
|
865
|
+
),
|
|
866
|
+
args=[],
|
|
867
|
+
keywords=[],
|
|
868
|
+
)
|
|
869
|
+
)
|
|
870
|
+
]
|
|
871
|
+
|
|
872
|
+
if page_path:
|
|
873
|
+
init_body.append(
|
|
874
|
+
ast.Assign(
|
|
875
|
+
targets=[
|
|
876
|
+
ast.Attribute(
|
|
877
|
+
value=ast.Name(id="self", ctx=ast.Load()),
|
|
878
|
+
attr="expected_path",
|
|
879
|
+
ctx=ast.Store(),
|
|
880
|
+
)
|
|
881
|
+
],
|
|
882
|
+
value=ast.Constant(value=page_path),
|
|
883
|
+
)
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
if aria_snapshot_path:
|
|
887
|
+
init_body.append(
|
|
888
|
+
ast.Assign(
|
|
889
|
+
targets=[
|
|
890
|
+
ast.Attribute(
|
|
891
|
+
value=ast.Name(id="self", ctx=ast.Load()),
|
|
892
|
+
attr="static_aria_snapshot_path",
|
|
893
|
+
ctx=ast.Store(),
|
|
894
|
+
)
|
|
895
|
+
],
|
|
896
|
+
value=ast.Constant(value=aria_snapshot_path),
|
|
897
|
+
)
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
if screenshot_path:
|
|
901
|
+
init_body.append(
|
|
902
|
+
ast.Assign(
|
|
903
|
+
targets=[
|
|
904
|
+
ast.Attribute(
|
|
905
|
+
value=ast.Name(id="self", ctx=ast.Load()),
|
|
906
|
+
attr="static_screenshot_path",
|
|
907
|
+
ctx=ast.Store(),
|
|
908
|
+
)
|
|
909
|
+
],
|
|
910
|
+
value=ast.Constant(value=screenshot_path),
|
|
911
|
+
)
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
element_nodes = self._build_init_body_ast(
|
|
915
|
+
elements_tree, "self", [], overrides, leaf_overrides, {}
|
|
916
|
+
)
|
|
917
|
+
init_body.extend(element_nodes)
|
|
918
|
+
if not element_nodes:
|
|
919
|
+
init_body.append(ast.Pass())
|
|
920
|
+
|
|
921
|
+
init_func = ast.FunctionDef(
|
|
922
|
+
name="__init__",
|
|
923
|
+
args=ast.arguments(
|
|
924
|
+
posonlyargs=[],
|
|
925
|
+
args=[ast.arg(arg="self")],
|
|
926
|
+
kwonlyargs=[],
|
|
927
|
+
kw_defaults=[],
|
|
928
|
+
defaults=[],
|
|
929
|
+
),
|
|
930
|
+
body=init_body,
|
|
931
|
+
decorator_list=[],
|
|
932
|
+
returns=ast.Constant(value=None),
|
|
933
|
+
type_params=[],
|
|
934
|
+
)
|
|
935
|
+
class_def.body.append(init_func)
|
|
936
|
+
module = ast.Module(body=[imports_node, class_def], type_ignores=[])
|
|
937
|
+
|
|
938
|
+
code = ast.unparse(ast.fix_missing_locations(module))
|
|
939
|
+
try:
|
|
940
|
+
import black
|
|
941
|
+
|
|
942
|
+
code = black.format_str(code, mode=black.Mode())
|
|
943
|
+
except Exception:
|
|
944
|
+
pass
|
|
945
|
+
return code
|
|
946
|
+
|
|
947
|
+
def _flatten_tree(self, tree: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
948
|
+
flat_list = []
|
|
949
|
+
for item in tree:
|
|
950
|
+
flat_list.append(item)
|
|
951
|
+
if item["children"]:
|
|
952
|
+
flat_list.extend(self._flatten_tree(item["children"]))
|
|
953
|
+
return flat_list
|
|
954
|
+
|
|
955
|
+
def _compute_test_id_prefix(self) -> str:
|
|
956
|
+
"""Находит общий префикс для test_id по токенам '-', '_'."""
|
|
957
|
+
if not self._seen_test_ids or len(self._seen_test_ids) < 2:
|
|
958
|
+
return ""
|
|
959
|
+
|
|
960
|
+
split_ids = []
|
|
961
|
+
for tid in self._seen_test_ids:
|
|
962
|
+
parts = re.split(r"[-_]+", tid)
|
|
963
|
+
split_ids.append(parts)
|
|
964
|
+
|
|
965
|
+
prefix_tokens: List[str] = []
|
|
966
|
+
for idx in range(min(len(parts) for parts in split_ids)):
|
|
967
|
+
token = split_ids[0][idx]
|
|
968
|
+
if all(len(parts) > idx and parts[idx] == token for parts in split_ids):
|
|
969
|
+
prefix_tokens.append(token)
|
|
970
|
+
else:
|
|
971
|
+
break
|
|
972
|
+
|
|
973
|
+
if not prefix_tokens:
|
|
974
|
+
return ""
|
|
975
|
+
|
|
976
|
+
# Общий префикс как строка без привязки к разделителю
|
|
977
|
+
return "-".join(prefix_tokens)
|
|
978
|
+
|
|
979
|
+
def _normalize_test_id_for_name(self, test_id: str) -> str:
|
|
980
|
+
"""Удаляет общий префикс test_id при построении имени."""
|
|
981
|
+
if not test_id:
|
|
982
|
+
return ""
|
|
983
|
+
|
|
984
|
+
if self._test_id_prefix is None:
|
|
985
|
+
self._test_id_prefix = self._compute_test_id_prefix() or ""
|
|
986
|
+
|
|
987
|
+
prefix = self._test_id_prefix
|
|
988
|
+
if prefix and test_id.startswith(prefix):
|
|
989
|
+
cut = len(prefix)
|
|
990
|
+
if len(test_id) > cut and test_id[cut] in ("-", "_"):
|
|
991
|
+
cut += 1
|
|
992
|
+
test_id = test_id[cut:]
|
|
993
|
+
|
|
994
|
+
return test_id
|
|
995
|
+
|
|
996
|
+
def generate_from_aria_snapshot(
|
|
997
|
+
self,
|
|
998
|
+
snapshot: Any,
|
|
999
|
+
class_name: str = "GeneratedPage",
|
|
1000
|
+
page_path: Optional[str] = None,
|
|
1001
|
+
aria_snapshot_path: Optional[str] = None,
|
|
1002
|
+
screenshot_path: Optional[str] = None,
|
|
1003
|
+
leaf_overrides: Optional[Dict[str, List[str]]] = None,
|
|
1004
|
+
) -> str:
|
|
1005
|
+
if not isinstance(snapshot, (dict, list)):
|
|
1006
|
+
snapshot = {}
|
|
1007
|
+
|
|
1008
|
+
# Сбрасываем состояние test_id для новой страницы
|
|
1009
|
+
self._seen_test_ids = []
|
|
1010
|
+
self._test_id_prefix = None
|
|
1011
|
+
|
|
1012
|
+
used_names: set[str] = set()
|
|
1013
|
+
elements_tree = self._parse_aria_node_recursive(
|
|
1014
|
+
snapshot, used_names, context={}
|
|
1015
|
+
)
|
|
1016
|
+
elements_tree = self._flatten_tree_recursive(elements_tree)
|
|
1017
|
+
|
|
1018
|
+
return self._generate_code(
|
|
1019
|
+
elements_tree,
|
|
1020
|
+
class_name,
|
|
1021
|
+
page_path,
|
|
1022
|
+
aria_snapshot_path,
|
|
1023
|
+
screenshot_path,
|
|
1024
|
+
{},
|
|
1025
|
+
leaf_overrides or {},
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
def generate_or_update_from_aria_snapshot(
|
|
1029
|
+
self,
|
|
1030
|
+
snapshot: Any,
|
|
1031
|
+
class_name: str = "GeneratedPage",
|
|
1032
|
+
page_path: Optional[str] = None,
|
|
1033
|
+
aria_snapshot_path: Optional[str] = None,
|
|
1034
|
+
screenshot_path: Optional[str] = None,
|
|
1035
|
+
existing_code: Optional[str] = None,
|
|
1036
|
+
) -> str:
|
|
1037
|
+
"""
|
|
1038
|
+
Генерирует код страницы, пытаясь сохранить пользовательские изменения из existing_code.
|
|
1039
|
+
Сохраняются:
|
|
1040
|
+
1. Docstrings класса.
|
|
1041
|
+
2. Пользовательские методы (все, кроме __init__).
|
|
1042
|
+
3. Декораторы класса.
|
|
1043
|
+
4. Имена переменных полей (smart mapping).
|
|
1044
|
+
"""
|
|
1045
|
+
if not existing_code:
|
|
1046
|
+
return self.generate_from_aria_snapshot(
|
|
1047
|
+
snapshot, class_name, page_path, aria_snapshot_path, screenshot_path
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
# 1. Генерируем "чистую" новую версию для сравнения
|
|
1051
|
+
new_code = self.generate_from_aria_snapshot(
|
|
1052
|
+
snapshot, class_name, page_path, aria_snapshot_path, screenshot_path
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
try:
|
|
1056
|
+
# 3. Находим существующий класс
|
|
1057
|
+
existing_tree = ast.parse(existing_code)
|
|
1058
|
+
print(f"DEBUG: Parsed tree body len: {len(existing_tree.body)}")
|
|
1059
|
+
|
|
1060
|
+
existing_class_node = next(
|
|
1061
|
+
(
|
|
1062
|
+
n
|
|
1063
|
+
for n in existing_tree.body
|
|
1064
|
+
if isinstance(n, ast.ClassDef) and n.name == class_name
|
|
1065
|
+
),
|
|
1066
|
+
None,
|
|
1067
|
+
)
|
|
1068
|
+
print(
|
|
1069
|
+
f"DEBUG: Found class node: {existing_class_node} for name {class_name}"
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
# 4. Extract field mappings and RE-GENERATE code
|
|
1073
|
+
leaf_overrides = {}
|
|
1074
|
+
if existing_class_node:
|
|
1075
|
+
leaf_overrides = self._extract_field_mappings(existing_class_node)
|
|
1076
|
+
|
|
1077
|
+
if leaf_overrides:
|
|
1078
|
+
new_code = self.generate_from_aria_snapshot(
|
|
1079
|
+
snapshot,
|
|
1080
|
+
class_name,
|
|
1081
|
+
page_path,
|
|
1082
|
+
aria_snapshot_path,
|
|
1083
|
+
screenshot_path,
|
|
1084
|
+
leaf_overrides,
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
# 5. Парсим финальную версию (с правильными именами) для вставки методов
|
|
1088
|
+
new_tree = ast.parse(new_code)
|
|
1089
|
+
new_class_node = next(
|
|
1090
|
+
(
|
|
1091
|
+
node
|
|
1092
|
+
for node in new_tree.body
|
|
1093
|
+
if isinstance(node, ast.ClassDef) and node.name == class_name
|
|
1094
|
+
),
|
|
1095
|
+
None,
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
if not existing_class_node or not new_class_node:
|
|
1099
|
+
# Fallback: couldn't parse/find classes, return new code as is
|
|
1100
|
+
return new_code
|
|
1101
|
+
|
|
1102
|
+
# 6. Переносим Docstring (если он был у пользователя)
|
|
1103
|
+
existing_docstring = ast.get_docstring(existing_class_node)
|
|
1104
|
+
if existing_docstring:
|
|
1105
|
+
# Проверяем, есть ли уже докстринг в новом
|
|
1106
|
+
if (
|
|
1107
|
+
new_class_node.body
|
|
1108
|
+
and isinstance(new_class_node.body[0], ast.Expr)
|
|
1109
|
+
and isinstance(new_class_node.body[0].value, ast.Constant)
|
|
1110
|
+
and isinstance(new_class_node.body[0].value.value, str)
|
|
1111
|
+
):
|
|
1112
|
+
new_class_node.body[0].value.value = existing_docstring
|
|
1113
|
+
else:
|
|
1114
|
+
new_class_node.body.insert(
|
|
1115
|
+
0, ast.Expr(value=ast.Constant(value=existing_docstring))
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
# 7. Переносим пользовательские методы
|
|
1119
|
+
for node in existing_class_node.body:
|
|
1120
|
+
if isinstance(node, ast.FunctionDef):
|
|
1121
|
+
if node.name == "__init__":
|
|
1122
|
+
continue
|
|
1123
|
+
new_class_node.body.append(node)
|
|
1124
|
+
# TODO: Other definitions (Assign constants, decorators etc) can be added here
|
|
1125
|
+
|
|
1126
|
+
# 8. Генерируем итоговый код
|
|
1127
|
+
merged_code = ast.unparse(ast.fix_missing_locations(new_tree))
|
|
1128
|
+
|
|
1129
|
+
try:
|
|
1130
|
+
import black
|
|
1131
|
+
|
|
1132
|
+
merged_code = black.format_str(merged_code, mode=black.Mode())
|
|
1133
|
+
except Exception:
|
|
1134
|
+
pass
|
|
1135
|
+
|
|
1136
|
+
return merged_code
|
|
1137
|
+
|
|
1138
|
+
except Exception:
|
|
1139
|
+
# Fallback to overwrite if merge fails
|
|
1140
|
+
return new_code
|