Improve sparse and structured catalog matching
This commit is contained in:
1 parent
2982161374
commit
42f8f07207
8 files changed
+739
-20
No files matched your search
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from app.catalog import CatalogItem, RawCatalogItem, load_raw_catalog
|
||||
from app.matcher import CatalogMatcher
|
||||
|
||||
|
||||
CATALOG_ITEMS = load_raw_catalog(
|
||||
Path(__file__).resolve().parents[1] / "catalog_excel.csv"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("item", CATALOG_ITEMS, ids=lambda item: item.sku)
|
||||
def test_full_catalog_name_retrieves_its_own_sku(
|
||||
matcher: CatalogMatcher, item: RawCatalogItem
|
||||
) -> None:
|
||||
result = matcher.match(item.name)
|
||||
|
||||
assert item.sku in {candidate.sku for candidate in result.candidates}
|
||||
|
||||
|
||||
def test_every_canonical_product_type_returns_only_its_own_items(
|
||||
matcher: CatalogMatcher,
|
||||
) -> None:
|
||||
items_by_sku = {item.sku: item for item in matcher.items}
|
||||
product_types = {item.product_type for item in matcher.items}
|
||||
|
||||
for product_type in product_types:
|
||||
result = matcher.match(product_type)
|
||||
|
||||
assert result.candidates, product_type
|
||||
assert all(
|
||||
items_by_sku[candidate.sku].product_type == product_type
|
||||
for candidate in result.candidates
|
||||
), product_type
|
||||
|
||||
|
||||
def test_every_catalog_model_code_returns_only_matching_items(
|
||||
matcher: CatalogMatcher,
|
||||
) -> None:
|
||||
items_by_sku = {item.sku: item for item in matcher.items}
|
||||
model_codes = {
|
||||
model
|
||||
for item in matcher.items
|
||||
for model in item.attributes.model_codes
|
||||
}
|
||||
|
||||
for model_code in model_codes:
|
||||
result = matcher.match(model_code)
|
||||
|
||||
assert result.candidates, model_code
|
||||
assert all(
|
||||
model_code in items_by_sku[candidate.sku].attributes.model_codes
|
||||
for candidate in result.candidates
|
||||
), model_code
|
||||
|
||||
|
||||
def test_every_catalog_brand_returns_only_its_own_items(
|
||||
matcher: CatalogMatcher,
|
||||
) -> None:
|
||||
items_by_sku = {item.sku: item for item in matcher.items}
|
||||
brands = {
|
||||
item.attributes.brand
|
||||
for item in matcher.items
|
||||
if item.attributes.brand is not None
|
||||
}
|
||||
|
||||
for brand in brands:
|
||||
result = matcher.match(brand)
|
||||
|
||||
assert result.candidates, brand
|
||||
assert all(
|
||||
items_by_sku[candidate.sku].attributes.brand == brand
|
||||
for candidate in result.candidates
|
||||
), brand
|
||||
|
||||
|
||||
def _number_text(value: Decimal) -> str:
|
||||
return format(value, "f")
|
||||
|
||||
|
||||
def _assert_query_returns_only(
|
||||
matcher: CatalogMatcher,
|
||||
query: str,
|
||||
predicate: Callable[[CatalogItem], bool],
|
||||
) -> None:
|
||||
items_by_sku = {item.sku: item for item in matcher.items}
|
||||
result = matcher.match(query)
|
||||
|
||||
assert result.candidates, query
|
||||
assert all(
|
||||
predicate(items_by_sku[candidate.sku]) for candidate in result.candidates
|
||||
), query
|
||||
|
||||
|
||||
def test_every_catalog_millimetre_value_is_searchable_without_product_name(
|
||||
matcher: CatalogMatcher,
|
||||
) -> None:
|
||||
values = {
|
||||
value
|
||||
for item in matcher.items
|
||||
for value in (
|
||||
*item.attributes.mm_values,
|
||||
*((item.attributes.dimensions[0],) if item.attributes.dimensions else ()),
|
||||
*((item.attributes.thread_length_mm,) if item.attributes.thread_length_mm else ()),
|
||||
)
|
||||
}
|
||||
|
||||
for value in values:
|
||||
_assert_query_returns_only(
|
||||
matcher,
|
||||
f"{_number_text(value)} мм",
|
||||
lambda item, expected=value: (
|
||||
expected in item.attributes.mm_values
|
||||
or bool(
|
||||
item.attributes.dimensions
|
||||
and item.attributes.dimensions[0] == expected
|
||||
)
|
||||
or item.attributes.thread_length_mm == expected
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_every_catalog_dimension_is_searchable_without_product_name(
|
||||
matcher: CatalogMatcher,
|
||||
) -> None:
|
||||
dimensions = {
|
||||
item.attributes.dimensions
|
||||
for item in matcher.items
|
||||
if item.attributes.dimensions
|
||||
}
|
||||
|
||||
for dimension in dimensions:
|
||||
query = "x".join(_number_text(value) for value in dimension)
|
||||
_assert_query_returns_only(
|
||||
matcher,
|
||||
query,
|
||||
lambda item, expected=dimension: (
|
||||
len(item.attributes.dimensions) >= len(expected)
|
||||
and item.attributes.dimensions[: len(expected)] == expected
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "suffix"),
|
||||
[
|
||||
("voltage_v", " В"),
|
||||
("power_w", " Вт"),
|
||||
("thread_size", ""),
|
||||
("grit", ""),
|
||||
("profile", ""),
|
||||
("sds_type", ""),
|
||||
("teeth", " зубьев"),
|
||||
],
|
||||
)
|
||||
def test_every_scalar_catalog_attribute_is_searchable_without_product_name(
|
||||
matcher: CatalogMatcher,
|
||||
field: str,
|
||||
suffix: str,
|
||||
) -> None:
|
||||
values = {
|
||||
getattr(item.attributes, field)
|
||||
for item in matcher.items
|
||||
if getattr(item.attributes, field) is not None
|
||||
}
|
||||
|
||||
for value in values:
|
||||
if field == "thread_size":
|
||||
query = f"M{_number_text(value)}"
|
||||
elif field == "grit":
|
||||
query = f"P{value}"
|
||||
else:
|
||||
value_text = _number_text(value) if isinstance(value, Decimal) else str(value)
|
||||
query = f"{value_text}{suffix}"
|
||||
_assert_query_returns_only(
|
||||
matcher,
|
||||
query,
|
||||
lambda item, expected=value: getattr(item.attributes, field) == expected,
|
||||
)
|
||||
Reference in new issue
Block a user