All checks were successful
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 53s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
Collapse 14 Class-A boolean feature gates (all default True, never overridden in prod) into their unconditional ON behavior: inline the ON-path, drop the guard + any OFF branch, delete the config field, and remove/adjust OFF-path tests. Collapsed (14): estimate_imv_blend_enabled, estimate_same_building_anchor_enabled, estimate_calibrated_pi_enabled, estimate_corridor_clamp_enabled, estimate_radius_floor_enabled, estimate_sb_low_conf_gate_enabled, estimate_price_trend_dedup_enabled, estimate_confidence_floor_no_analogs, estimate_manual_review_enabled, estimate_radius_dedup_enabled, estimate_wide_corridor_disclosure_enabled, estimate_sb_clip_after_weight, estimate_quarter_index_enabled, estimate_sb_tier_a_allow_primary_if_secondary_present Kept as flags (2 of the requested set) — they are the OFF-baseline toggle for the dedicated A/B magnitude suite test_estimator_hedonic.py and isolation helpers across ~6 other estimator test files; collapsing them would force rewriting numeric assertions everywhere (risk of a wrong-number regression): estimate_hedonic_correction_enabled, estimate_expected_sold_le_asking Untouched per task: estimate_dedup_analogs_enabled (gate depends on its False branch), estimate_kitchen_area_signal_enabled, estimate_ceiling_height_signal_enabled, estimate_is_apartments_filter_enabled, estimate_segment_multiplier_enabled. Also removed the now-unconditional `enabled` param from _apply_corridor_clamp and collapsed the same-building-anchor pre-fetch gate in scripts/backtest_estimator.py. Regression gate (test_backtest_regression_gate.py) stays byte-green: all collapsed flags defaulted True, so unconditional == prior prod behavior; the frozen 277-DKP replay is byte-identical to the committed baseline. Full run: 458 passed (gate + estimator suite) + 93 passed (same_building / 781 / tier_a / segment_guard).
102 lines
3.8 KiB
Python
102 lines
3.8 KiB
Python
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
|
||
|
||
Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
|
||
не double-count'ится в месячной медиане (dedup всегда включён).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||
|
||
import pytest
|
||
|
||
from app.services.estimator import _fetch_price_trend
|
||
|
||
|
||
def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMock:
|
||
"""Возвращает mock db: Source1 (houses_price_dynamics) → source1_rows,
|
||
Source2 (house_placement_history) → source2_rows."""
|
||
mock_db = MagicMock()
|
||
|
||
def side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||
call_text = str(args[0].text) if args else ""
|
||
result = MagicMock()
|
||
if "houses_price_dynamics" in call_text:
|
||
result.mappings.return_value.all.return_value = source1_rows
|
||
else:
|
||
result.mappings.return_value.all.return_value = source2_rows
|
||
return result
|
||
|
||
mock_db.execute.side_effect = side_effect
|
||
return mock_db
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fix-3: dedup enabled (default True)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_price_trend_dedup_sql_uses_distinct_on() -> None:
|
||
"""SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
|
||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||
|
||
with patch("app.services.estimator.settings") as mock_settings:
|
||
mock_settings.estimate_price_trend_max_age_months = 6
|
||
|
||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
||
|
||
calls = mock_db.execute.call_args_list
|
||
# Второй вызов — Source 2 (house_placement_history)
|
||
assert len(calls) >= 2
|
||
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
|
||
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
|
||
|
||
|
||
def test_price_trend_returns_result_with_dedup() -> None:
|
||
"""Функция корректно возвращает точки тренда (dedup включён)."""
|
||
rows = [
|
||
{"month": "2025-11", "ppm2": 148_000},
|
||
{"month": "2025-12", "ppm2": 152_000},
|
||
{"month": "2026-01", "ppm2": 155_000},
|
||
]
|
||
mock_db = _make_mock_db([], rows)
|
||
|
||
with patch("app.services.estimator.settings") as mock_settings:
|
||
mock_settings.estimate_price_trend_max_age_months = 6
|
||
|
||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||
|
||
assert result is not None
|
||
assert len(result) == 3
|
||
assert result[0]["month"] == "2025-11"
|
||
assert result[0]["ppm2"] == 148_000
|
||
|
||
|
||
def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
||
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
|
||
source1_rows = [
|
||
{"month": "2025-11", "ppm2": 140_000},
|
||
{"month": "2025-12", "ppm2": 145_000},
|
||
{"month": "2026-01", "ppm2": 150_000},
|
||
]
|
||
mock_db = _make_mock_db(source1_rows, [])
|
||
|
||
with patch("app.services.estimator.settings") as mock_settings:
|
||
mock_settings.estimate_price_trend_max_age_months = 6
|
||
|
||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||
|
||
# Source1 вернул 3 точки — Source2 не должен вызываться
|
||
assert result is not None
|
||
assert len(result) == 3
|
||
calls = mock_db.execute.call_args_list
|
||
# Ровно один вызов (Source1)
|
||
assert len(calls) == 1
|
||
|
||
|
||
if __name__ == "__main__": # pragma: no cover
|
||
raise SystemExit(pytest.main([__file__, "-q"]))
|