gendesign/backend/tests/api/v1/test_2464_confidence_zoning_source.py
bot-backend 956b4db010
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m23s
fix(ptica): достоверность видит зону ПЗЗ из НСПД, а не только из пустой таблицы (#2464)
_compute_confidence получал зону ТОЛЬКО из legacy-словаря `zoning`, который
питается таблицей pzz_zones_ekb. На проде она пуста — 0 строк (замер 19.08),
поэтому data_available там всегда False.

Настоящая зона живёт в nspd_zoning: территориальные зоны дампа НСПД, а для
участков в зазорах между зонами она синтезируется резолвером геопортала (см.
PR-A #financial-zoning-decouple). Из-за этого подскор «zoning» был 0.2 у КАЖДОГО
участка, а оговорка утверждала неправду.

Прогон analyze в прод-контейнере, участок 66:41:0402029:25:

    nspd_zoning.zone_code   = 'Ж-5'      <- зона известна
    zoning.data_available   = False
    confidence              = 0.61 medium
    подскор zoning          = 0.2
    оговорка: «ПЗЗ zone_code не известен — нельзя оценить разрешённое
               использование (G1 pending)»

Один и тот же ответ показывал зону Ж-5 и заявлял, что зона неизвестна. Подскоров
семь, цена в композите — (1.0 − 0.2)/7 = 0.114: 0.61 вместо 0.72.

Тесты переписаны после самопроверки: первая версия падала на origin/main с
TypeError, то есть красное означало «возможности нет», а не «значение неверно», и
контроли падали там же — контролями они не были. Теперь контроли не передают новый
аргумент и зелёные по обе стороны, а тест на уровне ручки красный ПО ПОВЕДЕНИЮ:
«ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна». Он же
сторожит проводку — параметр можно добавить в функцию и забыть передать.

Переформатирование — от pre-commit ruff 0.7.4 (#2864), не моё.
2026-08-19 18:09:11 +05:00

147 lines
7.1 KiB
Python
Raw 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.

"""#2464: достоверность должна видеть зону ПЗЗ из ОБОИХ источников.
Зона приходит двумя путями:
* `zoning` — старый per-parcel слой из таблицы `pzz_zones_ekb`. На проде она
**пуста** (0 строк, замер 19.08.2026), поэтому `data_available` там всегда False;
* `nspd_zoning` — территориальные зоны дампа НСПД, а для участков в зазорах между
зонами зона синтезируется резолвером геопортала.
Пока в `_compute_confidence` передавали только первый, подскор «zoning» был 0.2 у
КАЖДОГО участка, а оговорка утверждала неправду. Прогон analyze на проде, участок
66:41:0402029:25 (вызов функции в живом контейнере):
nspd_zoning.zone_code = 'Ж-5' <- зона известна
zoning.data_available = False
confidence = 0.61 medium
подскор zoning = 0.2
оговорка: «ПЗЗ zone_code не известен — нельзя оценить разрешённое использование»
То есть один и тот же ответ показывал зону Ж-5 и заявлял, что зона неизвестна.
Подскоров семь, цена ошибки в композите — (1.0 0.2) / 7 = 0.114.
"""
from __future__ import annotations
from typing import Any
from app.api.v1.parcels import _compute_confidence
_CAVEAT = "ПЗЗ zone_code не известен"
def _confidence(**over: Any) -> dict[str, Any]:
"""Вызов с общими аргументами.
`nspd_zoning` НЕ передаётся, если его явно не попросили: тесты-контроли обязаны
исполняться и на origin/main, где такого параметра ещё нет. Иначе они падали бы
там с TypeError и контролями не были бы — красное «нет такой возможности»
неотличимо от красного «значение неверно».
"""
base: dict[str, Any] = {
"source": "cad_quarter",
"poi_rows": [],
"district_row": None,
"competitor_rows": [],
"noise_map_rows_nearby": 0,
"air_q": None,
"weather": None,
"market_trend": None,
"zoning": {"data_available": False},
}
base.update(over)
return _compute_confidence(**base)
def test_nspd_zone_counts_as_known() -> None:
"""Зона есть только в nspd_zoning → подскор полный, ложной оговорки нет."""
res = _confidence(nspd_zoning={"zone_code": "Ж-5"})
assert res["breakdown"]["zoning"] == 1.0
assert not any(
_CAVEAT in c for c in res["caveats"]
), "оговорка «зона неизвестна» при известной зоне Ж-5 — ровно то, что видел прод"
def test_regulation_zone_index_also_counts() -> None:
"""Синтезированная геопорталом зона несёт regulation_zone_index, а не zone_code."""
res = _confidence(nspd_zoning={"regulation_zone_index": "Ж-2"})
assert res["breakdown"]["zoning"] == 1.0
def test_no_zone_anywhere_keeps_the_caveat() -> None:
"""Контроль: когда зоны нет НИ В ОДНОМ источнике, оговорка остаётся.
Зелёный по обе стороны правки — иначе правка просто глушила бы предупреждение.
"""
res = _confidence() # без nspd_zoning — исполняется и на origin/main
assert res["breakdown"]["zoning"] == 0.2
assert any(_CAVEAT in c for c in res["caveats"])
def test_legacy_source_still_honoured() -> None:
"""Контроль: если старый слой когда-нибудь наполнится, он по-прежнему считается."""
res = _confidence(zoning={"data_available": True}) # без nspd_zoning
assert res["breakdown"]["zoning"] == 1.0
def test_composite_gain_matches_the_measured_cost() -> None:
"""Цена дефекта в композите — ровно 0.114, как посчитано по проду."""
without = _confidence()
with_zone = _confidence(nspd_zoning={"zone_code": "Ж-5"})
assert len(without["breakdown"]) == 7, "число подскоров изменилось — пересчитать цену"
assert round(with_zone["value"] - without["value"], 2) == 0.11
# ── поведенческая проверка на уровне ручки + проводка ───────────────────────────
#
# Проверки выше падают на origin/main с TypeError — это «возможности нет», а не
# «значение неверно». Тест ниже красный ПО ПОВЕДЕНИЮ: на origin/main ручка вернёт
# ложную оговорку при известной зоне. Он же сторожит проводку: параметр можно
# добавить в функцию и забыть передать на вызове, и все проверки выше останутся
# зелёными.
def test_analyze_does_not_claim_unknown_zone_when_nspd_resolved_it() -> None:
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.core.db import get_db
from app.main import app
from tests.api.v1.test_analyze_market_price import (
_make_db_for_analyze,
_override_db,
_start_patches,
_stop_patches,
)
db = _make_db_for_analyze()
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
with patch(
"app.api.v1.parcels.get_quarter_dump_data",
return_value={
"nspd_zoning": {"zone_code": "Ж-5", "zone_name": "Многоэтажная жилая"},
"nspd_zouit_overlaps": [],
"nspd_engineering_nearby": [],
"nspd_dump": {"available": True, "stale": False, "harvest_triggered": False},
},
):
resp = TestClient(app).post("/api/v1/parcels/66:41:0204016:10/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
finally:
app.dependency_overrides.clear()
_stop_patches()
assert (body.get("nspd_zoning") or {}).get(
"zone_code"
) == "Ж-5", "предусловие теста не выполнено: зона не доехала до ответа"
caveats = " ".join(body.get("confidence_caveats") or [])
assert (
_CAVEAT not in caveats
), "ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна"
assert (body.get("confidence_breakdown") or {}).get("zoning") == 1.0