"""Юнит-тесты DUB-AI (запуск: .venv/Scripts/python tests/run_tests.py). Проверяют ключевые части без обращения к GPU: * поиск спана автора (точно / алиас / инициалы / кавычки / разделители); * канонизация имён студий; * снятие префикса строки дорожки; * сегментацию страниц на дорожки; * HTML -> текст; * BIO-выравнивание спана по offsets токенизатора. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from src.html_extract import (RE_LINE_PREFIX, _split_entries, # noqa: E402 extract_segments, html_to_text) from src.studio_norm import canonical, find_studio_span # noqa: E402 PASS = 0 FAIL = 0 def check(name: str, cond: bool, extra: str = "") -> None: global PASS, FAIL if cond: PASS += 1 print(f" ok {name}") else: FAIL += 1 print(f"FAIL {name} {extra}") # ---------------------------------------------------------------- спаны print("== find_studio_span ==") txt = "Профессиональный (многоголосый закадровый) | HDRezka Studio 18+" s, e = find_studio_span(txt, "HDrezka") check("HDrezka найден", s is not None and "HDRezka" in txt[s:e], f"span={(s, e)}") s, e = find_studio_span("Авторский (одноголосый закадровый) - Ю. Сербин", "Юрий Сербин") check("инициалы Ю. Сербин", "Сербин" in "Авторский (одноголосый закадровый) - Ю. Сербин"[s:e] if s is not None else False) s, e = find_studio_span("Авторский (одноголосый закадровый) Гаврилов", "Андрей Гаврилов") check("фамилия Гаврилов", s is not None and e is not None and s > 0) s, e = find_studio_span('Профессиональный (многоголосый закадровый) Студия «Кравец», LostFilm', "Студия Кравец, LostFilm") check("кавычки в тексте", s is not None) s, e = find_studio_span("Профессиональный (многоголосый закадровый) - SDI Media | TV 1000", "SDI Media TV 1000") check("разделитель |", s is not None) check("нет автора -> None", find_studio_span("Профессиональный (дублированный)", "") is None) check("нет вхождения -> None", find_studio_span("Профессиональный (дублированный)", "HDrezka") is None) # ---------------------------------------------------------------- канонизация print("== canonical ==") check("HDRezka Studio -> HDrezka", canonical("HDRezka Studio") == "HDrezka") check("hdrezka -> HDrezka", canonical("hdrezka") == "HDrezka") check("Read Head Sound -> Red Head Sound", canonical("Read Head Sound") == "Red Head Sound") check("Ultradox Studio -> Ultradox", canonical("Ultradox Studio") == "Ultradox") check("Jaskier ненормативная лексика -> Jaskier", canonical("Jaskier ненормативная лексика") == "Jaskier") check("официальный -> ''", canonical("официальный") == "") # ---------------------------------------------------------------- префиксы print("== RE_LINE_PREFIX ==") for line, body in [ ("Перевод: Профессиональный (дублированный)", "Профессиональный (дублированный)"), ("Перевод 2: Любительский (одноголосый закадровый)", "Любительский (одноголосый закадровый)"), ("Перевод #1: Дублированный", "Дублированный"), ("Озвучивание: Дублированное | Official", "Дублированное | Official"), ("Аудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)", "Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)"), ("Перевод:Профессиональный (дублированный)", "Профессиональный (дублированный)"), ]: check(f"префикс «{line[:20]}…»", RE_LINE_PREFIX.sub("", line) == body) # ---------------------------------------------------------------- сегментация print("== extract_segments ==") segs = extract_segments( "Описание релиза\n" "Перевод 1: Профессиональный (дублированный)\n" "Перевод 2: Профессиональный (двухголосый закадровый) tycoon\n" "Субтитры: русские, английские\n") check("две дорожки", len(segs) == 2, f"got {segs}") segs = extract_segments("Перевод 1: Профессиональный (дублированный) 2. Авторский (одноголосый закадровый) Гоблин") check("нумерация 1./2.", len(segs) == 2, f"got {segs}") segs = extract_segments("Перевод: Одноголосый, многоголосый закадровый") check("запятая-перечисление", len(segs) == 2, f"got {segs}") segs = extract_segments("Перевод: Профессиональный (многоголосый, закадровый)") check("скобки-уточнение не режутся", len(segs) == 1, f"got {segs}") segs = extract_segments("Перевод:\nПрофессиональный (дублированный) Невафильм") check("значение на следующей строке", segs == ["Профессиональный (дублированный) Невафильм"], f"got {segs}") segs = extract_segments("Перевод: Профессиональный (двухголосый закадровый) + Любительский (одноголосый закадровый)") check("разделитель +", len(segs) == 2, f"got {segs}") segs = extract_segments("Аудио 1: Opus, 6 ch, 221 Kbps | Русский AVO (Дмитрий Пучков)\n" "Аудио 2: Opus, 6 ch, 199 Kbps | Русский DVO (Кубик в Кубе)") check("nnm аудио-дорожки", len(segs) == 2, f"got {segs}") segs = extract_segments("Аудио: Русский (AC3, 6 ch, 640 Кбит/с), Английский (AC3)") check("codec-строка без типа пропущена", len(segs) == 0, f"got {segs}") check("_split_entries одиночная", _split_entries("Дублированный") == ["Дублированный"]) # ---------------------------------------------------------------- html print("== html_to_text ==") html = ("
hello
" check("fallback на весь текст", "hello" in html_to_text(html2)) print("== read_input / mojibake ==") import tempfile from src.html_extract import read_input with tempfile.TemporaryDirectory() as td: p = Path(td) / "mojibake.html" p.write_bytes("Перевод: Дублированный".encode("utf-8").decode("cp1251", errors="ignore").encode("utf-8")) check("mojibake восстановлен", "Дублированный" in read_input(p)) p2 = Path(td) / "cp1251.txt" p2.write_bytes("Перевод: Дублированный".encode("cp1251")) check("cp1251 прочитан", "Дублированный" in read_input(p2)) # ---------------------------------------------------------------- BIO print("== BIO-выравнивание (требует токенизатор) ==") try: from transformers import AutoTokenizer from src.train import make_collate tok = AutoTokenizer.from_pretrained("models/best") collate = make_collate(tok, {"проф. дубляж": 0}) text = "Авторский (одноголосый закадровый) - Сергей Визгунов" s0 = text.index("Сергей") enc, types, bio, ages, offsets = collate( [{"text": text, "translation_type": "проф. дубляж", "span": [s0, s0 + len("Сергей Визгунов")], "is_18plus": False}]) marked = {i for i, lab in enumerate(bio[0].tolist()) if lab >= 1} span_toks = [tok.convert_ids_to_tokens(int(enc["input_ids"][0, i])) for i in sorted(marked)] joined = "".join(t.lstrip("#") for t in span_toks).lower() # у ruBert-base токенизатор strips accents: «й» может стать «и» check("токены спана = ФИ", "визгунов" in joined and joined.startswith("сер"), f"tokens={span_toks}") check("первый токен B", bio[0, min(marked)].item() == 1 if marked else False) except Exception as e: # noqa: BLE001 print(f" пропущено (нет модели models/best): {e}") # ---------------------------------------------------------------- релиз / ссылки print("== release / fetch ==") from src.fetch import decode_bytes, detect_tracker # noqa: E402 from src.release import aggregate, title_segments # noqa: E402 check("tracker rutracker", detect_tracker("https://rutracker.org/forum/viewtopic.php?t=1") == "rutracker") check("tracker kinozal", detect_tracker("https://kinozal.tv/details.php?id=1") == "kinozal") check("tracker nnm", detect_tracker("https://nnmclub.to/forum/viewtopic.php?t=1") == "nnm") for bad in ("http://localhost:8000/", "http://127.0.0.1/", "http://169.254.169.254/", "https://rutracker.org.evil.com/", "file:///etc/passwd", "ftp://rutracker.org/"): try: detect_tracker(bad) check(f"SSRF отклонён: {bad}", False) except ValueError: check(f"SSRF отклонён: {bad}", True) check("decode cp1251", decode_bytes("Перевод".encode("cp1251")) == "Перевод") check("decode charset", decode_bytes("Перевод".encode("cp1251"), "text/html; charset=windows-1251") == "Перевод") check("title: ПМ (студия)", title_segments("Гангстерленд / 2025 / ПМ (LostFilm) / WEB-DLRip") == ["Профессиональный (многоголосый закадровый) | LostFilm"]) check("title: ДБ + список ПМ без студий", title_segments("Х / 2026 / ДБ (Red Head Sound), 5 x ПМ, ПД, ЛМ, СТ / 4K") == ["Профессиональный (дублированный) | Red Head Sound"]) check("title: пусто", title_segments("Просто фильм / 2020 / WEB-DL") == []) def _t(typ, st, age=False): return {"segment": f"{typ}|{st}", "translation_type": typ, "studio": st, "is_18plus": age} agg = aggregate([_t("проф. дубляж", "MovieDalen"), _t("проф. дубляж", "moviedalen", True), _t("проф. многоголосый", "LostFilm")]) check("aggregate: дедуп", len(agg["tracks"]) == 2, str(agg)) check("aggregate: 18+ = OR", agg["is_18plus"] is True) check("aggregate: студии", [x["studio"] for x in agg["studios"]] == ["MovieDalen", "LostFilm"]) check("aggregate: пусто", aggregate([])["is_18plus"] is False) from src.fetch import blocked_reason # noqa: E402 from src.release import merge_title_tracks, title_from_text # noqa: E402 check("заглушка Cloudflare", blocked_reason("