gendesign/tradein-mvp/backend/tests/test_3253_domclick_house_fields.py
bot-backend 721ceb9876
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
fix(tradein): выборка миграции 286 повторяет гейт 1:1, правило первой точки (#3376)
Ревью нашло, что миграция и код ловили РАЗНОЕ. Миграция брала базой предыдущую
СЫРУЮ строку (lag), гейт — предыдущую ОСТАВЛЕННУЮ. На 1M→10M→1M→10M (цена 1M)
lag-версия удаляла честную точку, на 1M→10M→1.05M→9.9M — не была идемпотентной
(второй прогон доедал 9.9M). Теперь кандидаты выбирает PL/pgSQL-цикл, пошагово
повторяющий drop_decimal_slips, а правило первой точки — отдельным INSERT..SELECT
уже по ОСТАВШИМСЯ строкам.

Правило первой точки — из прод-разбора: 12 из 20 остатков domklik это серии вида
330 000 → 3 300 000 (текущая цена 3 300 000) и 420 000 → 4 200 000 → 4 500 000,
где дефектная точка ПЕРВАЯ и базы слева у неё нет. Свидетелей по-прежнему два:
×10 ко второй точке И подтверждение второй третьей-или-текущей-ценой. Решение по
первой точке принимается по kept-серии, а не по сырой, — иначе гейт теряет
идемпотентность (перебор ловит 1122 таких прогона).

Идемпотентность доказана НА ГЕЙТЕ: property-тест gate(gate(s)) == gate(s) по всем
сериям длины 2-6 (19 525 серий × 3 текущие цены). Фальсифицирован обеими
поломками — сырая база даёт 136 красных прогонов, сырые соседи первой точки 1122.
Раз SQL зеркалит гейт, свойство переносится на миграцию.

Ещё в 286: третий свидетель ПРОТИВ удаления (цена подтверждена триггерной строкой
того же объявления — значит она реально наблюдалась в listings.price_rub) и
финальный шаг |diff_percent| > 100 → NULL по всем источникам, то же правило, что
validate_diff_percent на записи. Ожидаемое число удалений в шапке — 35 + ~12 из
двухсвидетельского предзамера, а не 76 (то была односвидетельская цифра).
Прогон обеих фаз дважды с ROLLBACK — tradein-mvp/scripts/sql/286_dryrun.sql.

yandex: проводка гейта снята как мёртвая. На том пути серия из двух точек, а
свидетель последней — текущая цена лота, то есть она же сама: ветка по построению
не могла выбросить ничего. Оставлен честный комментарий-потолок и ссылка на
follow-up (отлов требует DELETE на следующем наблюдении).

cian: после выброса точки соседу пересчитывается diff_percent (было только у
domclick). domclick: цена листинга берётся RETURNING'ом у UPDATE вместо отдельного
SELECT по PK, пересчёт вынесен в общий recompute_diff_percent с гейтом на пустую
цену (ручной ingest кладёт price_changes из JSONL без валидации).
2026-09-06 03:53:08 +05:00

180 lines
7.8 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.

"""Дом-поля карточки Домклика доезжают до houses (#3253).
Парсер читал houseInfo.info (тип стен/перекрытий, число квартир) и складывал всё
в listings.raw_payload, а в houses не переносил: на проде 400 домов-domklik против
3061 объявления, а houses.total_units = 0 у ВСЕХ источников при том, что
quarters_count уже лежал в собранных payload'ах.
(а) Значение: карточка с houseInfo → второй оператор получает total_units /
material_walls / material_floors ЧИСЛАМИ И СТРОКАМИ из фикстуры (не None).
(б) Непустое значение дома не затирается пустым из карточки — COALESCE в обе
стороны: и в SET, и в WHERE-гейте.
(в) Словарь: в material_walls едет КАНОН ДОМ.РФ («монолит»), а не сырая фраза
площадки («Монолитно-кирпичный») — иначе колонка становится двухсловарной.
"""
from __future__ import annotations
import json
import os
from types import SimpleNamespace
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.domclick.detail import (
DomClickDetailEnrichment,
canon_floor_type,
canon_wall_type,
parse_detail_html,
save_detail_enrichment,
)
_STATE = {
"productCard": {"id": "t3253-1", "address": {"guid": "bldg-guid-1"}},
"houseInfo": {
"info": {
"wallType": "Монолитно-кирпичный",
"floorType": "Железобетонный",
"quartersCount": 168,
"entranceCount": 4,
"buildingSeries": "II-68",
"energyEfficiency": "B",
}
},
}
_HTML = (
"<html><body><script>window.__SSR_STATE__ = "
f"{json.dumps(_STATE, ensure_ascii=False)};</script></body></html>"
)
class _FakeNested:
def __enter__(self) -> None:
return None
def __exit__(self, *exc: object) -> bool:
return False
class _FakeSession:
"""Пишет все execute(stmt, params); rowcount=1 у каждого оператора.
fetchone() → None: UPDATE listings отдаёт текущую цену через RETURNING — она нужна
гейту сдвигов разряда (#3376) как второй свидетель. Этой фикстуре цена не нужна,
«строки нет» гейт трактует как отсутствие свидетеля и серию не трогает; факт
«листинг найден» здесь по-прежнему приходит из rowcount, а не из RETURNING.
"""
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, Any]]] = []
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
self.calls.append((str(stmt), params or {}))
return SimpleNamespace(rowcount=1, fetchone=lambda: None)
def begin_nested(self) -> _FakeNested:
return _FakeNested()
def commit(self) -> None:
return None
def _house_call(db: _FakeSession) -> dict[str, Any]:
for sql, params in db.calls:
if "UPDATE houses" in sql:
return params
raise AssertionError(f"UPDATE houses не исполнен; операторы: {[s[:40] for s, _ in db.calls]}")
def test_house_fields_reach_houses_upsert() -> None:
"""(а)+(в) Значения из фикстуры доезжают до апсерта дома — канонизированными."""
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
assert e.raw_extra["quarters_count"] == 168
# В raw_payload сырая фраза площадки остаётся как есть — канон только в колонках.
assert e.raw_extra["wall_type"] == "Монолитно-кирпичный"
db = _FakeSession()
save_detail_enrichment(db, 555, e) # type: ignore[arg-type]
params = _house_call(db)
assert params["quarters_count"] == 168
assert params["wall_type"] == "монолит"
assert params["floor_type"] == "Железобетонные"
assert params["lid"] == 555
# ── (в) канонизация словаря (#3253) ───────────────────────────────────────────
# houses.material_walls на проде уже заполнена словарём ДОМ.РФ: кирпич 2662,
# железобетонная панель 2107, иное 1850, монолит 754. Домклик пишет свой словарь.
@pytest.mark.parametrize(
("raw", "expected"),
[
("Кирпичный", "кирпич"), # 2241 карточка на проде
("Панельный", "железобетонная панель"), # 1671
("Монолитный", "монолит"), # 2656
("Монолитно-кирпичный", "монолит"), # 773
("Блочный", "иное"),
("Деревянный", "иное"),
(" монолитный ", "монолит"), # регистр и пробелы не мешают
("Саманный", None), # незнакомое — сырьё в колонку НЕ пишем
("", None),
(None, None),
(42, None), # не-строка из JSON
],
)
def test_canon_wall_type_maps_to_domrf_dictionary(raw: object, expected: str | None) -> None:
assert canon_wall_type(raw) == expected
@pytest.mark.parametrize(
("raw", "expected"),
[
("Железобетонный", "Железобетонные"), # форма Домклика
("Железобетонные", "Железобетонные"), # форма, уже лежащая в колонке (avito)
("Деревянные", None),
(None, None),
],
)
def test_canon_floor_type_maps_to_column_form(raw: object, expected: str | None) -> None:
assert canon_floor_type(raw) == expected
def test_unknown_wall_type_is_not_written_to_houses() -> None:
"""Незнакомый тип стен не доезжает до houses — колонка не портится сырьём."""
e = DomClickDetailEnrichment(
item_id="t3253-3",
source_url="u",
raw_extra={"wall_type": "Саманный", "quarters_count": 12},
)
db = _FakeSession()
save_detail_enrichment(db, 558, e) # type: ignore[arg-type]
params = _house_call(db)
assert params["wall_type"] is None
assert params["quarters_count"] == 12 # остальные поля едут как обычно
def test_empty_card_does_not_overwrite_house() -> None:
"""(б) Карточка без дом-полей вообще не трогает houses."""
db = _FakeSession()
e = DomClickDetailEnrichment(item_id="t3253-2", source_url="u", raw_extra={"egrn_area": 42.0})
save_detail_enrichment(db, 556, e) # type: ignore[arg-type]
assert not [s for s, _ in db.calls if "UPDATE houses" in s]
def test_coalesce_protects_filled_columns() -> None:
"""(б) SET и WHERE-гейт оба COALESCE-семантика: пустым непустое не затирается."""
e = parse_detail_html(_HTML, "https://domclick.ru/card/sale__flat__t3253-1")
db = _FakeSession()
save_detail_enrichment(db, 557, e) # type: ignore[arg-type]
sql = next(s for s, _ in db.calls if "UPDATE houses" in s)
for col in ("total_units", "material_walls", "material_floors"):
assert f"COALESCE(h.{col}," in sql
assert f"h.{col} IS NULL" in sql