From 81bdee620319c5d120ecd97f79901ba4dab2fb87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 12 Jul 2026 15:31:42 +0400 Subject: [PATCH] =?UTF-8?q?v5.1.0:=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20buil?= =?UTF-8?q?der(20),=20extractor(15),=20scanner(20),=20replacer(10)=20?= =?UTF-8?q?=E2=80=94=2065=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2,=200=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/app.py | 2 +- tests/test_builder.py | 133 ++++++++++++++++++++++++++++++++++++++ tests/test_extractor.py | 137 ++++++++++++++++++++++++++++++++++++++++ tests/test_replacer.py | 102 ++++++++++++++++++++++++++++++ tests/test_scanner.py | 131 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 tests/test_builder.py create mode 100644 tests/test_extractor.py create mode 100644 tests/test_replacer.py create mode 100644 tests/test_scanner.py diff --git a/site/app.py b/site/app.py index 0a1a257..d977a5b 100644 --- a/site/app.py +++ b/site/app.py @@ -20,7 +20,7 @@ if _sys_path_root not in sys.path: sys.path.insert(0, _sys_path_root) # Версия приложения (меняется при изменениях) -VERSION = "5.0.9" +VERSION = "5.1.0" def create_app(): diff --git a/tests/test_builder.py b/tests/test_builder.py new file mode 100644 index 0000000..fc06478 --- /dev/null +++ b/tests/test_builder.py @@ -0,0 +1,133 @@ +""" +Тесты builder.py — создание ZIP и mapping.csv. + +Запуск: python3 tests/test_builder.py +""" + +import io +import zipfile +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from drhider.builder import build_zip, build_mapping_csv + +passed = 0 +failed = 0 + +def check(name, condition): + global passed, failed + if condition: + passed += 1 + print(f" ✅ {name}") + else: + failed += 1 + print(f" ❌ {name}") + + +# ═══════════════════════════════════════════════════════════════ +# Test build_zip +# ═══════════════════════════════════════════════════════════════ +print("\n=== build_zip ===") + +# 1. Базовый ZIP с одним файлом +zip_data = build_zip([("test.md", b"content")]) +with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + names = zf.namelist() + check("один файл в ZIP", len(names) == 1) + check("имя test.md", "test.md" in names) + check("контент верный", zf.read("test.md") == b"content") + +# 2. ZIP с несколькими файлами +zip_data = build_zip([ + ("договор_obfuscated.md", "# Договор\nТекст".encode("utf-8")), + ("спецификация_obfuscated.md", "| Услуга | Цена |".encode("utf-8")), +]) +with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + names = zf.namelist() + check("два файла", len(names) == 2) + check("mapping.csv нет", "mapping.csv" not in names) + check("договор читается", "Договор" in zf.read("договор_obfuscated.md").decode("utf-8")) + +# 3. ZIP с mapping.csv +zip_data = build_zip( + [("file.md", b"data")], + mapping_csv="тип,оригинал,замена\nperson,Иванов,Иванов_0001\n" +) +with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + names = zf.namelist() + check("mapping.csv есть", "mapping.csv" in names) + csv_content = zf.read("mapping.csv").decode("utf-8") + check("mapping с BOM", csv_content.startswith('\ufeff')) + check("данные в mapping", "Иванов_0001" in csv_content) + +# 4. ZIP с кириллицей в именах +zip_data = build_zip([("договор_№123.md", "текст".encode("utf-8"))]) +with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + check("кириллическое имя читается", "договор_№123.md" in zf.namelist()) + +# 5. ZIP с бинарными данными +binary = bytes(range(256)) +zip_data = build_zip([("binary.bin", binary)]) +with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + check("бинарный файл сохранён", zf.read("binary.bin") == binary) + +# 6. Пустой mapping +zip_data = build_zip([("f.md", b"x")], mapping_csv="") +with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + check("пустой mapping не добавляется", "mapping.csv" not in zf.namelist()) + + +# ═══════════════════════════════════════════════════════════════ +# Test build_mapping_csv +# ═══════════════════════════════════════════════════════════════ +print("\n=== build_mapping_csv ===") + +# 1. Простой mapping +mapping = {"Иванов И.И.": "Иванов_0001"} +csv_str = build_mapping_csv(mapping) +check("заголовок есть", csv_str.startswith("тип_данных,оригинал,замена")) +check("данные есть", "Иванов_0001" in csv_str) + +# 2. Пустой mapping +csv_str = build_mapping_csv({}) +lines = csv_str.strip().split("\n") +check("пустой — только заголовок", len(lines) == 1) + +# 3. Сортировка +mapping = {"Б": "Б_0001", "А": "А_0001"} +csv_str = build_mapping_csv(mapping) +check("сортировка А перед Б", csv_str.index("А_0001") < csv_str.index("Б_0001")) + +# 4. Кавычки в значениях +mapping = {'ООО "Ромашка"': "ООО_Технология_0001"} +csv_str = build_mapping_csv(mapping) +check("кавычки экранированы", 'ООО "Ромашка"' in csv_str or 'ООО ""Ромашка""' in csv_str) + + +# ═══════════════════════════════════════════════════════════════ +# Test: интеграция build_zip + build_mapping_csv +# ═══════════════════════════════════════════════════════════════ +print("\n=== build_zip + build_mapping_csv ===") + +mapping = {"Иванов И.И.": "Иванов_0001", "ООО Ромашка": "ООО_Технология_0001"} +csv = build_mapping_csv(mapping) +zip_data = build_zip([("doc.md", "# Договор с Иванов_0001".encode("utf-8"))], mapping_csv=csv) + +with zipfile.ZipFile(io.BytesIO(zip_data)) as zf: + check("два файла в ZIP", len(zf.namelist()) == 2) + doc = zf.read("doc.md").decode("utf-8") + check("doc с заменой", "Иванов_0001" in doc) + csv_content = zf.read("mapping.csv").decode("utf-8") + check("CSV с обеими заменами", "Иванов_0001" in csv_content and "ООО_Технология_0001" in csv_content) + + +# ═══════════════════════════════════════════════════════════════ +print(f"\n{'='*50}") +print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}") +if failed > 0: + print("❌ ТЕСТЫ ПРОВАЛЕНЫ") + sys.exit(1) +else: + print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ") diff --git a/tests/test_extractor.py b/tests/test_extractor.py new file mode 100644 index 0000000..7a24d12 --- /dev/null +++ b/tests/test_extractor.py @@ -0,0 +1,137 @@ +""" +Тесты extractor.py — извлечение текста, ZIP-распаковка. + +Запуск: python3 tests/test_extractor.py +""" + +import io +import zipfile +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from drhider.extractor import extract_text, expand_zips, docx_to_markdown, pdf_to_markdown + +passed = 0 +failed = 0 + +def check(name, condition): + global passed, failed + if condition: + passed += 1 + print(f" ✅ {name}") + else: + failed += 1 + print(f" ❌ {name}") + + +# ═══════════════════════════════════════════════════════════════ +# extract_text +# ═══════════════════════════════════════════════════════════════ +print("\n=== extract_text ===") + +# 1. .txt +text = extract_text("test.txt", "Просто текст".encode("utf-8")) +check(".txt — текст извлечён", text == "Просто текст") + +# 2. .txt с кириллицей +text = extract_text("документ.txt", "Привет мир".encode("utf-8")) +check(".txt — кириллица", "Привет" in text) + +# 3. .doc (бинарный — пропускается) +text = extract_text("старый.doc", b"\xD0\xCF\x11\xE0\x00" * 10) +check(".doc — заглушка", "not supported" in text.lower() or "binary" in text.lower()) + +# 4. Неизвестное расширение → UTF-8 +text = extract_text("file.xyz", "hello".encode("utf-8")) +check(".xyz — как текст", text == "hello") + + +# ═══════════════════════════════════════════════════════════════ +# expand_zips +# ═══════════════════════════════════════════════════════════════ +print("\n=== expand_zips ===") + +# 1. Не-ZIP файлы проходят без изменений +files = [("test.txt", b"content", "text/plain")] +result = expand_zips(files) +check("не-ZIP — без изменений", len(result) == 1 and result[0][0] == "test.txt") + +# 2. ZIP распаковывается +buf = io.BytesIO() +with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr("inner.txt", b"hello") +result = expand_zips([("archive.zip", buf.getvalue(), "application/zip")]) +check("ZIP — распакован", len(result) == 1 and result[0][0] == "inner.txt") +check("ZIP — контент верный", result[0][1] == b"hello") + +# 3. ZIP с несколькими файлами +buf = io.BytesIO() +with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr("a.txt", b"aaa") + zf.writestr("b.txt", b"bbb") +result = expand_zips([("multi.zip", buf.getvalue(), "")]) +check("ZIP — два файла", len(result) == 2) + +# 4. Path traversal защита +buf = io.BytesIO() +with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr("../../etc/passwd", b"bad") +result = expand_zips([("evil.zip", buf.getvalue(), "")]) +check("path traversal — заблокирован", all("/" not in r[0] and ".." not in r[0] for r in result)) + +# 5. Директории пропускаются +buf = io.BytesIO() +with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr("dir/", b"") + zf.writestr("dir/file.txt", b"ok") +result = expand_zips([("dirs.zip", buf.getvalue(), "")]) +check("директории — пропущены", len(result) == 1) + + +# ═══════════════════════════════════════════════════════════════ +# docx_to_markdown +# ═══════════════════════════════════════════════════════════════ +print("\n=== docx_to_markdown ===") + +try: + from docx import Document + # Создаём тестовый DOCX + doc = Document() + doc.add_heading("Заголовок", level=1) + p = doc.add_paragraph("Обычный текст") + p.add_run(" жирный").bold = True + doc.add_paragraph("") + + # Таблица + table = doc.add_table(rows=2, cols=2) + table.rows[0].cells[0].text = "A" + table.rows[0].cells[1].text = "B" + table.rows[1].cells[0].text = "1" + table.rows[1].cells[1].text = "2" + + buf = io.BytesIO() + doc.save(buf) + md = docx_to_markdown(buf.getvalue()) + + check("DOCX — заголовок #", "# Заголовок" in md) + check("DOCX — жирный **", "жирный**" in md and "**" in md) + check("DOCX — таблица", "| A | B |" in md) + check("DOCX — разделитель таблицы", "---|---|" in md) + check("DOCX — данные таблицы", "| 1 | 2 |" in md) + +except ImportError: + print(" ⚠️ python-docx не установлен — тесты DOCX пропущены") + + +# ═══════════════════════════════════════════════════════════════ +# Итог +# ═══════════════════════════════════════════════════════════════ +print(f"\n{'='*50}") +print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}") +if failed > 0: + print("❌ ТЕСТЫ ПРОВАЛЕНЫ") + sys.exit(1) +else: + print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ") diff --git a/tests/test_replacer.py b/tests/test_replacer.py new file mode 100644 index 0000000..1954fe2 --- /dev/null +++ b/tests/test_replacer.py @@ -0,0 +1,102 @@ +""" +Тесты replacer.py — apply_replacements. + +Запуск: python3 tests/test_replacer.py +""" + +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from drhider.replacer import apply_replacements + +passed = 0 +failed = 0 + +def check(name, condition): + global passed, failed + if condition: + passed += 1 + print(f" ✅ {name}") + else: + failed += 1 + print(f" ❌ {name}") + + +# ═══════════════════════════════════════════════════════════════ +# apply_replacements +# ═══════════════════════════════════════════════════════════════ +print("\n=== apply_replacements ===") + +# 1. Простая замена +text = "Директор Иванов И.И. подписал" +mapping = {"Иванов И.И.": "Иванов_0001"} +keys = sorted(mapping.keys(), key=len, reverse=True) +result = apply_replacements(text, mapping, keys) +check("простая замена", result == "Директор Иванов_0001 подписал") + +# 2. Длинное before короткого +text = "ИНН 123456789012 и ИНН 1234567890" +mapping = {"ИНН 123456789012": "ИНН_0001", "ИНН 1234567890": "ИНН_0002"} +keys = sorted(mapping.keys(), key=len, reverse=True) +result = apply_replacements(text, mapping, keys) +check("длинное раньше короткого", "ИНН_0001" in result and "ИНН_0002" in result) +check("12-значный не задет 10-значным", result == "ИНН_0001 и ИНН_0002") + +# 3. Границы слова +text = "Email: test@mail.ru написать" +mapping = {"test@mail.ru": "email_0001@fake"} +keys = sorted(mapping.keys(), key=len, reverse=True) +result = apply_replacements(text, mapping, keys) +check("email заменён", "email_0001@fake" in result) + +# 4. Множественные замены +text = "Иванов И.И. и Петров А.С. подписали" +mapping = {"Иванов И.И.": "Иванов_0001", "Петров А.С.": "Петров_0002"} +keys = sorted(mapping.keys(), key=len, reverse=True) +result = apply_replacements(text, mapping, keys) +check("две замены", "Иванов_0001" in result and "Петров_0002" in result) + +# 5. Спецсимволы в ключе +text = 'ООО "Ромашка" заключила' +mapping = {'ООО "Ромашка"': "ООО_Технология_0001"} +keys = sorted(mapping.keys(), key=len, reverse=True) +result = apply_replacements(text, mapping, keys) +check("кавычки в ключе", "ООО_Технология_0001" in result) + +# 6. Кириллица +text = "Москва, ул. Ленина, д. 42" +mapping = {"Москва": "Москва_0001", "Ленина": "Ленина_0015"} +keys = sorted(mapping.keys(), key=len, reverse=True) +result = apply_replacements(text, mapping, keys) +check("кириллица заменена", "Москва_0001" in result and "Ленина_0015" in result) + +# 7. Частичное совпадение не заменяется +text = "Ивановский проспект" +mapping = {"Иванов": "Иванов_0001"} +keys = sorted(mapping.keys(), key=len, reverse=True) +result = apply_replacements(text, mapping, keys) +check("частичное совпадение — не заменено", result == "Ивановский проспект") + +# 8. Пустой mapping +text = "ничего не меняется" +result = apply_replacements(text, {}, []) +check("пустой mapping", result == text) + +# 9. Markdown текст +text = "**ООО Ромашка** заключила с **Иванов И.И.**" +mapping = {"ООО Ромашка": "ООО_Технология_0001", "Иванов И.И.": "Иванов_0002"} +keys = sorted(mapping.keys(), key=len, reverse=True) +result = apply_replacements(text, mapping, keys) +check("Markdown bold сохранён", "**ООО_Технология_0001**" in result) + + +# ═══════════════════════════════════════════════════════════════ +print(f"\n{'='*50}") +print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}") +if failed > 0: + print("❌ ТЕСТЫ ПРОВАЛЕНЫ") + sys.exit(1) +else: + print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ") diff --git a/tests/test_scanner.py b/tests/test_scanner.py new file mode 100644 index 0000000..881279c --- /dev/null +++ b/tests/test_scanner.py @@ -0,0 +1,131 @@ +""" +Тесты scanner.py — scan_regex, _next_token. + +Запуск: python3 tests/test_scanner.py +""" + +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from drhider.scanner import scan_regex, _next_token + +passed = 0 +failed = 0 + +def check(name, condition): + global passed, failed + if condition: + passed += 1 + print(f" ✅ {name}") + else: + failed += 1 + print(f" ❌ {name}") + + +# ═══════════════════════════════════════════════════════════════ +# _next_token +# ═══════════════════════════════════════════════════════════════ +print("\n=== _next_token ===") + +counters = {} + +# 1. Базовый токен +token = _next_token("person_name", counters) +check("person → Иванов/Петров/etc", token.endswith("_0001")) +check("счётчик увеличился", counters.get("person_Иванов", 0) == 1 or any(v == 1 for v in counters.values())) + +# 2. Второй токен того же типа — новый номер +token2 = _next_token("person_name", counters) +check("второй person — новый счётчик", any(v >= 1 for v in counters.values())) + +# 3. Разные типы — разные счётчики +token3 = _next_token("company", counters) +check("company ≠ person", token3 != token) + +# 4. Неизвестный тип → fallback +counters2 = {} +token4 = _next_token("unknown_xyz", counters2) +check("неизвестный тип — Данные_0001", token4.startswith("Данные_") and "_0001" in token4) + +# 5. phone +counters3 = {} +token5 = _next_token("phone", counters3) +check("phone — +7_000_000_0001", token5 == "+7_000_000_0001") + +# 6. address +counters4 = {} +token6 = _next_token("address", counters4) +check("address — город_0001", token6.endswith("_0001") and len(token6) > 5) + + +# ═══════════════════════════════════════════════════════════════ +# scan_regex +# ═══════════════════════════════════════════════════════════════ +print("\n=== scan_regex ===") + +# 1. Телефон +mapping = {} +counters = {} +scan_regex("Звоните +7 (495) 789-41-35", mapping, counters) +check("телефон найден", len(mapping) == 1) +check("телефон — токен", list(mapping.values())[0].startswith("+7_000_000_")) + +# 2. Email +mapping = {} +counters = {} +scan_regex("Пишите на info@nubes.ru", mapping, counters) +check("email найден", len(mapping) == 1) +check("email — токен", list(mapping.values())[0].startswith("email_")) + +# 3. ИНН +mapping = {} +counters = {} +scan_regex("ИНН 9706005293", mapping, counters) +check("ИНН найден", len(mapping) == 1) +check("ИНН — токен", list(mapping.values())[0].startswith("ИНН_")) + +# 4. Компания +mapping = {} +counters = {} +scan_regex('ООО "Ромашка" заключила договор', mapping, counters) +check("компания найдена", len(mapping) == 1) +check("компания — токен", "_" in list(mapping.values())[0]) + +# 5. ФИО +mapping = {} +counters = {} +scan_regex("Директор Иванов И.И. подписал", mapping, counters) +check("ФИО найдено", len(mapping) == 1) +check("ФИО — токен", "_" in list(mapping.values())[0]) + +# 6. Множественные сущности +mapping = {} +counters = {} +scan_regex("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759", mapping, counters) +check("три сущности", len(mapping) == 3) + +# 7. Дубликаты не перезаписываются +mapping = {} +counters = {} +scan_regex("+7 (495) 789-41-35 и ещё +7 (495) 789-41-35", mapping, counters) +check("дубликат — одна запись", len(mapping) == 1) + +# 8. Согласованность: один счётчик +mapping = {} +counters = {} +scan_regex("ИНН 9706005293 и КПП 772401001", mapping, counters) +inn_val = mapping["ИНН 9706005293"] +kpp_val = mapping["КПП 772401001"] +check("ИНН и КПП разные токены", inn_val != kpp_val) + + +# ═══════════════════════════════════════════════════════════════ +print(f"\n{'='*50}") +print(f"Результат: {passed} OK, {failed} FAIL из {passed + failed}") +if failed > 0: + print("❌ ТЕСТЫ ПРОВАЛЕНЫ") + sys.exit(1) +else: + print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")