All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
112 lines
6.8 KiB
Python
112 lines
6.8 KiB
Python
"""Сторож на дома, уехавшие от собственных объявлений (#2996).
|
||
|
||
Прод 21.08.2026: 23 дома стоят вне Свердловской области, к ним привязано 591
|
||
объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»),
|
||
координаты — Варшава, Белград, Таллин, Владивосток, Ижевск.
|
||
|
||
Причина найдена и она НЕ в живом пути: 22 из 23 несут в `raw_payload` след
|
||
разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер
|
||
напрямую, без резолва города. Живой путь чист — 0 записей вне региона в
|
||
`geocode_cache` из 10 448 и 2 из 88 544 у `listings`.
|
||
|
||
**17 из 22 помечены геокодером `precision: "exact"`.** Точность отвечает на
|
||
«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не
|
||
может. Поэтому сторож проверяет ПРИНАДЛЕЖНОСТЬ, а не «точность».
|
||
|
||
Инвариант нарочно не географический — «дом рядом со своими объявлениями», а не
|
||
«дом внутри рамки области». Рамка сломалась бы при выходе в Москву; этот
|
||
инвариант переживёт расширение.
|
||
|
||
Порог 100 км не подобран на глаз. Замер по 9 052 домам с объявлениями:
|
||
|
||
ближе 1 км 8 914 (98.5 %)
|
||
1–5 км 64
|
||
5–25 км 40
|
||
25–100 км 9 ← настоящие пригороды: Сарапулка, Кедровка,
|
||
Чусовское Озеро, Ревда, Первоуральск (макс. 46.7)
|
||
дальше 100 км 25 ← ближайший 119.2, максимум 5 079
|
||
|
||
Между 46.7 и 119.2 км нет НИ ОДНОГО дома. 100 лежит в середине пустого
|
||
промежутка, и ловит на два дома больше, чем рамка по области (оба проверены —
|
||
настоящие, «Ул. Белинского/Фурманова» за 207 км с 34 объявлениями).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
import inspect
|
||
import re
|
||
|
||
|
||
def test_threshold_sits_in_the_measured_gap() -> None:
|
||
"""Головной: порог задан и лежит между пригородами и патологией.
|
||
|
||
46.7 км — самый дальний настоящий пригород, 119.2 — ближайший дефект.
|
||
Порог вне этого промежутка либо ловит пригороды, либо пропускает дефекты.
|
||
"""
|
||
from app.api.v1 import admin
|
||
|
||
порог = getattr(admin, "_MISLOCATED_KM", None)
|
||
assert порог is not None, (
|
||
"порога нет вовсе — сторож не задан, дома в Варшаве и Таллине никем не считаются"
|
||
)
|
||
assert 46.7 < порог < 119.2, (
|
||
f"порог {порог} вне измеренного промежутка (46.7, 119.2): ниже — ловит "
|
||
"настоящие пригороды, выше — пропускает дефекты"
|
||
)
|
||
|
||
|
||
def test_endpoint_exists_and_reports_scale() -> None:
|
||
"""Ручка обязана отдавать не только счётчик, но и МАСШТАБ.
|
||
|
||
Одно число «25» не даёт принять решение об очистке: нужно, сколько
|
||
объявлений привязано к этим домам (на проде 591) и какие это дома.
|
||
"""
|
||
from app.api.v1 import admin
|
||
|
||
ручка = getattr(admin, "get_mislocated_houses", None)
|
||
модель = getattr(admin, "MislocatedHousesResponse", None)
|
||
assert ручка is not None and модель is not None, (
|
||
"ручки нет — дома в Варшаве и Таллине не перечисляет никто; "
|
||
f"есть ли обработчик: {ручка is not None}, есть ли модель: {модель is not None}"
|
||
)
|
||
поля = модель.model_fields
|
||
for нужное in ("threshold_km", "houses_count", "listings_count", "houses"):
|
||
assert нужное in поля, f"нет поля {нужное}; есть: {sorted(поля)}"
|
||
|
||
|
||
def test_query_compares_house_to_its_own_listings() -> None:
|
||
"""Контроль смысла: инвариант «рядом со своими», а не «внутри рамки».
|
||
|
||
Рамка по области сломалась бы при выходе в Москву — ровно то, ради чего
|
||
заведена #2996. Ловит «починку» через координатный BETWEEN.
|
||
"""
|
||
from app.api.v1 import admin
|
||
|
||
запрос = getattr(admin, "_MISLOCATED_SQL", None)
|
||
assert запрос is not None, "запроса нет — проверять принадлежность нечем"
|
||
src = str(запрос)
|
||
assert "l.house_id_fk = h.id" in src, "дом не сравнивается со СВОИМИ объявлениями"
|
||
assert "percentile_disc(0.5)" in src, "берётся не медиана координат объявлений"
|
||
assert not re.search(r"ST_Y\(.*BETWEEN", src), (
|
||
"появилась географическая рамка — она не переживёт расширение региона"
|
||
)
|
||
|
||
|
||
def test_check_stays_out_of_the_polled_endpoint() -> None:
|
||
"""Контроль цены: тяжёлая проверка не должна попасть в опрашиваемую ручку.
|
||
|
||
Замер прода: этот запрос ~445 мс, обе выборки /scraper/data-quality вместе —
|
||
27 мс, а фронт опрашивает её каждые 120 с. Первая редакция этой правки клала
|
||
проверку туда и давала 17-кратное удорожание; замер это и остановил.
|
||
"""
|
||
from app.api.v1 import admin
|
||
|
||
src = inspect.getsource(admin.get_data_quality)
|
||
assert "percentile_disc" not in src, (
|
||
"тяжёлый агрегат вернулся в опрашиваемую каждые 2 минуты ручку"
|
||
)
|
||
assert "mislocated" not in src.lower(), "проверка снова подмешана в data-quality"
|