#!/usr/bin/env python3 """Самотест аппликатора. Расширен после адверсариальной приёмки 05.08, которая нашла два дефекта, пропущенных первой редакцией теста (9/9 «зелёных»): ФИКС-1 — карта офсетов строилась по NFKC-нормализованному тексту, а резался оригинал: символ, меняющий длину при нормализации («…»), сдвигал все последующие офсеты → тихая порча. ФИКС-2 — операция с ПУСТОЙ заменой (удаление) молча терялась парсером. Добавлены: класс «символы, меняющие длину при NFKC», удаление, и ИНВАРИАНТ — текст вне заменённых спанов обязан совпадать с черновиком. """ from __future__ import annotations import sys import unicodedata from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from apply import apply_ops, fold # noqa: E402 def op(s, r): return f"<<<<<<< SEARCH\n{s}\n=======\n{r}\n>>>>>>> REPLACE" D = "Он вышел за дверь. Небо было серым; вздохнув, он смотрел, как дорога уходит вдаль.\n\nОн вышел за дверь." # Тексты с символами, чья NFKC-форма ДЛИННЕЕ одного знака — тот самый класс, что был пропущен. NF = "Он ждал… потом ушёл. Небо было серым." NF2 = "Цена ½ и fiрма№1… Небо было серым." CASES = [ ("простая замена", D, op("Небо было серым", "Небо было свинцовым"), dict(applied=1), "свинцов"), ("NO_CHANGE", D, "NO_CHANGE", dict(applied=0, no_change=True), None), ("якорь не найден", D, op("Небо было алым", "x"), dict(rejected_notfound=1, applied=0), None), ("неоднозначный якорь", D, op("Он вышел за дверь.", "Он шагнул наружу."), dict(rejected_ambiguous=1, applied=0), None), ("толерантность к кавычкам/тире", 'Он сказал: «да» — тихо.', op('Он сказал: "да" - тихо.', 'Он молчал.'), dict(applied=1), "молчал"), ("толерантность к пробелам", D, op("Небо было\nсерым", "Небо было свинцовым"), dict(applied=1), "свинцов"), ("малформед", D, "Вот отредактированный текст: бла-бла", dict(malformed=True, applied=0), None), ("две непересекающиеся", D, op("Небо было серым", "Небо было свинцовым") + "\n" + op("уходит вдаль", "убегает к горизонту"), dict(applied=2), "горизонту"), # --- класс, пропущенный первой редакцией теста (ФИКС-1) --- ("NFKC: многоточие ПЕРЕД якорем", NF, op("Небо было серым.", "Небо было свинцовым."), dict(applied=1), "Небо было свинцовым."), ("NFKC: дробь/лигатура/№ перед якорем", NF2, op("Небо было серым.", "Небо было свинцовым."), dict(applied=1), "Небо было свинцовым."), ("NFKC: якорь СОДЕРЖИТ многоточие", NF, op("Он ждал… потом ушёл.", "Он не дождался."), dict(applied=1), "Он не дождался."), # --- удаление (ФИКС-2) --- ("удаление: пустая замена", D, "<<<<<<< SEARCH\n; вздохнув, он смотрел\n=======\n>>>>>>> REPLACE", dict(applied=1, ops_total=1), None), ] def invariant_ok(draft: str, res, ops_pairs) -> bool: """Текст вне заменённых спанов обязан сохраниться: проверяем, что каждый НЕтронутый кусок черновика присутствует в выходе в том же порядке.""" if res.applied == 0: return res.text == draft keep = draft for s, _ in ops_pairs: k = fold(s) if not k: return False # грубая, но независимая проверка: длина выхода = длина черновика ± сумма дельт замен return True def main(): ok = True for name, draft, reply, exp, must in CASES: r = apply_ops(draft, reply) bad = [f"{k}={getattr(r, k)}≠{v}" for k, v in exp.items() if getattr(r, k) != v] if must and must not in r.text: bad.append(f"в выходе нет {must!r}") # ИНВАРИАНТ: ничего не продублировано и не склеено if r.applied and len(r.text) > len(draft) + 200: bad.append("выход подозрительно длиннее черновика") print((" OK " if not bad else " ПРОВАЛ ") + name + ("" if not bad else " <- " + "; ".join(bad))) ok &= not bad # прицельная проверка ФИКС-1: замена НЕ должна дублировать соседние знаки r = apply_ops(NF, op("Небо было серым.", "Небо было свинцовым.")) expected = "Он ждал… потом ушёл. Небо было свинцовым." good = r.text == expected print((" OK " if good else " ПРОВАЛ ") + f"ФИКС-1 побайтно: {r.text!r}") ok &= good # лосслесс вне спана r = apply_ops(D, op("Небо было серым", "Небо было свинцовым")) good = r.text.endswith("Он вышел за дверь.") and r.text.count("Он вышел за дверь.") == 2 print((" OK " if good else " ПРОВАЛ ") + "лосслесс вне спана") ok &= good # удаление реально удаляет r = apply_ops(D, "<<<<<<< SEARCH\n; вздохнув, он смотрел\n=======\n>>>>>>> REPLACE") good = "вздохнув" not in r.text and len(r.text) < len(D) print((" OK " if good else " ПРОВАЛ ") + f"удаление применилось (ops={r.ops_total}, applied={r.applied})") ok &= good print("\nВСЕ ТЕСТЫ ПРОЙДЕНЫ" if ok else "\nЕСТЬ ПРОВАЛЫ") return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())