persona-dsl 26.1.20.8__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 +221 -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 +146 -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 +1064 -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.20.8.dist-info/METADATA +35 -0
- persona_dsl-26.1.20.8.dist-info/RECORD +86 -0
- persona_dsl-26.1.20.8.dist-info/WHEEL +5 -0
- persona_dsl-26.1.20.8.dist-info/entry_points.txt +6 -0
- persona_dsl-26.1.20.8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import time
|
|
4
|
+
import difflib
|
|
5
|
+
import yaml
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, List, Any
|
|
8
|
+
|
|
9
|
+
import allure
|
|
10
|
+
|
|
11
|
+
from persona_dsl.components.expectation import Expectation
|
|
12
|
+
from persona_dsl.utils.artifacts import sanitize_filename, artifacts_dir
|
|
13
|
+
from persona_dsl.utils.path import remove_by_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize(obj: Any, sort_keys: bool = True) -> Any:
|
|
17
|
+
"""
|
|
18
|
+
Рекурсивно нормализует JSON-структуру:
|
|
19
|
+
- сортирует ключи словарей (если sort_keys=True)
|
|
20
|
+
- оставляет списки как есть
|
|
21
|
+
"""
|
|
22
|
+
if isinstance(obj, dict):
|
|
23
|
+
items_gen = ((k, _normalize(v, sort_keys)) for k, v in obj.items())
|
|
24
|
+
items: Any
|
|
25
|
+
if sort_keys:
|
|
26
|
+
items = sorted(items_gen, key=lambda kv: kv[0])
|
|
27
|
+
else:
|
|
28
|
+
items = items_gen
|
|
29
|
+
return {k: v for k, v in items}
|
|
30
|
+
if isinstance(obj, list):
|
|
31
|
+
return [_normalize(v, sort_keys) for v in obj]
|
|
32
|
+
return obj
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MatchesAriaSnapshot(Expectation):
|
|
36
|
+
"""
|
|
37
|
+
Ожидание: ARIA-снепшот (YAML строка) совпадает с baseline (.yaml).
|
|
38
|
+
- baseline ищется по умолчанию в tests/snapshots/<name>.yaml.
|
|
39
|
+
- при отсутствии baseline:
|
|
40
|
+
* если update=True или UPDATE_SNAPSHOTS=1 — baseline создаётся из actual;
|
|
41
|
+
* иначе — ошибка.
|
|
42
|
+
- перед сравнением можно исключить пути (ignore_paths) и нормализовать порядок ключей (sort_keys=True).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
snapshot_name: str,
|
|
48
|
+
update: bool = False,
|
|
49
|
+
baseline_dir: Optional[str] = None,
|
|
50
|
+
ignore_paths: Optional[List[str]] = None,
|
|
51
|
+
sort_keys: bool = True,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Args:
|
|
55
|
+
snapshot_name: имя baseline-файла (например, "home-aria.yaml" или без .yaml).
|
|
56
|
+
update: разрешить создание/обновление baseline.
|
|
57
|
+
baseline_dir: каталог для baseline (по умолчанию tests/snapshots).
|
|
58
|
+
ignore_paths: список путей (dot/bracket), которые следует удалить перед сравнением.
|
|
59
|
+
sort_keys: сортировать ли ключи словарей перед сравнением.
|
|
60
|
+
"""
|
|
61
|
+
self.snapshot_name = snapshot_name
|
|
62
|
+
self.update = bool(update)
|
|
63
|
+
self.baseline_dir = baseline_dir
|
|
64
|
+
self.ignore_paths = ignore_paths or []
|
|
65
|
+
self.sort_keys = bool(sort_keys)
|
|
66
|
+
|
|
67
|
+
def _get_step_description(self, persona: Any) -> str:
|
|
68
|
+
return f"совпадает с ARIA-снепшотом '{self.snapshot_name}'"
|
|
69
|
+
|
|
70
|
+
def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
|
|
71
|
+
actual_value = args[0]
|
|
72
|
+
if not isinstance(actual_value, str):
|
|
73
|
+
raise TypeError(
|
|
74
|
+
f"MatchesAriaSnapshot ожидает YAML строку, получено: {type(actual_value)}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Преобразуем YAML в словарь для нормализации и сравнения
|
|
78
|
+
try:
|
|
79
|
+
actual_dict = yaml.safe_load(actual_value)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
raise ValueError(f"Не удалось распарсить YAML снепшот: {e}") from e
|
|
82
|
+
|
|
83
|
+
snap_name = sanitize_filename(self.snapshot_name)
|
|
84
|
+
if not snap_name.lower().endswith((".yaml", ".yml")):
|
|
85
|
+
snap_name += ".yaml"
|
|
86
|
+
base_dir = (
|
|
87
|
+
Path(self.baseline_dir)
|
|
88
|
+
if self.baseline_dir
|
|
89
|
+
else (Path.cwd() / "tests" / "snapshots")
|
|
90
|
+
)
|
|
91
|
+
baseline_path = base_dir / snap_name
|
|
92
|
+
|
|
93
|
+
# Подготовим копии для нормализации/фильтрации
|
|
94
|
+
def _prepare(val: Any) -> Any:
|
|
95
|
+
import copy
|
|
96
|
+
|
|
97
|
+
v = copy.deepcopy(val)
|
|
98
|
+
for p in self.ignore_paths:
|
|
99
|
+
remove_by_path(v, p, strict=False)
|
|
100
|
+
return _normalize(v, self.sort_keys)
|
|
101
|
+
|
|
102
|
+
actual_norm = _prepare(actual_dict)
|
|
103
|
+
|
|
104
|
+
# Если baseline отсутствует — возможно создаём
|
|
105
|
+
if not baseline_path.exists():
|
|
106
|
+
if self.update or os.getenv("UPDATE_SNAPSHOTS") == "1":
|
|
107
|
+
baseline_path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
with open(baseline_path, "w", encoding="utf-8") as f:
|
|
109
|
+
yaml.dump(
|
|
110
|
+
actual_norm,
|
|
111
|
+
f,
|
|
112
|
+
default_flow_style=False,
|
|
113
|
+
allow_unicode=True,
|
|
114
|
+
indent=2,
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
allure.attach(
|
|
118
|
+
yaml.dump(
|
|
119
|
+
actual_norm,
|
|
120
|
+
default_flow_style=False,
|
|
121
|
+
allow_unicode=True,
|
|
122
|
+
indent=2,
|
|
123
|
+
),
|
|
124
|
+
name=f"baseline-created:{snap_name}",
|
|
125
|
+
attachment_type=allure.attachment_type.TEXT,
|
|
126
|
+
)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
return
|
|
130
|
+
raise AssertionError(
|
|
131
|
+
f"ARIA baseline не найден: {baseline_path}. "
|
|
132
|
+
f"Запустите с UPDATE_SNAPSHOTS=1 или установите update=True для создания baseline."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Сравнение
|
|
136
|
+
with open(baseline_path, "r", encoding="utf-8") as f:
|
|
137
|
+
try:
|
|
138
|
+
base_data = yaml.safe_load(f)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
raise AssertionError(
|
|
141
|
+
f"Не удалось прочитать baseline YAML {baseline_path}: {e}"
|
|
142
|
+
) from e
|
|
143
|
+
base_norm = _prepare(base_data)
|
|
144
|
+
|
|
145
|
+
if base_norm == actual_norm:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
# Если разрешён update — обновляем baseline и выходим
|
|
149
|
+
if self.update or os.getenv("UPDATE_SNAPSHOTS") == "1":
|
|
150
|
+
shutil.copyfile(baseline_path, baseline_path.with_suffix(".bak.yaml"))
|
|
151
|
+
with open(baseline_path, "w", encoding="utf-8") as f:
|
|
152
|
+
yaml.dump(
|
|
153
|
+
actual_norm,
|
|
154
|
+
f,
|
|
155
|
+
default_flow_style=False,
|
|
156
|
+
allow_unicode=True,
|
|
157
|
+
indent=2,
|
|
158
|
+
)
|
|
159
|
+
try:
|
|
160
|
+
allure.attach(
|
|
161
|
+
yaml.dump(
|
|
162
|
+
actual_norm,
|
|
163
|
+
default_flow_style=False,
|
|
164
|
+
allow_unicode=True,
|
|
165
|
+
indent=2,
|
|
166
|
+
),
|
|
167
|
+
name=f"baseline-updated:{snap_name}",
|
|
168
|
+
attachment_type=allure.attachment_type.TEXT,
|
|
169
|
+
)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# Иначе — формируем артефакты diff
|
|
175
|
+
ts = int(time.time() * 1000)
|
|
176
|
+
art_dir = artifacts_dir("snapshots")
|
|
177
|
+
actual_path = art_dir / f"{ts}-actual-{snap_name}"
|
|
178
|
+
diff_path = art_dir / f"{ts}-diff-{snap_name}.diff"
|
|
179
|
+
|
|
180
|
+
with open(actual_path, "w", encoding="utf-8") as f:
|
|
181
|
+
yaml.dump(
|
|
182
|
+
actual_norm, f, default_flow_style=False, allow_unicode=True, indent=2
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
base_str = yaml.dump(
|
|
186
|
+
base_norm, default_flow_style=False, allow_unicode=True, indent=2
|
|
187
|
+
).splitlines(keepends=True)
|
|
188
|
+
act_str = yaml.dump(
|
|
189
|
+
actual_norm, default_flow_style=False, allow_unicode=True, indent=2
|
|
190
|
+
).splitlines(keepends=True)
|
|
191
|
+
udiff = "".join(
|
|
192
|
+
difflib.unified_diff(
|
|
193
|
+
base_str, act_str, fromfile="baseline", tofile="actual"
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
with open(diff_path, "w", encoding="utf-8") as f:
|
|
198
|
+
f.write(udiff)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
allure.attach.file(
|
|
202
|
+
str(baseline_path),
|
|
203
|
+
name=f"baseline:{snap_name}",
|
|
204
|
+
attachment_type=allure.attachment_type.TEXT,
|
|
205
|
+
)
|
|
206
|
+
allure.attach.file(
|
|
207
|
+
str(actual_path),
|
|
208
|
+
name=f"actual:{snap_name}",
|
|
209
|
+
attachment_type=allure.attachment_type.TEXT,
|
|
210
|
+
)
|
|
211
|
+
allure.attach(
|
|
212
|
+
udiff,
|
|
213
|
+
name=f"diff:{snap_name}",
|
|
214
|
+
attachment_type=allure.attachment_type.TEXT,
|
|
215
|
+
)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
raise AssertionError(
|
|
220
|
+
f"ARIA YAML не совпадает с baseline: {baseline_path}. Diff: {diff_path}"
|
|
221
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional, Dict, Any
|
|
6
|
+
|
|
7
|
+
import allure
|
|
8
|
+
|
|
9
|
+
from persona_dsl.components.expectation import Expectation
|
|
10
|
+
from persona_dsl.utils.artifacts import sanitize_filename, artifacts_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MatchesScreenshot(Expectation):
|
|
14
|
+
"""
|
|
15
|
+
Ожидание: файл-скриншот совпадает с baseline-снимком (PNG) с допуском threshold.
|
|
16
|
+
- baseline хранится по умолчанию в tests/snapshots/<snapshot_name>.
|
|
17
|
+
- при отсутствии baseline:
|
|
18
|
+
* если update=True или UPDATE_SNAPSHOTS=1 — baseline создаётся из actual;
|
|
19
|
+
* иначе — ошибка.
|
|
20
|
+
- при расхождении > threshold — сохраняется diff и прикрепляется в Allure.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
snapshot_name: str,
|
|
26
|
+
threshold: float = 0.0,
|
|
27
|
+
update: bool = False,
|
|
28
|
+
ignore_regions: Optional[List[Dict[str, int]]] = None,
|
|
29
|
+
baseline_dir: Optional[str] = None,
|
|
30
|
+
):
|
|
31
|
+
"""
|
|
32
|
+
Args:
|
|
33
|
+
snapshot_name: имя файла baseline (можно с подкаталогом), например "home.png".
|
|
34
|
+
threshold: допустимая доля отличающихся пикселей (0..1).
|
|
35
|
+
update: разрешить создание/обновление baseline.
|
|
36
|
+
ignore_regions: области для маскирования различий: [{x,y,w,h}, ...].
|
|
37
|
+
baseline_dir: каталог для baseline; по умолчанию tests/snapshots.
|
|
38
|
+
"""
|
|
39
|
+
self.snapshot_name = snapshot_name
|
|
40
|
+
self.threshold = float(threshold)
|
|
41
|
+
self.update = bool(update)
|
|
42
|
+
self.ignore_regions = ignore_regions or []
|
|
43
|
+
self.baseline_dir = baseline_dir
|
|
44
|
+
|
|
45
|
+
def _get_step_description(self, persona: Any) -> str:
|
|
46
|
+
return f"совпадает со скриншотом '{self.snapshot_name}' (threshold={self.threshold})"
|
|
47
|
+
|
|
48
|
+
def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
|
|
49
|
+
actual_path = args[0]
|
|
50
|
+
if not isinstance(actual_path, str):
|
|
51
|
+
raise TypeError(
|
|
52
|
+
f"MatchesScreenshot ожидает строковый путь к PNG, получено: {type(actual_path)}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from PIL import Image, ImageChops, ImageDraw
|
|
57
|
+
except Exception as e:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"Для сравнения скриншотов требуется пакет 'pillow'. Установите его."
|
|
60
|
+
) from e
|
|
61
|
+
|
|
62
|
+
# Определяем baseline-путь
|
|
63
|
+
snap_name = sanitize_filename(self.snapshot_name)
|
|
64
|
+
if not snap_name.lower().endswith(".png"):
|
|
65
|
+
snap_name += ".png"
|
|
66
|
+
base_dir = (
|
|
67
|
+
Path(self.baseline_dir)
|
|
68
|
+
if self.baseline_dir
|
|
69
|
+
else (Path.cwd() / "tests" / "snapshots")
|
|
70
|
+
)
|
|
71
|
+
baseline_path = base_dir / snap_name
|
|
72
|
+
actual_path_p = Path(actual_path)
|
|
73
|
+
|
|
74
|
+
# Если baseline отсутствует — либо создаём (update), либо валим
|
|
75
|
+
if not baseline_path.exists():
|
|
76
|
+
if self.update or os.getenv("UPDATE_SNAPSHOTS") == "1":
|
|
77
|
+
baseline_path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
shutil.copyfile(actual_path_p, baseline_path)
|
|
79
|
+
allure.attach.file(
|
|
80
|
+
str(actual_path_p),
|
|
81
|
+
name=f"baseline-created:{snap_name}",
|
|
82
|
+
attachment_type=allure.attachment_type.PNG,
|
|
83
|
+
)
|
|
84
|
+
return
|
|
85
|
+
raise AssertionError(
|
|
86
|
+
f"Baseline не найден: {baseline_path}. "
|
|
87
|
+
f"Запустите с UPDATE_SNAPSHOTS=1 или установите update=True для обновления baseline."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Загружаем изображения
|
|
91
|
+
with Image.open(baseline_path).convert("RGBA") as img_base, Image.open(
|
|
92
|
+
actual_path_p
|
|
93
|
+
).convert("RGBA") as img_act:
|
|
94
|
+
# Разные размеры — явная ошибка (можно обновить baseline)
|
|
95
|
+
if img_base.size != img_act.size:
|
|
96
|
+
if self.update or os.getenv("UPDATE_SNAPSHOTS") == "1":
|
|
97
|
+
shutil.copyfile(actual_path_p, baseline_path)
|
|
98
|
+
allure.attach.file(
|
|
99
|
+
str(actual_path_p),
|
|
100
|
+
name=f"baseline-updated:{snap_name}",
|
|
101
|
+
attachment_type=allure.attachment_type.PNG,
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
raise AssertionError(
|
|
105
|
+
f"Размеры изображений различаются: baseline={img_base.size}, actual={img_act.size}. "
|
|
106
|
+
f"Обновите baseline (UPDATE_SNAPSHOTS=1 или update=True)."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Маскируем области ignore_regions (одинаково на обоих изображениях)
|
|
110
|
+
if self.ignore_regions:
|
|
111
|
+
draw_b = ImageDraw.Draw(img_base)
|
|
112
|
+
draw_a = ImageDraw.Draw(img_act)
|
|
113
|
+
for r in self.ignore_regions:
|
|
114
|
+
x, y, w, h = (
|
|
115
|
+
int(r.get("x", 0)),
|
|
116
|
+
int(r.get("y", 0)),
|
|
117
|
+
int(r.get("w", 0)),
|
|
118
|
+
int(r.get("h", 0)),
|
|
119
|
+
)
|
|
120
|
+
if w > 0 and h > 0:
|
|
121
|
+
draw_b.rectangle([x, y, x + w, y + h], fill=(0, 0, 0, 255))
|
|
122
|
+
draw_a.rectangle([x, y, x + w, y + h], fill=(0, 0, 0, 255))
|
|
123
|
+
|
|
124
|
+
# Считаем долю отличающихся пикселей
|
|
125
|
+
diff = ImageChops.difference(img_base, img_act).convert("L")
|
|
126
|
+
nonzero = sum(1 for px in list(diff.getdata()) if px != 0)
|
|
127
|
+
total = diff.size[0] * diff.size[1]
|
|
128
|
+
frac = nonzero / total if total > 0 else 0.0
|
|
129
|
+
|
|
130
|
+
if frac <= self.threshold:
|
|
131
|
+
return # Успех
|
|
132
|
+
|
|
133
|
+
# Готовим артефакты: сохраняем actual/baseline/diff и прикрепляем
|
|
134
|
+
ts = int(time.time() * 1000)
|
|
135
|
+
art_dir = artifacts_dir("screenshots")
|
|
136
|
+
diff_path = art_dir / f"{ts}-diff-{snap_name}"
|
|
137
|
+
# Сохраняем визуализацию diff
|
|
138
|
+
diff_img = diff # уже L; можно прикрепить напрямую
|
|
139
|
+
diff_img.save(diff_path)
|
|
140
|
+
|
|
141
|
+
allure.attach.file(
|
|
142
|
+
str(baseline_path),
|
|
143
|
+
name=f"baseline:{snap_name}",
|
|
144
|
+
attachment_type=allure.attachment_type.PNG,
|
|
145
|
+
)
|
|
146
|
+
allure.attach.file(
|
|
147
|
+
str(actual_path_p),
|
|
148
|
+
name=f"actual:{snap_name}",
|
|
149
|
+
attachment_type=allure.attachment_type.PNG,
|
|
150
|
+
)
|
|
151
|
+
allure.attach.file(
|
|
152
|
+
str(diff_path),
|
|
153
|
+
name=f"diff:{snap_name}",
|
|
154
|
+
attachment_type=allure.attachment_type.PNG,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
raise AssertionError(
|
|
158
|
+
f"Скриншот не совпадает с baseline: {baseline_path}. "
|
|
159
|
+
f"Доля отличий={frac:.6f}, допустимо <= {self.threshold}. Diff сохранён: {diff_path}"
|
|
160
|
+
)
|