gendesign/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py
bot-backend 306fd82836
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 3m5s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 2m26s
fix(tradein): свести высоту потолков к одной колонке ceiling_height_m (#2779)
2026-08-07 09:18:48 +00:00

193 lines
8.5 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.

"""#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",
]