"""#2674 — домовая оценка Авито перестаёт врать про ремонт и тип дома. Покрывает три дефекта из эпика: 1. renovation_type берётся из моды listings.repair_state и проходит через существующий estimator._IMV_REPAIR_MAP (был захардкожен литерал 'cosmetic': 2685 из 2685 запросов ушли как «косметический ремонт»). 2. Неизвестный тип дома НЕ уезжает дефолтом 'panel' (самый дешёвый класс → системное занижение), а помечает дом и экономит запрос. Отдельно — camelCase-вокабуляр Циана (monolithBrick / gasSilicateBlock / stalin) распознаётся, к нижнему регистру он не приводится. 3. Прогон с saved=0 и ненулевыми errors не помечается 'done'. БД и сеть замоканы — реального Postgres/Авито не нужно. """ from __future__ import annotations import os from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services import house_imv_backfill as hib from app.services.product_handlers import _job_house_imv_backfill # ── (1) renovation_type из данных через существующий маппинг ────────────────── def _db_for_pick(listing_row: dict[str, Any], house_row: dict[str, Any] | None) -> MagicMock: """MagicMock-Session: два .mappings().first() подряд (listings-агрегат, houses).""" db = MagicMock() db.execute.return_value.mappings.return_value.first.side_effect = [listing_row, house_row] return db def _listing_row(**over: Any) -> dict[str, Any]: base: dict[str, Any] = { "rooms": 2, "area_m2": 52.0, "floor": 3, "total_floors": 10, "house_type": "panel", "repair_state": None, } base.update(over) return base @pytest.mark.parametrize( ("repair_state", "expected"), [ ("needs_repair", "required"), ("standard", "cosmetic"), ("good", "euro"), ("excellent", "designer"), ], ) def test_renovation_type_comes_from_listings_via_estimator_map( repair_state: str, expected: str ) -> None: """Мода repair_state → renovation_type ровно по estimator._IMV_REPAIR_MAP.""" from app.services.estimator import _IMV_REPAIR_MAP params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=repair_state), None), 1) assert params["renovation_type"] == expected # Не второй словарь: значение обязано совпадать с источником правды. assert params["renovation_type"] == _IMV_REPAIR_MAP[repair_state] def test_renovation_type_not_hardcoded_cosmetic() -> None: """Regression #2674: 'good' больше не превращается в 'cosmetic'.""" params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state="good"), None), 1) assert params["renovation_type"] != "cosmetic" def test_unknown_repair_state_stays_cosmetic() -> None: """Анти-оверрич: ремонт неизвестен → середина шкалы 'cosmetic', дом не теряем.""" params = hib.pick_lot_params(_db_for_pick(_listing_row(repair_state=None), None), 1) assert params["renovation_type"] == "cosmetic" assert params["house_type"] == "panel" # дом всё ещё пригоден к запросу # ── (2) тип дома: неизвестный не врёт, camelCase распознаётся ───────────────── @pytest.mark.parametrize( ("raw", "expected"), [ # camelCase из Циана — нижним регистром НЕ лечится (ключ канона через '_'). ("monolithBrick", "monolithic"), ("gasSilicateBlock", "block"), ("aerocreteBlock", "block"), ("stalin", "brick"), # каноничные значения продолжают работать ("panel", "panel"), ("monolith", "monolithic"), ("monolith_brick", "monolithic"), ], ) def test_map_house_type_recognises_camel_case(raw: str, expected: str) -> None: assert hib._map_house_type(raw) == expected @pytest.mark.parametrize("raw", [None, "", "other", "wireframe", "какая-то дичь"]) def test_map_house_type_unknown_is_none_not_panel(raw: str | None) -> None: """Regression #2674: нет типа / не распознан → None, а НЕ дефолт 'panel'.""" assert hib._map_house_type(raw) is None def test_pick_lot_params_unknown_house_type_yields_none() -> None: """Типа нет ни в listings, ни в houses → house_type=None (не 'panel').""" db = _db_for_pick(_listing_row(house_type=None), {"house_type": None, "total_floors": 9}) assert hib.pick_lot_params(db, 1)["house_type"] is None @pytest.mark.asyncio async def test_unknown_house_type_skips_request_and_marks_house() -> None: """Неизвестный тип → запрос к площадке НЕ уходит, дом помечен no_params.""" params = { "rooms": 2, "area_m2": 52.0, "floor": 3, "floor_at_home": 10, "house_type": None, "renovation_type": "cosmetic", "has_balcony": True, "has_loggia": False, } houses = [{"id": 11, "address": "ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}] db = MagicMock() with ( patch.object(hib, "pick_lot_params", return_value=params), patch.object(hib, "evaluate_via_imv", new_callable=AsyncMock) as mock_eval, patch.object(hib, "_mark_status") as mock_mark, ): # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, # затем основную по only_status. Фейк отвечает одним и тем же списком на любой # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). db.execute.return_value.mappings.return_value.all.side_effect = [[], houses] result = await hib.backfill_house_imv(db, batch_size=10, request_delay_sec=0.0) mock_eval.assert_not_called() mock_mark.assert_called_once_with(db, 11, "no_params", "unknown house_type") assert result.skipped == 1 assert result.saved == 0 # ── (3) прогон с нулём сохранённых и ошибками не «успешен» ──────────────────── class _RunsRecorder: """Duck-typed ctx.runs: пишет, чем закончился прогон.""" def __init__(self) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] def update_heartbeat(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: return None def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: self.calls.append(("mark_done", counters)) def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: self.calls.append(("mark_failed", counters)) async def _drive_job(*, saved: int, errors: int, skipped: int = 0) -> list[tuple[str, dict]]: runs = _RunsRecorder() enrichment = MagicMock() enrichment.house_imv_backfill = AsyncMock( return_value=hib.HouseIMVBackfillResult( checked=saved + errors + skipped, saved=saved, skipped=skipped, errors=errors, duration_sec=1.0, ) ) ctx = MagicMock(runs=runs, enrichment=enrichment) await _job_house_imv_backfill(MagicMock(), 1, {}, ctx) return runs.calls @pytest.mark.asyncio async def test_zero_saved_with_errors_is_not_done() -> None: """Прод-случай: 31 прогон подряд saved=0 / errors≈35 из 50 уходил в 'done'.""" calls = await _drive_job(saved=0, errors=35, skipped=15) assert calls[-1][0] == "mark_failed" assert calls[-1][1]["saved"] == 0 assert calls[-1][1]["errors"] == 35 @pytest.mark.asyncio async def test_honest_empty_stays_done() -> None: """Анти-оверрич: ноль сохранённых без ошибок (всё в skipped) — честная пустота.""" calls = await _drive_job(saved=0, errors=0, skipped=50) assert calls[-1][0] == "mark_done" @pytest.mark.asyncio async def test_partial_success_stays_done() -> None: """Анти-оверрич: что-то сохранили — прогон успешен, даже если были ошибки.""" calls = await _drive_job(saved=3, errors=7) assert calls[-1][0] == "mark_done" @pytest.mark.asyncio async def test_counters_feed_total_seen_and_new_count() -> None: """#2674: без этих ключей _column_counts оставляет колонку total_seen=0, и алерт «3 подряд done с нулевым результатом» (#2625) выстрелил бы даже на полностью успешном прогоне. На проде так лежат все 39 прогонов source'а. """ from app.services.scrape_runs import _column_counts calls = await _drive_job(saved=50, errors=0) counters = calls[-1][1] assert _column_counts(counters) == (50, 50)