from __future__ import annotations import pytest from app.matcher import CatalogMatcher @pytest.mark.parametrize( ("message", "sku"), [ ("дрель ударная prowerk pw-750 в наличии?", "INS-0008"), ("кабель шввп 2х0.5 сколько за метр", "KAB-0017"), ("диск пильный 190 на 48 зубьев", "DSK-0034"), ("уровень 1000 мм есть?", "RIN-0024"), ("гайки м10 нужны", "KRP-0020"), ("лента фум 12 мм", "RAS-0060"), ("перчатки нитриловые есть?", "PER-0002"), ("проф труба 20х20 стенка полтора", "TRB-0002"), ("гкл 9.5 сколько лист", "GKL-0001"), ("хомуты пластиковые 4.8х400", "RAS-0014"), ("сдс бур 6 на 110", "BIT-0074"), ("наждачка р120 листами", "RAS-0050"), ("круг зачистной на 125", "DSK-0018"), ("бита ph2 50 мм", "BIT-0005"), ("саморезы по дереву 3.5х45 пачка 200", "SAM-0010"), ("труба профильная 20х20х2", "TRB-0003"), ("ввгнг лс 3х1.5", "KAB-0010"), ("диск отрезной по металлу 115х1.2", "DSK-0002"), ("бур sds 10х210", "BIT-0080"), ("дюбель-гвоздь 6х60", "KRP-0048"), ], ) def test_unique_queries_are_matched( matcher: CatalogMatcher, message: str, sku: str ) -> None: result = matcher.match(message) assert result.status == "matched" assert [candidate.sku for candidate in result.candidates] == [sku] assert 0.0 <= result.candidates[0].confidence <= 1.0 @pytest.mark.parametrize( "message", [ "саморезы по металлу 4.2х70", "труба профильная 35х35", "кабель ввгнг 4х2.5", "ушм на 150", "бита t50", "шпилька м16 метровая", "бур 10х220", "бита ph2 70 мм", ], ) def test_explicit_attribute_conflicts_are_not_found( matcher: CatalogMatcher, message: str ) -> None: result = matcher.match(message) assert result.status == "not_found" assert result.candidates == [] @pytest.mark.parametrize( "message", [ "нужен кабель", "дайте дюбелей", "сверло нужно", "какие есть диски", "перфоратор посоветуйте", "шуруповерт как у макиты, только дешевле", "шурик на 12в недорогой", "болгарка на 230 какая есть", "гкл 12.5", ], ) def test_under_specified_product_queries_are_ambiguous( matcher: CatalogMatcher, message: str ) -> None: result = matcher.match(message) assert result.status == "ambiguous" assert 2 <= len(result.candidates) <= 3 def test_unspecified_packaging_remains_ambiguous(matcher: CatalogMatcher) -> None: result = matcher.match("здравствуйте, есть саморезы гкл 3.5х25?") assert result.status == "ambiguous" assert {candidate.sku for candidate in result.candidates} == { "SAM-0061", "SAM-0062", "SAM-0063", } def test_package_word_filters_out_kilogram_item(matcher: CatalogMatcher) -> None: result = matcher.match("саморезы по дереву 4.2 на 75, пачку") assert result.status == "ambiguous" assert {candidate.sku for candidate in result.candidates} == { "SAM-0022", "SAM-0023", } @pytest.mark.parametrize( "message", [ "здравствуйте, вы до скольки работаете?", "можно оплатить картой при получении?", "где находится ваш магазин", "статус заказа 4512 подскажите", "спасибо, заказ получил, все отлично", "", ], ) def test_non_product_messages_are_not_found( matcher: CatalogMatcher, message: str ) -> None: result = matcher.match(message) assert result.status == "not_found" assert result.candidates == [] def test_product_evidence_has_priority_over_service_words(matcher: CatalogMatcher) -> None: result = matcher.match("статус заказа 4512 и еще нужен бур 8х160") assert result.status == "matched" 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-0017", "INS-0010", "INS-0003", ] def test_cheap_preference_orders_candidates_by_ascending_price( matcher: CatalogMatcher, ) -> None: result = matcher.match("шуруповерт как у макиты, только дешевле") prices_by_sku = {item.sku: item.price for item in matcher.items} candidate_prices = [ prices_by_sku[candidate.sku] for candidate in result.candidates ] assert candidate_prices == sorted(candidate_prices) @pytest.mark.parametrize( "brand", ["ToolKraft", "toolkraft", "тулкрафт", "тул крафт"] ) def test_brand_only_toolkraft_query_returns_catalog_items( matcher: CatalogMatcher, brand: str ) -> None: result = matcher.match(brand) assert result.status == "ambiguous" assert [candidate.sku for candidate in result.candidates] == [ "INS-0022", "INS-0023", "INS-0024", ] def test_unknown_toolkraft_model_does_not_fall_back_to_brand_results( matcher: CatalogMatcher, ) -> None: result = matcher.match("ToolKraft TK-999") assert result.status == "not_found" assert result.candidates == [] @pytest.mark.parametrize( ("query", "sku"), [ ("Арс-12", "INS-0017"), ("АРС12", "INS-0017"), ("АРС-18", "INS-0018"), ("PW-12", "INS-0010"), ("TK-12", "INS-0024"), ("ТР-12", "INS-0003"), ], ) def test_model_code_can_omit_only_its_final_letter( matcher: CatalogMatcher, query: str, sku: str ) -> None: result = matcher.match(query) assert result.status == "matched" assert [candidate.sku for candidate in result.candidates] == [sku] @pytest.mark.parametrize( "query", [ "АРС-1", "АРС-999", "PW-90", "TK-999", "УШМ АРС-12", ], ) def test_partial_model_does_not_guess_numeric_or_conflicting_models( matcher: CatalogMatcher, query: str ) -> None: result = matcher.match(query) assert result.status == "not_found" assert result.candidates == [] def test_complete_neighboring_model_keeps_exact_priority( matcher: CatalogMatcher, ) -> None: result = matcher.match("АРС-125") assert result.status == "matched" assert [candidate.sku for candidate in result.candidates] == ["INS-0020"] @pytest.mark.parametrize("query", ["шурпуоверт на 12в", "шураыввавёрт на 12в"]) def test_product_type_survives_typos(matcher: CatalogMatcher, query: str) -> None: result = matcher.match(query) assert result.status == "ambiguous" assert all(candidate.sku.startswith("INS-") for candidate in result.candidates) @pytest.mark.parametrize( ("query", "skus"), [ ("удар", ("INS-0008", "INS-0009", "INS-0022")), ("ударная", ("INS-0008", "INS-0009", "INS-0022")), ("удар мощный", ("INS-0008", "INS-0009", "INS-0022")), ( "удар мощно 900 хромированый анус долбить", ("INS-0009", "INS-0016", "INS-0023"), ), ( "дрель удар мощно 900 хромированый анус долбить", ("INS-0009", "INS-0023", "INS-0002"), ), ], ) def test_trigram_fallback_finds_product_type_from_sparse_or_noisy_text( matcher: CatalogMatcher, query: str, skus: tuple[str, ...] ) -> None: result = matcher.match(query) assert result.status == "ambiguous" assert tuple(candidate.sku for candidate in result.candidates) == skus items_by_sku = {item.sku: item for item in matcher.items} assert { items_by_sku[candidate.sku].product_type for candidate in result.candidates } == {"дрель"} @pytest.mark.parametrize( "query", [ "анус", "долбить", "какой адрес", "аквариум 125 мм", "", "qwerty asdf zxcv", ], ) def test_trigram_fallback_rejects_weak_or_accidental_similarity( matcher: CatalogMatcher, query: str ) -> None: result = matcher.match(query) assert result.status == "not_found" assert result.candidates == [] def test_bare_size_is_parsed_without_unit(matcher: CatalogMatcher) -> None: assert matcher.match("бита ph2 50").candidates[0].sku == "BIT-0005" assert matcher.match("диск пильный 190 48 зубьев").candidates[0].sku == "DSK-0034" def test_unknown_size_does_not_fall_back_to_nearest_product( matcher: CatalogMatcher, ) -> None: result = matcher.match("ушм 150") assert result.status == "not_found" assert result.candidates == [] @pytest.mark.parametrize( ("query", "sku"), [ ("165 мм 24 зуба", "DSK-0029"), ("165 мм 36 зубов", "DSK-0030"), ("165 мм 48 зубьев", "DSK-0031"), ("190 мм 24 зуба", "DSK-0032"), ("190 мм 36 зубов", "DSK-0033"), ("190 мм 48 зубьев", "DSK-0034"), ("210 мм 24 зуба", "DSK-0035"), ("210 мм 36 зубов", "DSK-0036"), ("210 мм 48 зубьев", "DSK-0037"), ], ) def test_tooth_count_infers_saw_blade_without_product_name( matcher: CatalogMatcher, query: str, sku: str ) -> None: result = matcher.match(query) assert result.status == "matched" assert [candidate.sku for candidate in result.candidates] == [sku] @pytest.mark.parametrize( ("query", "skus"), [ ("24 зуба", ("DSK-0029", "DSK-0032", "DSK-0035")), ("36 зубов", ("DSK-0030", "DSK-0033", "DSK-0036")), ("48 зубьев", ("DSK-0031", "DSK-0034", "DSK-0037")), ], ) def test_bare_tooth_count_returns_all_matching_saw_blade_sizes( matcher: CatalogMatcher, query: str, skus: tuple[str, ...] ) -> None: result = matcher.match(query) assert result.status == "ambiguous" assert tuple(candidate.sku for candidate in result.candidates) == skus def test_unknown_tooth_count_does_not_return_nearest_saw_blade( matcher: CatalogMatcher, ) -> None: result = matcher.match("210 мм 25 зубьев") assert result.status == "not_found" assert result.candidates == [] @pytest.mark.parametrize( "query", [ "пильный диск 190x48t", "190x48T", "190x48z", "190 48t", "190/48T", ], ) def test_compact_saw_notation_parses_diameter_and_teeth( matcher: CatalogMatcher, query: str ) -> None: result = matcher.match(query) assert result.status == "matched" assert [candidate.sku for candidate in result.candidates] == ["DSK-0034"] def test_bare_t_suffix_infers_saw_blades(matcher: CatalogMatcher) -> None: result = matcher.match("24T") assert result.status == "ambiguous" assert [candidate.sku for candidate in result.candidates] == [ "DSK-0029", "DSK-0032", "DSK-0035", ] @pytest.mark.parametrize( ("query", "status", "skus"), [ ("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", ()), ], ) def test_attribute_only_queries_use_exact_catalog_values( matcher: CatalogMatcher, query: str, status: str, skus: tuple[str, ...], ) -> None: result = matcher.match(query) assert result.status == status assert tuple(candidate.sku for candidate in result.candidates) == skus