All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m27s
Deploy Trade-In / build-backend (push) Successful in 1m33s
Deploy Trade-In / deploy (push) Successful in 2m12s
344 lines
14 KiB
Python
344 lines
14 KiB
Python
"""Per-field priority resolution tests."""
|
||
|
||
from datetime import datetime
|
||
|
||
from app.services.matching.conflict_resolution import (
|
||
HOUSE_FIELD_PRIORITY,
|
||
LISTING_FIELD_PRIORITY,
|
||
resolve_house_field,
|
||
resolve_listing_field,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pre-existing tests (preserved)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_house_year_built_prefers_cian_bti() -> None:
|
||
out = resolve_house_field(
|
||
"year_built",
|
||
{"avito_houses_catalog": 2020, "cian_bti": 2021, "cian_serp": 2022},
|
||
)
|
||
assert out == 2021
|
||
|
||
|
||
def test_house_unknown_field_first_non_null() -> None:
|
||
assert resolve_house_field("totally_unknown", {"x": None, "y": 5}) == 5
|
||
|
||
|
||
def test_listing_owners_count_avito_domoteka_only() -> None:
|
||
out = resolve_listing_field("owners_count", {"avito_domoteka": 2, "cian_serp": 99})
|
||
assert out == 2
|
||
|
||
|
||
def test_listing_photo_urls_union() -> None:
|
||
out = resolve_listing_field(
|
||
"photo_urls",
|
||
{"avito": ["a.jpg", "b.jpg"], "cian": ["b.jpg", "c.jpg"]},
|
||
)
|
||
assert set(out) == {"a.jpg", "b.jpg", "c.jpg"}
|
||
|
||
|
||
def test_listing_kadastr_first_non_null() -> None:
|
||
out = resolve_listing_field("kadastr_num", {"avito": None, "cian": "66:1:1:1"})
|
||
assert out == "66:1:1:1"
|
||
|
||
|
||
def test_house_priority_dict_has_year_built() -> None:
|
||
assert "cian_bti" in HOUSE_FIELD_PRIORITY["year_built"]
|
||
|
||
|
||
def test_listing_priority_dict_has_owners() -> None:
|
||
assert "avito_domoteka" in LISTING_FIELD_PRIORITY["owners_count"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Yandex house priority tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestYandexHousePriority:
|
||
def test_house_lat_yandex_realty_nb_when_cian_missing(self) -> None:
|
||
out = resolve_house_field("lat", {"yandex_realty_nb": 56.85})
|
||
assert out == 56.85
|
||
|
||
def test_house_lat_cian_preferred_over_yandex(self) -> None:
|
||
out = resolve_house_field("lat", {"cian_serp": 56.83, "yandex_realty_nb": 56.85})
|
||
assert out == 56.83
|
||
|
||
def test_house_year_built_yandex_valuation_picked(self) -> None:
|
||
out = resolve_house_field("year_built", {"yandex_valuation": 1981})
|
||
assert out == 1981
|
||
|
||
def test_house_year_built_cian_bti_preferred(self) -> None:
|
||
out = resolve_house_field("year_built", {"cian_bti": 1980, "yandex_valuation": 1981})
|
||
assert out == 1980
|
||
|
||
def test_house_text_reviews_count_yandex_only(self) -> None:
|
||
out = resolve_house_field("text_reviews_count", {"yandex_realty_nb": 353})
|
||
assert out == 353
|
||
|
||
def test_house_corpus_count_yandex_only(self) -> None:
|
||
out = resolve_house_field("corpus_count", {"yandex_realty_nb": 3})
|
||
assert out == 3
|
||
|
||
def test_house_commission_year_cian_serp_preferred(self) -> None:
|
||
out = resolve_house_field("commission_year", {"cian_serp": 2022, "yandex_realty_nb": 2023})
|
||
assert out == 2022
|
||
|
||
def test_house_commission_month_yandex_only(self) -> None:
|
||
out = resolve_house_field("commission_month", {"yandex_realty_nb": "июнь"})
|
||
assert out == "июнь"
|
||
|
||
def test_house_developer_name_cian_preferred(self) -> None:
|
||
out = resolve_house_field(
|
||
"developer_name",
|
||
{"cian": "PRINZIP", "yandex_realty_nb": "PRINZIP недвижимость"},
|
||
)
|
||
assert out == "PRINZIP"
|
||
|
||
def test_house_has_lift_cian_bti_preferred_over_yandex_valuation(self) -> None:
|
||
out = resolve_house_field("has_lift", {"cian_bti": True, "yandex_valuation": True})
|
||
assert out is True
|
||
|
||
def test_house_has_no_ceiling_height_rule(self) -> None:
|
||
"""#2699: правила для houses.ceiling_height быть не должно — колонки нет.
|
||
|
||
Раньше здесь стояло `resolve_house_field("ceiling_height", ...) == 2.7`.
|
||
Тест зеленел, но проверял ФАНТОМ: колонки `ceiling_height` в таблице
|
||
`houses` не существует (прод: 0 колонок LIKE '%ceiling%'), правило не
|
||
могло сработать ни разу. Высота потолков — атрибут объявления.
|
||
"""
|
||
from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
|
||
|
||
assert "ceiling_height" not in HOUSE_FIELD_PRIORITY
|
||
|
||
def test_has_panorama_removed_from_house_priority(self) -> None:
|
||
"""#2674 (хвост): правило снято вместе с колонкой houses.has_panorama (мигр. 259).
|
||
|
||
В отличие от ceiling_height выше, это правило было ИСПОЛНИМО — колонка
|
||
существовала, единственный источник её писал. Разрешать было нечего:
|
||
yandex_valuation отдавал False всегда (0 true из 1536 страниц на проде),
|
||
потому что слова «панорам» на странице оценки нет вовсе.
|
||
"""
|
||
from app.services.matching.conflict_resolution import HOUSE_FIELD_PRIORITY
|
||
|
||
assert "has_panorama" not in HOUSE_FIELD_PRIORITY
|
||
|
||
def test_house_yandex_total_listings_yandex_valuation_only(self) -> None:
|
||
out = resolve_house_field("yandex_total_listings", {"yandex_valuation": 42})
|
||
assert out == 42
|
||
|
||
def test_house_house_class_yandex_realty_nb_fallback(self) -> None:
|
||
out = resolve_house_field("house_class", {"yandex_realty_nb": "бизнес"})
|
||
assert out == "бизнес"
|
||
|
||
def test_house_house_class_avito_preferred_over_yandex(self) -> None:
|
||
out = resolve_house_field(
|
||
"house_class",
|
||
{"avito_houses_catalog": "комфорт", "yandex_realty_nb": "бизнес"},
|
||
)
|
||
assert out == "комфорт"
|
||
|
||
def test_house_total_area_ha_yandex_only(self) -> None:
|
||
out = resolve_house_field("total_area_ha", {"yandex_realty_nb": 12.5})
|
||
assert out == 12.5
|
||
|
||
def test_house_has_lift_yandex_valuation_fallback_when_cian_missing(self) -> None:
|
||
out = resolve_house_field("has_lift", {"yandex_valuation": False})
|
||
assert out is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Yandex listing priority tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestYandexListingPriority:
|
||
def test_listing_description_cian_preferred_over_yandex_detail(self) -> None:
|
||
out = resolve_listing_field(
|
||
"description",
|
||
{"cian_serp": "cian text", "yandex_detail": "yandex text"},
|
||
)
|
||
assert out == "cian text"
|
||
|
||
def test_listing_house_type_yandex_detail_used_when_cian_avito_missing(self) -> None:
|
||
out = resolve_listing_field("house_type", {"yandex_detail": "brick"})
|
||
assert out == "brick"
|
||
|
||
def test_listing_agency_name_yandex_only(self) -> None:
|
||
out = resolve_listing_field("agency_name", {"yandex_detail": "Агентство «Диал»"})
|
||
assert out == "Агентство «Диал»"
|
||
|
||
def test_listing_agency_founded_year_yandex_only(self) -> None:
|
||
out = resolve_listing_field("agency_founded_year", {"yandex_detail": 2005})
|
||
assert out == 2005
|
||
|
||
def test_listing_agency_objects_count_yandex_only(self) -> None:
|
||
out = resolve_listing_field("agency_objects_count", {"yandex_detail": 120})
|
||
assert out == 120
|
||
|
||
def test_listing_views_total_yandex_only(self) -> None:
|
||
out = resolve_listing_field("views_total_yandex", {"yandex_detail": 874})
|
||
assert out == 874
|
||
|
||
def test_listing_publish_date_relative_yandex_only(self) -> None:
|
||
out = resolve_listing_field("publish_date_relative", {"yandex_detail": "3 дня назад"})
|
||
assert out == "3 дня назад"
|
||
|
||
def test_listing_agency_name_none_when_no_yandex(self) -> None:
|
||
out = resolve_listing_field("agency_name", {"cian_serp": None})
|
||
assert out is None
|
||
|
||
def test_listing_description_avito_detail_preferred_over_yandex(self) -> None:
|
||
out = resolve_listing_field(
|
||
"description",
|
||
{"avito_detail": "avito desc", "yandex_detail": "yandex desc"},
|
||
)
|
||
assert out == "avito desc"
|
||
|
||
def test_listing_house_type_cian_preferred_over_yandex_detail(self) -> None:
|
||
out = resolve_listing_field(
|
||
"house_type",
|
||
{"cian_serp": "панель", "yandex_detail": "кирпич"},
|
||
)
|
||
assert out == "панель"
|
||
|
||
def test_listing_yandex_detail_keys_registered(self) -> None:
|
||
"""Ensure all Yandex-unique listing keys are in priority dict."""
|
||
yandex_keys = [
|
||
"agency_name",
|
||
"agency_founded_year",
|
||
"agency_objects_count",
|
||
"views_total_yandex",
|
||
"publish_date_relative",
|
||
]
|
||
for key in yandex_keys:
|
||
assert key in LISTING_FIELD_PRIORITY, f"{key!r} missing from LISTING_FIELD_PRIORITY"
|
||
rule = LISTING_FIELD_PRIORITY[key]
|
||
assert rule == ["yandex_detail"], f"{key!r} rule mismatch: {rule!r}"
|
||
|
||
def test_house_yandex_keys_registered(self) -> None:
|
||
"""Ensure all Yandex-unique house keys are in priority dict."""
|
||
yandex_keys = [
|
||
"text_reviews_count",
|
||
"corpus_count",
|
||
"total_area_ha",
|
||
"commission_month",
|
||
"yandex_total_listings",
|
||
]
|
||
for key in yandex_keys:
|
||
assert key in HOUSE_FIELD_PRIORITY, f"{key!r} missing from HOUSE_FIELD_PRIORITY"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Freshness-based conflict resolution (#1539) — last_seen_at wins, deterministic
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestFreshnessResolution:
|
||
"""first_non_null / fallback must prefer the freshest source, not dict order."""
|
||
|
||
def test_first_non_null_fresher_source_wins(self) -> None:
|
||
# kadastr_num uses the 'first_non_null' rule.
|
||
candidates = {"avito": "66:1:1:OLD", "cian": "66:1:1:NEW"}
|
||
timestamps = {
|
||
"avito": datetime(2026, 1, 1),
|
||
"cian": datetime(2026, 6, 1), # fresher
|
||
}
|
||
out = resolve_listing_field("kadastr_num", candidates, timestamps)
|
||
assert out == "66:1:1:NEW"
|
||
|
||
def test_first_non_null_fresher_source_wins_regardless_of_order(self) -> None:
|
||
# Same data, opposite insertion order — result must not flip.
|
||
candidates = {"cian": "66:1:1:NEW", "avito": "66:1:1:OLD"}
|
||
timestamps = {
|
||
"avito": datetime(2026, 1, 1),
|
||
"cian": datetime(2026, 6, 1),
|
||
}
|
||
out = resolve_listing_field("kadastr_num", candidates, timestamps)
|
||
assert out == "66:1:1:NEW"
|
||
|
||
def test_freshness_picks_avito_when_avito_is_newer(self) -> None:
|
||
candidates = {"avito": "AVITO", "cian": "CIAN"}
|
||
timestamps = {
|
||
"avito": datetime(2026, 6, 1), # fresher
|
||
"cian": datetime(2026, 1, 1),
|
||
}
|
||
out = resolve_listing_field("kadastr_num", candidates, timestamps)
|
||
assert out == "AVITO"
|
||
|
||
def test_null_only_returns_none(self) -> None:
|
||
out = resolve_listing_field(
|
||
"kadastr_num",
|
||
{"avito": None, "cian": None},
|
||
{"avito": datetime(2026, 6, 1), "cian": datetime(2026, 1, 1)},
|
||
)
|
||
assert out is None
|
||
|
||
def test_null_source_skipped_even_if_fresher(self) -> None:
|
||
# cian is fresher but null → avito's non-null value wins.
|
||
candidates = {"avito": "VALUE", "cian": None}
|
||
timestamps = {
|
||
"avito": datetime(2026, 1, 1),
|
||
"cian": datetime(2026, 6, 1),
|
||
}
|
||
out = resolve_listing_field("kadastr_num", candidates, timestamps)
|
||
assert out == "VALUE"
|
||
|
||
def test_tie_breaks_by_source_name_deterministic(self) -> None:
|
||
# Equal timestamps → lexicographically smallest source name wins.
|
||
ts = datetime(2026, 6, 1)
|
||
out1 = resolve_listing_field(
|
||
"kadastr_num",
|
||
{"avito": "A", "cian": "C"},
|
||
{"avito": ts, "cian": ts},
|
||
)
|
||
out2 = resolve_listing_field(
|
||
"kadastr_num",
|
||
{"cian": "C", "avito": "A"},
|
||
{"cian": ts, "avito": ts},
|
||
)
|
||
assert out1 == "A" # "avito" < "cian"
|
||
assert out1 == out2 # order-independent
|
||
|
||
def test_no_timestamps_falls_back_deterministically(self) -> None:
|
||
# Without timestamps, fallback is deterministic by sorted source name.
|
||
out1 = resolve_listing_field("kadastr_num", {"avito": "A", "cian": "C"})
|
||
out2 = resolve_listing_field("kadastr_num", {"cian": "C", "avito": "A"})
|
||
assert out1 == "A"
|
||
assert out1 == out2
|
||
|
||
def test_partial_timestamps_timed_source_preferred(self) -> None:
|
||
# Only one source carries a timestamp → it is treated as the freshest.
|
||
candidates = {"avito": "A", "cian": "C"}
|
||
timestamps = {"cian": datetime(2026, 6, 1)} # avito has none
|
||
out = resolve_listing_field("kadastr_num", candidates, timestamps)
|
||
assert out == "C"
|
||
|
||
def test_house_unknown_field_freshness(self) -> None:
|
||
# Unknown house field also uses freshness fallback.
|
||
candidates = {"src_a": 1, "src_b": 2}
|
||
timestamps = {"src_a": datetime(2026, 1, 1), "src_b": datetime(2026, 6, 1)}
|
||
assert resolve_house_field("totally_unknown", candidates, timestamps) == 2
|
||
|
||
def test_priority_list_fallback_uses_freshness(self) -> None:
|
||
# No ranked source present for 'lat' → freshest non-null wins.
|
||
candidates = {"unranked_old": 56.80, "unranked_new": 56.90}
|
||
timestamps = {
|
||
"unranked_old": datetime(2026, 1, 1),
|
||
"unranked_new": datetime(2026, 6, 1),
|
||
}
|
||
out = resolve_house_field("lat", candidates, timestamps)
|
||
assert out == 56.90
|
||
|
||
def test_priority_list_ranking_beats_freshness(self) -> None:
|
||
# Explicit ranking is authoritative: cian_serp ranked above yandex even if
|
||
# yandex is fresher.
|
||
candidates = {"cian_serp": 56.83, "yandex_realty_nb": 56.85}
|
||
timestamps = {
|
||
"cian_serp": datetime(2026, 1, 1),
|
||
"yandex_realty_nb": datetime(2026, 6, 1), # fresher but lower-ranked
|
||
}
|
||
out = resolve_house_field("lat", candidates, timestamps)
|
||
assert out == 56.83
|