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
193 lines
8.5 KiB
Python
193 lines
8.5 KiB
Python
"""#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",
|
||
]
|