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

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