"""Дом-поля карточки Домклика доезжают до 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 = ( "" ) 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