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", ()), "": ("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)