"""Сторож на дома, уехавшие от собственных объявлений (#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"