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