gendesign/tradein-mvp/backend/tests/matching/test_conflict_resolution.py
bot-backend 3e30b97d6b
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 4m30s
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/yandex): снести признак «панорама» — его нет на площадке
has_panorama разбирался, писался и обещался публичным контрактом
market.v_houses, но положительных значений не имел ни одного: 0 true из
1536 страниц yandex_valuation за 24.05–12.08.2026 (false 1536, ключ
отсутствует 0), houses — 12 непустых из 9468, все false.

Соседний has_lift выглядел контрольной группой («текст читается, разбор
работает»), но контролирует он только факт отрисовки страницы: "Лифт" in
body_text ловит подстроку внутри отрицания, и на живой пятиэтажке 1977 г.
со строкой «Лифт отсутствует» парсер отдаёт True. На проде 300 из 313
пятиэтажек «с лифтом». Гейт отрицания для has_lift — отдельной правкой,
здесь не смешиваю.

Живая проверка боевым трактом 13.08.2026 (curl_cffi impersonate=chrome120
+ прод-прокси, RealScraperConfig + get_scraper_delay — ровно как
estimator.py; только чтение, в БД не писал): три адреса Екатеринбурга,
все HTTP 200, полный HTML ~1.19 МБ каждый. Вхождений «анорам» без учёта
регистра — 0/0/0, равно как panorama / 3D-тур / Виртуальн / Street.
Мета при этом разобралась целиком (1974/9эт/панель/2,50/46 объектов;
2020/18эт; 1977/5эт/кирпич/184 объекта). Метка не «переехала» в атрибут
или JSON-стейт — её нет в документе вовсе.

Это третий вид нуля: не «уже починено» и не «заблокировано выше по
потоку», а НЕПРИМЕНИМО к предметной области. Ноль был механически
гарантирован самим кодом — поиск подстроки по тексту, где такого слова не
бывает, — и о доме не говорил ничего.

Цена: снос ломает публичный контракт market.v_houses (мигр. 154 прямо
называет удаление колонки ломающим изменением). Принято осознанно —
читателей вне tradein-mvp нет (git grep пуст, в т.ч. newbuilding_crossload.py).
CREATE OR REPLACE VIEW колонку не удаляет, поэтому DROP VIEW → DROP COLUMN
→ CREATE VIEW, и вместе с DROP VIEW теряются гранты: GRANT SELECT для
gendesign_reader восстановлен явно.

Оговорка честности: сырой HTML прошлых сборов не хранится (raw_payload
держит только body_len/items_count), поэтому «метка была и исчезла в мае»
доказательно не опровергается — но и в измеренной истории 1536 страниц
положительных ноль.

Refs #2674
2026-08-13 01:04:18 +05:00

344 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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