From ecc10a4413338500277e5e7904af619dbb207adf Mon Sep 17 00:00:00 2001 From: jze9 Date: Tue, 11 Aug 2026 17:11:08 +0500 Subject: [PATCH] =?UTF-8?q?test:=20=D1=8E=D0=BD=D0=B8=D1=82-=D1=81=D1=83?= =?UTF-8?q?=D0=B8=D1=82=20L1/L2/L3=20+=20=D0=93=D0=9E=D0=A1=D0=A2=20(41=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82)=20=D0=B8=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=20=D0=B2=20CI=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первый настоящий автоматический тест-суит проекта — раньше регрессии ловились руками. Покрыта чистая логика детекции и форматирования (без БД/Redis/GPU/Ollama): - worker-indexer: L1 Winnowing (точные совпадения, идемпотентность отпечатка, диапазон signed int64) и L2 MinHash LSH (шинглы, Jaccard, near-duplicate + upsert через in-memory-фолбэк). - worker-gpu: L3 FAISS — возврат doc_id из PostgreSQL (IndexIDMap2), идемпотентность add_vectors (remove-before-add, без дублей), self-match ≈ 1, ранжирование. Прямо стережёт баги, из-за которых индекс переписывался. - worker-gost: ГОСТ 7.1-2003 и 7.0.5-2008 — авторы (≤3 / 4+ «и др.»/et al.), статья/книга/web, DOI, порядок сортировки кириллица→латиница, стр. в ссылке. Обвязка: per-service pytest.ini/conftest/requirements-test. scripts/run_tests.sh гоняет тесты в изолированных python:3.11-slim контейнерах (не засоряя хост), через Tsinghua-зеркало. CI: job `test` теперь гейтит `deploy` (needs: test) — падение тестов блокирует прод-деплой. make test / make test-one SVC=... Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/deploy.yml | 22 +++++ Makefile | 11 ++- scripts/run_tests.sh | 67 ++++++++++++++ services/worker-gost/conftest.py | 6 ++ services/worker-gost/pytest.ini | 3 + services/worker-gost/requirements-test.txt | 2 + services/worker-gost/tests/test_gost_7_0_5.py | 32 +++++++ services/worker-gost/tests/test_gost_7_1.py | 80 +++++++++++++++++ services/worker-gpu/conftest.py | 6 ++ services/worker-gpu/pytest.ini | 3 + services/worker-gpu/requirements-test.txt | 6 ++ .../worker-gpu/tests/test_faiss_manager.py | 88 +++++++++++++++++++ services/worker-indexer/conftest.py | 6 ++ services/worker-indexer/pytest.ini | 3 + services/worker-indexer/requirements-test.txt | 8 ++ services/worker-indexer/tests/test_minhash.py | 62 +++++++++++++ .../worker-indexer/tests/test_winnowing.py | 79 +++++++++++++++++ 17 files changed, 480 insertions(+), 4 deletions(-) create mode 100755 scripts/run_tests.sh create mode 100644 services/worker-gost/conftest.py create mode 100644 services/worker-gost/pytest.ini create mode 100644 services/worker-gost/requirements-test.txt create mode 100644 services/worker-gost/tests/test_gost_7_0_5.py create mode 100644 services/worker-gost/tests/test_gost_7_1.py create mode 100644 services/worker-gpu/conftest.py create mode 100644 services/worker-gpu/pytest.ini create mode 100644 services/worker-gpu/requirements-test.txt create mode 100644 services/worker-gpu/tests/test_faiss_manager.py create mode 100644 services/worker-indexer/conftest.py create mode 100644 services/worker-indexer/pytest.ini create mode 100644 services/worker-indexer/requirements-test.txt create mode 100644 services/worker-indexer/tests/test_minhash.py create mode 100644 services/worker-indexer/tests/test_winnowing.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 81ef740..f24a42e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -13,7 +13,29 @@ concurrency: cancel-in-progress: false jobs: + test: + runs-on: deploy + steps: + - name: Клонировать репозиторий (depth 1 — для тестов история не нужна) + run: | + set -euo pipefail + SRC="$(mktemp -d)" + echo "SRC=$SRC" >> "$GITHUB_ENV" + git clone --branch main --depth 1 \ + https://gitea.jze9.ru/jze9/anti-plagiarism.git "$SRC" + + - name: Юнит-тесты (L1 winnowing, L2 minhash, L3 FAISS, ГОСТ) в контейнерах + run: | + set -euo pipefail + cd "$SRC" + bash scripts/run_tests.sh + + - name: Убрать временный чекаут + if: always() + run: rm -rf "${SRC:-/tmp/none}" + deploy: + needs: test # деплой только если юнит-тесты прошли runs-on: deploy steps: - name: Клонировать репозиторий (полностью — нужен git-лог для diff) diff --git a/Makefile b/Makefile index ca2149d..9812e27 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev build migrate logs shell-api shell-gpu lint test down ps restart clean test-up test-down test-logs test-ps +.PHONY: dev build migrate logs shell-api shell-gpu lint test test-one down ps restart clean test-up test-down test-logs test-ps # ─── Переменные ──────────────────────────────────────────────────────────────── # Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра @@ -107,11 +107,14 @@ lint: lint-frontend: cd services/frontend && npm run lint +# Юнит-тесты чистой логики (L1/L2/L3 + ГОСТ) в изолированных контейнерах — +# без БД/Redis/GPU. Тот же скрипт гоняет CI как гейт перед деплоем. test: - $(COMPOSE_PROD) exec api pytest tests/ -v + bash scripts/run_tests.sh -test-cov: - $(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html +# Тесты одного сервиса, напр.: make test-one SVC=worker-gost +test-one: + bash scripts/run_tests.sh $(SVC) # ─── Утилиты ────────────────────────────────────────────────────────────────── # ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) — diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 0000000..e31eae5 --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Прогон юнит-тестов всех сервисов в изолированных python:3.11-slim контейнерах. +# +# Тесты бьют по ЧИСТОЙ логике детекции и форматирования и не требуют внешней +# инфраструктуры (БД/Redis/RabbitMQ/GPU/Ollama) — поэтому гоняются одинаково на +# машине разработчика и в CI. Запуск в контейнере не засоряет хост зависимостями. +# +# Использование: +# scripts/run_tests.sh # все сервисы +# scripts/run_tests.sh worker-gost # только один сервис +# +# PIP_INDEX_URL можно переопределить (по умолчанию — Tsinghua-зеркало, т.к. +# pypi.org из LAN недоступен). +set -euo pipefail + +MIRROR="${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple/}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="python:3.11-slim" + +# service:apt-пакеты (нужны faiss-cpu → libgomp1) +SERVICES=( + "worker-indexer:" + "worker-gost:" + "worker-gpu:libgomp1" +) + +run_service() { + local svc="$1" apt_pkgs="$2" + local dir="$ROOT/services/$svc" + if [[ ! -f "$dir/requirements-test.txt" ]]; then + echo "⚠ $svc: нет requirements-test.txt — пропуск" + return 0 + fi + echo "──────────────────────────────────────────────" + echo "▶ Тесты: $svc" + echo "──────────────────────────────────────────────" + docker run --rm \ + -v "$dir":/app -w /app \ + -e PIP_INDEX_URL="$MIRROR" \ + -e PIP_DISABLE_PIP_VERSION_CHECK=1 \ + "$IMAGE" bash -c " + set -e + ${apt_pkgs:+apt-get update -qq && apt-get install -y -qq --no-install-recommends $apt_pkgs >/dev/null && rm -rf /var/lib/apt/lists/*} + pip install --no-cache-dir --timeout 60 -q -r requirements-test.txt + python -m pytest + " +} + +FILTER="${1:-}" +failed=0 +for entry in "${SERVICES[@]}"; do + svc="${entry%%:*}" + apt="${entry#*:}" + [[ -n "$FILTER" && "$FILTER" != "$svc" ]] && continue + if ! run_service "$svc" "$apt"; then + failed=1 + echo "✗ $svc: тесты упали" + fi +done + +echo "══════════════════════════════════════════════" +if [[ $failed -eq 0 ]]; then + echo "✅ Все юнит-тесты прошли" +else + echo "❌ Есть упавшие тесты" + exit 1 +fi diff --git a/services/worker-gost/conftest.py b/services/worker-gost/conftest.py new file mode 100644 index 0000000..ac185d2 --- /dev/null +++ b/services/worker-gost/conftest.py @@ -0,0 +1,6 @@ +"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) diff --git a/services/worker-gost/pytest.ini b/services/worker-gost/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/services/worker-gost/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q diff --git a/services/worker-gost/requirements-test.txt b/services/worker-gost/requirements-test.txt new file mode 100644 index 0000000..814eea4 --- /dev/null +++ b/services/worker-gost/requirements-test.txt @@ -0,0 +1,2 @@ +# Зависимости для юнит-тестов worker-gost (ГОСТ-форматтеры — чистая логика). +pytest==8.2.0 diff --git a/services/worker-gost/tests/test_gost_7_0_5.py b/services/worker-gost/tests/test_gost_7_0_5.py new file mode 100644 index 0000000..08d9485 --- /dev/null +++ b/services/worker-gost/tests/test_gost_7_0_5.py @@ -0,0 +1,32 @@ +"""Юнит-тесты кратких библиографических ссылок по ГОСТ Р 7.0.5-2008.""" + +from app.formatters.gost_7_0_5 import GOST705Formatter, format_short + + +def test_short_author_and_year(): + assert format_short({"authors": [{"last_name": "Иванов"}], "year": 2023}) == "[Иванов, 2023]" + + +def test_short_without_author_uses_title(): + out = format_short({"title": "Большая книга про всё сразу", "year": 2020}) + assert out == "[Большая книга про..., 2020]" + + +def test_short_no_author_no_title(): + assert format_short({"year": 2019}) == "[2019]" + + +def test_short_missing_year_defaults(): + assert format_short({"authors": [{"last_name": "Петров"}]}) == "[Петров, б. г.]" + + +def test_inline_with_page(): + f = GOST705Formatter() + doc = {"authors": [{"last_name": "Иванов"}], "year": 2023} + assert f.format_inline(doc, page="15") == "[Иванов, 2023, с. 15]" + + +def test_inline_without_page(): + f = GOST705Formatter() + doc = {"authors": [{"last_name": "Иванов"}], "year": 2023} + assert f.format_inline(doc) == "[Иванов, 2023]" diff --git a/services/worker-gost/tests/test_gost_7_1.py b/services/worker-gost/tests/test_gost_7_1.py new file mode 100644 index 0000000..9372553 --- /dev/null +++ b/services/worker-gost/tests/test_gost_7_1.py @@ -0,0 +1,80 @@ +"""Юнит-тесты полного библиографического описания по ГОСТ 7.1-2003.""" + +from app.formatters.gost_7_1 import ( + GOST71Formatter, + _format_author, + _format_authors, + _get_sort_key, + format_full, +) + + +def test_author_with_initials(): + assert _format_author({"last_name": "Иванов", "initials": "И. И."}) == "Иванов И. И." + + +def test_author_initials_get_trailing_dot(): + assert _format_author({"last_name": "Иванов", "initials": "И. И"}) == "Иванов И. И." + + +def test_author_from_first_name(): + assert ( + _format_author({"last_name": "Петров", "first_name": "Пётр Петрович"}) + == "Петров П.П." + ) + + +def test_author_last_name_only(): + assert _format_author({"last_name": "Сидоров"}) == "Сидоров" + + +def test_author_empty(): + assert _format_author({}) == "" + + +def test_authors_up_to_three_all_listed(): + authors = [{"last_name": "А"}, {"last_name": "Б"}, {"last_name": "В"}] + assert _format_authors(authors) == "А, Б, В" + + +def test_authors_four_plus_truncated_ru(): + authors = [{"last_name": n} for n in ("А", "Б", "В", "Г")] + assert _format_authors(authors, lang="ru") == "А, Б, В, и др." + + +def test_authors_four_plus_truncated_en(): + authors = [{"last_name": n} for n in ("A", "B", "C", "D")] + assert _format_authors(authors, lang="en") == "A, B, C, et al." + + +def test_article_has_journal_year_and_doi(): + doc = { + "title": "Заголовок статьи", + "journal": "Вестник науки", + "year": 2023, + "volume": "5", + "issue": "2", + "pages": "10-20", + "doi": "10.1234/abc", + "authors": [{"last_name": "Иванов", "initials": "И.И."}], + } + out = format_full(doc) + assert out.startswith("Иванов И.И.. Заголовок статьи") + assert "// Вестник науки" in out + assert "— 2023" in out + assert "Т. 5" in out and "№ 2" in out + assert "С. 10-20" in out + assert out.endswith("DOI: 10.1234/abc") + + +def test_web_resource_marked_and_has_url(): + doc = {"title": "Портал", "url": "https://example.org", "year": 2024} + out = GOST71Formatter().format_full(doc) + assert "[Электронный ресурс]" in out + assert "URL: https://example.org" in out + + +def test_sort_key_cyrillic_before_latin(): + ru = _get_sort_key({"authors": [{"last_name": "Яковлев"}]}) + en = _get_sort_key({"authors": [{"last_name": "Adams"}]}) + assert ru < en # кириллица (префикс 0) сортируется раньше латиницы (префикс 1) diff --git a/services/worker-gpu/conftest.py b/services/worker-gpu/conftest.py new file mode 100644 index 0000000..ac185d2 --- /dev/null +++ b/services/worker-gpu/conftest.py @@ -0,0 +1,6 @@ +"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) diff --git a/services/worker-gpu/pytest.ini b/services/worker-gpu/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/services/worker-gpu/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q diff --git a/services/worker-gpu/requirements-test.txt b/services/worker-gpu/requirements-test.txt new file mode 100644 index 0000000..7e4621f --- /dev/null +++ b/services/worker-gpu/requirements-test.txt @@ -0,0 +1,6 @@ +# Зависимости для юнит-тестов worker-gpu (FAISS-индекс L3, в памяти, без GPU). +# torch/sentence-transformers НЕ нужны — тестируется только логика индекса. +pytest==8.2.0 +faiss-cpu==1.8.0 +numpy==1.26.4 +pydantic-settings==2.2.1 diff --git a/services/worker-gpu/tests/test_faiss_manager.py b/services/worker-gpu/tests/test_faiss_manager.py new file mode 100644 index 0000000..08d2635 --- /dev/null +++ b/services/worker-gpu/tests/test_faiss_manager.py @@ -0,0 +1,88 @@ +"""Юнит-тесты FAISSManager (уровень 3 — семантический индекс). + +Проверяют то, что было сломано в старой реализации и переписано: +- возврат doc_id из PostgreSQL (IndexIDMap2), а не позиционного номера; +- идемпотентность add_vectors по doc_id (remove-before-add, без дублей); +- корректность self-match (косинус ≈ 1) и ранжирования. + +Работают целиком в памяти — диск (FAISS_INDEX_PATH) не трогается. +""" + +import numpy as np +import pytest + +pytest.importorskip("faiss") + +from app.config import settings # noqa: E402 +from app.faiss_manager import FAISSManager # noqa: E402 + +DIM = settings.EMBED_DIM + + +def _unit(vecs: np.ndarray) -> np.ndarray: + """Нормировать строки к единичной длине (для косинуса через inner product).""" + vecs = np.asarray(vecs, dtype=np.float32) + norms = np.linalg.norm(vecs, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + return vecs / norms + + +@pytest.fixture(autouse=True) +def fresh_index(): + """Свежий пустой индекс в памяти на каждый тест; диск не читаем и не пишем.""" + FAISSManager._index = FAISSManager._new_index() + yield + FAISSManager._index = None + + +def test_empty_search_returns_nothing(): + assert FAISSManager.search(np.zeros(DIM, dtype=np.float32)) == [] + assert FAISSManager.total_vectors() == 0 + + +def test_add_and_self_search_returns_postgres_doc_id(): + rng = np.random.default_rng(42) + vecs = _unit(rng.standard_normal((3, DIM))) + FAISSManager.add_vectors(vecs, [101, 202, 303]) + assert FAISSManager.total_vectors() == 3 + + results = FAISSManager.search(vecs[1], k=1) + assert results, "поиск ничего не вернул" + top_id, score = results[0] + assert top_id == 202 # IndexIDMap2 возвращает doc_id, а не позицию + assert score == pytest.approx(1.0, abs=1e-4) # self-match: косинус ≈ 1 + + +def test_add_is_idempotent_by_doc_id(): + rng = np.random.default_rng(0) + FAISSManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [1, 2]) + + # Повторный эмбеддинг тех же id не должен плодить дубли (remove-before-add) + updated = _unit(rng.standard_normal((2, DIM))) + FAISSManager.add_vectors(updated, [1, 2]) + assert FAISSManager.total_vectors() == 2 + + # ...и поиск возвращает ОБНОВЛЁННЫЙ вектор + top_id, score = FAISSManager.search(updated[0], k=1)[0] + assert top_id == 1 + assert score == pytest.approx(1.0, abs=1e-4) + + +def test_search_ranks_nearest_first(): + rng = np.random.default_rng(7) + v = _unit(rng.standard_normal((1, DIM)))[0] + near = _unit((v + 0.01 * rng.standard_normal(DIM))[None, :])[0] + far = _unit(rng.standard_normal((1, DIM)))[0] + FAISSManager.add_vectors(np.stack([near, far]), [10, 20]) + + results = FAISSManager.search(v, k=2) + assert [doc_id for doc_id, _ in results][0] == 10 # ближайший — near + + +def test_search_respects_k_and_index_size(): + rng = np.random.default_rng(1) + FAISSManager.add_vectors(_unit(rng.standard_normal((2, DIM))), [5, 6]) + # k больше числа векторов не должно приводить к падению или id == -1 + results = FAISSManager.search(_unit(rng.standard_normal((1, DIM)))[0], k=100) + assert len(results) == 2 + assert all(doc_id in (5, 6) for doc_id, _ in results) diff --git a/services/worker-indexer/conftest.py b/services/worker-indexer/conftest.py new file mode 100644 index 0000000..ac185d2 --- /dev/null +++ b/services/worker-indexer/conftest.py @@ -0,0 +1,6 @@ +"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`.""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) diff --git a/services/worker-indexer/pytest.ini b/services/worker-indexer/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/services/worker-indexer/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q diff --git a/services/worker-indexer/requirements-test.txt b/services/worker-indexer/requirements-test.txt new file mode 100644 index 0000000..bdcbe3c --- /dev/null +++ b/services/worker-indexer/requirements-test.txt @@ -0,0 +1,8 @@ +# Зависимости для юнит-тестов worker-indexer (чистая логика L1/L2, без инфры). +# Redis намеренно НЕ ставим — тогда MinHash LSH детерминированно уходит +# в in-memory-фолбэк, что и нужно тестам. +pytest==8.2.0 +xxhash==3.4.1 +datasketch==1.6.5 +numpy==1.26.4 +pydantic-settings==2.2.1 diff --git a/services/worker-indexer/tests/test_minhash.py b/services/worker-indexer/tests/test_minhash.py new file mode 100644 index 0000000..e485a29 --- /dev/null +++ b/services/worker-indexer/tests/test_minhash.py @@ -0,0 +1,62 @@ +"""Юнит-тесты MinHash LSH (уровень 2 — нечёткие совпадения). + +Redis в тестовом окружении недоступен → get_lsh() детерминированно падает в +in-memory-фолбэк, что и проверяется здесь. +""" + +from app.algorithms import minhash + + +def test_get_shingles_basic(): + assert minhash.get_shingles("один два три четыре", k=3) == { + "один два три", + "два три четыре", + } + + +def test_get_shingles_short_text(): + assert minhash.get_shingles("одно", k=3) == {"одно"} + assert minhash.get_shingles("", k=3) == set() + + +def test_identical_text_jaccard_is_one(): + t = "нейронные сети глубокого обучения распознают образы на изображениях точно" + assert minhash.compute_jaccard_minhash(t, t) == 1.0 + + +def test_disjoint_text_jaccard_near_zero(): + a = "квантовая физика элементарных частиц и теория поля" + b = "кулинарные рецепты домашней выпечки пшеничного хлеба" + assert minhash.compute_jaccard_minhash(a, b) < 0.1 + + +def test_lsh_finds_near_duplicate_and_ignores_unrelated(): + minhash.reset_lsh() + try: + base = ( + "нейронные сети глубокого обучения применяются для распознавания " + "образов на цифровых изображениях и в задачах компьютерного зрения" + ) + minhash.add_to_lsh("doc:1", base) + + near = base.replace("изображениях", "фотографиях") + assert "doc:1" in minhash.find_similar(near) + + unrelated = ( + "экономический анализ рынка недвижимости в крупных городах страны " + "за последние несколько отчётных финансовых кварталов подряд" + ) + assert "doc:1" not in minhash.find_similar(unrelated) + finally: + minhash.reset_lsh() + + +def test_lsh_upsert_does_not_duplicate(): + minhash.reset_lsh() + try: + text = "обработка естественного языка методами машинного обучения и статистики" + minhash.add_to_lsh("doc:9", text) + minhash.add_to_lsh("doc:9", text) # повторно тот же ключ — upsert, не дубль + assert minhash.find_similar(text).count("doc:9") == 1 + finally: + minhash.reset_lsh() diff --git a/services/worker-indexer/tests/test_winnowing.py b/services/worker-indexer/tests/test_winnowing.py new file mode 100644 index 0000000..69a0060 --- /dev/null +++ b/services/worker-indexer/tests/test_winnowing.py @@ -0,0 +1,79 @@ +"""Юнит-тесты алгоритма Winnowing (уровень 1 — точные/частичные совпадения).""" + +from app.algorithms.winnowing import ( + compute_similarity, + get_ngrams, + hash_ngram, + jaccard_similarity, + winnow, +) + +# Достаточно длинный текст, чтобы окно Winnowing реально отработало +LONG = ( + "машинное обучение позволяет извлекать закономерности из больших объёмов " + "данных без явного программирования каждого правила вручную аналитиком" +) + + +def test_get_ngrams_basic(): + assert get_ngrams(["a", "b", "c", "d"], k=2) == ["a b", "b c", "c d"] + + +def test_get_ngrams_too_short_is_empty(): + assert get_ngrams(["a", "b"], k=5) == [] + + +def test_hash_ngram_is_deterministic(): + assert hash_ngram("одна и та же строка") == hash_ngram("одна и та же строка") + + +def test_hash_ngram_fits_signed_int64(): + # Хэш обязан умещаться в PostgreSQL BIGINT (signed 64-bit) + for s in ("a", "тест", "one two three four five", "𝔘𝔫𝔦𝔠𝔬𝔡𝔢"): + h = hash_ngram(s) + assert -(2**63) <= h <= 2**63 - 1 + + +def test_winnow_short_text_is_empty(): + assert winnow("три слова тут", k=5) == set() + + +def test_winnow_identical_text_identical_fingerprint(): + assert winnow(LONG) == winnow(LONG) + + +def test_winnow_is_case_insensitive(): + assert winnow(LONG) == winnow(LONG.upper()) + + +def test_jaccard_identical_is_one(): + fp = winnow(LONG) + assert fp # непустой отпечаток + assert jaccard_similarity(fp, fp) == 1.0 + + +def test_jaccard_disjoint_is_zero(): + assert jaccard_similarity({1, 2, 3}, {4, 5, 6}) == 0.0 + + +def test_jaccard_empty_is_zero(): + assert jaccard_similarity(set(), {1, 2}) == 0.0 + + +def test_compute_similarity_identical_documents_is_one(): + # Ключевая гарантия L1: копия документа детектится как 100% совпадение + assert compute_similarity(LONG, LONG) == 1.0 + + +def test_compute_similarity_unrelated_documents_is_low(): + other = ( + "рецепт домашнего хлеба на закваске требует терпения муки воды соли " + "и тёплого места для медленного подъёма теста в течение ночи" + ) + assert compute_similarity(LONG, other) < 0.1 + + +def test_compute_similarity_partial_overlap_is_between(): + modified = LONG + " добавлен ещё один совершенно новый хвост предложения здесь" + sim = compute_similarity(LONG, modified) + assert 0.0 < sim < 1.0