from __future__ import annotations import argparse import itertools import json import os import re import shutil import subprocess import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[3] TEST_ROOT = ROOT / ".template" / "development" / "tests" MANIFEST_PATH = TEST_ROOT / "manifest.json" SNAPSHOT_ROOT = TEST_ROOT / "snapshots" class TestFailure(RuntimeError): pass def check_architecture() -> None: """Проверяет направление зависимостей без запуска Typst renderer.""" template_root = ROOT / ".template" / "lib" import_pattern = re.compile(r'#import\s+"([^"]+)"') graph: dict[Path, set[Path]] = {} for source in template_root.rglob("*.typ"): graph[source.resolve()] = set() text = source.read_text(encoding="utf-8") source_area = source.relative_to(template_root).parts[0] code_without_line_comments = re.sub(r"//.*", "", text) if source_area == "domain": forbidden_runtime = re.search( r"(?:#(?:set|show)\b|\b(?:page|place|image|query|state)\s*\()", code_without_line_comments, ) if forbidden_runtime: raise TestFailure( f"Domain-модуль {source.relative_to(ROOT)} содержит presentation/runtime вызов " f"{forbidden_runtime.group(0)!r}." ) for raw_target in import_pattern.findall(text): if raw_target.startswith("@"): continue target = (source.parent / raw_target).resolve() try: relative = target.relative_to(template_root.resolve()) except ValueError: continue if target.suffix == ".typ": graph[source.resolve()].add(target) target_area = relative.parts[0] forbidden = { "domain": {"application", "infrastructure", "presentation"}, "infrastructure": {"application", "presentation"}, "application": {"presentation"}, }.get(source_area, set()) if target_area in forbidden: raise TestFailure( "Нарушена DDD-граница: " f"{source.relative_to(ROOT)} -> {target.relative_to(ROOT)}" ) if re.search(r'#import\s+"[^\"]*(?:main\.typ|chapters/|assets/|\.private/)', text): raise TestFailure( f"Library-модуль {source.relative_to(ROOT)} импортирует пользовательский слой." ) visiting: set[Path] = set() visited: set[Path] = set() def visit(node: Path, stack: list[Path]) -> None: if node in visiting: cycle = stack[stack.index(node):] + [node] rendered = " -> ".join(str(item.relative_to(ROOT)) for item in cycle) raise TestFailure(f"Циклические Typst-импорты: {rendered}") if node in visited: return visiting.add(node) stack.append(node) for target in graph.get(node, set()): if target in graph: visit(target, stack) stack.pop() visiting.remove(node) visited.add(node) for node in graph: visit(node, []) def check_workspace() -> None: """Проверяет минимальный корень и синхронизируемую конфигурацию VS Code.""" allowed_directories = { ".git", ".private", ".template", ".vscode", "assets", "chapters", "docs", } actual_directories = {item.name for item in ROOT.iterdir() if item.is_dir()} unexpected = actual_directories - allowed_directories if unexpected: raise TestFailure( "В корне обнаружены лишние каталоги: " + ", ".join(sorted(unexpected)) ) required_files = { ".gitignore", "README.md", "main.typ", } missing_files = {name for name in required_files if not (ROOT / name).is_file()} if missing_files: raise TestFailure( "В корне отсутствуют обязательные файлы: " + ", ".join(sorted(missing_files)) ) extensions_path = ROOT / ".vscode" / "extensions.json" settings_path = ROOT / ".vscode" / "settings.json" extensions = json.loads(extensions_path.read_text(encoding="utf-8")) recommendations = set(extensions.get("recommendations", [])) required_extensions = { "alefragnani.bookmarks", "streetsidesoftware.code-spell-checker", "streetsidesoftware.code-spell-checker-russian", "ms-ceintl.vscode-language-pack-ru", "myriad-dreamin.tinymist", "wayou.vscode-todo-highlight", "gruntfuggly.todo-tree", "mhutchie.git-graph", "local.typst-typewriter", "zotst.zotst", } missing_extensions = required_extensions - recommendations if missing_extensions: raise TestFailure( "В extensions.json отсутствуют рекомендации: " + ", ".join(sorted(missing_extensions)) ) settings = json.loads(settings_path.read_text(encoding="utf-8")) if settings.get("files.autoSave") != "afterDelay": raise TestFailure("Workspace должен включать files.autoSave=afterDelay.") if settings.get("files.exclude", {}).get(".template") is not True: raise TestFailure(".template должен быть скрыт в пользовательском Explorer VS Code.") if settings.get("files.exclude", {}).get(".vscode") is not True: raise TestFailure(".vscode должен быть скрыт в пользовательском Explorer VS Code.") if settings.get("files.exclude", {}).get("docs") is True: raise TestFailure("Публичная документация docs/ не должна быть скрыта.") if settings.get("typstTypewriter.navigator.mainFiles") != ["main.typ"]: raise TestFailure("Typewriter должен показывать единственный main.typ.") gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8") if ".private/" not in gitignore: raise TestFailure("В .gitignore отсутствует защита каталога .private/.") if ".vscode/" in gitignore: raise TestFailure(".vscode должен синхронизироваться через Git.") private_example = ROOT / "docs" / "examples" / "private" / "settings.typ" if not private_example.is_file(): raise TestFailure("Отсутствует безопасный пример .private/settings.typ.") private_example_text = private_example.read_text(encoding="utf-8") for employee_id in ( "musikhin", "guzeev", "fedorov", "ilyasov", "khimichev", "brusnicin", "ozornin", "buhartdinov", "tkachenko", ): if f"{employee_id}:" not in private_example_text: raise TestFailure( f"В примере private settings отсутствует сотрудник {employee_id}." ) tasks = json.loads((ROOT / ".vscode" / "tasks.json").read_text(encoding="utf-8")) task_labels = {task.get("label") for task in tasks.get("tasks", [])} if "Scientia: собрать PDF с приватными данными" in task_labels: raise TestFailure("Отдельная private build task больше не должна использоваться.") if (ROOT / ".template" / "development" / "tools" / "build-with-private-assets.ps1").exists(): raise TestFailure("Устаревший ZIP installer должен быть удалён.") for company_id in ("scientia", "technology", "too"): company_path = ROOT / ".template" / "companies" / company_id / "data.json" company = json.loads(company_path.read_text(encoding="utf-8")) if company.get("sign_image") is not None or company.get("stamp_image") is not None: raise TestFailure( f"Публичный профиль {company_id} не должен содержать подпись или печать." ) public_docs = ( "README.md", "documents.md", "formatting.md", "vscode.md", "git.md", "private-assets.md", "writing-style.md", "troubleshooting.md", ) for name in public_docs: if not (ROOT / "docs" / name).is_file(): raise TestFailure(f"Публичная документация неполна: docs/{name}.") for starter in ("report", "letter", "commercial-offer", "contract"): starter_root = ROOT / "docs" / "examples" / "documents" / starter if not (starter_root / "main.typ").is_file() or not (starter_root / "chapters").is_dir(): raise TestFailure(f"Публичный пример {starter} неполон.") for obsolete in ("document.typ", "draft.typ", "clean-copy.typ"): if (ROOT / obsolete).exists(): raise TestFailure(f"Лишняя точка входа в корне: {obsolete}.") def run(command: list[str], *, cwd: Path = ROOT) -> str: result = subprocess.run( command, cwd=cwd, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) if result.returncode != 0: rendered = subprocess.list2cmdline(command) raise TestFailure(f"Команда завершилась с кодом {result.returncode}:\n{rendered}\n{result.stdout}") return result.stdout def find_executable( name: str, *, env_var: str | None = None, required: bool = True, ) -> Path | None: if env_var and os.environ.get(env_var): candidate = Path(os.environ[env_var]) if candidate.is_file(): return candidate located = shutil.which(name) if located: return Path(located) executable = name + (".exe" if os.name == "nt" else "") candidates: list[Path] = [] poppler_bin = os.environ.get("POPPLER_BIN") if poppler_bin: candidates.append(Path(poppler_bin) / executable) if os.name == "nt": candidates.append( Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" / "dependencies" / "native" / "poppler" / "Library" / "bin" / executable ) for candidate in candidates: if candidate.is_file(): return candidate if required: raise TestFailure( f"Не найден исполняемый файл {name}. Добавьте его в PATH" + (f" или задайте {env_var}" if env_var else "") + ("/POPPLER_BIN." if name.startswith("pdf") else ".") ) return None def parse_version(value: str) -> tuple[int, ...]: match = re.search(r"(\d+)\.(\d+)\.(\d+)", value) if not match: raise TestFailure(f"Не удалось определить версию из строки: {value!r}") return tuple(int(part) for part in match.groups()) def load_manifest() -> dict: return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) def compile_fixture( typst: Path, entry: Path, output: Path, creation_timestamp: int, inputs: dict[str, str] | None = None, ) -> str: command = [ str(typst), "compile", "--root", str(ROOT), "--creation-timestamp", str(creation_timestamp), ] for key, value in sorted((inputs or {}).items()): command.extend(("--input", f"{key}={value}")) command.extend((str(entry), str(output))) return run(command) def compile_expected_failure( typst: Path, entry: Path, output: Path, creation_timestamp: int, required_errors: list[str], inputs: dict[str, str] | None = None, ) -> None: command = [ str(typst), "compile", "--root", str(ROOT), "--creation-timestamp", str(creation_timestamp), ] for key, value in sorted((inputs or {}).items()): command.extend(("--input", f"{key}={value}")) command.extend((str(entry), str(output))) result = subprocess.run( command, cwd=ROOT, text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) if result.returncode == 0: raise TestFailure(f"{entry.relative_to(ROOT)}: ожидалась ошибка компиляции.") for marker in required_errors: if marker not in result.stdout: raise TestFailure( f"{entry.relative_to(ROOT)}: в diagnostic не найден маркер {marker!r}.\n" f"Фактический вывод:\n{result.stdout}" ) def run_unit_test(typst: Path, module: str, creation_timestamp: int) -> None: expression = f'import "/{module.replace(os.sep, "/")}": run; run()' output = run( [ str(typst), "eval", "--root", str(ROOT), "--creation-timestamp", str(creation_timestamp), expression, ] ).strip() if json.loads(output) != "ok": raise TestFailure(f"Unit-модуль {module} вернул неожиданное значение: {output}") def inspect_pdf(pdfinfo: Path, pdf: Path) -> tuple[int, str]: info = run([str(pdfinfo), str(pdf)]) pages_match = re.search(r"^Pages:\s+(\d+)$", info, flags=re.MULTILINE) size_match = re.search(r"^Page size:.*\(([^)]+)\)$", info, flags=re.MULTILINE) if not pages_match or not size_match: raise TestFailure(f"Не удалось разобрать pdfinfo для {pdf}:\n{info}") return int(pages_match.group(1)), size_match.group(1).strip() def extract_text(pdftotext: Path | None, pdf: Path, target: Path) -> str: if pdftotext: run([str(pdftotext), "-layout", str(pdf), str(target)]) return target.read_text(encoding="utf-8", errors="replace") try: from pypdf import PdfReader except ImportError as exc: raise TestFailure( "Для проверки текста требуется pdftotext или Python-пакет pypdf." ) from exc reader = PdfReader(str(pdf)) return "\n".join((page.extract_text() or "") for page in reader.pages) def count_pdf_links(pdf: Path) -> tuple[int, int]: try: from pypdf import PdfReader except ImportError as exc: raise TestFailure("Для проверки PDF-ссылок требуется Python-пакет pypdf.") from exc external = internal = 0 for page in PdfReader(str(pdf)).pages: for raw_annotation in page.get("/Annots", ()): annotation = raw_annotation.get_object() if annotation.get("/Subtype") != "/Link": continue action = annotation.get("/A") if action is not None and action.get_object().get("/URI") is not None: external += 1 else: internal += 1 return external, internal def render_page(pdftoppm: Path, pdf: Path, page: int, dpi: int, target: Path) -> Path: target.parent.mkdir(parents=True, exist_ok=True) run( [ str(pdftoppm), "-png", "-r", str(dpi), "-f", str(page), "-l", str(page), "-singlefile", str(pdf), str(target.with_suffix("")), ] ) return target def image_difference(expected: Path, actual: Path) -> float: try: from PIL import Image, ImageChops except ImportError as exc: raise TestFailure("Для visual regression требуется пакет Pillow.") from exc with Image.open(expected).convert("RGB") as left, Image.open(actual).convert("RGB") as right: if left.size != right.size: return 1.0 diff = ImageChops.difference(left, right) histogram = diff.histogram() total = sum(value * (index % 256) for index, value in enumerate(histogram)) maximum = left.width * left.height * 3 * 255 return total / maximum def main() -> int: parser = argparse.ArgumentParser(description="Регрессионные тесты шаблона документов Scientia") parser.add_argument("--fixture", action="append", help="Запустить только fixture с указанным id") parser.add_argument("--compile-only", action="store_true", help="Не запускать PDF и visual проверки") parser.add_argument( "--update-snapshots", action="store_true", help="Явно обновить visual snapshots после ручного подтверждения", ) args = parser.parse_args() manifest = load_manifest() typst = find_executable("typst", env_var="TYPST_BIN") version_output = run([str(typst), "--version"]).strip() if parse_version(version_output) < parse_version(manifest["typst_min"]): raise TestFailure( f"Требуется Typst {manifest['typst_min']}+, найден {version_output}." ) pdfinfo = pdftotext = pdftoppm = None if not args.compile_only: pdfinfo = find_executable("pdfinfo") pdftotext = find_executable("pdftotext", required=False) pdftoppm = find_executable("pdftoppm") creation_timestamp = int(manifest["creation_timestamp"]) selected = set(args.fixture or []) known = { item["id"] for item in ( *manifest["fixtures"], *manifest.get("negative_fixtures", []), *manifest.get("compile_matrices", []), ) } unknown = selected - known if unknown: raise TestFailure(f"Неизвестные fixtures: {', '.join(sorted(unknown))}") if not selected: print("[static] architecture") check_architecture() print("[static] workspace") check_workspace() for unit in manifest.get("unit_tests", []): if selected: continue print(f"[unit] {unit['id']}") run_unit_test(typst, unit["module"], creation_timestamp) temp_dir = Path(tempfile.mkdtemp(prefix="scientia-tests-")) failed = False try: for matrix in manifest.get("compile_matrices", []): if selected and matrix["id"] not in selected: continue keys = tuple(matrix["parameters"]) values = tuple(matrix["parameters"][key] for key in keys) for combination in itertools.product(*values): inputs = dict(zip(keys, combination)) suffix = ",".join(f"{key}={value}" for key, value in inputs.items()) print(f"[matrix] {matrix['id']}[{suffix}]") safe_suffix = re.sub(r"[^A-Za-z0-9_.-]+", "-", suffix) compile_fixture( typst, ROOT / matrix["entry"], temp_dir / f"{matrix['id']}-{safe_suffix}.pdf", creation_timestamp, inputs=inputs, ) for fixture in manifest.get("negative_fixtures", []): if selected and fixture["id"] not in selected: continue fixture_id = fixture["id"] print(f"[negative] {fixture_id}") compile_expected_failure( typst, ROOT / fixture["entry"], temp_dir / f"{fixture_id}.pdf", creation_timestamp, fixture.get("required_errors", []), inputs=fixture.get("inputs"), ) for fixture in manifest["fixtures"]: if selected and fixture["id"] not in selected: continue fixture_id = fixture["id"] print(f"[compile] {fixture_id}") entry = ROOT / fixture["entry"] output = temp_dir / f"{fixture_id}.pdf" compile_fixture( typst, entry, output, creation_timestamp, inputs=fixture.get("inputs"), ) if args.compile_only: continue assert pdfinfo and pdftoppm pages, page_size = inspect_pdf(pdfinfo, output) expected_pages = int(fixture["expected_pages"]) if expected_pages and pages != expected_pages: raise TestFailure( f"{fixture_id}: ожидалось {expected_pages} страниц, получено {pages}." ) if page_size != fixture["expected_page_size"]: raise TestFailure( f"{fixture_id}: ожидался формат {fixture['expected_page_size']}, получен {page_size}." ) text = extract_text(pdftotext, output, temp_dir / f"{fixture_id}.txt") for marker in fixture.get("required_text", []): if marker not in text: raise TestFailure(f"{fixture_id}: в PDF не найден текстовый маркер {marker!r}.") for marker in fixture.get("forbidden_text", []): if marker in text: raise TestFailure(f"{fixture_id}: в PDF найден запрещённый текстовый маркер {marker!r}.") for marker, minimum in fixture.get("required_text_counts", {}).items(): actual = text.count(marker) if actual < int(minimum): raise TestFailure( f"{fixture_id}: маркер {marker!r} найден {actual} раз; " f"требуется не менее {minimum}." ) if "minimum_external_links" in fixture or "minimum_internal_links" in fixture: external_links, internal_links = count_pdf_links(output) minimum_external = int(fixture.get("minimum_external_links", 0)) minimum_internal = int(fixture.get("minimum_internal_links", 0)) if external_links < minimum_external: raise TestFailure( f"{fixture_id}: внешних PDF-ссылок {external_links}; " f"требуется не менее {minimum_external}." ) if internal_links < minimum_internal: raise TestFailure( f"{fixture_id}: внутренних PDF-ссылок {internal_links}; " f"требуется не менее {minimum_internal}." ) for page in fixture.get("snapshot_pages", []): if page > pages: raise TestFailure(f"{fixture_id}: snapshot page {page} больше количества страниц {pages}.") actual = render_page( pdftoppm, output, page, int(manifest["dpi"]), temp_dir / fixture_id / f"page-{page}.png", ) expected = SNAPSHOT_ROOT / fixture_id / f"page-{page}.png" if args.update_snapshots: expected.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(actual, expected) print(f"[snapshot:update] {expected.relative_to(ROOT)}") continue if not expected.is_file(): raise TestFailure( f"Нет snapshot {expected.relative_to(ROOT)}. " "После ручной проверки запустите --update-snapshots." ) difference = image_difference(expected, actual) threshold = float(fixture.get("visual_threshold", manifest["visual_threshold"])) if difference > threshold: raise TestFailure( f"{fixture_id}, страница {page}: visual diff {difference:.6f} " f"превышает порог {threshold:.6f}. Фактический файл: {actual}" ) print(f"[snapshot:ok] {fixture_id}/page-{page} diff={difference:.6f}") print(f"OK: {version_output}") return 0 except Exception: failed = True raise finally: if failed: print(f"Временные результаты сохранены: {temp_dir}", file=sys.stderr) else: shutil.rmtree(temp_dir, ignore_errors=True) if __name__ == "__main__": try: raise SystemExit(main()) except TestFailure as exc: print(f"FAIL: {exc}", file=sys.stderr) raise SystemExit(1)