"""#2699 — высота потолков сведена к одной колонке listings.ceiling_height_m. Что было (прод-замер 2026-08-07, 94 373 объявления): * ``ceiling_height`` (мигр. 019, numeric(3,2)) — 8 554 значения: cian 855, yandex 7 699. ``ceiling_height_m`` (мигр. 111, numeric(5,2)) — 14 824: avito 7 149, yandex 7 675. * Пересечение 7 675 строк, РАСХОЖДЕНИЙ НОЛЬ — одно и то же значение в двух ящиках, а не два конфликтующих источника. * Эстиматор (``_apply_kitchen_ceiling_signal``) читает только ``_m`` → 855 циановских + 24 яндексовых значения не видит никто. * Гейт правдоподобия был только у yandex SERP → avito detail нагнал 26 значений > 6 м (максимум 29.90) и 83 ровных 0.00. Каждый тест ниже КРАСНЫЙ на коде до #2699. """ from __future__ import annotations from contextlib import contextmanager from unittest.mock import MagicMock import pytest from scraper_kit.ceiling_height import plausible_ceiling_m from scraper_kit.providers.avito.detail import _parse_height_m from scraper_kit.providers.cian.detail import DetailEnrichment as CianEnrichment from scraper_kit.providers.cian.detail import save_detail_enrichment as cian_save from scraper_kit.providers.yandex.detail import DetailEnrichment as YandexEnrichment from scraper_kit.providers.yandex.detail import save_detail_enrichment as yandex_save from app.services.matching.conflict_resolution import ( HOUSE_FIELD_PRIORITY, LISTING_FIELD_PRIORITY, ) # ── гейт правдоподобия ─────────────────────────────────────────────────────── @pytest.mark.parametrize( ("raw", "expected"), [ (2.7, 2.7), # типовая панелька (2.0, 2.0), # нижняя граница включительно (6.0, 6.0), # верхняя граница включительно (двухуровневая карточка) (5.97, 5.97), # реальный максимум cian на проде ("2,7".replace(",", "."), 2.7), # строковый вход от парсера (29.90, None), # прод-максимум мусора avito (18.0, None), # мусор yandex SERP из #2007 (0.0, None), # 83 строки avito на проде (1.70, None), # ниже жилого потолка (9.99, None), # влезает в numeric(3,2), но потолком не является (None, None), ("не число", None), ], ) def test_plausible_ceiling_m_band(raw: object, expected: float | None) -> None: assert plausible_ceiling_m(raw) == expected # type: ignore[arg-type] def test_avito_parse_height_rejects_impossible() -> None: """КОРЕНЬ мусора: парсер avito брал первое число строки без проверки. Красный до #2699: возвращал 29.9 / 0.0 и они уезжали в ceiling_height_m, который читает эстиматор. """ assert _parse_height_m("2,7 м") == pytest.approx(2.7) assert _parse_height_m("3 м") == pytest.approx(3.0) assert _parse_height_m("29,9 м") is None assert _parse_height_m("0 м") is None assert _parse_height_m("нет данных") is None # ── писатели пишут В КАНОН ─────────────────────────────────────────────────── def _mock_db() -> MagicMock: """Session-заглушка, копящая (sql, params) каждого execute.""" db = MagicMock() calls: list[tuple[str, dict]] = [] @contextmanager def _nested(): # type: ignore[no-untyped-def] yield MagicMock() db.begin_nested.side_effect = _nested def _execute(sql, params=None): # type: ignore[no-untyped-def] calls.append((str(sql), params or {})) result = MagicMock() result.fetchone.return_value = None result.scalar_one_or_none.return_value = None result.rowcount = 1 return result db.execute.side_effect = _execute db._calls = calls return db def _yandex_enrichment(ceiling_height: float) -> YandexEnrichment: return YandexEnrichment( offer_id="1234567890", source_url="https://realty.yandex.ru/offer/1234567890/", ceiling_height=ceiling_height, ) def _listings_update_sql(db: MagicMock) -> str: for sql, _params in db._calls: if "UPDATE listings" in sql: return sql raise AssertionError("не нашли UPDATE listings") def test_cian_detail_writes_canonical_column() -> None: """855 значений Циана уходили в колонку, которую эстиматор не читает. Красный до #2699: UPDATE присваивал `ceiling_height`, не `ceiling_height_m`. """ db = _mock_db() cian_save(db, 55, CianEnrichment(ceiling_height=2.7)) sql = _listings_update_sql(db) assert "ceiling_height_m = COALESCE" in sql assert "ceiling_height = COALESCE" not in sql def test_yandex_detail_writes_canonical_column() -> None: """Красный до #2699: yandex detail тоже писал только в 019-колонку.""" db = _mock_db() yandex_save(db, 77, _yandex_enrichment(2.55)) sql = _listings_update_sql(db) assert "ceiling_height_m = COALESCE" in sql assert "ceiling_height = COALESCE" not in sql def test_detail_writers_gate_impossible_values() -> None: """Невозможная высота не доезжает до БД ни одним из detail-путей.""" db_cian = _mock_db() cian_save(db_cian, 55, CianEnrichment(ceiling_height=29.9)) assert next(p for s, p in db_cian._calls if "UPDATE listings" in s)["ch"] is None db_yandex = _mock_db() yandex_save(db_yandex, 77, _yandex_enrichment(0.0)) assert next(p for s, p in db_yandex._calls if "UPDATE listings" in s)["ceiling_height"] is None def test_base_upsert_no_longer_writes_deprecated_column() -> None: """save_listings писал ОДИН param в ОБЕ колонки — источник дубля. Красный до #2699: SQL содержал `ceiling_height,` в списке колонок INSERT. Проверяем по исходнику модуля: собрать SQL иначе можно только выполнив upsert против живой БД. """ import re from pathlib import Path import scraper_kit.base as base src = Path(base.__file__).read_text(encoding="utf-8") # SQL-упоминания колонки: `ceiling_height` не как префикс `ceiling_height_m` # и не внутри комментария (`--`). offenders = [ line for line in src.splitlines() # `ceiling_height` в позиции SQL-колонки: перед `,` (список INSERT) или # перед `=` (SET). Импорт модуля-гейта и комментарии не считаются. if re.search(r"\bceiling_height\b(?!_m)\s*[,=]", line) and "--" not in line ] assert offenders == [], f"base.py всё ещё пишет DEPRECATED-колонку: {offenders}" # ── карта приоритетов больше не указывает в пустоту ────────────────────────── def test_house_priority_has_no_phantom_ceiling_rule() -> None: """Колонки `ceiling_height` в таблице houses нет и не было. Красный до #2699: правило существовало и не могло сработать ни разу. """ assert not [k for k in HOUSE_FIELD_PRIORITY if "ceiling" in k] def test_listing_priority_names_the_real_column() -> None: """Красный до #2699: ключ назывался `ceiling_height` — колонка есть, но её не читает ни один потребитель, влияющий на цену.""" assert "ceiling_height" not in LISTING_FIELD_PRIORITY assert LISTING_FIELD_PRIORITY["ceiling_height_m"] == [ "cian_detail", "avito_detail", "yandex_detail", ]