textmachine/eval/editor_contract/selftest_apply.py

97 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())