run-tests.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. from __future__ import annotations
  2. import argparse
  3. import itertools
  4. import json
  5. import os
  6. import re
  7. import shutil
  8. import subprocess
  9. import sys
  10. import tempfile
  11. from pathlib import Path
  12. ROOT = Path(__file__).resolve().parents[3]
  13. TEST_ROOT = ROOT / ".template" / "development" / "tests"
  14. MANIFEST_PATH = TEST_ROOT / "manifest.json"
  15. SNAPSHOT_ROOT = TEST_ROOT / "snapshots"
  16. class TestFailure(RuntimeError):
  17. pass
  18. def check_architecture() -> None:
  19. """Проверяет направление зависимостей без запуска Typst renderer."""
  20. template_root = ROOT / ".template" / "lib"
  21. import_pattern = re.compile(r'#import\s+"([^"]+)"')
  22. graph: dict[Path, set[Path]] = {}
  23. for source in template_root.rglob("*.typ"):
  24. graph[source.resolve()] = set()
  25. text = source.read_text(encoding="utf-8")
  26. source_area = source.relative_to(template_root).parts[0]
  27. code_without_line_comments = re.sub(r"//.*", "", text)
  28. if source_area == "domain":
  29. forbidden_runtime = re.search(
  30. r"(?:#(?:set|show)\b|\b(?:page|place|image|query|state)\s*\()",
  31. code_without_line_comments,
  32. )
  33. if forbidden_runtime:
  34. raise TestFailure(
  35. f"Domain-модуль {source.relative_to(ROOT)} содержит presentation/runtime вызов "
  36. f"{forbidden_runtime.group(0)!r}."
  37. )
  38. for raw_target in import_pattern.findall(text):
  39. if raw_target.startswith("@"):
  40. continue
  41. target = (source.parent / raw_target).resolve()
  42. try:
  43. relative = target.relative_to(template_root.resolve())
  44. except ValueError:
  45. continue
  46. if target.suffix == ".typ":
  47. graph[source.resolve()].add(target)
  48. target_area = relative.parts[0]
  49. forbidden = {
  50. "domain": {"application", "infrastructure", "presentation"},
  51. "infrastructure": {"application", "presentation"},
  52. "application": {"presentation"},
  53. }.get(source_area, set())
  54. if target_area in forbidden:
  55. raise TestFailure(
  56. "Нарушена DDD-граница: "
  57. f"{source.relative_to(ROOT)} -> {target.relative_to(ROOT)}"
  58. )
  59. if re.search(r'#import\s+"[^\"]*(?:main\.typ|chapters/|assets/|\.private/)', text):
  60. raise TestFailure(
  61. f"Library-модуль {source.relative_to(ROOT)} импортирует пользовательский слой."
  62. )
  63. visiting: set[Path] = set()
  64. visited: set[Path] = set()
  65. def visit(node: Path, stack: list[Path]) -> None:
  66. if node in visiting:
  67. cycle = stack[stack.index(node):] + [node]
  68. rendered = " -> ".join(str(item.relative_to(ROOT)) for item in cycle)
  69. raise TestFailure(f"Циклические Typst-импорты: {rendered}")
  70. if node in visited:
  71. return
  72. visiting.add(node)
  73. stack.append(node)
  74. for target in graph.get(node, set()):
  75. if target in graph:
  76. visit(target, stack)
  77. stack.pop()
  78. visiting.remove(node)
  79. visited.add(node)
  80. for node in graph:
  81. visit(node, [])
  82. def check_workspace() -> None:
  83. """Проверяет минимальный корень и синхронизируемую конфигурацию VS Code."""
  84. allowed_directories = {
  85. ".git",
  86. ".private",
  87. ".template",
  88. ".vscode",
  89. "assets",
  90. "chapters",
  91. "docs",
  92. }
  93. actual_directories = {item.name for item in ROOT.iterdir() if item.is_dir()}
  94. unexpected = actual_directories - allowed_directories
  95. if unexpected:
  96. raise TestFailure(
  97. "В корне обнаружены лишние каталоги: " + ", ".join(sorted(unexpected))
  98. )
  99. required_files = {
  100. ".gitignore",
  101. "README.md",
  102. "main.typ",
  103. }
  104. missing_files = {name for name in required_files if not (ROOT / name).is_file()}
  105. if missing_files:
  106. raise TestFailure(
  107. "В корне отсутствуют обязательные файлы: " + ", ".join(sorted(missing_files))
  108. )
  109. extensions_path = ROOT / ".vscode" / "extensions.json"
  110. settings_path = ROOT / ".vscode" / "settings.json"
  111. extensions = json.loads(extensions_path.read_text(encoding="utf-8"))
  112. recommendations = set(extensions.get("recommendations", []))
  113. required_extensions = {
  114. "alefragnani.bookmarks",
  115. "streetsidesoftware.code-spell-checker",
  116. "streetsidesoftware.code-spell-checker-russian",
  117. "ms-ceintl.vscode-language-pack-ru",
  118. "myriad-dreamin.tinymist",
  119. "wayou.vscode-todo-highlight",
  120. "gruntfuggly.todo-tree",
  121. "mhutchie.git-graph",
  122. "local.typst-typewriter",
  123. "zotst.zotst",
  124. }
  125. missing_extensions = required_extensions - recommendations
  126. if missing_extensions:
  127. raise TestFailure(
  128. "В extensions.json отсутствуют рекомендации: "
  129. + ", ".join(sorted(missing_extensions))
  130. )
  131. settings = json.loads(settings_path.read_text(encoding="utf-8"))
  132. if settings.get("files.autoSave") != "afterDelay":
  133. raise TestFailure("Workspace должен включать files.autoSave=afterDelay.")
  134. if settings.get("files.exclude", {}).get(".template") is not True:
  135. raise TestFailure(".template должен быть скрыт в пользовательском Explorer VS Code.")
  136. if settings.get("files.exclude", {}).get(".vscode") is not True:
  137. raise TestFailure(".vscode должен быть скрыт в пользовательском Explorer VS Code.")
  138. if settings.get("files.exclude", {}).get("docs") is True:
  139. raise TestFailure("Публичная документация docs/ не должна быть скрыта.")
  140. if settings.get("typstTypewriter.navigator.mainFiles") != ["main.typ"]:
  141. raise TestFailure("Typewriter должен показывать единственный main.typ.")
  142. gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8")
  143. if ".private/" not in gitignore:
  144. raise TestFailure("В .gitignore отсутствует защита каталога .private/.")
  145. if ".vscode/" in gitignore:
  146. raise TestFailure(".vscode должен синхронизироваться через Git.")
  147. private_example = ROOT / "docs" / "examples" / "private" / "settings.typ"
  148. if not private_example.is_file():
  149. raise TestFailure("Отсутствует безопасный пример .private/settings.typ.")
  150. private_example_text = private_example.read_text(encoding="utf-8")
  151. for employee_id in (
  152. "musikhin", "guzeev", "fedorov", "ilyasov", "khimichev",
  153. "brusnicin", "ozornin", "buhartdinov", "tkachenko",
  154. ):
  155. if f"{employee_id}:" not in private_example_text:
  156. raise TestFailure(
  157. f"В примере private settings отсутствует сотрудник {employee_id}."
  158. )
  159. tasks = json.loads((ROOT / ".vscode" / "tasks.json").read_text(encoding="utf-8"))
  160. task_labels = {task.get("label") for task in tasks.get("tasks", [])}
  161. if "Scientia: собрать PDF с приватными данными" in task_labels:
  162. raise TestFailure("Отдельная private build task больше не должна использоваться.")
  163. if (ROOT / ".template" / "development" / "tools" / "build-with-private-assets.ps1").exists():
  164. raise TestFailure("Устаревший ZIP installer должен быть удалён.")
  165. for company_id in ("scientia", "technology", "too"):
  166. company_path = ROOT / ".template" / "companies" / company_id / "data.json"
  167. company = json.loads(company_path.read_text(encoding="utf-8"))
  168. if company.get("sign_image") is not None or company.get("stamp_image") is not None:
  169. raise TestFailure(
  170. f"Публичный профиль {company_id} не должен содержать подпись или печать."
  171. )
  172. public_docs = (
  173. "README.md",
  174. "documents.md",
  175. "formatting.md",
  176. "vscode.md",
  177. "git.md",
  178. "private-assets.md",
  179. "writing-style.md",
  180. "troubleshooting.md",
  181. )
  182. for name in public_docs:
  183. if not (ROOT / "docs" / name).is_file():
  184. raise TestFailure(f"Публичная документация неполна: docs/{name}.")
  185. for starter in ("report", "letter", "commercial-offer", "contract"):
  186. starter_root = ROOT / "docs" / "examples" / "documents" / starter
  187. if not (starter_root / "main.typ").is_file() or not (starter_root / "chapters").is_dir():
  188. raise TestFailure(f"Публичный пример {starter} неполон.")
  189. for obsolete in ("document.typ", "draft.typ", "clean-copy.typ"):
  190. if (ROOT / obsolete).exists():
  191. raise TestFailure(f"Лишняя точка входа в корне: {obsolete}.")
  192. def run(command: list[str], *, cwd: Path = ROOT) -> str:
  193. result = subprocess.run(
  194. command,
  195. cwd=cwd,
  196. text=True,
  197. encoding="utf-8",
  198. errors="replace",
  199. stdout=subprocess.PIPE,
  200. stderr=subprocess.STDOUT,
  201. )
  202. if result.returncode != 0:
  203. rendered = subprocess.list2cmdline(command)
  204. raise TestFailure(f"Команда завершилась с кодом {result.returncode}:\n{rendered}\n{result.stdout}")
  205. return result.stdout
  206. def find_executable(
  207. name: str,
  208. *,
  209. env_var: str | None = None,
  210. required: bool = True,
  211. ) -> Path | None:
  212. if env_var and os.environ.get(env_var):
  213. candidate = Path(os.environ[env_var])
  214. if candidate.is_file():
  215. return candidate
  216. located = shutil.which(name)
  217. if located:
  218. return Path(located)
  219. executable = name + (".exe" if os.name == "nt" else "")
  220. candidates: list[Path] = []
  221. poppler_bin = os.environ.get("POPPLER_BIN")
  222. if poppler_bin:
  223. candidates.append(Path(poppler_bin) / executable)
  224. if os.name == "nt":
  225. candidates.append(
  226. Path.home()
  227. / ".cache"
  228. / "codex-runtimes"
  229. / "codex-primary-runtime"
  230. / "dependencies"
  231. / "native"
  232. / "poppler"
  233. / "Library"
  234. / "bin"
  235. / executable
  236. )
  237. for candidate in candidates:
  238. if candidate.is_file():
  239. return candidate
  240. if required:
  241. raise TestFailure(
  242. f"Не найден исполняемый файл {name}. Добавьте его в PATH"
  243. + (f" или задайте {env_var}" if env_var else "")
  244. + ("/POPPLER_BIN." if name.startswith("pdf") else ".")
  245. )
  246. return None
  247. def parse_version(value: str) -> tuple[int, ...]:
  248. match = re.search(r"(\d+)\.(\d+)\.(\d+)", value)
  249. if not match:
  250. raise TestFailure(f"Не удалось определить версию из строки: {value!r}")
  251. return tuple(int(part) for part in match.groups())
  252. def load_manifest() -> dict:
  253. return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
  254. def compile_fixture(
  255. typst: Path,
  256. entry: Path,
  257. output: Path,
  258. creation_timestamp: int,
  259. inputs: dict[str, str] | None = None,
  260. ) -> str:
  261. command = [
  262. str(typst),
  263. "compile",
  264. "--root",
  265. str(ROOT),
  266. "--creation-timestamp",
  267. str(creation_timestamp),
  268. ]
  269. for key, value in sorted((inputs or {}).items()):
  270. command.extend(("--input", f"{key}={value}"))
  271. command.extend((str(entry), str(output)))
  272. return run(command)
  273. def compile_expected_failure(
  274. typst: Path,
  275. entry: Path,
  276. output: Path,
  277. creation_timestamp: int,
  278. required_errors: list[str],
  279. inputs: dict[str, str] | None = None,
  280. ) -> None:
  281. command = [
  282. str(typst),
  283. "compile",
  284. "--root",
  285. str(ROOT),
  286. "--creation-timestamp",
  287. str(creation_timestamp),
  288. ]
  289. for key, value in sorted((inputs or {}).items()):
  290. command.extend(("--input", f"{key}={value}"))
  291. command.extend((str(entry), str(output)))
  292. result = subprocess.run(
  293. command,
  294. cwd=ROOT,
  295. text=True,
  296. encoding="utf-8",
  297. errors="replace",
  298. stdout=subprocess.PIPE,
  299. stderr=subprocess.STDOUT,
  300. )
  301. if result.returncode == 0:
  302. raise TestFailure(f"{entry.relative_to(ROOT)}: ожидалась ошибка компиляции.")
  303. for marker in required_errors:
  304. if marker not in result.stdout:
  305. raise TestFailure(
  306. f"{entry.relative_to(ROOT)}: в diagnostic не найден маркер {marker!r}.\n"
  307. f"Фактический вывод:\n{result.stdout}"
  308. )
  309. def run_unit_test(typst: Path, module: str, creation_timestamp: int) -> None:
  310. expression = f'import "/{module.replace(os.sep, "/")}": run; run()'
  311. output = run(
  312. [
  313. str(typst),
  314. "eval",
  315. "--root",
  316. str(ROOT),
  317. "--creation-timestamp",
  318. str(creation_timestamp),
  319. expression,
  320. ]
  321. ).strip()
  322. if json.loads(output) != "ok":
  323. raise TestFailure(f"Unit-модуль {module} вернул неожиданное значение: {output}")
  324. def inspect_pdf(pdfinfo: Path, pdf: Path) -> tuple[int, str]:
  325. info = run([str(pdfinfo), str(pdf)])
  326. pages_match = re.search(r"^Pages:\s+(\d+)$", info, flags=re.MULTILINE)
  327. size_match = re.search(r"^Page size:.*\(([^)]+)\)$", info, flags=re.MULTILINE)
  328. if not pages_match or not size_match:
  329. raise TestFailure(f"Не удалось разобрать pdfinfo для {pdf}:\n{info}")
  330. return int(pages_match.group(1)), size_match.group(1).strip()
  331. def extract_text(pdftotext: Path | None, pdf: Path, target: Path) -> str:
  332. if pdftotext:
  333. run([str(pdftotext), "-layout", str(pdf), str(target)])
  334. return target.read_text(encoding="utf-8", errors="replace")
  335. try:
  336. from pypdf import PdfReader
  337. except ImportError as exc:
  338. raise TestFailure(
  339. "Для проверки текста требуется pdftotext или Python-пакет pypdf."
  340. ) from exc
  341. reader = PdfReader(str(pdf))
  342. return "\n".join((page.extract_text() or "") for page in reader.pages)
  343. def count_pdf_links(pdf: Path) -> tuple[int, int]:
  344. try:
  345. from pypdf import PdfReader
  346. except ImportError as exc:
  347. raise TestFailure("Для проверки PDF-ссылок требуется Python-пакет pypdf.") from exc
  348. external = internal = 0
  349. for page in PdfReader(str(pdf)).pages:
  350. for raw_annotation in page.get("/Annots", ()):
  351. annotation = raw_annotation.get_object()
  352. if annotation.get("/Subtype") != "/Link":
  353. continue
  354. action = annotation.get("/A")
  355. if action is not None and action.get_object().get("/URI") is not None:
  356. external += 1
  357. else:
  358. internal += 1
  359. return external, internal
  360. def render_page(pdftoppm: Path, pdf: Path, page: int, dpi: int, target: Path) -> Path:
  361. target.parent.mkdir(parents=True, exist_ok=True)
  362. run(
  363. [
  364. str(pdftoppm),
  365. "-png",
  366. "-r",
  367. str(dpi),
  368. "-f",
  369. str(page),
  370. "-l",
  371. str(page),
  372. "-singlefile",
  373. str(pdf),
  374. str(target.with_suffix("")),
  375. ]
  376. )
  377. return target
  378. def image_difference(expected: Path, actual: Path) -> float:
  379. try:
  380. from PIL import Image, ImageChops
  381. except ImportError as exc:
  382. raise TestFailure("Для visual regression требуется пакет Pillow.") from exc
  383. with Image.open(expected).convert("RGB") as left, Image.open(actual).convert("RGB") as right:
  384. if left.size != right.size:
  385. return 1.0
  386. diff = ImageChops.difference(left, right)
  387. histogram = diff.histogram()
  388. total = sum(value * (index % 256) for index, value in enumerate(histogram))
  389. maximum = left.width * left.height * 3 * 255
  390. return total / maximum
  391. def main() -> int:
  392. parser = argparse.ArgumentParser(description="Регрессионные тесты шаблона документов Scientia")
  393. parser.add_argument("--fixture", action="append", help="Запустить только fixture с указанным id")
  394. parser.add_argument("--compile-only", action="store_true", help="Не запускать PDF и visual проверки")
  395. parser.add_argument(
  396. "--update-snapshots",
  397. action="store_true",
  398. help="Явно обновить visual snapshots после ручного подтверждения",
  399. )
  400. args = parser.parse_args()
  401. manifest = load_manifest()
  402. typst = find_executable("typst", env_var="TYPST_BIN")
  403. version_output = run([str(typst), "--version"]).strip()
  404. if parse_version(version_output) < parse_version(manifest["typst_min"]):
  405. raise TestFailure(
  406. f"Требуется Typst {manifest['typst_min']}+, найден {version_output}."
  407. )
  408. pdfinfo = pdftotext = pdftoppm = None
  409. if not args.compile_only:
  410. pdfinfo = find_executable("pdfinfo")
  411. pdftotext = find_executable("pdftotext", required=False)
  412. pdftoppm = find_executable("pdftoppm")
  413. creation_timestamp = int(manifest["creation_timestamp"])
  414. selected = set(args.fixture or [])
  415. known = {
  416. item["id"]
  417. for item in (
  418. *manifest["fixtures"],
  419. *manifest.get("negative_fixtures", []),
  420. *manifest.get("compile_matrices", []),
  421. )
  422. }
  423. unknown = selected - known
  424. if unknown:
  425. raise TestFailure(f"Неизвестные fixtures: {', '.join(sorted(unknown))}")
  426. if not selected:
  427. print("[static] architecture")
  428. check_architecture()
  429. print("[static] workspace")
  430. check_workspace()
  431. for unit in manifest.get("unit_tests", []):
  432. if selected:
  433. continue
  434. print(f"[unit] {unit['id']}")
  435. run_unit_test(typst, unit["module"], creation_timestamp)
  436. temp_dir = Path(tempfile.mkdtemp(prefix="scientia-tests-"))
  437. failed = False
  438. try:
  439. for matrix in manifest.get("compile_matrices", []):
  440. if selected and matrix["id"] not in selected:
  441. continue
  442. keys = tuple(matrix["parameters"])
  443. values = tuple(matrix["parameters"][key] for key in keys)
  444. for combination in itertools.product(*values):
  445. inputs = dict(zip(keys, combination))
  446. suffix = ",".join(f"{key}={value}" for key, value in inputs.items())
  447. print(f"[matrix] {matrix['id']}[{suffix}]")
  448. safe_suffix = re.sub(r"[^A-Za-z0-9_.-]+", "-", suffix)
  449. compile_fixture(
  450. typst,
  451. ROOT / matrix["entry"],
  452. temp_dir / f"{matrix['id']}-{safe_suffix}.pdf",
  453. creation_timestamp,
  454. inputs=inputs,
  455. )
  456. for fixture in manifest.get("negative_fixtures", []):
  457. if selected and fixture["id"] not in selected:
  458. continue
  459. fixture_id = fixture["id"]
  460. print(f"[negative] {fixture_id}")
  461. compile_expected_failure(
  462. typst,
  463. ROOT / fixture["entry"],
  464. temp_dir / f"{fixture_id}.pdf",
  465. creation_timestamp,
  466. fixture.get("required_errors", []),
  467. inputs=fixture.get("inputs"),
  468. )
  469. for fixture in manifest["fixtures"]:
  470. if selected and fixture["id"] not in selected:
  471. continue
  472. fixture_id = fixture["id"]
  473. print(f"[compile] {fixture_id}")
  474. entry = ROOT / fixture["entry"]
  475. output = temp_dir / f"{fixture_id}.pdf"
  476. compile_fixture(
  477. typst,
  478. entry,
  479. output,
  480. creation_timestamp,
  481. inputs=fixture.get("inputs"),
  482. )
  483. if args.compile_only:
  484. continue
  485. assert pdfinfo and pdftoppm
  486. pages, page_size = inspect_pdf(pdfinfo, output)
  487. expected_pages = int(fixture["expected_pages"])
  488. if expected_pages and pages != expected_pages:
  489. raise TestFailure(
  490. f"{fixture_id}: ожидалось {expected_pages} страниц, получено {pages}."
  491. )
  492. if page_size != fixture["expected_page_size"]:
  493. raise TestFailure(
  494. f"{fixture_id}: ожидался формат {fixture['expected_page_size']}, получен {page_size}."
  495. )
  496. text = extract_text(pdftotext, output, temp_dir / f"{fixture_id}.txt")
  497. for marker in fixture.get("required_text", []):
  498. if marker not in text:
  499. raise TestFailure(f"{fixture_id}: в PDF не найден текстовый маркер {marker!r}.")
  500. for marker, minimum in fixture.get("required_text_counts", {}).items():
  501. actual = text.count(marker)
  502. if actual < int(minimum):
  503. raise TestFailure(
  504. f"{fixture_id}: маркер {marker!r} найден {actual} раз; "
  505. f"требуется не менее {minimum}."
  506. )
  507. if "minimum_external_links" in fixture or "minimum_internal_links" in fixture:
  508. external_links, internal_links = count_pdf_links(output)
  509. minimum_external = int(fixture.get("minimum_external_links", 0))
  510. minimum_internal = int(fixture.get("minimum_internal_links", 0))
  511. if external_links < minimum_external:
  512. raise TestFailure(
  513. f"{fixture_id}: внешних PDF-ссылок {external_links}; "
  514. f"требуется не менее {minimum_external}."
  515. )
  516. if internal_links < minimum_internal:
  517. raise TestFailure(
  518. f"{fixture_id}: внутренних PDF-ссылок {internal_links}; "
  519. f"требуется не менее {minimum_internal}."
  520. )
  521. for page in fixture.get("snapshot_pages", []):
  522. if page > pages:
  523. raise TestFailure(f"{fixture_id}: snapshot page {page} больше количества страниц {pages}.")
  524. actual = render_page(
  525. pdftoppm,
  526. output,
  527. page,
  528. int(manifest["dpi"]),
  529. temp_dir / fixture_id / f"page-{page}.png",
  530. )
  531. expected = SNAPSHOT_ROOT / fixture_id / f"page-{page}.png"
  532. if args.update_snapshots:
  533. expected.parent.mkdir(parents=True, exist_ok=True)
  534. shutil.copy2(actual, expected)
  535. print(f"[snapshot:update] {expected.relative_to(ROOT)}")
  536. continue
  537. if not expected.is_file():
  538. raise TestFailure(
  539. f"Нет snapshot {expected.relative_to(ROOT)}. "
  540. "После ручной проверки запустите --update-snapshots."
  541. )
  542. difference = image_difference(expected, actual)
  543. threshold = float(fixture.get("visual_threshold", manifest["visual_threshold"]))
  544. if difference > threshold:
  545. raise TestFailure(
  546. f"{fixture_id}, страница {page}: visual diff {difference:.6f} "
  547. f"превышает порог {threshold:.6f}. Фактический файл: {actual}"
  548. )
  549. print(f"[snapshot:ok] {fixture_id}/page-{page} diff={difference:.6f}")
  550. print(f"OK: {version_output}")
  551. return 0
  552. except Exception:
  553. failed = True
  554. raise
  555. finally:
  556. if failed:
  557. print(f"Временные результаты сохранены: {temp_dir}", file=sys.stderr)
  558. else:
  559. shutil.rmtree(temp_dir, ignore_errors=True)
  560. if __name__ == "__main__":
  561. try:
  562. raise SystemExit(main())
  563. except TestFailure as exc:
  564. print(f"FAIL: {exc}", file=sys.stderr)
  565. raise SystemExit(1)