Add regression corpus and opt-in catalog test route

This commit is contained in:
Fiden committed 2026-08-07 17:44:17 +03:00
1 parent 11e1a4c4a2
commit e23fdcfc0d
6 files changed
+475 -12

No files matched your search

+45 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app
from app.main import app, create_app
client = TestClient(app)
@@ -47,3 +47,47 @@ def test_empty_message_list_is_valid() -> None:
def test_unknown_request_fields_are_rejected() -> None:
response = client.post("/match", json={"messages": [], "debug": True})
assert response.status_code == 422
def test_opt_in_info_route_returns_catalog_item() -> None:
test_app = create_app(enable_test_info_route=True)
test_client = TestClient(test_app)
assert [route.path for route in test_app.routes] == [
"/match",
"/get_info_by/{sku}",
]
response = test_client.get("/get_info_by/bit-0073")
assert response.status_code == 200
assert set(response.json()) == {"sku", "name", "unit", "price"}
assert response.json()["sku"] == "BIT-0073"
assert response.json()["name"]
assert response.json()["unit"]
assert float(response.json()["price"]) > 0
def test_opt_in_info_route_returns_404_for_unknown_sku() -> None:
test_client = TestClient(create_app(enable_test_info_route=True))
response = test_client.get("/get_info_by/UNKNOWN-0000")
assert response.status_code == 404
assert response.json() == {"detail": "SKU not found"}
def test_opt_in_routes_allow_requests_from_local_html_page() -> None:
test_client = TestClient(create_app(enable_test_info_route=True))
response = test_client.options(
"/match",
headers={
"Origin": "null",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type",
},
)
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "*"
assert "POST" in response.headers["access-control-allow-methods"]
assert "GET" in response.headers["access-control-allow-methods"]
+14
View File
@@ -126,6 +126,20 @@ def test_product_evidence_has_priority_over_service_words(matcher: CatalogMatche
assert result.candidates[0].sku == "BIT-0077"
@pytest.mark.parametrize("brand", ["makita", "макита", "макиты", "макиту"])
def test_makita_transliterations_share_the_same_brand_alias(
matcher: CatalogMatcher, brand: str
) -> None:
result = matcher.match(f"шуруповерт как у {brand}, только дешевле")
assert result.status == "ambiguous"
assert [candidate.sku for candidate in result.candidates] == [
"INS-0010",
"INS-0017",
"INS-0003",
]
@pytest.mark.parametrize("query", ["шурпуоверт на 12в", "шураыввавёрт на 12в"])
def test_product_type_survives_typos(matcher: CatalogMatcher, query: str) -> None:
result = matcher.match(query)
+234
View File
@@ -0,0 +1,234 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
import pytest
from app.matcher import CatalogMatcher
ROOT = Path(__file__).resolve().parents[1]
MESSAGE_PATHS = (ROOT / "messages.txt", ROOT / "more_messages.txt")
MatchStatus = Literal["matched", "ambiguous", "not_found"]
ExpectedResult = tuple[MatchStatus, tuple[str, ...]]
@dataclass(frozen=True, slots=True)
class MessageCase:
id: str
source: str
line_number: int
message: str
def _load_message_cases() -> tuple[MessageCase, ...]:
cases: list[MessageCase] = []
for path in MESSAGE_PATHS:
for line_number, raw_line in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
message = raw_line.strip()
if not message:
continue
cases.append(
MessageCase(
id=f"{path.name}:{line_number}",
source=path.name,
line_number=line_number,
message=message,
)
)
return tuple(cases)
MESSAGE_CASES = _load_message_cases()
# Baseline approved for the current corpus. The key is the message text so adding
# or moving unrelated lines does not invalidate every expectation below.
EXPECTED_RESULTS: dict[str, ExpectedResult] = {
"здравствуйте, есть саморезы гкл 3.5х25?": ("ambiguous", ("SAM-0063", "SAM-0061", "SAM-0062")),
"дрель ударная prowerk pw-750 в наличии?": ("matched", ("INS-0008",)),
"кабель шввп 2х0.5 сколько за метр": ("matched", ("KAB-0017",)),
"диск пильный 190 на 48 зубьев": ("matched", ("DSK-0034",)),
"уровень 1000 мм есть?": ("matched", ("RIN-0024",)),
"гайки м10 нужны": ("matched", ("KRP-0020",)),
"лента фум 12 мм": ("matched", ("RAS-0060",)),
"перчатки нитриловые есть?": ("matched", ("PER-0002",)),
"проф труба 20х20 стенка полтора": ("matched", ("TRB-0002",)),
"шурик на 12в недорогой": ("ambiguous", ("INS-0010", "INS-0017", "INS-0003")),
"болгарка на 230 какая есть": ("ambiguous", ("INS-0014", "INS-0028", "INS-0007")),
"гкл 9.5 сколько лист": ("matched", ("GKL-0001",)),
"хомуты пластиковые 4.8х400": ("matched", ("RAS-0014",)),
"сдс бур 6 на 110": ("matched", ("BIT-0074",)),
"наждачка р120 листами": ("matched", ("RAS-0050",)),
"круг зачистной на 125": ("matched", ("DSK-0018",)),
"бита ph2 50 мм": ("matched", ("BIT-0005",)),
"саморезы по дереву 3.5х45 пачка 200": ("matched", ("SAM-0010",)),
"труба профильная 20х20х2": ("matched", ("TRB-0003",)),
"ввгнг лс 3х1.5": ("matched", ("KAB-0010",)),
"диск отрезной по металлу 115х1.2": ("matched", ("DSK-0002",)),
"бур sds 10х210": ("matched", ("BIT-0080",)),
"дюбель-гвоздь 6х60": ("matched", ("KRP-0048",)),
"саморезы по металлу 4.2х70": ("not_found", ()),
"труба профильная 35х35": ("not_found", ()),
"кабель ввгнг 4х2.5": ("not_found", ()),
"ушм на 150": ("not_found", ()),
"бита t50": ("not_found", ()),
"шпилька м16 метровая": ("not_found", ()),
"нужен кабель": ("ambiguous", ("KAB-0004", "KAB-0005", "KAB-0008")),
"дайте дюбелей": ("ambiguous", ("KRP-0050", "KRP-0047", "KRP-0048")),
"сверло нужно": ("ambiguous", ("BIT-0060", "BIT-0061", "BIT-0062")),
"какие есть диски": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
"перфоратор посоветуйте": ("ambiguous", ("INS-0012", "INS-0026", "INS-0005")),
"шуруповерт как у макиты, только дешевле": ("ambiguous", ("INS-0010", "INS-0017", "INS-0003")),
"здравствуйте, вы до скольки работаете?": ("not_found", ()),
"можно оплатить картой при получении?": ("not_found", ()),
"где находится ваш магазин": ("not_found", ()),
"статус заказа 4512 подскажите": ("not_found", ()),
"спасибо, заказ получил, все отлично": ("not_found", ()),
"добрый день, саморезы по дереву 3.5х25 пачка 200 штук": ("matched", ("SAM-0001",)),
"саморез гкл 3,5 на 25 кг": ("matched", ("SAM-0063",)),
"нужны черные саморезы 4.2x75 упаковка 1000": ("matched", ("SAM-0023",)),
"самарезы па дереву 3.5х45 200шт": ("ambiguous", ("SAM-0010", "SAM-0012", "SAM-0011")),
"саморезы 99х999": ("not_found", ()),
"дюбель гвоздь 6x40": ("matched", ("KRP-0047",)),
"дюбеля 8 на 80 есть?": ("matched", ("KRP-0049",)),
"анкерный болт м10": ("ambiguous", ("KRP-0009", "KRP-0010", "KRP-0011")),
"гайка м8 оцинкованная": ("matched", ("KRP-0019",)),
"шайбы м12 сколько стоят": ("ambiguous", ("KRP-0032", "KRP-0031")),
"болт м6х30 50 штук": ("ambiguous", ("KRP-0002", "KRP-0001", "KRP-0003")),
"шпилька м10 один метр": ("matched", ("KRP-0034",)),
"крепеж какой-нибудь": ("not_found", ()),
"кабель ввг нг ls 3*2,5": ("matched", ("KAB-0011",)),
"провод пвс 2х1.5 нужен": ("not_found", ()),
"ШВВП 2 X 0,75, цена за метр?": ("matched", ("KAB-0018",)),
"кабель кг 3х2,5": ("not_found", ()),
"кабель для чайника": ("ambiguous", ("KAB-0004", "KAB-0005", "KAB-0008")),
"3х1.5": ("not_found", ()),
"ввгнг 20х20": ("not_found", ()),
"труба профильная 40x20x2": ("not_found", ()),
"профтруба 60 40 3": ("ambiguous", ("TRB-0009", "TRB-0010", "TRB-0015")),
"труба квадратная двадцать на двадцать": ("ambiguous", ("TRB-0003", "TRB-0007", "TRB-0008")),
"труба 35х35х2": ("not_found", ()),
"нужна труба длиной до луны": ("ambiguous", ("TRB-0003", "TRB-0007", "TRB-0008")),
"гипсокартон 12,5 влагостойкий": ("matched", ("GKL-0003",)),
"гкл обычный 9.5 мм": ("matched", ("GKL-0001",)),
"лист гипса зеленый": ("not_found", ()),
"гипсокортан 12.5": ("ambiguous", ("GKL-0002", "GKL-0004", "GKL-0003")),
"фанера 18 мм": ("not_found", ()),
"дрель prowerk pw-750": ("matched", ("INS-0008",)),
"аккумуляторная дрель 18в": ("not_found", ()),
"шуруповёрт 12 вольт самый дешёвый": ("ambiguous", ("INS-0010", "INS-0017", "INS-0003")),
"перфоратор sds plus 800 вт": ("not_found", ()),
"болгарка 125": ("ambiguous", ("INS-0013", "INS-0027", "INS-0006")),
"ушм 230 мм 2200 вт": ("not_found", ()),
"лобзик посоветуйте": ("ambiguous", ("INS-0031", "INS-0029")),
"makita df333 есть?": ("not_found", ()),
"инструмент чтобы сверлить стену": ("ambiguous", ("BIT-0067", "BIT-0068", "BIT-0069")),
"дрэл 9999 ват": ("not_found", ()),
"бур sds-plus 8x160": ("matched", ("BIT-0077",)),
"бур 10 на 210 sds+": ("matched", ("BIT-0080",)),
"бур SDS MAX 20х400": ("not_found", ()),
"сверло по металлу 6 мм": ("matched", ("BIT-0045",)),
"сверло по дереву 10x120": ("not_found", ()),
"бита ph2 25": ("matched", ("BIT-0004",)),
"набор бит torx": ("not_found", ()),
"бита t30 50 мм": ("matched", ("BIT-0029",)),
"бита t99": ("not_found", ()),
"насадка на шуруповерт": ("ambiguous", ("INS-0010", "INS-0011", "INS-0024")),
"диск отрезной 125х1,0 металл": ("matched", ("DSK-0003",)),
"круг по металлу 230 на 2.5": ("not_found", ()),
"пильный диск 190x48t": ("ambiguous", ("DSK-0034", "DSK-0032", "DSK-0033")),
"алмазный диск 125 бетон": ("ambiguous", ("DSK-0025", "DSK-0026", "DSK-0027")),
"диск на болгарку": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
"круг квадратный 12х34": ("not_found", ()),
"наждачная бумага p80": ("matched", ("RAS-0049",)),
"шкурка нулёвка": ("ambiguous", ("RAS-0049", "RAS-0048", "RAS-0052")),
"круг лепестковый 125 р40": ("matched", ("RAS-0054",)),
"лента фум 19 мм": ("not_found", ()),
"изолента синяя 15 мм": ("not_found", ()),
"хомут 4,8x300 черный": ("not_found", ()),
"стяжки пластиковые": ("ambiguous", ("RAS-0009", "RAS-0011", "RAS-0012")),
"перчатки нитриловые размер l": ("matched", ("PER-0002",)),
"перчатки хб с пвх": ("matched", ("PER-0001",)),
"мешки для мусора 120 литров": ("ambiguous", ("RAS-0035", "RAS-0034")),
"пена монтажная зимняя": ("ambiguous", ("RAS-0015", "RAS-0016")),
"герметик силиконовый прозрачный": ("ambiguous", ("RAS-0018", "RAS-0017")),
"клей жидкие гвозди": ("ambiguous", ("KRP-0043", "KRP-0044", "KRP-0045")),
"WD-40 400 мл": ("not_found", ()),
"здравствуйте": ("not_found", ()),
"вы сегодня открыты?": ("not_found", ()),
"какой адрес": ("not_found", ()),
"есть доставка в химки?": ("not_found", ()),
"можно вернуть товар без чека": ("not_found", ()),
"примите заказ 12345": ("not_found", ()),
"где мой заказ 000001": ("not_found", ()),
"оплатил, но денег нет": ("not_found", ()),
"спасибо большое!": ("not_found", ()),
"позовите человека": ("not_found", ()),
"1234567890": ("not_found", ()),
"000000000000000000": ("not_found", ()),
"42": ("not_found", ()),
"qwerty asdf zxcv": ("not_found", ()),
"йцукен фывапр олдж": ("not_found", ()),
"абракадабра крокодил вертолёт": ("not_found", ()),
"!@#$%^&*()": ("not_found", ()),
"..........": ("not_found", ()),
"купить купить купить": ("not_found", ()),
"null": ("not_found", ()),
"None": ("not_found", ()),
"SELECT * FROM catalog;": ("not_found", ()),
"<script>alert(1)</script>": ("not_found", ()),
"../../etc/passwd": ("not_found", ()),
"🤖🔩🔨": ("not_found", ()),
"САМОБРЕЗЫ ГКЛ 3.5Х25???": ("not_found", ()),
"ну эээ диск этот круглый короче": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
"мне два метра того красного": ("not_found", ()),
"хочу всё и сразу за сто рублей": ("not_found", ()),
"статус заказа 4512 и бур 8х160": ("matched", ("BIT-0077",)),
"диск": ("ambiguous", ("DSK-0011", "DSK-0012", "DSK-0013")),
}
def test_message_files_are_present_and_have_expected_size() -> None:
counts = {
path.name: sum(
bool(line.strip())
for line in path.read_text(encoding="utf-8").splitlines()
)
for path in MESSAGE_PATHS
}
assert counts["messages.txt"] > 0
assert counts["more_messages.txt"] >= 100
messages = [case.message for case in MESSAGE_CASES]
assert len(messages) == len(set(messages)), "message texts must be unique"
assert set(EXPECTED_RESULTS) == set(messages)
@pytest.mark.parametrize("case", MESSAGE_CASES, ids=lambda case: case.id)
def test_every_message_can_be_matched(
matcher: CatalogMatcher, case: MessageCase
) -> None:
result = matcher.match(case.message)
assert result.message == case.message
assert result.status in {"matched", "ambiguous", "not_found"}
assert len(result.candidates) <= 3
assert len({candidate.sku for candidate in result.candidates}) == len(
result.candidates
)
assert all(0.0 <= candidate.confidence <= 1.0 for candidate in result.candidates)
if result.status == "matched":
assert len(result.candidates) == 1
elif result.status == "ambiguous":
assert 2 <= len(result.candidates) <= 3
else:
assert result.candidates == []
expected_status, expected_skus = EXPECTED_RESULTS[case.message]
assert result.status == expected_status
assert tuple(candidate.sku for candidate in result.candidates) == expected_skus