291 lines
15 KiB
Python
291 lines
15 KiB
Python
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()
|
||
VALIDATED_MESSAGE_CASES = tuple(
|
||
case for case in MESSAGE_CASES if case.source == "messages.txt"
|
||
)
|
||
MORE_MESSAGE_CASES = tuple(
|
||
case for case in MESSAGE_CASES if case.source == "more_messages.txt"
|
||
)
|
||
|
||
# Hand-reviewed acceptance results for every required messages.txt query.
|
||
# Do not regenerate this table from CatalogMatcher output: it is the independent
|
||
# oracle that should catch semantic regressions in the implementation.
|
||
VALIDATED_MESSAGE_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-0017", "INS-0010", "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-0017", "INS-0010", "INS-0003")),
|
||
"здравствуйте, вы до скольки работаете?": ("not_found", ()),
|
||
"можно оплатить картой при получении?": ("not_found", ()),
|
||
"где находится ваш магазин": ("not_found", ()),
|
||
"статус заказа 4512 подскажите": ("not_found", ()),
|
||
"спасибо, заказ получил, все отлично": ("not_found", ()),
|
||
}
|
||
|
||
# The larger exploratory corpus is useful as a regression snapshot, but it is
|
||
# intentionally kept separate from the hand-reviewed acceptance set above.
|
||
MORE_MESSAGE_BASELINE_RESULTS: dict[str, ExpectedResult] = {
|
||
"добрый день, саморезы по дереву 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": ("ambiguous", ("KAB-0022", "KAB-0002", "KAB-0010")),
|
||
"ввгнг 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",)),
|
||
"лист гипса зеленый": ("ambiguous", ("GKL-0001", "GKL-0002", "GKL-0004")),
|
||
"гипсокортан 12.5": ("ambiguous", ("GKL-0002", "GKL-0004", "GKL-0003")),
|
||
"фанера 18 мм": ("not_found", ()),
|
||
"дрель prowerk pw-750": ("matched", ("INS-0008",)),
|
||
"аккумуляторная дрель 18в": ("not_found", ()),
|
||
"шуруповёрт 12 вольт самый дешёвый": ("ambiguous", ("INS-0017", "INS-0010", "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": ("matched", ("DSK-0034",)),
|
||
"алмазный диск 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")),
|
||
"24 зуба": ("ambiguous", ("DSK-0029", "DSK-0032", "DSK-0035")),
|
||
"210 мм 24 зуба": ("matched", ("DSK-0035",)),
|
||
"125 мм": ("ambiguous", ("INS-0013", "DSK-0025", "RAS-0054")),
|
||
"12 в": ("ambiguous", ("INS-0003", "INS-0010", "INS-0024")),
|
||
"900 вт": ("ambiguous", ("INS-0009", "INS-0013")),
|
||
"M10": ("ambiguous", ("KRP-0030", "KRP-0020", "KRP-0009")),
|
||
"P120": ("ambiguous", ("DSK-0024", "RAS-0050", "RAS-0057")),
|
||
"SDS-plus": ("ambiguous", ("BIT-0073", "INS-0012")),
|
||
"PH2": ("ambiguous", ("BIT-0004", "RIN-0010")),
|
||
"20x20x2": ("matched", ("TRB-0003",)),
|
||
"ToolKraft 125 мм": ("matched", ("INS-0027",)),
|
||
"99999 мм": ("not_found", ()),
|
||
"аквариум 125 мм": ("not_found", ()),
|
||
"удар": ("ambiguous", ("INS-0008", "INS-0009", "INS-0022")),
|
||
"ударная": ("ambiguous", ("INS-0008", "INS-0009", "INS-0022")),
|
||
"удар мощный": ("ambiguous", ("INS-0008", "INS-0009", "INS-0022")),
|
||
"удар мощно 900 хромированый анус долбить": ("ambiguous", ("INS-0009", "INS-0016", "INS-0023")),
|
||
"дрель удар мощно 900 хромированый анус долбить": ("ambiguous", ("INS-0009", "INS-0023", "INS-0002")),
|
||
"анус": ("not_found", ()),
|
||
"долбить": ("not_found", ()),
|
||
"Арс-12": ("matched", ("INS-0017",)),
|
||
}
|
||
|
||
EXPECTED_RESULTS = VALIDATED_MESSAGE_RESULTS | MORE_MESSAGE_BASELINE_RESULTS
|
||
|
||
|
||
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)
|
||
assert set(VALIDATED_MESSAGE_RESULTS) == {
|
||
case.message for case in MESSAGE_CASES if case.source == "messages.txt"
|
||
}
|
||
assert set(MORE_MESSAGE_BASELINE_RESULTS) == {
|
||
case.message for case in MESSAGE_CASES if case.source == "more_messages.txt"
|
||
}
|
||
|
||
|
||
def _assert_expected_result(
|
||
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
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"case", VALIDATED_MESSAGE_CASES, ids=lambda case: case.id
|
||
)
|
||
def test_validated_messages_txt_result(
|
||
matcher: CatalogMatcher, case: MessageCase
|
||
) -> None:
|
||
_assert_expected_result(matcher, case)
|
||
|
||
|
||
@pytest.mark.parametrize("case", MORE_MESSAGE_CASES, ids=lambda case: case.id)
|
||
def test_more_messages_regression(
|
||
matcher: CatalogMatcher, case: MessageCase
|
||
) -> None:
|
||
_assert_expected_result(matcher, case)
|