All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
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 5m7s
Post-merge прогон main после #3440 дал 5 failed: `_fetch_deals() got an unexpected keyword argument 'rooms'`. Семантический конфликт мержа, а не поломка: ветка отводилась до #3256, который УБРАЛ параметр `rooms` (сделки Росреестра комнатность не несут). Текстового конфликта git не увидел, pre-merge CI ветки был зелёным, красным стало только на объединённом дереве. В проде эффекта нет: `_fetch_deals` из `app/` не вызывается ни разу ни до, ни после мержа — функцию держат только эти тесты. `uv run python -m pytest tests/test_3051_moscow_okrug_bands.py` — 24 passed, ruff чист. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
487 lines
26 KiB
Python
487 lines
26 KiB
Python
"""#3051 — полосы цен по округам Москвы + региональный потолок ppm².
|
||
|
||
Что проверяется:
|
||
1. Ключ города сделки (deal_city_key / deal_city_key_sql) — один на derivation
|
||
полос и на все читающие места estimator'а.
|
||
2. Регион 66 не двигается: src_city у его сделок пуст → ключ равен city.
|
||
3. Новый потолок не срезает дорогой московский округ, старый глобальный —
|
||
срезал бы.
|
||
4. Полосы региона 66 по новой формуле потолка совпадают со старым литералом.
|
||
|
||
Живого Postgres нет — SQL-инварианты проверяются по тексту запроса, поведение
|
||
фильтра — на подменённой Session (паттерн из tests/test_dkp_corridor_as_of_2846.py).
|
||
Авторитетная проверка инварианта региона 66 сделана на проде 2026-09-10 прогоном
|
||
новой derivation в режиме SELECT: 383 строки против 383 текущих, все совпали по
|
||
(ppm2_min, ppm2_max, n_deals, tier); сделок региона 66, где ключ != city, — 0.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import estimator as est
|
||
from app.services.deal_city_key import (
|
||
DEAL_CITY_KEY_COLUMN,
|
||
deal_city_band_bounds_sql,
|
||
deal_city_band_join_sql,
|
||
deal_city_key,
|
||
deal_city_key_sql,
|
||
resolve_city_band,
|
||
)
|
||
from app.tasks.deal_city_price_bands_refresh import (
|
||
_REDERIVE_SQL,
|
||
REGION_CEILING_CAP_Q,
|
||
REGION_CEILING_FLOOR,
|
||
REGION_CEILING_MEDIAN_MULT,
|
||
REGION_CEILING_MEDIAN_Q,
|
||
region_ppm2_max,
|
||
)
|
||
|
||
# Замеры прода 2026-09-10, на которых калибровался потолок.
|
||
_R66_P50 = 52_706
|
||
_R66_P9999 = 615_312
|
||
_R77_P50 = 294_457
|
||
_R77_P9999 = 1_944_535
|
||
_R77_CEILING = 1_766_742 # ПИН прода: множитель * p50 региона 77 при текущих константах
|
||
_MAX_OKRUG_P99 = 1_405_882 # самый дорогой округ Москвы
|
||
|
||
|
||
def _sql_code(sql: str) -> str:
|
||
"""Текст запроса без строк-комментариев — числа из комментариев не считаем."""
|
||
return "\n".join(ln for ln in sql.splitlines() if not ln.strip().startswith("--"))
|
||
|
||
|
||
# ── 1. Ключ города ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_key_region66_equals_city() -> None:
|
||
"""Свердловская область: src_city пуст у всех сделок → ключ равен city."""
|
||
assert deal_city_key({"city": "Асбест", "raw_payload": {}}) == "Асбест"
|
||
assert deal_city_key({"city": "Асбест", "raw_payload": None}) == "Асбест"
|
||
assert deal_city_key({"city": "Асбест"}) == "Асбест"
|
||
# NULLIF(x, '') в SQL: пустая строка — то же, что отсутствие значения.
|
||
assert deal_city_key({"city": "Асбест", "raw_payload": {"src_city": ""}}) == "Асбест"
|
||
|
||
|
||
def test_key_moscow_with_src_city_is_okrug() -> None:
|
||
row = {"city": "Москва", "raw_payload": {"src_city": "муниципальный округ Хамовники"}}
|
||
assert deal_city_key(row) == "муниципальный округ Хамовники"
|
||
|
||
|
||
def test_key_moscow_without_src_city_is_moscow() -> None:
|
||
"""6.73% московских сделок без src_city → собственная строка-фолбэк 'Москва'."""
|
||
assert deal_city_key({"city": "Москва", "raw_payload": {"src_city": None}}) == "Москва"
|
||
assert deal_city_key({"city": "Москва", "raw_payload": {}}) == "Москва"
|
||
|
||
|
||
def test_key_column_wins_over_raw_payload() -> None:
|
||
"""Готовую колонку city_key (её считает SQL) не переопределяем питоном."""
|
||
row = {
|
||
DEAL_CITY_KEY_COLUMN: "муниципальный округ Метрогородок",
|
||
"city": "Москва",
|
||
"raw_payload": {"src_city": "муниципальный округ Хамовники"},
|
||
}
|
||
assert deal_city_key(row) == "муниципальный округ Метрогородок"
|
||
|
||
|
||
def test_key_sql_shape() -> None:
|
||
assert deal_city_key_sql("d") == "COALESCE(NULLIF(d.raw_payload->>'src_city', ''), d.city)"
|
||
assert deal_city_key_sql("") == "COALESCE(NULLIF(raw_payload->>'src_city', ''), city)"
|
||
|
||
|
||
# ── 2. SQL-инварианты derivation и читающих запросов ─────────────────────────
|
||
|
||
|
||
def test_rederive_sql_groups_by_key_and_has_regional_ceiling() -> None:
|
||
sql = str(_REDERIVE_SQL)
|
||
key = deal_city_key_sql("")
|
||
assert f"GROUP BY region_code, {key}" in sql
|
||
assert f"{key} AS city" in sql
|
||
# Потолок стал региональным: литерала-константы 800000 в ветках tiered больше
|
||
# нет, он остался только якорем внутри GREATEST в region_stats.
|
||
assert "region_ppm2_max" in sql
|
||
assert f"percentile_cont({REGION_CEILING_CAP_Q})" in sql
|
||
code = _sql_code(sql)
|
||
# Якорь GREATEST в region_stats — единственное место с этим числом.
|
||
assert code.count(str(REGION_CEILING_FLOOR)) == 1
|
||
assert "LEAST(c.ppm2_p99, r.region_ppm2_max)" in sql
|
||
# p99 города больше не клампится литералом на этапе city_stats.
|
||
assert "LEAST(round(percentile_cont(0.99)" not in sql
|
||
|
||
|
||
def test_estimator_reads_bands_by_same_key() -> None:
|
||
"""Все три читающих места используют ТО ЖЕ выражение ключа, что derivation."""
|
||
src = open(est.__file__, encoding="utf-8").read()
|
||
# Оба SQL-джойна ДКП-коридора (street и city-wide widen) подставляют ОДНУ
|
||
# константу, а не свою копию выражения; сама константа — из deal_city_key.
|
||
assert src.count("{_DEAL_CITY_BAND_JOIN_D}") == 2
|
||
assert src.count("{_DEAL_CITY_BAND_BOUNDS}") == 2
|
||
assert est._DEAL_CITY_BAND_JOIN_D == deal_city_band_join_sql("d", indent=" " * 20)
|
||
assert est._DEAL_CITY_BAND_BOUNDS == deal_city_band_bounds_sql(indent=" " * 22)
|
||
assert est._DEAL_CITY_KEY_SQL_PLAIN == deal_city_key_sql("")
|
||
# Питоновский путь: ключ считает SQL в SELECT сделок.
|
||
assert "{_DEAL_CITY_KEY_SQL_PLAIN} AS {DEAL_CITY_KEY_COLUMN}" in src
|
||
# Одноступенчатых джойна и границ (только b) не осталось.
|
||
assert "b.city = d.city" not in src
|
||
assert "COALESCE(b.ppm2_min, CAST(" not in src
|
||
|
||
|
||
# ── 3. Потолок ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_ceiling_formula_is_single_source() -> None:
|
||
"""СТОРОЖ: SQL-выражение потолка собрано из ТЕХ ЖЕ констант, что region_ppm2_max().
|
||
|
||
Раньше формула жила двумя копиями (SQL + локальная копия в этом файле), и
|
||
подмена множителя 6 на 3 оставляла все тесты зелёными, роняя потолок Москвы
|
||
с 1766742 до 883371. Теперь каждая константа обязана встретиться в собранном
|
||
SQL ровно столько раз, сколько её кладёт сборка, а питоновская сторона — та
|
||
же функция region_ppm2_max из модуля derivation, а не копия.
|
||
"""
|
||
code = _sql_code(str(_REDERIVE_SQL))
|
||
assert code.count(str(REGION_CEILING_FLOOR)) == 1 # якорь GREATEST
|
||
assert code.count(f"percentile_cont({REGION_CEILING_CAP_Q})") == 1 # шапка p99.99
|
||
median_sql = f"{REGION_CEILING_MEDIAN_MULT} * round(percentile_cont({REGION_CEILING_MEDIAN_Q})"
|
||
assert code.count(median_sql) == 1 # множитель медианы
|
||
# Обе ветви питоновской функции наблюдаемы и построены из тех же чисел.
|
||
assert region_ppm2_max(10**9, 10**6) == REGION_CEILING_MEDIAN_MULT * 10**6
|
||
assert region_ppm2_max(0, 0) == REGION_CEILING_FLOOR
|
||
|
||
|
||
def test_ceiling_region66_unchanged() -> None:
|
||
"""Регион 66: формула вырождается ровно в прежний литерал 800000."""
|
||
assert region_ppm2_max(_R66_P9999, _R66_P50) == 800_000
|
||
# Запас: чтобы потолок сдвинулся, нужны ОДНОВРЕМЕННО медиана > 133 333 и
|
||
# p99.99 > 800 000 (сейчас 52 706 и 615 312).
|
||
assert REGION_CEILING_MEDIAN_MULT * _R66_P50 < REGION_CEILING_FLOOR
|
||
assert _R66_P9999 < REGION_CEILING_FLOOR
|
||
|
||
|
||
def test_ceiling_region77_lifts_and_is_guarded() -> None:
|
||
assert region_ppm2_max(_R77_P9999, _R77_P50) == _R77_CEILING
|
||
# Шапка p99.99 не даёт множителю медианы разогнать потолок бесконечно.
|
||
assert region_ppm2_max(900_000, _R77_P50) == 900_000
|
||
# Якорь 800000 не даёт потолку упасть ниже исторического даже при обвале цен.
|
||
assert region_ppm2_max(100_000, 10_000) == REGION_CEILING_FLOOR
|
||
|
||
|
||
def test_new_ceiling_does_not_cut_expensive_okrug() -> None:
|
||
"""Самый дорогой округ (p99=1 405 882) целиком помещается под потолок 77."""
|
||
assert _MAX_OKRUG_P99 < region_ppm2_max(_R77_P9999, _R77_P50)
|
||
assert _MAX_OKRUG_P99 > 800_000 # старый литерал резал бы его
|
||
|
||
|
||
def test_plausible_deal_uses_okrug_band() -> None:
|
||
bands = {
|
||
(77, "муниципальный округ Хамовники"): (100_000, _MAX_OKRUG_P99),
|
||
(77, "Москва"): (22_475, 772_165),
|
||
(66, "Асбест"): (8_000, 254_831),
|
||
}
|
||
# Дорогой округ: 1.3 М ₽/м² — легитимный рынок, проходит.
|
||
assert est._is_plausible_deal(
|
||
1_300_000,
|
||
5,
|
||
12,
|
||
city="муниципальный округ Хамовники",
|
||
city_fallback="Москва",
|
||
bands=bands,
|
||
region_code=77,
|
||
)
|
||
# Тот же ppm² под глобальным потолком (DEAL_MAX_PPM2=800000) был бы отброшен.
|
||
assert not est._is_plausible_deal(
|
||
1_300_000, 5, 12, city="Москва", city_fallback="Москва", bands={}, region_code=77
|
||
)
|
||
# Строка-фолбэк 'Москва' — осмысленная полоса, а не ЕКБ-константы:
|
||
# 30 000 ₽/м² ниже DEAL_MIN_PPM2=50000, но внутри московского фолбэка.
|
||
assert est._is_plausible_deal(
|
||
30_000, 5, 12, city="Москва", city_fallback="Москва", bands=bands, region_code=77
|
||
)
|
||
assert not est._is_plausible_deal(
|
||
30_000, 5, 12, city="Москва", city_fallback="Москва", bands={}, region_code=77
|
||
)
|
||
# Регион 66 — прежнее поведение.
|
||
assert est._is_plausible_deal(
|
||
41_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=bands, region_code=66
|
||
)
|
||
assert not est._is_plausible_deal(
|
||
300_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=bands, region_code=66
|
||
)
|
||
# Екатеринбург намеренно не в таблице → глобальные константы.
|
||
assert est._is_plausible_deal(
|
||
120_000,
|
||
3,
|
||
5,
|
||
city="Екатеринбург",
|
||
city_fallback="Екатеринбург",
|
||
bands=bands,
|
||
region_code=66,
|
||
)
|
||
|
||
|
||
# ── 4. _fetch_deals: ключ доезжает до фильтра и не течёт в ответ ─────────────
|
||
|
||
|
||
def _deal_row(**over: Any) -> dict[str, Any]:
|
||
row = {
|
||
"source": "rosreestr",
|
||
"address": "Москва, ул. Остоженка, 1",
|
||
"lat": 55.74,
|
||
"lon": 37.6,
|
||
"rooms": 2,
|
||
"area_m2": 80.0,
|
||
"floor": 5,
|
||
"total_floors": 12,
|
||
"price_rub": 104_000_000.0,
|
||
"price_per_m2": 1_300_000.0,
|
||
"city": "Москва",
|
||
"region_code": 77,
|
||
DEAL_CITY_KEY_COLUMN: "муниципальный округ Хамовники",
|
||
"deal_date": None,
|
||
"days_on_market": None,
|
||
"cadastral_number": None,
|
||
"distance_m": 100.0,
|
||
}
|
||
row.update(over)
|
||
return row
|
||
|
||
|
||
def _db(deal_rows: list[dict[str, Any]], band_rows: list[dict[str, Any]]) -> Any:
|
||
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||
result = MagicMock()
|
||
sql = str(query)
|
||
if "FROM deal_city_price_bands" in sql:
|
||
result.mappings.return_value.all.return_value = band_rows
|
||
else:
|
||
result.mappings.return_value.all.return_value = deal_rows
|
||
return result
|
||
|
||
db = MagicMock()
|
||
db.execute.side_effect = _execute
|
||
return db
|
||
|
||
|
||
_BANDS_ROWS = [
|
||
{
|
||
"region_code": 77,
|
||
"city": "муниципальный округ Хамовники",
|
||
"ppm2_min": 100_000,
|
||
"ppm2_max": _MAX_OKRUG_P99,
|
||
},
|
||
{"region_code": 77, "city": "Москва", "ppm2_min": 22_475, "ppm2_max": 772_165},
|
||
{"region_code": 66, "city": "Асбест", "ppm2_min": 8_000, "ppm2_max": 254_831},
|
||
]
|
||
|
||
|
||
def _fetch(
|
||
rows: list[dict[str, Any]], band_rows: list[dict[str, Any]] | None = None
|
||
) -> list[dict[str, Any]]:
|
||
est._city_price_bands_cache = None
|
||
try:
|
||
# `rooms` у `_fetch_deals` больше нет: #3256 убрал параметр, а не просто
|
||
# перестал класть его в SQL — сделки Росреестра комнатность не несут.
|
||
# Тест писался на ветке, отведённой до того тикета, и разъехался с main
|
||
# молча: текстового конфликта в мерже не было, красным стало только на
|
||
# post-merge прогоне (#3440).
|
||
return est._fetch_deals(
|
||
_db(rows, _BANDS_ROWS if band_rows is None else band_rows),
|
||
lat=55.74,
|
||
lon=37.6,
|
||
area=80.0,
|
||
radius_m=1000,
|
||
)
|
||
finally:
|
||
est._city_price_bands_cache = None
|
||
|
||
|
||
def test_fetch_deals_keeps_expensive_okrug_and_hides_key_column() -> None:
|
||
out = _fetch([_deal_row()])
|
||
assert len(out) == 1
|
||
# Служебная колонка не должна утечь в actual_deals ответа API.
|
||
assert DEAL_CITY_KEY_COLUMN not in out[0]
|
||
assert out[0]["city"] == "Москва"
|
||
|
||
|
||
def test_fetch_deals_drops_same_price_under_moscow_fallback_band() -> None:
|
||
"""Та же сделка без src_city ключуется как 'Москва' → 1.3 М ₽/м² вне полосы."""
|
||
assert _fetch([_deal_row(**{DEAL_CITY_KEY_COLUMN: "Москва"})]) == []
|
||
|
||
|
||
def test_fetch_deals_region66_unaffected() -> None:
|
||
row = _deal_row(
|
||
city="Асбест",
|
||
region_code=66,
|
||
price_per_m2=41_000.0,
|
||
price_rub=3_280_000.0,
|
||
**{DEAL_CITY_KEY_COLUMN: "Асбест"},
|
||
)
|
||
out = _fetch([row])
|
||
assert len(out) == 1
|
||
assert DEAL_CITY_KEY_COLUMN not in out[0]
|
||
|
||
|
||
# ── 5. Двухступенчатый поиск полосы (#3051, окно между деплоем и рефрешем) ────
|
||
|
||
|
||
def test_band_lookup_order_okrug_then_city_then_globals() -> None:
|
||
"""Ступени: полоса округа → полоса города сделки → глобальные ЕКБ-константы."""
|
||
default = (est.DEAL_MIN_PPM2, est.DEAL_MAX_PPM2)
|
||
bands = {
|
||
(77, "муниципальный округ Хамовники"): (100_000, _MAX_OKRUG_P99),
|
||
(77, "Москва"): (22_475, 772_165),
|
||
}
|
||
okrug = "муниципальный округ Хамовники"
|
||
assert resolve_city_band(bands, 77, okrug, "Москва", default) == (100_000, _MAX_OKRUG_P99)
|
||
# Строки округа ещё нет (ночной рефреш не прогонялся) → городская полоса.
|
||
assert resolve_city_band(bands, 77, "муниципальный округ Некрасовка", "Москва", default) == (
|
||
22_475,
|
||
772_165,
|
||
)
|
||
# Нет ни округа, ни города — только тогда глобальные константы.
|
||
assert resolve_city_band(bands, 77, "округ Некрасовка", "Тверь", default) is default
|
||
assert resolve_city_band({}, 77, okrug, "Москва", default) is default
|
||
assert resolve_city_band(None, 77, okrug, "Москва", default) is default
|
||
# Регион 66: ключ ступени 1 равен ключу ступени 2 → один и тот же результат.
|
||
r66 = {(66, "Асбест"): (8_000, 254_831)}
|
||
assert resolve_city_band(r66, 66, "Асбест", "Асбест", default) == (8_000, 254_831)
|
||
assert resolve_city_band(r66, 66, "Екатеринбург", "Екатеринбург", default) is default
|
||
|
||
|
||
def test_sql_band_join_and_bounds_are_two_step() -> None:
|
||
"""В SQL двухступенчатость — второй LEFT JOIN и трёхаргументный COALESCE."""
|
||
join = est._DEAL_CITY_BAND_JOIN_D
|
||
assert join.count("LEFT JOIN deal_city_price_bands") == 2
|
||
assert "AND b.city = " + deal_city_key_sql("d") in join # ступень 1 — округ
|
||
assert "AND bc.city = d.city" in join # ступень 2 — город сделки
|
||
bounds = est._DEAL_CITY_BAND_BOUNDS
|
||
assert "COALESCE(b.ppm2_min, bc.ppm2_min, CAST(:ppm_min AS int))" in bounds
|
||
assert "COALESCE(b.ppm2_max, bc.ppm2_max, CAST(:ppm_max AS int))" in bounds
|
||
|
||
|
||
def test_plausible_deal_degrades_to_city_band_not_ekb_constants() -> None:
|
||
"""Окно деплоя: по региону 77 в таблице ровно одна строка 'Москва'."""
|
||
bands = {(77, "Москва"): (22_475, 772_165)}
|
||
okrug = "муниципальный округ Некрасовка"
|
||
# 30 000 ₽/м² ниже DEAL_MIN_PPM2=50000, но внутри московского фолбэка → keep.
|
||
assert est._is_plausible_deal(
|
||
30_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77
|
||
)
|
||
# Явный отказ от второй ступени: та же сделка проваливается в ЕКБ-калибровку
|
||
# и отбрасывается. Пропуск city_fallback здесь дал бы TypeError — см. тест ниже.
|
||
assert not est._is_plausible_deal(
|
||
30_000, 5, 12, city=okrug, city_fallback=None, bands=bands, region_code=77
|
||
)
|
||
# Городской потолок при этом продолжает работать.
|
||
assert not est._is_plausible_deal(
|
||
900_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77
|
||
)
|
||
# Регион 66: обе ступени — один ключ, поведение прежнее.
|
||
r66 = {(66, "Асбест"): (8_000, 254_831)}
|
||
assert est._is_plausible_deal(
|
||
41_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=r66, region_code=66
|
||
)
|
||
assert not est._is_plausible_deal(
|
||
300_000, 3, 5, city="Асбест", city_fallback="Асбест", bands=r66, region_code=66
|
||
)
|
||
|
||
|
||
_MOSCOW_ONLY_BANDS = [
|
||
{"region_code": 77, "city": "Москва", "ppm2_min": 22_475, "ppm2_max": 772_165}
|
||
]
|
||
|
||
|
||
def test_fetch_deals_before_first_refresh_uses_city_band() -> None:
|
||
"""_fetch_deals в том же окне: округ без своей строки судится полосой 'Москва'."""
|
||
cheap = _deal_row(price_per_m2=30_000.0, price_rub=2_400_000.0)
|
||
assert len(_fetch([cheap], _MOSCOW_ONLY_BANDS)) == 1 # ЕКБ-пол 50000 отбросил бы
|
||
assert _fetch([cheap], []) == [] # без полос вообще — те самые ЕКБ-константы
|
||
# Дорогая сделка того же округа пока отсекается городским потолком 772165 —
|
||
# окружная полоса (до 1 405 882) появится после первого ночного рефреша.
|
||
assert _fetch([_deal_row()], _MOSCOW_ONLY_BANDS) == []
|
||
|
||
|
||
def test_fetch_deals_region66_two_step_is_identical() -> None:
|
||
"""Регион 66: src_city пуст → обе ступени дают 'Асбест', результат прежний."""
|
||
row = _deal_row(
|
||
city="Асбест",
|
||
region_code=66,
|
||
price_per_m2=41_000.0,
|
||
price_rub=3_280_000.0,
|
||
**{DEAL_CITY_KEY_COLUMN: "Асбест"},
|
||
)
|
||
r66 = [{"region_code": 66, "city": "Асбест", "ppm2_min": 8_000, "ppm2_max": 254_831}]
|
||
assert len(_fetch([row], r66)) == 1
|
||
assert _fetch([row], []) == [] # 41 000 < DEAL_MIN_PPM2 — глобальная ступень жива
|
||
|
||
|
||
# ── 6. Предикат исключения ЕКБ и обязательность второй ступени ───────────────
|
||
|
||
|
||
def test_rederive_excludes_ekb_by_city_key_not_raw_column() -> None:
|
||
"""Исключение ЕКБ судится тем же выражением, по которому идёт GROUP BY.
|
||
|
||
Раньше предикат смотрел на СЫРУЮ колонку city, а группировка — уже на ключ.
|
||
Совпадение держалось на данных: прод 2026-09-11, регион 66 — 108 623 сделки,
|
||
исключено по city 55 749, по ключу 55 749, расхождение 0. Появись источник с
|
||
src_city='Екатеринбург' у сделки с другим city — сырой предикат пропустил бы
|
||
её в derivation, завёл строку полосы 'Екатеринбург', и ступень 1 нашла бы её
|
||
для настоящих ЕКБ-сделок, сломав намеренное исключение.
|
||
"""
|
||
sql = str(_REDERIVE_SQL)
|
||
key = deal_city_key_sql("")
|
||
assert sql.count(f"NOT (region_code = 66 AND {key} = 'Екатеринбург')") == 2
|
||
assert "region_code = 66 AND city = 'Екатеринбург'" not in sql
|
||
|
||
|
||
def test_rederive_requires_nonnull_city_key_not_raw_column() -> None:
|
||
"""Непустоту судит выражение КЛЮЧА, а не сырая колонка city.
|
||
|
||
Раньше оба CTE фильтровали `AND city IS NOT NULL`, хотя GROUP BY и предикат
|
||
ЕКБ уже жили на ключе. Сделка с непустым src_city и NULL в city даёт валидный
|
||
ключ, но выпадала из derivation ЦЕЛИКОМ — и из своей городской строки, и из
|
||
региональной статистики, молча занижая n_deals и перцентили региона, включая
|
||
потолок. Прод 2026-09-11: в популяции 321 560 сделок, city IS NULL — 0 строк,
|
||
поэтому сегодняшний результат не меняется ни по одному региону (66 — 52 874
|
||
строки, p1=15345, p50=52706, p99.99=615312; 77 — 212 937 строк,
|
||
34221 / 294457 / 1944535; обе формы дают одно и то же, расхождение 0).
|
||
"""
|
||
code = _sql_code(str(_REDERIVE_SQL))
|
||
key = deal_city_key_sql("")
|
||
assert code.count(f"AND {key} IS NOT NULL") == 2
|
||
assert "AND city IS NOT NULL" not in code
|
||
|
||
|
||
def test_plausible_deal_requires_city_fallback_when_bands_given() -> None:
|
||
"""Пропуск второй ступени при переданных полосах — TypeError, а не тихий дефект.
|
||
|
||
До правки такой вызов молча возвращал False: московская сделка по 30 000 ₽/м²
|
||
(внутри городской полосы 22475..772165) судилась ЕКБ-полом DEAL_MIN_PPM2=50000.
|
||
"""
|
||
bands = {(77, "Москва"): (22_475, 772_165)}
|
||
okrug = "муниципальный округ Некрасовка"
|
||
with pytest.raises(TypeError, match="city_fallback"):
|
||
est._is_plausible_deal(30_000, 5, 12, city=okrug, bands=bands, region_code=77)
|
||
# Явный None разрешён — отказ от второй ступени виден в коде вызова.
|
||
assert not est._is_plausible_deal(
|
||
30_000, 5, 12, city=okrug, city_fallback=None, bands=bands, region_code=77
|
||
)
|
||
assert est._is_plausible_deal(
|
||
30_000, 5, 12, city=okrug, city_fallback="Москва", bands=bands, region_code=77
|
||
)
|
||
|
||
|
||
def test_plausible_deal_without_bands_keeps_positional_calls() -> None:
|
||
"""bands=None: судить нечем, обе ступени вырождаются в глобальные константы.
|
||
|
||
Поэтому параметр обязателен УСЛОВНО: безусловный сломал бы 30 позиционных
|
||
вызовов tests/test_deals_sanitize.py, не поймав ни одного реального дефекта.
|
||
"""
|
||
assert est._is_plausible_deal(150_000, 5, 9)
|
||
assert not est._is_plausible_deal(39_700, 5, 9)
|
||
assert est._is_plausible_deal(est.DEAL_MIN_PPM2, 1, None)
|
||
assert not est._is_plausible_deal(est.DEAL_MAX_PPM2 + 1, 5, 9)
|