test: юнит-суит L1/L2/L3 + ГОСТ (41 тест) и гейт в CI перед деплоем

Первый настоящий автоматический тест-суит проекта — раньше регрессии ловились
руками. Покрыта чистая логика детекции и форматирования (без БД/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 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-08-11 17:11:08 +05:00
parent a2d3625f2a
commit ecc10a4413
17 changed files with 480 additions and 4 deletions

View File

@@ -13,7 +13,29 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: 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: deploy:
needs: test # деплой только если юнит-тесты прошли
runs-on: deploy runs-on: deploy
steps: steps:
- name: Клонировать репозиторий (полностью — нужен git-лог для diff) - name: Клонировать репозиторий (полностью — нужен git-лог для diff)

View File

@@ -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, остальная инфра # Прод: docker-compose.prod.yml — app-сервисы + локальный ES, остальная инфра
@@ -107,11 +107,14 @@ lint:
lint-frontend: lint-frontend:
cd services/frontend && npm run lint cd services/frontend && npm run lint
# Юнит-тесты чистой логики (L1/L2/L3 + ГОСТ) в изолированных контейнерах —
# без БД/Redis/GPU. Тот же скрипт гоняет CI как гейт перед деплоем.
test: test:
$(COMPOSE_PROD) exec api pytest tests/ -v bash scripts/run_tests.sh
test-cov: # Тесты одного сервиса, напр.: make test-one SVC=worker-gost
$(COMPOSE_PROD) exec api pytest tests/ -v --cov=app --cov-report=html test-one:
bash scripts/run_tests.sh $(SVC)
# ─── Утилиты ────────────────────────────────────────────────────────────────── # ─── Утилиты ──────────────────────────────────────────────────────────────────
# ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) — # ВНИМАНИЕ: -v удаляет только ЛОКАЛЬНЫЕ volume (es_prod_data, faiss_index_prod) —

67
scripts/run_tests.sh Executable file
View File

@@ -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

View File

@@ -0,0 +1,6 @@
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -0,0 +1,2 @@
# Зависимости для юнит-тестов worker-gost (ГОСТ-форматтеры — чистая логика).
pytest==8.2.0

View File

@@ -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]"

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,6 @@
"""Добавляет корень сервиса в sys.path, чтобы тесты импортировали пакет `app`."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

View File

@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -q

View File

@@ -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

View File

@@ -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()

View File

@@ -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