gendesign/tradein-mvp/backend/tests/test_house_imv_params_honesty.py
bot-backend c927b77777
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Failing after 3m29s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / deploy (push) Has been skipped
fix(tradein/imv): «временная» ошибка снова временная — 1390 домов возвращаются в очередь (#2843)
2026-08-12 16:06:24 +00:00

224 lines
9.7 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.

"""#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)