chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
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
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
This commit is contained in:
parent
1782dae0e2
commit
cdf493f345
161 changed files with 958 additions and 1034 deletions
|
|
@ -49,9 +49,7 @@ class OwnPlannedProjectCreate(BaseModel):
|
|||
planned_release_month: date | None = Field(
|
||||
None, description="Планируемый месяц выхода в продажу (нормализуется к 1-му числу)"
|
||||
)
|
||||
price_min_per_m2: float | None = Field(
|
||||
None, ge=0, description="Нижняя граница цены, ₽/м² (≥0)"
|
||||
)
|
||||
price_min_per_m2: float | None = Field(None, ge=0, description="Нижняя граница цены, ₽/м² (≥0)")
|
||||
price_max_per_m2: float | None = Field(
|
||||
None, ge=0, description="Верхняя граница цены, ₽/м² (≥0)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -316,9 +316,7 @@ def refresh_ddu_price_indicator(db: Session, *, concurrently: bool = True) -> in
|
|||
db.commit()
|
||||
except OperationalError as e:
|
||||
if concurrently and "cannot refresh materialized view" in str(e).lower():
|
||||
logger.warning(
|
||||
"ddu_indicator CONCURRENTLY failed (MV not populated), falling back"
|
||||
)
|
||||
logger.warning("ddu_indicator CONCURRENTLY failed (MV not populated), falling back")
|
||||
db.rollback()
|
||||
db.execute(text("REFRESH MATERIALIZED VIEW mv_ddu_price_indicator"))
|
||||
db.commit()
|
||||
|
|
|
|||
|
|
@ -665,8 +665,7 @@ def prinzip_insights() -> dict[str, Any]:
|
|||
{
|
||||
"district": "Чкаловский / Железнодорожный",
|
||||
"why": (
|
||||
"Растущие районы, 0% PRINZIP, низкая конкуренция. "
|
||||
"Тест 60-80 м² без премиума."
|
||||
"Растущие районы, 0% PRINZIP, низкая конкуренция. Тест 60-80 м² без премиума."
|
||||
),
|
||||
},
|
||||
],
|
||||
|
|
@ -688,7 +687,7 @@ def prinzip_insights() -> dict[str, Any]:
|
|||
{
|
||||
"name": "Холдинг Форум-групп",
|
||||
"model": (
|
||||
"113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²."
|
||||
"113 тыс м² × sold 54% × Δ +21пп лидер velocity. 3-к доля 21.5%, ср. 61 м²."
|
||||
),
|
||||
},
|
||||
],
|
||||
|
|
@ -1874,7 +1873,7 @@ def _active_competitors_count(
|
|||
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
|
||||
if target_class:
|
||||
n = _q(
|
||||
"AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls",
|
||||
"AND district_name = :dn AND COALESCE(obj_class, obj_class_fallback) = :cls",
|
||||
{"rc": region_code, "dn": district_name, "cls": target_class},
|
||||
)
|
||||
if n >= 2:
|
||||
|
|
|
|||
|
|
@ -251,9 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
|
|||
if summary:
|
||||
lines.append(str(summary))
|
||||
|
||||
if not any(
|
||||
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
|
||||
):
|
||||
if not any(section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")):
|
||||
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
||||
return _assemble(lines), sections_used
|
||||
|
||||
|
|
|
|||
|
|
@ -229,8 +229,7 @@ def run_crossload(db: Session | None = None) -> dict[str, Any]:
|
|||
except Exception as exc:
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"etl_newbuilding_crossload: upsert failed "
|
||||
"source=%s ext_id=%s: %s",
|
||||
"etl_newbuilding_crossload: upsert failed source=%s ext_id=%s: %s",
|
||||
params.get("source"),
|
||||
params.get("ext_house_id"),
|
||||
exc,
|
||||
|
|
|
|||
|
|
@ -270,9 +270,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None:
|
|||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
rate_path = _as_dict(data.get("rate_path"))
|
||||
rate_str = (
|
||||
", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||
)
|
||||
rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None
|
||||
rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")])
|
||||
|
||||
headers = [
|
||||
|
|
|
|||
|
|
@ -85,8 +85,7 @@ _CONCEPT_FOOTPRINT_STYLE = {
|
|||
}
|
||||
|
||||
_MAP_UNAVAILABLE_HTML = (
|
||||
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
|
||||
"в отчёте</div>"
|
||||
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют в отчёте</div>'
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -348,9 +348,7 @@ def compute_affordability(
|
|||
# Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
|
||||
# ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
|
||||
market_scenario_rate = (
|
||||
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
|
||||
if scenario_rate is not None
|
||||
else None
|
||||
scenario_rate + _KEY_RATE_MARKET_SPREAD_PP if scenario_rate is not None else None
|
||||
)
|
||||
payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS)
|
||||
if payment is not None:
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ def get_house_type(section_type: str) -> HouseType:
|
|||
return _BY_KEY[section_type]
|
||||
except KeyError as exc:
|
||||
raise KeyError(
|
||||
f"unknown house type {section_type!r}; " f"available: {', '.join(sorted(_BY_KEY))}"
|
||||
f"unknown house type {section_type!r}; available: {', '.join(sorted(_BY_KEY))}"
|
||||
) from exc
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -316,8 +316,7 @@ def parse_parcel(
|
|||
raise ParcelGeometryError("buildable area degenerated after setback")
|
||||
if buildable.area < MIN_BUILDABLE_AREA_SQM:
|
||||
raise ParcelGeometryError(
|
||||
f"buildable area {buildable.area:.1f} sqm below minimum "
|
||||
f"{MIN_BUILDABLE_AREA_SQM} sqm"
|
||||
f"buildable area {buildable.area:.1f} sqm below minimum {MIN_BUILDABLE_AREA_SQM} sqm"
|
||||
)
|
||||
|
||||
effective_step = _coarsen_step_for_budget(buildable, grid_step_m)
|
||||
|
|
|
|||
|
|
@ -231,9 +231,7 @@ def _call_with_retries(
|
|||
# #1209: cap И серверное Retry-After (раньше min(...,30) применялся
|
||||
# только к exp.backoff). _MAX_BACKOFF_S — единый потолок для обеих
|
||||
# веток, защищает anyio-threadpool от blocking на часы.
|
||||
raw_wait = float(
|
||||
e.retry_after if e.retry_after is not None else 2**attempt
|
||||
)
|
||||
raw_wait = float(e.retry_after if e.retry_after is not None else 2**attempt)
|
||||
wait = min(raw_wait, _MAX_BACKOFF_S)
|
||||
logger.warning(
|
||||
"llm: HTTP %s (attempt %d/%d), backing off %.1fs (raw=%.1fs)",
|
||||
|
|
|
|||
|
|
@ -156,9 +156,7 @@ def _quarter_from_text(row_text: str) -> tuple[int, int] | None:
|
|||
|
||||
def build_card_url(org_id: int) -> str:
|
||||
"""URL карточки организации в реестре ФАС (грид публикаций форм 14 / 4_6)."""
|
||||
return (
|
||||
f"{_CARD_URL}?reg={_REG}&orgId={org_id}" f"&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
|
||||
)
|
||||
return f"{_CARD_URL}?reg={_REG}&orgId={org_id}&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}"
|
||||
|
||||
|
||||
def build_template_url(guid: str, pub_id: str) -> str:
|
||||
|
|
|
|||
|
|
@ -357,9 +357,7 @@ def compute_gate_verdict(
|
|||
warnings.append(
|
||||
Warning(
|
||||
code="ZOUIT_CAD_SZZ",
|
||||
detail=(
|
||||
f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}"
|
||||
),
|
||||
detail=(f"СЗЗ ({overlap.get('type_zone', '')}): {overlap.get('name', '')}"),
|
||||
)
|
||||
)
|
||||
elif net_kind is not None or any(
|
||||
|
|
@ -376,8 +374,7 @@ def compute_gate_verdict(
|
|||
Warning(
|
||||
code="ZOUIT_CAD_OTHER",
|
||||
detail=(
|
||||
f"ЗОУИТ cad ({overlap.get('type_zone', '')}): "
|
||||
f"{overlap.get('name', '')}"
|
||||
f"ЗОУИТ cad ({overlap.get('type_zone', '')}): {overlap.get('name', '')}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -943,8 +943,7 @@ def compute_offer_price_trend(
|
|||
delta_pct = (last_median - first_median) / first_median * 100.0
|
||||
|
||||
logger.info(
|
||||
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d "
|
||||
"lots_latest=%s delta_pct=%s",
|
||||
"offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d lots_latest=%s delta_pct=%s",
|
||||
center_lat,
|
||||
center_lon,
|
||||
radius_m,
|
||||
|
|
|
|||
|
|
@ -91,10 +91,10 @@ def _build_overpass_query(key: str, value: str, el_type: str) -> str:
|
|||
bbox = f"({south},{west},{north},{east})"
|
||||
if el_type == "nwr":
|
||||
# node + way: точки подключения бывают и точкой, и площадкой
|
||||
return f"[out:json][timeout:30];" f'nwr["{key}"="{value}"]{bbox};' f"out geom;"
|
||||
return f'[out:json][timeout:30];nwr["{key}"="{value}"]{bbox};out geom;'
|
||||
if el_type == "way":
|
||||
return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;"
|
||||
return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;"
|
||||
return f'[out:json][timeout:30];way["{key}"="{value}"]{bbox};out geom;'
|
||||
return f'[out:json][timeout:30];node["{key}"="{value}"]{bbox};out body;'
|
||||
|
||||
|
||||
async def fetch_overpass_noise() -> list[dict]:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from app.core.db import SessionLocal
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PKK6_URL = "https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/" "MapServer/5/query"
|
||||
PKK6_URL = "https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/MapServer/5/query"
|
||||
# bbox ЕКБ: (xmin, ymin, xmax, ymax) в WGS84
|
||||
EKB_BBOX = (60.5, 56.7, 60.75, 56.95)
|
||||
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ def sync_objective_group(
|
|||
db.rollback()
|
||||
reports_failed += 1
|
||||
logger.exception(
|
||||
"sync_objective_group: parser failed for %s/%s/%s " "raw_id=%s: %s",
|
||||
"sync_objective_group: parser failed for %s/%s/%s raw_id=%s: %s",
|
||||
section,
|
||||
rtype,
|
||||
rname,
|
||||
|
|
|
|||
|
|
@ -219,9 +219,7 @@ def summarise(results: list[VectorizeResult]) -> None:
|
|||
total_raster = sum(r.raster_bytes for r in results)
|
||||
total_svg = sum(r.svg_bytes for r in results)
|
||||
agg_ratio = total_raster / total_svg if total_svg else float("inf")
|
||||
print(
|
||||
f"aggregate : {total_raster}B raster -> {total_svg}B svg " f"({agg_ratio:.2f}x overall)"
|
||||
)
|
||||
print(f"aggregate : {total_raster}B raster -> {total_svg}B svg ({agg_ratio:.2f}x overall)")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ def test_nspd_zone_counts_as_known() -> None:
|
|||
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 — ровно то, что видел прод"
|
||||
assert not any(_CAVEAT in c for c in res["caveats"]), (
|
||||
"оговорка «зона неизвестна» при известной зоне Ж-5 — ровно то, что видел прод"
|
||||
)
|
||||
|
||||
|
||||
def test_regulation_zone_index_also_counts() -> None:
|
||||
|
|
@ -137,11 +137,11 @@ def test_analyze_does_not_claim_unknown_zone_when_nspd_resolved_it() -> None:
|
|||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
assert (body.get("nspd_zoning") or {}).get(
|
||||
"zone_code"
|
||||
) == "Ж-5", "предусловие теста не выполнено: зона не доехала до ответа"
|
||||
assert (body.get("nspd_zoning") or {}).get("zone_code") == "Ж-5", (
|
||||
"предусловие теста не выполнено: зона не доехала до ответа"
|
||||
)
|
||||
caveats = " ".join(body.get("confidence_caveats") or [])
|
||||
assert (
|
||||
_CAVEAT not in caveats
|
||||
), "ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна"
|
||||
assert _CAVEAT not in caveats, (
|
||||
"ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна"
|
||||
)
|
||||
assert (body.get("confidence_breakdown") or {}).get("zoning") == 1.0
|
||||
|
|
|
|||
|
|
@ -136,9 +136,9 @@ def test_water_not_reported_as_noise_source() -> None:
|
|||
noise = body.get("noise") or {}
|
||||
sources = noise.get("nearby_sources") or noise.get("sources") or []
|
||||
types = {s.get("source_type") for s in sources}
|
||||
assert (
|
||||
"water" not in types and "utility" not in types
|
||||
), f"нешумовой слой попал в источники шума: {sources}"
|
||||
assert "water" not in types and "utility" not in types, (
|
||||
f"нешумовой слой попал в источники шума: {sources}"
|
||||
)
|
||||
|
||||
|
||||
def test_no_false_map_not_loaded_caveat_when_only_water_nearby() -> None:
|
||||
|
|
|
|||
|
|
@ -53,9 +53,9 @@ def test_noise_no_longer_feeds_the_risk_label() -> None:
|
|||
"""
|
||||
блок = _risks_block_source()
|
||||
assert "noise_db_max" not in блок, f"шум по-прежнему участвует в риск-блоке:\n{блок[:400]}"
|
||||
assert not re.search(
|
||||
r'"(high|medium|low)"', блок
|
||||
), f"в риск-блоке остались словесные градации риска:\n{блок[:400]}"
|
||||
assert not re.search(r'"(high|medium|low)"', блок), (
|
||||
f"в риск-блоке остались словесные градации риска:\n{блок[:400]}"
|
||||
)
|
||||
|
||||
|
||||
def test_noise_score_itself_is_preserved() -> None:
|
||||
|
|
|
|||
|
|
@ -90,9 +90,9 @@ class TestCompetitorsHaveStatusFields:
|
|||
competitors = [dict(r.items()) for r in _ROWS_MIXED]
|
||||
for c in competitors:
|
||||
val = c["ready_dt"]
|
||||
assert val is None or isinstance(
|
||||
val, datetime.date
|
||||
), f"ready_dt имеет неожиданный тип {type(val)}: {val}"
|
||||
assert val is None or isinstance(val, datetime.date), (
|
||||
f"ready_dt имеет неожиданный тип {type(val)}: {val}"
|
||||
)
|
||||
|
||||
|
||||
class TestCompetitorsSortOrder:
|
||||
|
|
@ -110,9 +110,9 @@ class TestCompetitorsSortOrder:
|
|||
|
||||
sorted_rows = sorted(_ROWS_MIXED, key=_sort_key)
|
||||
first = dict(sorted_rows[0].items())
|
||||
assert (
|
||||
first["site_status"] == "Строящиеся"
|
||||
), f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'"
|
||||
assert first["site_status"] == "Строящиеся", (
|
||||
f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'"
|
||||
)
|
||||
|
||||
def test_flat_count_desc_would_break_order(self) -> None:
|
||||
"""Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми."""
|
||||
|
|
@ -201,22 +201,22 @@ class TestObjPricingPushdown:
|
|||
"""
|
||||
sql = self._competitor_sql()
|
||||
bounds = "WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000"
|
||||
assert (
|
||||
f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql
|
||||
), "среднее цены должно фильтроваться границами правдоподобия (#2464-D)"
|
||||
assert f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql, (
|
||||
"среднее цены должно фильтроваться границами правдоподобия (#2464-D)"
|
||||
)
|
||||
# Тот же набор кормит счётчик выборки — иначе счётчик обещает шире, чем
|
||||
# реально участвовало в среднем.
|
||||
assert (
|
||||
f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql
|
||||
), "lots_with_price должен считать ту же популяцию, что и среднее"
|
||||
assert f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql, (
|
||||
"lots_with_price должен считать ту же популяцию, что и среднее"
|
||||
)
|
||||
# FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены
|
||||
# молча урежут счётчики продаж/остатка, которые считают ВСЕ лоты.
|
||||
assert (
|
||||
"COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
|
||||
), "units_sold не должен зависеть от границ цены"
|
||||
assert (
|
||||
"COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
|
||||
), "units_available не должен зависеть от границ цены"
|
||||
assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql, (
|
||||
"units_sold не должен зависеть от границ цены"
|
||||
)
|
||||
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql, (
|
||||
"units_available не должен зависеть от границ цены"
|
||||
)
|
||||
|
||||
def test_obj_pricing_dedups_physflat_inline(self) -> None:
|
||||
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.
|
||||
|
|
@ -234,9 +234,9 @@ class TestObjPricingPushdown:
|
|||
"\n", " "
|
||||
), "obj_lots_latest должен дедупить по physflat-ключу"
|
||||
assert "snapshot_date DESC, ol.id DESC" in sql, "берём последний снапшот физлота"
|
||||
assert (
|
||||
"v_objective_lots_latest" not in sql
|
||||
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
|
||||
assert "v_objective_lots_latest" not in sql, (
|
||||
"request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
|
||||
)
|
||||
|
||||
|
||||
class TestCompetitorAvgAreaPd:
|
||||
|
|
|
|||
|
|
@ -285,9 +285,9 @@ def test_inline_weights_rejects_nan() -> None:
|
|||
content=raw_body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert (
|
||||
resp.status_code == 422
|
||||
), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}"
|
||||
assert resp.status_code == 422, (
|
||||
f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}"
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
|
|
|||
|
|
@ -215,9 +215,7 @@ def test_list_insights_filter_confidential_false() -> None:
|
|||
listing = InsightList(total=0, limit=50, offset=0, rows=[])
|
||||
with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list:
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/api/v1/insights", params={"is_confidential": "false"}, headers=_AUTH
|
||||
)
|
||||
resp = client.get("/api/v1/insights", params={"is_confidential": "false"}, headers=_AUTH)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert mock_list.call_args.kwargs["is_confidential"] is False
|
||||
|
||||
|
|
@ -261,9 +259,7 @@ def test_put_insight_returns_updated() -> None:
|
|||
def test_put_insight_not_found_returns_404() -> None:
|
||||
with patch("app.api.v1.insights.update_insight", return_value=None):
|
||||
client = TestClient(app)
|
||||
resp = client.put(
|
||||
"/api/v1/insights/999", json={"title": "x"}, headers=_AUTH
|
||||
)
|
||||
resp = client.put("/api/v1/insights/999", json={"title": "x"}, headers=_AUTH)
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -65,9 +65,9 @@ class TestBuildMarketPulseHonesty:
|
|||
)
|
||||
|
||||
assert pulse["competitors_total"] == true_total
|
||||
assert pulse["competitors_total"] != len(
|
||||
rows
|
||||
), "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)"
|
||||
assert pulse["competitors_total"] != len(rows), (
|
||||
"regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)"
|
||||
)
|
||||
|
||||
def test_coverage_pct_computed_against_true_total_not_capped_list(self) -> None:
|
||||
"""coverage_pct = priced / TRUE total — раньше делилось на len(rows) (капнутый
|
||||
|
|
@ -182,9 +182,9 @@ class TestNeighborsSummaryHonesty:
|
|||
summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999")
|
||||
|
||||
assert summary["count_buildings_100m"] == true_total
|
||||
assert summary["count_buildings_100m"] != len(
|
||||
neighbors
|
||||
), "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)"
|
||||
assert summary["count_buildings_100m"] != len(neighbors), (
|
||||
"regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)"
|
||||
)
|
||||
assert summary["neighbors_truncated"] is True
|
||||
|
||||
def test_neighbors_list_itself_unaffected_by_count_fix(self) -> None:
|
||||
|
|
|
|||
|
|
@ -77,9 +77,7 @@ def _make_out(
|
|||
def test_create_own_project_returns_201_and_sets_created_by() -> None:
|
||||
"""POST → 201; created_by берётся из X-Authenticated-User, не из тела."""
|
||||
expected = _make_out()
|
||||
with patch(
|
||||
"app.api.v1.own_projects.create_own_project", return_value=expected
|
||||
) as mock_create:
|
||||
with patch("app.api.v1.own_projects.create_own_project", return_value=expected) as mock_create:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/own-projects",
|
||||
|
|
@ -106,9 +104,7 @@ def test_create_own_project_with_unit_mix() -> None:
|
|||
"""unit_mix в теле → пробрасывается в payload сервиса."""
|
||||
mix = {"studio": 0.3, "1k": 0.4, "2k": 0.2, "3k": 0.1}
|
||||
expected = _make_out(unit_mix=mix)
|
||||
with patch(
|
||||
"app.api.v1.own_projects.create_own_project", return_value=expected
|
||||
) as mock_create:
|
||||
with patch("app.api.v1.own_projects.create_own_project", return_value=expected) as mock_create:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/own-projects",
|
||||
|
|
@ -183,9 +179,7 @@ def test_create_own_project_without_auth_header_returns_401() -> None:
|
|||
|
||||
def test_list_own_projects_returns_envelope() -> None:
|
||||
"""GET → OwnPlannedProjectList {total, limit, offset, rows}."""
|
||||
listing = OwnPlannedProjectList(
|
||||
total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)]
|
||||
)
|
||||
listing = OwnPlannedProjectList(total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)])
|
||||
with patch("app.api.v1.own_projects.list_own_projects", return_value=listing):
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/own-projects", headers=_AUTH)
|
||||
|
|
@ -199,9 +193,7 @@ def test_list_own_projects_returns_envelope() -> None:
|
|||
def test_list_own_projects_passes_filters_to_service() -> None:
|
||||
"""Фильтры district/obj_class/created_by → в сервис как kwargs."""
|
||||
listing = OwnPlannedProjectList(total=0, limit=50, offset=0, rows=[])
|
||||
with patch(
|
||||
"app.api.v1.own_projects.list_own_projects", return_value=listing
|
||||
) as mock_list:
|
||||
with patch("app.api.v1.own_projects.list_own_projects", return_value=listing) as mock_list:
|
||||
client = TestClient(app)
|
||||
resp = client.get(
|
||||
"/api/v1/own-projects",
|
||||
|
|
@ -240,9 +232,7 @@ def test_put_own_project_returns_updated() -> None:
|
|||
updated = _make_out(name="Переименовано")
|
||||
with patch("app.api.v1.own_projects.update_own_project", return_value=updated):
|
||||
client = TestClient(app)
|
||||
resp = client.put(
|
||||
"/api/v1/own-projects/1", json={"name": "Переименовано"}, headers=_AUTH
|
||||
)
|
||||
resp = client.put("/api/v1/own-projects/1", json={"name": "Переименовано"}, headers=_AUTH)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["name"] == "Переименовано"
|
||||
|
||||
|
|
|
|||
|
|
@ -449,9 +449,9 @@ def test_competitors_avg_price_populated() -> None:
|
|||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
comp = resp.json()["competitors"][0]
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(
|
||||
150_000.0
|
||||
), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
|
||||
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
||||
)
|
||||
# OBJ-3 #307: domrf-hit → price_source='domrf'.
|
||||
assert comp["price_source"] == "domrf"
|
||||
finally:
|
||||
|
|
@ -705,9 +705,9 @@ def test_sold_count_sql_is_fanout_safe() -> None:
|
|||
objective_lot_id).
|
||||
"""
|
||||
sql = _sold_sql_text()
|
||||
assert (
|
||||
"COUNT(DISTINCT objective_lot_id)" in sql
|
||||
), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
|
||||
assert "COUNT(DISTINCT objective_lot_id)" in sql, (
|
||||
"fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
|
||||
)
|
||||
# COUNT(*) допустим внутри как агрегат? нет — sold-count агрегирует только distinct lot.
|
||||
assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"
|
||||
|
||||
|
|
|
|||
|
|
@ -104,9 +104,9 @@ class TestNeighborsSummarySql:
|
|||
for kw in forbidden_aliases:
|
||||
# ищем паттерн ``WITH <kw> AS (`` или ``, <kw> AS (`` — оба
|
||||
# формы CTE-биндинга.
|
||||
assert (
|
||||
f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql
|
||||
), f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
|
||||
assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, (
|
||||
f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)"
|
||||
)
|
||||
|
||||
|
||||
# ── parcel_ird_overlaps SQL ──────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -121,9 +121,9 @@ def test_prod_deploy_declares_shared_concurrency_group(name: str) -> None:
|
|||
группы снова разрешат параллельный запуск.
|
||||
"""
|
||||
conc = yaml.safe_load(_text(name)).get("concurrency") or {}
|
||||
assert (
|
||||
conc.get("group") == "deploy-prod"
|
||||
), f"{name}: группа concurrency = {conc.get('group')!r}, ожидалась общая 'deploy-prod'"
|
||||
assert conc.get("group") == "deploy-prod", (
|
||||
f"{name}: группа concurrency = {conc.get('group')!r}, ожидалась общая 'deploy-prod'"
|
||||
)
|
||||
assert conc.get("cancel-in-progress") is False, (
|
||||
f"{name}: cancel-in-progress должен быть false — отменённый деплой оставляет "
|
||||
"прод на старом коде ровно так же, как упавший"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ FIXTURE_KEYRATE_XML = (
|
|||
# где угодно), несмотря на schema-блок и namespace на KR.
|
||||
FIXTURE_KEYRATE_DIFFGRAM = (
|
||||
'<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
|
||||
"<soap:Body><KeyRateResponse xmlns=\"http://web.cbr.ru/\"><KeyRateResult>"
|
||||
'<soap:Body><KeyRateResponse xmlns="http://web.cbr.ru/"><KeyRateResult>'
|
||||
'<diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"'
|
||||
' xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">'
|
||||
'<KeyRate xmlns="">'
|
||||
|
|
|
|||
|
|
@ -129,9 +129,9 @@ def test_income_extracts_sverdlovsk_only() -> None:
|
|||
assert all(r.indicator_type == "income_per_capita" for r in rows)
|
||||
assert all(r.unit == "руб" for r in rows)
|
||||
assert all(r.frequency == "quarterly" for r in rows)
|
||||
assert all(
|
||||
r.period_type == "quarter" for r in rows
|
||||
), "квартальные строки должны иметь period_type='quarter'"
|
||||
assert all(r.period_type == "quarter" for r in rows), (
|
||||
"квартальные строки должны иметь period_type='quarter'"
|
||||
)
|
||||
|
||||
|
||||
def test_income_concrete_values_and_dates() -> None:
|
||||
|
|
|
|||
|
|
@ -602,6 +602,6 @@ async def test_list_objects_in_building_real() -> None:
|
|||
|
||||
assert listing.objdoc_id == 42065602
|
||||
assert listing.flats_count > 150, f"Ожидали >150 помещений, получили {listing.flats_count}"
|
||||
assert (
|
||||
len(listing.flats_cad_nums) > 150
|
||||
), f"Ожидали >150 cad_nums помещений, получили {len(listing.flats_cad_nums)}"
|
||||
assert len(listing.flats_cad_nums) > 150, (
|
||||
f"Ожидали >150 cad_nums помещений, получили {len(listing.flats_cad_nums)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -250,9 +250,9 @@ class TestGetFeaturesInBboxGrid:
|
|||
return [good_feat]
|
||||
|
||||
result = self._grid(_wms)
|
||||
assert any(
|
||||
f.feature_id == "feat-ok" for f in result
|
||||
), "успешные ячейки должны попасть в результат, даже если часть слоя упала"
|
||||
assert any(f.feature_id == "feat-ok" for f in result), (
|
||||
"успешные ячейки должны попасть в результат, даже если часть слоя упала"
|
||||
)
|
||||
|
||||
def test_returns_nspd_feature_instances(self) -> None:
|
||||
"""Метод возвращает list[NSPDFeature] а не NSPDBulkFeature."""
|
||||
|
|
@ -346,9 +346,9 @@ class TestClassifyEngineeringKind:
|
|||
],
|
||||
)
|
||||
def test_classify(self, props: dict[str, Any], expected: str) -> None:
|
||||
assert (
|
||||
classify_engineering_kind(props) == expected
|
||||
), f"props={props!r} → expected {expected!r}"
|
||||
assert classify_engineering_kind(props) == expected, (
|
||||
f"props={props!r} → expected {expected!r}"
|
||||
)
|
||||
|
||||
def test_field_priority_params_name_over_purpose(self) -> None:
|
||||
"""params_name проверяется раньше purpose."""
|
||||
|
|
@ -422,13 +422,13 @@ class TestFetchLayerDispatch:
|
|||
called_layer_ids = [call.args[0] for call in mock_grid.call_args_list]
|
||||
from app.services.scrapers.nspd_client import LAYERS
|
||||
|
||||
assert (
|
||||
LAYERS["territorial_zones"] in called_layer_ids
|
||||
), "territorial_zones должен использовать grid-walk"
|
||||
assert LAYERS["territorial_zones"] in called_layer_ids, (
|
||||
"territorial_zones должен использовать grid-walk"
|
||||
)
|
||||
assert LAYERS["red_lines"] in called_layer_ids, "red_lines должен использовать grid-walk"
|
||||
assert (
|
||||
LAYERS["engineering_structures"] in called_layer_ids
|
||||
), "engineering_structures должен использовать grid-walk"
|
||||
assert LAYERS["engineering_structures"] in called_layer_ids, (
|
||||
"engineering_structures должен использовать grid-walk"
|
||||
)
|
||||
# parcels и buildings — legacy, не grid
|
||||
called_legacy_ids = [call.args[0] for call in mock_legacy.call_args_list]
|
||||
assert LAYERS["parcels"] in called_legacy_ids, "parcels должен идти через legacy"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_byte
|
|||
FIXTURE_META = (
|
||||
"property,value\n"
|
||||
"standardversion,https://rosstat.gov.ru/storage/.../versiya_4.0.pdf\n"
|
||||
'identifier,7708234640-population\n'
|
||||
"identifier,7708234640-population\n"
|
||||
'title,"Численность постоянного населения"\n'
|
||||
"created,20180905\n"
|
||||
"modified,20140919\n"
|
||||
|
|
@ -58,7 +58,7 @@ FIXTURE_META = (
|
|||
# значение в 'total', год в 'year'. Запятая — разделитель полей; значения — целые.
|
||||
FIXTURE_POPULATION_DATA = (
|
||||
"number,year,kode,region,municipalities,total,urban,rural\n"
|
||||
'1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,'
|
||||
"1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,"
|
||||
"446406,209929,236477\n"
|
||||
"2,2014,6500000000,Свердловская область,Муниципальные образования Свердловской области,"
|
||||
"4320677,3500000,820677\n"
|
||||
|
|
|
|||
|
|
@ -60,8 +60,16 @@ def test_list_runs_for_light_projection_base_table_order_limit() -> None:
|
|||
# LIGHT: result-блоб НЕ выбирается
|
||||
assert "RESULT" not in upper, "list_runs_for НЕ должен тянуть тяжёлый result"
|
||||
# выбраны именно метаданные
|
||||
for col in ("ID", "CAD_NUM", "CREATED_AT", "STATUS", "SCHEMA_VERSION",
|
||||
"DISTRICT", "CONFIDENCE", "CREATED_BY"):
|
||||
for col in (
|
||||
"ID",
|
||||
"CAD_NUM",
|
||||
"CREATED_AT",
|
||||
"STATUS",
|
||||
"SCHEMA_VERSION",
|
||||
"DISTRICT",
|
||||
"CONFIDENCE",
|
||||
"CREATED_BY",
|
||||
):
|
||||
assert col in upper, f"ожидали колонку {col} в LIGHT-проекции"
|
||||
assert "ORDER BY CREATED_AT DESC" in flat
|
||||
assert "LIMIT CAST(:LIMIT AS INTEGER)" in upper
|
||||
|
|
|
|||
|
|
@ -102,9 +102,9 @@ def test_ordinary_error_still_skips_only_that_quarter() -> None:
|
|||
"""
|
||||
outcome, touched = _run(RuntimeError("битый ответ одного квартала"))
|
||||
|
||||
assert not isinstance(
|
||||
outcome, BaseException
|
||||
), f"обычная ошибка обрушила весь прогон: {outcome!r}"
|
||||
assert not isinstance(outcome, BaseException), (
|
||||
f"обычная ошибка обрушила весь прогон: {outcome!r}"
|
||||
)
|
||||
assert len(touched) == 3, f"тронуто кварталов {len(touched)}, ожидалось 3: {touched}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ def test_document_carries_the_measured_window_not_the_norm() -> None:
|
|||
|
||||
html = _build_html([_variant(schedule_is_default=False, sales_months=54.0)])
|
||||
|
||||
assert (
|
||||
"распродажа 54 мес" in html
|
||||
), "в методической сноске стоит не тот срок, по которому посчитан NPV"
|
||||
assert "распродажа 54 мес" in html, (
|
||||
"в методической сноске стоит не тот срок, по которому посчитан NPV"
|
||||
)
|
||||
assert "распродажа 30 мес," not in html
|
||||
|
|
|
|||
|
|
@ -101,9 +101,9 @@ def test_record_count_is_still_shown(pairs_fn: Any, fmt: str) -> None:
|
|||
"""Контроль: само число записей из отчёта не пропало — подпись правится, не значение."""
|
||||
pairs = pairs_fn()
|
||||
counters = [(k, v) for k, v in pairs if re.fullmatch(r"\d+", v.strip())]
|
||||
assert any(
|
||||
int(v) == _RECORDS for _, v in counters
|
||||
), f"{fmt}: число ЗОУИТ-записей ({_RECORDS}) исчезло из сводки: {counters}"
|
||||
assert any(int(v) == _RECORDS for _, v in counters), (
|
||||
f"{fmt}: число ЗОУИТ-записей ({_RECORDS}) исчезло из сводки: {counters}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")])
|
||||
|
|
@ -111,6 +111,6 @@ def test_types_are_still_listed(pairs_fn: Any, fmt: str) -> None:
|
|||
"""Контроль: перечисление типов на месте — читатель по-прежнему видит, какие они."""
|
||||
pairs = pairs_fn()
|
||||
joined = " ".join(f"{k} {v}" for k, v in pairs)
|
||||
assert (
|
||||
"Охранная зона ЛЭП" in joined and "Приаэродромная территория" in joined
|
||||
), f"{fmt}: типы ЗОУИТ пропали из сводки"
|
||||
assert "Охранная зона ЛЭП" in joined and "Приаэродромная территория" in joined, (
|
||||
f"{fmt}: типы ЗОУИТ пропали из сводки"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ def test_label_no_longer_claims_a_flood_verdict() -> None:
|
|||
"""Метка называет измеренное, а не вывод, которого не делали."""
|
||||
html = build_full_report_html_part_a(_result(flag=False), cad="00:00:0000000:0000")
|
||||
|
||||
assert (
|
||||
_OLD_LABEL not in html
|
||||
), "метка утверждает результат проверки зон затопления, которой не было"
|
||||
assert _OLD_LABEL not in html, (
|
||||
"метка утверждает результат проверки зон затопления, которой не было"
|
||||
)
|
||||
assert "ближе 200 м" in html, "метка должна называть измеренное — близость водотока"
|
||||
|
||||
|
||||
|
|
@ -75,6 +75,6 @@ def test_docx_twin_uses_the_same_label() -> None:
|
|||
"""
|
||||
from app.services.exporters import full_report_docx, full_report_html
|
||||
|
||||
assert (
|
||||
full_report_docx.FLOOD_PROXIMITY_LABEL is full_report_html.FLOOD_PROXIMITY_LABEL
|
||||
), "DOCX держит свою копию метки — форматы разъедутся при следующей правке"
|
||||
assert full_report_docx.FLOOD_PROXIMITY_LABEL is full_report_html.FLOOD_PROXIMITY_LABEL, (
|
||||
"DOCX держит свою копию метки — форматы разъедутся при следующей правке"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -356,9 +356,9 @@ class TestContractKeysWritten:
|
|||
payload_12mo = _scenario("base", deficit_12mo=0.34)
|
||||
cell_value = _scenario_deficit_cell(payload_12mo)
|
||||
# Для основного горизонта — голое число, не строка с «(гор. N мес)».
|
||||
assert not isinstance(
|
||||
cell_value, str
|
||||
), f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'"
|
||||
assert not isinstance(cell_value, str), (
|
||||
f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'"
|
||||
)
|
||||
assert cell_value == 0.34
|
||||
|
||||
def test_overall_score_in_cells(self) -> None:
|
||||
|
|
|
|||
|
|
@ -157,9 +157,9 @@ class TestCoverageFactor:
|
|||
f = _coverage_factor(0.4)
|
||||
assert "ближних ЖК" in f.note, f.note
|
||||
assert "Objective" in f.note, f.note
|
||||
assert (
|
||||
"будущ" not in f.note
|
||||
), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами"
|
||||
assert "будущ" not in f.note, (
|
||||
"нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами"
|
||||
)
|
||||
|
||||
def test_high_coverage(self) -> None:
|
||||
f = _coverage_factor(0.75)
|
||||
|
|
|
|||
|
|
@ -150,9 +150,9 @@ class TestSeasonalFactors:
|
|||
"""
|
||||
months = _months(36)
|
||||
adj = seasonal_factors(months, [0] * 36)
|
||||
assert (
|
||||
adj.n_full_years == 0
|
||||
), f"expected 0 full years on all-zero series, got {adj.n_full_years}"
|
||||
assert adj.n_full_years == 0, (
|
||||
f"expected 0 full years on all-zero series, got {adj.n_full_years}"
|
||||
)
|
||||
assert adj.applied is False
|
||||
assert all(f == 1.0 for f in adj.factors.values())
|
||||
|
||||
|
|
@ -166,9 +166,9 @@ class TestSeasonalFactors:
|
|||
# Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес.
|
||||
units = [10 if d.month <= 6 else 0 for d in months]
|
||||
adj = seasonal_factors(months, units)
|
||||
assert (
|
||||
adj.n_full_years == 0
|
||||
), f"partial-coverage years should not count as full, got {adj.n_full_years}"
|
||||
assert adj.n_full_years == 0, (
|
||||
f"partial-coverage years should not count as full, got {adj.n_full_years}"
|
||||
)
|
||||
assert adj.applied is False
|
||||
|
||||
def test_real_nonzero_series_passes_guard_and_applies(self) -> None:
|
||||
|
|
|
|||
|
|
@ -609,9 +609,7 @@ class TestDetectCollapsed:
|
|||
def test_empty_forecasts_not_collapsed(self) -> None:
|
||||
# Нет данных → нет вердикта «схлопнулось» (не помечаем пустой отчёт collapsed).
|
||||
cons = _scenario("conservative", forecasts=[])
|
||||
base = _scenario(
|
||||
"base", forecasts=[_forecast_stub(horizon=12)]
|
||||
)
|
||||
base = _scenario("base", forecasts=[_forecast_stub(horizon=12)])
|
||||
aggr = _scenario("aggressive", forecasts=[])
|
||||
assert _detect_collapsed([cons, base, aggr]) is False
|
||||
|
||||
|
|
@ -750,9 +748,7 @@ class TestComputeScenariosCollapseDetection:
|
|||
# demand aggressive на 1e-3 от cons/base — за пределами abs_tol=1e-6.
|
||||
offset = 1e-3 if call_count["n"] == 3 else 0.0
|
||||
return [
|
||||
_forecast_stub(
|
||||
horizon=h, deficit_index=0.05, projected_demand_units=100.0 + offset
|
||||
)
|
||||
_forecast_stub(horizon=h, deficit_index=0.05, projected_demand_units=100.0 + offset)
|
||||
for h in horizons
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@ def test_objective_median_sql_dedups_inline_not_via_view() -> None:
|
|||
sql = str(concepts._OBJECTIVE_MEDIAN_SQL)
|
||||
assert "DISTINCT ON (" in sql, "должен дедупить физлоты inline"
|
||||
assert "snapshot_date DESC" in sql, "берём последний снапшот физлота"
|
||||
assert (
|
||||
"v_objective_lots_latest" not in sql
|
||||
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
|
||||
assert "v_objective_lots_latest" not in sql, (
|
||||
"request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
|
||||
)
|
||||
|
||||
|
||||
def test_objective_median_selected_when_sample_large_enough() -> None:
|
||||
|
|
|
|||
|
|
@ -82,9 +82,7 @@ def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP:
|
|||
|
||||
def test_financial_revenue_includes_parking() -> None:
|
||||
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
|
||||
model = financial.compute_financial(
|
||||
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
|
||||
)
|
||||
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=50_000_000.0)
|
||||
# revenue = жильё (1000 * 145_000) + паркинг comfort (10 * 1_300_000).
|
||||
assert model.revenue_residential_rub == 1000.0 * 145_000.0
|
||||
assert model.revenue_parking_rub == 10 * 1_300_000.0
|
||||
|
|
@ -93,9 +91,7 @@ def test_financial_revenue_includes_parking() -> None:
|
|||
|
||||
def test_financial_cost_cascade_includes_all_lines() -> None:
|
||||
t = _teap(residential=1000.0, gfa=1300.0, parking=10)
|
||||
model = financial.compute_financial(
|
||||
teap=t, housing_class="comfort", land_cost_rub=50_000_000.0
|
||||
)
|
||||
model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=50_000_000.0)
|
||||
# СМР = GFA*СМР + паркинг comfort*себест (10 * 1_000_000).
|
||||
assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0
|
||||
# Каждая статья каскада > 0 при ненулевых вводных.
|
||||
|
|
@ -299,15 +295,18 @@ def test_financial_parking_margin_positive_for_all_classes() -> None:
|
|||
cost = financial._PARKING_COST_PER_SPOT[hc] # type: ignore[index]
|
||||
assert price > cost, hc
|
||||
# Конкретные маржи из спецификации: econom +450k, comfort +300k, business +100k.
|
||||
assert financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT[
|
||||
"econom"
|
||||
] == 450_000.0
|
||||
assert financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT[
|
||||
"comfort"
|
||||
] == 300_000.0
|
||||
assert financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT[
|
||||
"business"
|
||||
] == 100_000.0
|
||||
assert (
|
||||
financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT["econom"]
|
||||
== 450_000.0
|
||||
)
|
||||
assert (
|
||||
financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT["comfort"]
|
||||
== 300_000.0
|
||||
)
|
||||
assert (
|
||||
financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT["business"]
|
||||
== 100_000.0
|
||||
)
|
||||
|
||||
|
||||
def test_financial_econom_parking_cheaper_than_business() -> None:
|
||||
|
|
@ -399,12 +398,18 @@ def test_synthesize_program_zero_site_area_no_division_error() -> None:
|
|||
def test_synthesize_program_sections_do_not_affect_teap() -> None:
|
||||
# sections — метаданные программы; площади уже свёрнуты → ТЭП от них не зависит.
|
||||
one = teap.synthesize_teap_from_program(
|
||||
total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0,
|
||||
housing_class="comfort", sections=1,
|
||||
total_footprint_sqm=2000.0,
|
||||
floors=10,
|
||||
site_area_sqm=5000.0,
|
||||
housing_class="comfort",
|
||||
sections=1,
|
||||
)
|
||||
six = teap.synthesize_teap_from_program(
|
||||
total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0,
|
||||
housing_class="comfort", sections=6,
|
||||
total_footprint_sqm=2000.0,
|
||||
floors=10,
|
||||
site_area_sqm=5000.0,
|
||||
housing_class="comfort",
|
||||
sections=6,
|
||||
)
|
||||
assert one == six
|
||||
|
||||
|
|
|
|||
|
|
@ -127,9 +127,7 @@ def test_call_cap_returns_fallback(_enabled: None, monkeypatch: pytest.MonkeyPat
|
|||
"""call_index >= llm_max_calls_per_request → fallback, провайдер не вызывается."""
|
||||
monkeypatch.setattr(settings, "llm_max_calls_per_request", 2)
|
||||
prov = _FakeOpenAILike()
|
||||
res = complete(
|
||||
system_prompt="sys", payload=SafePayload(text="hi"), provider=prov, call_index=2
|
||||
)
|
||||
res = complete(system_prompt="sys", payload=SafePayload(text="hi"), provider=prov, call_index=2)
|
||||
assert res.reason == "call_cap"
|
||||
assert prov.calls == 0
|
||||
|
||||
|
|
@ -241,9 +239,7 @@ def test_rate_limited_retries_then_fallback(
|
|||
assert prov.calls == 3
|
||||
|
||||
|
||||
def test_rate_limited_retry_after_capped(
|
||||
_enabled: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_rate_limited_retry_after_capped(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""#1209: серверный Retry-After (86400с при quota-exhaustion) должен капаться
|
||||
_MAX_BACKOFF_S — иначе time.sleep блокирует anyio-threadpool на часы.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -88,9 +88,7 @@ def test_complete_builds_request_body(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
return _chat_response()
|
||||
|
||||
monkeypatch.setattr(OpenAIProvider, "_post", fake_post)
|
||||
resp = _provider().complete(
|
||||
[{"role": "user", "content": "привет"}], max_output_tokens=256
|
||||
)
|
||||
resp = _provider().complete([{"role": "user", "content": "привет"}], max_output_tokens=256)
|
||||
|
||||
assert captured["body"]["model"] == "gpt-4o-mini"
|
||||
assert captured["body"]["max_tokens"] == 256
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ def test_single_waf_does_not_abort() -> None:
|
|||
Ловит «починку» через abort-on-first: тогда один переходный блок стоил бы
|
||||
всей ночной докачки.
|
||||
"""
|
||||
stats, touched = _run(lambda oid: (_waf(oid) if oid == 5 else True))
|
||||
stats, touched = _run(lambda oid: _waf(oid) if oid == 5 else True)
|
||||
|
||||
assert len(touched) == len(_OBJ_IDS), f"прогон оборвался на одиночном блоке: {len(touched)}"
|
||||
assert "aborted_on_waf" not in stats
|
||||
|
|
@ -116,7 +116,7 @@ def test_streak_resets_after_success() -> None:
|
|||
Без сброса два разрозненных блока плюс третий где-то в конце оборвали бы прогон.
|
||||
"""
|
||||
blocked = {2, 4, 6, 8, 10}
|
||||
stats, touched = _run(lambda oid: (_waf(oid) if oid in blocked else True))
|
||||
stats, touched = _run(lambda oid: _waf(oid) if oid in blocked else True)
|
||||
|
||||
assert len(touched) == len(_OBJ_IDS), f"прогон оборвался: тронуто {len(touched)}"
|
||||
assert "aborted_on_waf" not in stats
|
||||
|
|
|
|||
|
|
@ -38,9 +38,9 @@ def test_izyatie_in_title_wins_over_later_rezervirovanie() -> None:
|
|||
f"{_ЗАГОЛОВОК_ИЗЪЯТИЕ}. Изъятию подлежат участки, ранее зарезервированные "
|
||||
"постановлением о резервировании земель от 12.03.2019."
|
||||
)
|
||||
assert (
|
||||
_detect_kind(text, "изъятие") == "изъятие"
|
||||
), "документ об изъятии классифицирован по случайному упоминанию в теле"
|
||||
assert _detect_kind(text, "изъятие") == "изъятие", (
|
||||
"документ об изъятии классифицирован по случайному упоминанию в теле"
|
||||
)
|
||||
|
||||
|
||||
def test_all_parcels_of_the_document_are_affected() -> None:
|
||||
|
|
|
|||
|
|
@ -98,14 +98,14 @@ def test_negation_does_not_swallow_a_real_status_in_same_block() -> None:
|
|||
Разбор по первому совпадению вернул бы None и потерял бы его.
|
||||
"""
|
||||
result = parse_catalog_flat(_html_with_badge("Квартира не продана. Статус: в продаже"))
|
||||
assert (
|
||||
result.get("status") == STATUS_FREE
|
||||
), f"настоящий статус в блоке потерян: {result.get('status')!r}"
|
||||
assert result.get("status") == STATUS_FREE, (
|
||||
f"настоящий статус в блоке потерян: {result.get('status')!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_ne_inside_another_word_is_not_a_negation() -> None:
|
||||
"""Контроль границы слова: «не» внутри другого слова не считается отрицанием."""
|
||||
result = parse_catalog_flat(_html_with_badge("Цене снижена, квартира продана"))
|
||||
assert (
|
||||
result.get("status") == STATUS_SOLD
|
||||
), f"«не» в хвосте слова «Цене» съело настоящий статус: {result.get('status')!r}"
|
||||
assert result.get("status") == STATUS_SOLD, (
|
||||
f"«не» в хвосте слова «Цене» съело настоящий статус: {result.get('status')!r}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ def test_docstring_names_the_actual_behaviour() -> None:
|
|||
хорошо». Ловит «починку» через вычёркивание неудобной фразы.
|
||||
"""
|
||||
doc = (inspect.getdoc(mod._page_contains_table) or "").lower()
|
||||
assert (
|
||||
"false positive" in doc or "оглавлен" in doc
|
||||
), "докстрока не предупреждает о ложных срабатываниях на оглавлении"
|
||||
assert "false positive" in doc or "оглавлен" in doc, (
|
||||
"докстрока не предупреждает о ложных срабатываниях на оглавлении"
|
||||
)
|
||||
|
||||
|
||||
def test_toc_line_really_is_a_false_positive() -> None:
|
||||
|
|
@ -97,7 +97,7 @@ def test_seed_comment_does_not_send_to_a_dead_host() -> None:
|
|||
|
||||
src = Path(inspect.getsourcefile(mod)).resolve().parents[3]
|
||||
текст = (src / "app" / "workers" / "tasks" / "ekb_ppt_tep_sync.py").read_text(encoding="utf-8")
|
||||
assert (
|
||||
"НЕ СУЩЕСТВУЕТ" in текст or "не резолв" in текст.lower()
|
||||
), "комментарий сида не предупреждает, что gisogd.ekburg.ru мёртв"
|
||||
assert "НЕ СУЩЕСТВУЕТ" in текст or "не резолв" in текст.lower(), (
|
||||
"комментарий сида не предупреждает, что gisogd.ekburg.ru мёртв"
|
||||
)
|
||||
assert "gisogd66.midural.ru" in текст, "не назван живой портал ГИСОГД-СО"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ def test_extract_next_data_from_html() -> None:
|
|||
|
||||
def test_extract_next_data_single_quotes() -> None:
|
||||
"""Тег с одинарными кавычками тоже должен парситься."""
|
||||
html = "<script id='__NEXT_DATA__'>" '{"props":{"pageProps":{}}}' "</script>"
|
||||
html = '<script id=\'__NEXT_DATA__\'>{"props":{"pageProps":{}}}</script>'
|
||||
result = extract_next_data(html)
|
||||
assert "props" in result
|
||||
|
||||
|
|
|
|||
|
|
@ -76,13 +76,7 @@ def _html_with_badge(badge_text: str, nav_text: str = "другие кварти
|
|||
def _html_with_label(label: str, value: str, nav_text: str = "") -> str:
|
||||
"""HTML с лейблом «Статус» и значением в следующем блоке."""
|
||||
nav = f"<span>{nav_text}</span>" if nav_text else ""
|
||||
return (
|
||||
"<!doctype html><html><body>"
|
||||
f"{nav}"
|
||||
f"<span>{label}</span>"
|
||||
f"<span>{value}</span>"
|
||||
"</body></html>"
|
||||
)
|
||||
return f"<!doctype html><html><body>{nav}<span>{label}</span><span>{value}</span></body></html>"
|
||||
|
||||
|
||||
def _html_blocks_only(blocks: list[tuple[str, str]]) -> str:
|
||||
|
|
|
|||
|
|
@ -115,10 +115,7 @@ def test_plan_from_img_proximity_to_label() -> None:
|
|||
"""<img> без plan-hint в атрибутах, но рядом с блоком «Планировка»."""
|
||||
plain = f"{BASE_URL}/api/ext/file/imgabc.png"
|
||||
html = (
|
||||
"<!doctype html><html><body>"
|
||||
"<div>Планировка</div>"
|
||||
f'<img alt="" src="{plain}">'
|
||||
"</body></html>"
|
||||
f'<!doctype html><html><body><div>Планировка</div><img alt="" src="{plain}"></body></html>'
|
||||
)
|
||||
got = extract_plan_image_url(html, _collector_for(html))
|
||||
assert got == plain
|
||||
|
|
|
|||
|
|
@ -60,14 +60,14 @@ class TestUpsertObjectSqlDoUpdateSet:
|
|||
# clear back to NULL, not get stuck "still problematic" forever).
|
||||
sql = " ".join(str(UPSERT_OBJECT_SQL).split())
|
||||
for col in _OBJECT_PREVIOUSLY_OMITTED:
|
||||
assert (
|
||||
f"{col} = EXCLUDED.{col}" in sql
|
||||
), f"{col} should be direct `= EXCLUDED.{col}` (not COALESCE):\n{sql}"
|
||||
assert f"{col} = EXCLUDED.{col}" in sql, (
|
||||
f"{col} should be direct `= EXCLUDED.{col}` (not COALESCE):\n{sql}"
|
||||
)
|
||||
# guard against a COALESCE(EXCLUDED.col, ...) formulation, which would
|
||||
# permanently pin a stale value once ever set instead of refreshing it.
|
||||
assert (
|
||||
f"COALESCE(EXCLUDED.{col}," not in sql
|
||||
), f"{col} must not be wrapped in COALESCE (needs to be able to clear to NULL)"
|
||||
assert f"COALESCE(EXCLUDED.{col}," not in sql, (
|
||||
f"{col} must not be wrapped in COALESCE (needs to be able to clear to NULL)"
|
||||
)
|
||||
|
||||
def test_all_insert_columns_covered_by_do_update_set_or_conflict_target(self) -> None:
|
||||
# Locks in the class of bug (INSERT populates a column, DO UPDATE SET
|
||||
|
|
|
|||
|
|
@ -77,9 +77,9 @@ def test_coord_swap_ekb_point_inside_bbox() -> None:
|
|||
wrong_lat = float(coords[1]) # 60.61 → это вне ЕКБ если использовать как lat
|
||||
wrong_lon = float(coords[0]) # 56.83 → это вне ЕКБ если использовать как lon
|
||||
# Финляндия/Норвегия: lat=60.61 формально в диапазоне 55–58.5 FALSE → lat перепутан
|
||||
assert not (
|
||||
55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0
|
||||
), f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}"
|
||||
assert not (55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0), (
|
||||
f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}"
|
||||
)
|
||||
|
||||
|
||||
def test_coord_swap_inverted_would_fail_bbox_check() -> None:
|
||||
|
|
|
|||
|
|
@ -66,9 +66,9 @@ def test_savepoint_is_actually_entered(name: str) -> None:
|
|||
"""
|
||||
db = PostgresLikeSession(fail_on=(), rows=[]) # здоровый путь
|
||||
_call(name, db)
|
||||
assert (
|
||||
db.savepoints_entered >= 1
|
||||
), f"{name}: запрос выполнен вне SAVEPOINT — при сбое сессия останется aborted"
|
||||
assert db.savepoints_entered >= 1, (
|
||||
f"{name}: запрос выполнен вне SAVEPOINT — при сбое сессия останется aborted"
|
||||
)
|
||||
assert db.calls >= 1, f"{name}: запрос вообще не выполнялся"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,6 @@ def test_select_default_is_ordered() -> None:
|
|||
"""
|
||||
from app.services.site_finder.weight_profiles import _SELECT_DEFAULT
|
||||
|
||||
assert re.search(
|
||||
r"ORDER BY\s+id\s+ASC", _SELECT_DEFAULT
|
||||
), f"в _SELECT_DEFAULT нет тай-брейка по id:\n{_SELECT_DEFAULT}"
|
||||
assert re.search(r"ORDER BY\s+id\s+ASC", _SELECT_DEFAULT), (
|
||||
f"в _SELECT_DEFAULT нет тай-брейка по id:\n{_SELECT_DEFAULT}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -85,9 +85,9 @@ def test_borrowed_session_is_not_committed_per_organization() -> None:
|
|||
db, visited, n_orgs = _run(own=False)
|
||||
|
||||
assert len(visited) == n_orgs
|
||||
assert (
|
||||
db.commits == 1
|
||||
), f"на чужой сессии {db.commits} коммитов — транзакцией распоряжается вызывающий"
|
||||
assert db.commits == 1, (
|
||||
f"на чужой сессии {db.commits} коммитов — транзакцией распоряжается вызывающий"
|
||||
)
|
||||
assert not db.closed, "чужая сессия закрыта — её закрывает вызывающий"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -54,18 +54,18 @@ def test_far_is_used_when_pct_is_impossible() -> None:
|
|||
"""Отбрасываем только испорченный параметр, остальной расчёт остаётся верным."""
|
||||
t = _teap(max_building_pct=150.0, max_far=2.0)
|
||||
assert t is not None
|
||||
assert t.total_floor_area_sqm == pytest.approx(
|
||||
_ПЛОЩАДЬ * 2.0
|
||||
), f"GFA посчитана не по КСИТ: {t.total_floor_area_sqm}"
|
||||
assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 2.0), (
|
||||
f"GFA посчитана не по КСИТ: {t.total_floor_area_sqm}"
|
||||
)
|
||||
|
||||
|
||||
def test_impossible_far_is_dropped() -> None:
|
||||
"""КСИТ 500 — признак порчи разбора, а не сверхплотной застройки."""
|
||||
t = _teap(max_far=500.0, max_building_pct=40.0, max_floors=10)
|
||||
assert t is not None
|
||||
assert t.total_floor_area_sqm == pytest.approx(
|
||||
_ПЛОЩАДЬ * 0.4 * 10
|
||||
), f"использован невозможный КСИТ: GFA={t.total_floor_area_sqm}"
|
||||
assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 0.4 * 10), (
|
||||
f"использован невозможный КСИТ: GFA={t.total_floor_area_sqm}"
|
||||
)
|
||||
|
||||
|
||||
def test_impossible_floors_is_dropped() -> None:
|
||||
|
|
@ -127,6 +127,6 @@ def test_footprint_never_exceeds_parcel_even_without_pct_and_floors() -> None:
|
|||
f"пятно {t.built_area_sqm} на участке {_ПЛОЩАДЬ} — нарушена геометрия, "
|
||||
"а не только правдоподобие регламента"
|
||||
)
|
||||
assert t.total_floor_area_sqm == pytest.approx(
|
||||
_ПЛОЩАДЬ * 2.0
|
||||
), "GFA не должна меняться от ограничения пятна"
|
||||
assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 2.0), (
|
||||
"GFA не должна меняться от ограничения пятна"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -95,9 +95,9 @@ def test_label_is_independent_of_overlap_order() -> None:
|
|||
"ZOUIT_NETWORK_OBREMENENIE",
|
||||
)
|
||||
assert d1 is not None and d2 is not None
|
||||
assert (
|
||||
d1["detail"] == d2["detail"]
|
||||
), f"подпись зависит от порядка:\n{d1['detail']}\n{d2['detail']}"
|
||||
assert d1["detail"] == d2["detail"], (
|
||||
f"подпись зависит от порядка:\n{d1['detail']}\n{d2['detail']}"
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_kind_named_once() -> None:
|
||||
|
|
|
|||
|
|
@ -543,14 +543,14 @@ def test_supply_joins_flats_per_object_latest_snapshot() -> None:
|
|||
sql_text = str(_SUPPLY_BATCH_SQL.text)
|
||||
# per-object последний снимок flats
|
||||
assert "flats_latest" in sql_text, "нет flats_latest CTE → supply снова на глобал-max снимке"
|
||||
assert (
|
||||
"DISTINCT ON (f.obj_id)" in sql_text
|
||||
), "flats не дедупятся per-object → нет per-obj снимка"
|
||||
assert "DISTINCT ON (f.obj_id)" in sql_text, (
|
||||
"flats не дедупятся per-object → нет per-obj снимка"
|
||||
)
|
||||
# НЕ должно быть джойна по единой внешней дате-параметру (регрессия #1944)
|
||||
assert ":latest_snap" not in sql_text, "глобальный :latest_snap вернулся → supply=0 регрессия"
|
||||
assert (
|
||||
"MAX(snapshot_date)" not in sql_text
|
||||
), "глобальный MAX(snapshot_date) → supply=0 регрессия"
|
||||
assert "MAX(snapshot_date)" not in sql_text, (
|
||||
"глобальный MAX(snapshot_date) → supply=0 регрессия"
|
||||
)
|
||||
|
||||
|
||||
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────
|
||||
|
|
@ -592,16 +592,16 @@ def test_cap_and_redistribute_invariants(
|
|||
"""
|
||||
result, cap_skipped = _cap_and_redistribute(pct_map)
|
||||
|
||||
assert (
|
||||
cap_skipped == expect_pathological
|
||||
), f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}"
|
||||
assert (
|
||||
sum(result.values()) == 100
|
||||
), f"sum={sum(result.values())} != 100 для {pct_map} → {result}"
|
||||
assert cap_skipped == expect_pathological, (
|
||||
f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}"
|
||||
)
|
||||
assert sum(result.values()) == 100, (
|
||||
f"sum={sum(result.values())} != 100 для {pct_map} → {result}"
|
||||
)
|
||||
if not expect_pathological:
|
||||
assert (
|
||||
max(result.values()) <= MAX_BUCKET_SHARE_PCT
|
||||
), f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map} → {result}"
|
||||
assert max(result.values()) <= MAX_BUCKET_SHARE_PCT, (
|
||||
f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map} → {result}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -623,14 +623,14 @@ def test_cap_reproduced_failing_cases(
|
|||
) -> None:
|
||||
"""Review round-2 reproduced cases: 2-bucket — pathological, 3-bucket — fit cap."""
|
||||
result, cap_skipped = _cap_and_redistribute(deals)
|
||||
assert (
|
||||
cap_skipped == expect_pathological
|
||||
), f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}"
|
||||
assert cap_skipped == expect_pathological, (
|
||||
f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}"
|
||||
)
|
||||
assert sum(result.values()) == 100, f"sum != 100 для {label} → {result}"
|
||||
if not expect_pathological:
|
||||
assert (
|
||||
max(result.values()) <= MAX_BUCKET_SHARE_PCT
|
||||
), f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label} → {result}"
|
||||
assert max(result.values()) <= MAX_BUCKET_SHARE_PCT, (
|
||||
f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label} → {result}"
|
||||
)
|
||||
|
||||
|
||||
def test_cap_iteration_count_bounded() -> None:
|
||||
|
|
@ -773,9 +773,9 @@ def test_group_radius_objects_prod_case_groups() -> None:
|
|||
assert len(groups) == 6, f"ожидалось 6 групп, получено {len(groups)}: {got}"
|
||||
# load-bearing инвариант: 7 ключей и безымянные Эфесы разделены >300 м
|
||||
assert frozenset({15731}) in got, "7 ключей должна быть отдельной группой"
|
||||
assert (
|
||||
frozenset({55320, 55321, 55322}) in got
|
||||
), "3 безымянных Эфеса — один кластер, отдельный от 7 ключей"
|
||||
assert frozenset({55320, 55321, 55322}) in got, (
|
||||
"3 безымянных Эфеса — один кластер, отдельный от 7 ключей"
|
||||
)
|
||||
assert got == expected
|
||||
|
||||
|
||||
|
|
@ -1155,9 +1155,9 @@ def test_supply_only_velocity_group_not_duplicated() -> None:
|
|||
]
|
||||
for call in supply_calls:
|
||||
params = call.args[1] if len(call.args) > 1 else call.kwargs
|
||||
assert "Траектория" not in (
|
||||
params.get("names") or []
|
||||
), "velocity-проект не должен уходить в supply-only запрос"
|
||||
assert "Траектория" not in (params.get("names") or []), (
|
||||
"velocity-проект не должен уходить в supply-only запрос"
|
||||
)
|
||||
|
||||
|
||||
def test_supply_only_empty_snapshot_yields_empty_block() -> None:
|
||||
|
|
|
|||
|
|
@ -133,18 +133,14 @@ def _competitors_response(*obj_ids: int) -> CompetitorsResponse:
|
|||
|
||||
def test_parking_wire_happy_path(monkeypatch: Any) -> None:
|
||||
"""top-конкурент сматчился на здание → получает parking_ratio из НСПД."""
|
||||
monkeypatch.setattr(
|
||||
competitors_mod, "get_competitors", lambda **_: _competitors_response(101)
|
||||
)
|
||||
monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(101))
|
||||
monkeypatch.setattr(
|
||||
competitors_mod,
|
||||
"resolve_cad_for_domrf",
|
||||
lambda *_a, **_k: BuildingMatch("66:41:0106036:183", 40995027, 12.3),
|
||||
)
|
||||
listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100)
|
||||
monkeypatch.setattr(
|
||||
competitors_mod, "get_building_premises_for_match", lambda _m: listing
|
||||
)
|
||||
monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", lambda _m: listing)
|
||||
|
||||
out = competitors_mod.get_competitors_parking(
|
||||
MagicMock(), "66:41:0303001:1", CompetitorsRequest()
|
||||
|
|
@ -192,9 +188,7 @@ def test_parking_wire_limits_to_top_n(monkeypatch: Any) -> None:
|
|||
|
||||
def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None:
|
||||
"""geom-match промахнулся → конкурент в items с None-полями, matched_count=0."""
|
||||
monkeypatch.setattr(
|
||||
competitors_mod, "get_competitors", lambda **_: _competitors_response(7)
|
||||
)
|
||||
monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(7))
|
||||
monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: None)
|
||||
premises = MagicMock()
|
||||
monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", premises)
|
||||
|
|
@ -212,9 +206,7 @@ def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None:
|
|||
|
||||
def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None:
|
||||
"""Здание сматчилось, но НСПД premises=None → cad есть, parking_ratio None."""
|
||||
monkeypatch.setattr(
|
||||
competitors_mod, "get_competitors", lambda **_: _competitors_response(9)
|
||||
)
|
||||
monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(9))
|
||||
monkeypatch.setattr(
|
||||
competitors_mod,
|
||||
"resolve_cad_for_domrf",
|
||||
|
|
@ -234,9 +226,7 @@ def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None:
|
|||
|
||||
def test_parking_wire_premises_exception_graceful(monkeypatch: Any) -> None:
|
||||
"""Неожиданное исключение в premises-lookup → конкурент без паркинга, не 500."""
|
||||
monkeypatch.setattr(
|
||||
competitors_mod, "get_competitors", lambda **_: _competitors_response(11)
|
||||
)
|
||||
monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(11))
|
||||
monkeypatch.setattr(
|
||||
competitors_mod,
|
||||
"resolve_cad_for_domrf",
|
||||
|
|
|
|||
|
|
@ -321,9 +321,9 @@ def test_json_path_candidates_end_with_expected_suffix() -> None:
|
|||
expected_suffix = pathlib.Path("data") / "pat" / "koltsovo_367p_subzones.json"
|
||||
for candidate in _JSON_PATH_CANDIDATES:
|
||||
# последние 3 части пути совпадают с ожидаемыми
|
||||
assert (
|
||||
candidate.parts[-3:] == expected_suffix.parts
|
||||
), f"Кандидат {candidate} не заканчивается на {expected_suffix}"
|
||||
assert candidate.parts[-3:] == expected_suffix.parts, (
|
||||
f"Кандидат {candidate} не заканчивается на {expected_suffix}"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_json_path_finds_real_file() -> None:
|
||||
|
|
|
|||
|
|
@ -201,9 +201,9 @@ def test_sql_excludes_the_amendments_group() -> None:
|
|||
держаться на том, что таких строк «пока нет».
|
||||
"""
|
||||
sql = str(_PERMITS_NEARBY_SQL)
|
||||
assert (
|
||||
"doc_group IN ('RS', 'RV')" in sql
|
||||
), f"запрос не сужен по группе — строки 'IZ' сломают total_count:\n{sql}"
|
||||
assert "doc_group IN ('RS', 'RV')" in sql, (
|
||||
f"запрос не сужен по группе — строки 'IZ' сломают total_count:\n{sql}"
|
||||
)
|
||||
|
||||
|
||||
def test_total_equals_rs_plus_rv_even_if_iz_leaks_in() -> None:
|
||||
|
|
|
|||
|
|
@ -395,12 +395,12 @@ class TestLayer2Hidden:
|
|||
norm = " ".join(sql.split())
|
||||
# Оба FILTER-предложения (n_with_free_flats COUNT и hidden_units SUM) должны
|
||||
# гейтить flat_count IS NOT NULL наравне с free_flats IS NOT NULL.
|
||||
assert (
|
||||
norm.count("flat_count IS NOT NULL") == 2
|
||||
), f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
|
||||
assert (
|
||||
norm.count("free_flats IS NOT NULL") == 2
|
||||
), f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
|
||||
assert norm.count("flat_count IS NOT NULL") == 2, (
|
||||
f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
|
||||
)
|
||||
assert norm.count("free_flats IS NOT NULL") == 2, (
|
||||
f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
|
||||
)
|
||||
|
||||
def test_row_with_null_flat_count_and_positive_free_flats_excluded_end_to_end(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ def test_quarter_dump_docstring_does_not_claim_17_requests() -> None:
|
|||
from app.services.scrapers.nspd_client import QuarterDump
|
||||
|
||||
doc = inspect.getdoc(QuarterDump) or ""
|
||||
assert (
|
||||
"не сжигать rate-limit на 17 запросов" not in doc
|
||||
), "в докстроке осталось число 17, противоречащее grid-walk"
|
||||
assert "не сжигать rate-limit на 17 запросов" not in doc, (
|
||||
"в докстроке осталось число 17, противоречащее grid-walk"
|
||||
)
|
||||
|
||||
|
||||
def test_on_demand_docstring_does_not_promise_a_60s_window() -> None:
|
||||
|
|
@ -105,6 +105,6 @@ def test_docstrings_state_the_actual_behaviour() -> None:
|
|||
assert "include_zouit" in qd, "не назван фактический дефолт дампа"
|
||||
|
||||
od = inspect.getdoc(find_active_on_demand_job) or ""
|
||||
assert (
|
||||
"НИКОГДА" in od or "никогда" in od
|
||||
), "не сказано, что failed не возвращается независимо от давности"
|
||||
assert "НИКОГДА" in od or "никогда" in od, (
|
||||
"не сказано, что failed не возвращается независимо от давности"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ def test_water_result_keeps_period() -> None:
|
|||
patch.object(mod.zipfile, "ZipFile", MagicMock()),
|
||||
):
|
||||
res = mod.load_water_reserves_from_docx(MagicMock(), "supply", b"", "http://x")
|
||||
assert (
|
||||
res.get("period") == "III кв. 2025"
|
||||
), f"период выброшен из ответа: {res} — по логам он есть, у вызывающего нет"
|
||||
assert res.get("period") == "III кв. 2025", (
|
||||
f"период выброшен из ответа: {res} — по логам он есть, у вызывающего нет"
|
||||
)
|
||||
assert res.get("records") == 1 and res.get("inserted") == 1, res
|
||||
|
||||
|
||||
|
|
@ -108,12 +108,12 @@ def test_placement_warning_uses_actual_footprint_not_catalog() -> None:
|
|||
|
||||
src = inspect.getsource(mod.place_program)
|
||||
хвост = src[src.index("участок мал") :]
|
||||
assert (
|
||||
"fp_w," in хвост and "fp_d," in хвост
|
||||
), f"в предупреждении не фактические габариты:\n{хвост[:320]}"
|
||||
assert (
|
||||
"house.footprint_w_m," not in хвост and "house.footprint_d_m," not in хвост
|
||||
), f"в предупреждении остался каталожный размер:\n{хвост[:320]}"
|
||||
assert "fp_w," in хвост and "fp_d," in хвост, (
|
||||
f"в предупреждении не фактические габариты:\n{хвост[:320]}"
|
||||
)
|
||||
assert "house.footprint_w_m," not in хвост and "house.footprint_d_m," not in хвост, (
|
||||
f"в предупреждении остался каталожный размер:\n{хвост[:320]}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("имя", ["fp_w", "fp_d"])
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class _PostgresLikeDb:
|
|||
def execute(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
if self.aborted:
|
||||
raise AbortedTransactionError(
|
||||
"current transaction is aborted, commands ignored until end of " "transaction block"
|
||||
"current transaction is aborted, commands ignored until end of transaction block"
|
||||
)
|
||||
self.calls += 1
|
||||
if self.calls == 1 and self._fail_first:
|
||||
|
|
|
|||
|
|
@ -116,9 +116,9 @@ class TestActiveCompetitorsCountSqlShape:
|
|||
# site_status legitimately appears in the CTE's SELECT list (it's the
|
||||
# column DISTINCT ON needs to expose) -- what must NOT appear is a
|
||||
# filter predicate on it inside the CTE's WHERE.
|
||||
assert (
|
||||
"site_status = 'Строящиеся'" not in cte_body
|
||||
), f"site_status must not pre-filter the DISTINCT ON CTE (volatile field):\n{cte_body}"
|
||||
assert "site_status = 'Строящиеся'" not in cte_body, (
|
||||
f"site_status must not pre-filter the DISTINCT ON CTE (volatile field):\n{cte_body}"
|
||||
)
|
||||
assert "site_status = 'Строящиеся'" in outer_body
|
||||
|
||||
def test_district_and_class_are_volatile_applied_after_distinct_on(self) -> None:
|
||||
|
|
@ -136,12 +136,12 @@ class TestActiveCompetitorsCountSqlShape:
|
|||
)
|
||||
cte_body, outer_body = _split_latest_cte(_executed_sql(db, 0))
|
||||
# CTE WHERE must scope on ONLY the stable region_cd — no volatile predicate.
|
||||
assert (
|
||||
"district_name = :dn" not in cte_body
|
||||
), f"district_name (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
|
||||
assert (
|
||||
"COALESCE(obj_class, obj_class_fallback) = :cls" not in cte_body
|
||||
), f"obj_class (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
|
||||
assert "district_name = :dn" not in cte_body, (
|
||||
f"district_name (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
|
||||
)
|
||||
assert "COALESCE(obj_class, obj_class_fallback) = :cls" not in cte_body, (
|
||||
f"obj_class (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}"
|
||||
)
|
||||
# Both live in the outer WHERE, applied to the deduped true-latest row.
|
||||
assert "district_name = :dn" in outer_body
|
||||
assert "COALESCE(obj_class, obj_class_fallback) = :cls" in outer_body
|
||||
|
|
@ -161,9 +161,9 @@ class TestActiveCompetitorsCountSqlShape:
|
|||
where_start = cte_body.index("WHERE ") + len("WHERE ")
|
||||
where_end = cte_body.index("ORDER BY", where_start)
|
||||
where_clause = cte_body[where_start:where_end].strip()
|
||||
assert (
|
||||
where_clause == "region_cd = :rc"
|
||||
), f"CTE WHERE must scope on ONLY stable region_cd, got: {where_clause!r}"
|
||||
assert where_clause == "region_cd = :rc", (
|
||||
f"CTE WHERE must scope on ONLY stable region_cd, got: {where_clause!r}"
|
||||
)
|
||||
|
||||
def test_no_double_colon_cast(self) -> None:
|
||||
import re
|
||||
|
|
|
|||
|
|
@ -397,12 +397,12 @@ class TestMsk66ToWgs84:
|
|||
result = msk66_to_wgs84("1534814.7997", "394813.2001")
|
||||
assert result is not None
|
||||
lon, lat = result
|
||||
assert (
|
||||
abs(lon - 60.619637) < _TOL_LON_DEG
|
||||
), f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)"
|
||||
assert (
|
||||
abs(lat - 56.871948) < _TOL_LAT_DEG
|
||||
), f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)"
|
||||
assert abs(lon - 60.619637) < _TOL_LON_DEG, (
|
||||
f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)"
|
||||
)
|
||||
assert abs(lat - 56.871948) < _TOL_LAT_DEG, (
|
||||
f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)"
|
||||
)
|
||||
|
||||
def test_koltsovo_cad_crosscheck(self) -> None:
|
||||
"""X=1544026.7997/Y=381585.4401 — сверка с centroid cad_parcels 66:41:0503018:248.
|
||||
|
|
@ -412,12 +412,12 @@ class TestMsk66ToWgs84:
|
|||
result = msk66_to_wgs84("1544026.7997", "381585.4401")
|
||||
assert result is not None
|
||||
lon, lat = result
|
||||
assert (
|
||||
abs(lon - 60.768255) < _TOL_LON_DEG
|
||||
), f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)"
|
||||
assert (
|
||||
abs(lat - 56.752330) < _TOL_LAT_DEG
|
||||
), f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)"
|
||||
assert abs(lon - 60.768255) < _TOL_LON_DEG, (
|
||||
f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)"
|
||||
)
|
||||
assert abs(lat - 56.752330) < _TOL_LAT_DEG, (
|
||||
f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)"
|
||||
)
|
||||
|
||||
def test_smorodinovaya_cad_crosscheck(self) -> None:
|
||||
"""X=1526550.1397/Y=387129.3801 — сверка с centroid cad_parcels 66:41:0306057:89.
|
||||
|
|
@ -427,12 +427,12 @@ class TestMsk66ToWgs84:
|
|||
result = msk66_to_wgs84("1526550.1397", "387129.3801")
|
||||
assert result is not None
|
||||
lon, lat = result
|
||||
assert (
|
||||
abs(lon - 60.483224) < _TOL_LON_DEG
|
||||
), f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)"
|
||||
assert (
|
||||
abs(lat - 56.803547) < _TOL_LAT_DEG
|
||||
), f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)"
|
||||
assert abs(lon - 60.483224) < _TOL_LON_DEG, (
|
||||
f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)"
|
||||
)
|
||||
assert abs(lat - 56.803547) < _TOL_LAT_DEG, (
|
||||
f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)"
|
||||
)
|
||||
|
||||
def test_respublikanskaya_cad_crosscheck(self) -> None:
|
||||
"""X=1531655.2797/Y=398430.2401 — ул. Республиканская 1а, cad 66:41:0106051:10.
|
||||
|
|
@ -443,12 +443,12 @@ class TestMsk66ToWgs84:
|
|||
result = msk66_to_wgs84("1531655.2797", "398430.2401")
|
||||
assert result is not None
|
||||
lon, lat = result
|
||||
assert (
|
||||
abs(lon - 60.568320) < _TOL_LON_DEG
|
||||
), f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)"
|
||||
assert (
|
||||
abs(lat - 56.904674) < _TOL_LAT_DEG
|
||||
), f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)"
|
||||
assert abs(lon - 60.568320) < _TOL_LON_DEG, (
|
||||
f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)"
|
||||
)
|
||||
assert abs(lat - 56.904674) < _TOL_LAT_DEG, (
|
||||
f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)"
|
||||
)
|
||||
|
||||
def test_kosmonavtov_in_ekb(self) -> None:
|
||||
"""Пр. Космонавтов — попадает в ЕКБ-регион."""
|
||||
|
|
|
|||
|
|
@ -162,10 +162,10 @@ def test_upsert_sql_coalesce_external_ids():
|
|||
assert "COALESCE" in do_update_section, "DO UPDATE должен содержать COALESCE"
|
||||
|
||||
# yandex_jk_id — строка вида "YANDEX_JK_ID = COALESCE("
|
||||
assert (
|
||||
"YANDEX_JK_ID = COALESCE(" in do_update_section
|
||||
), "yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
|
||||
assert "YANDEX_JK_ID = COALESCE(" in do_update_section, (
|
||||
"yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
|
||||
)
|
||||
# cian_internal_house_id — строка вида "CIAN_INTERNAL_HOUSE_ID = COALESCE("
|
||||
assert (
|
||||
"CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section
|
||||
), "cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
|
||||
assert "CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section, (
|
||||
"cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -422,9 +422,9 @@ class TestRealisticSrokFallback:
|
|||
)
|
||||
srok = result["summary"]["months_to_sellout_total"]
|
||||
assert srok is not None
|
||||
assert (
|
||||
lo <= srok <= hi
|
||||
), f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"
|
||||
assert lo <= srok <= hi, (
|
||||
f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"
|
||||
)
|
||||
|
||||
def test_scope_has_n_competitors(self) -> None:
|
||||
"""scope.n_competitors присутствует и равен district+class competitors."""
|
||||
|
|
@ -522,9 +522,9 @@ class TestObjectivePerBucketPath:
|
|||
# Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0
|
||||
studio = bkt_map.get("Студии 15-30")
|
||||
assert studio is not None
|
||||
assert studio["velocity_per_month"] == pytest.approx(
|
||||
3.5, rel=0.01
|
||||
), f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
|
||||
assert studio["velocity_per_month"] == pytest.approx(3.5, rel=0.01), (
|
||||
f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
|
||||
)
|
||||
assert studio.get("velocity_source") == "objective_per_bucket"
|
||||
|
||||
def test_objective_velocities_vary(self) -> None:
|
||||
|
|
|
|||
|
|
@ -553,9 +553,9 @@ class TestWindDirectionAllNone:
|
|||
f"ожидался None, получено {wind['dominant_direction_deg']!r} — "
|
||||
"вероятно, fabricated 0.0° из-за atan2(0,0)"
|
||||
)
|
||||
assert (
|
||||
wind["dominant_direction_label"] is None
|
||||
), f"ожидался None, получено {wind['dominant_direction_label']!r}"
|
||||
assert wind["dominant_direction_label"] is None, (
|
||||
f"ожидался None, получено {wind['dominant_direction_label']!r}"
|
||||
)
|
||||
|
||||
def test_missing_wind_key_gives_none_direction(self) -> None:
|
||||
"""winddirection_10m_dominant отсутствует в ответе → dominant_direction_deg/label = None."""
|
||||
|
|
|
|||
|
|
@ -106,9 +106,7 @@ def _run() -> dict[tuple[str, date], int]:
|
|||
session.execute(text(_TEMP))
|
||||
for cad, d, url in _SEED:
|
||||
session.execute(
|
||||
text(
|
||||
"INSERT INTO land_reservation (cad_num, act_date, doc_url)" " VALUES (:c,:d,:u)"
|
||||
),
|
||||
text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"),
|
||||
{"c": cad, "d": d, "u": url},
|
||||
)
|
||||
for chunk in _body():
|
||||
|
|
|
|||
|
|
@ -123,9 +123,9 @@ def test_plain_unique_does_not_deduplicate(db) -> None:
|
|||
Без этой проверки зелёный тест выше неотличим от «оно и так работало».
|
||||
"""
|
||||
_add_constraint(db, nulls_not_distinct=False)
|
||||
assert (
|
||||
_insert_twice(db) == 2
|
||||
), "обычный UNIQUE неожиданно поймал дубль — значит тест выше ничего не доказывает"
|
||||
assert _insert_twice(db) == 2, (
|
||||
"обычный UNIQUE неожиданно поймал дубль — значит тест выше ничего не доказывает"
|
||||
)
|
||||
|
||||
|
||||
def test_records_with_act_number_still_deduplicate(db) -> None:
|
||||
|
|
@ -159,9 +159,9 @@ def test_migration_dedup_statement_matches_the_key(db) -> None:
|
|||
assert delete_stmt is not None, "в миграции нет DELETE — дедуп не выполняется"
|
||||
body = delete_stmt.group(0)
|
||||
assert "a.cad_num = b.cad_num" in body, "дедуп не по cad_num"
|
||||
assert (
|
||||
"a.act_number IS NULL" in body and "b.act_number IS NULL" in body
|
||||
), "дедуп затрагивает записи С номером акта — они и так были уникальны"
|
||||
assert "a.act_number IS NULL" in body and "b.act_number IS NULL" in body, (
|
||||
"дедуп затрагивает записи С номером акта — они и так были уникальны"
|
||||
)
|
||||
assert "a.id > b.id" in body, "не задан выживающий (минимальный id)"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -169,9 +169,9 @@ def test_revenue_and_deals_are_named_by_their_scope(seeded) -> None:
|
|||
просто перестала показывать поле в UI.
|
||||
"""
|
||||
stats = _stats(seeded)
|
||||
assert (
|
||||
stats.get("revenue_window") == _IN_WINDOW_REVENUE
|
||||
), f"revenue_window = {stats.get('revenue_window')}, ожидалось {_IN_WINDOW_REVENUE}"
|
||||
assert stats.get("revenue_window") == _IN_WINDOW_REVENUE, (
|
||||
f"revenue_window = {stats.get('revenue_window')}, ожидалось {_IN_WINDOW_REVENUE}"
|
||||
)
|
||||
assert stats.get("deals_window") == _IN_WINDOW_DEALS
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ def _nspd(db) -> dict:
|
|||
|
||||
payload = compute_freshness(db)
|
||||
rows = [s for s in payload["sources"] if s["source"] == "nspd"]
|
||||
assert (
|
||||
len(rows) == 1
|
||||
), f"источник nspd не найден в реестре: {[s['source'] for s in payload['sources']]}"
|
||||
assert len(rows) == 1, (
|
||||
f"источник nspd не найден в реестре: {[s['source'] for s in payload['sources']]}"
|
||||
)
|
||||
return rows[0]
|
||||
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ def test_attempt_is_still_recorded(db) -> None:
|
|||
assert src["last_attempt_at"] is not None
|
||||
assert src["last_success_at"] is not None
|
||||
assert src["last_attempt_at"] > src["last_success_at"], (
|
||||
"последняя попытка должна быть новее последнего успеха — иначе провалы " "не видны вообще"
|
||||
"последняя попытка должна быть новее последнего успеха — иначе провалы не видны вообще"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -141,9 +141,9 @@ def test_loader_knows_the_amendments_group() -> None:
|
|||
"""
|
||||
from app.services.scrapers.gisogd66 import GROUP_CODE
|
||||
|
||||
assert (
|
||||
GROUP_CODE.get("DocIZ") == "IZ"
|
||||
), f"группа изменений не грузится; GROUP_CODE = {GROUP_CODE}"
|
||||
assert GROUP_CODE.get("DocIZ") == "IZ", (
|
||||
f"группа изменений не грузится; GROUP_CODE = {GROUP_CODE}"
|
||||
)
|
||||
|
||||
|
||||
def test_loader_upserts_by_source_key() -> None:
|
||||
|
|
@ -158,9 +158,9 @@ def test_loader_upserts_by_source_key() -> None:
|
|||
|
||||
src = inspect.getsource(_upsert_permit)
|
||||
assert "ON CONFLICT (source_key)" in src, "UPSERT конфликтует не по source_key"
|
||||
assert (
|
||||
"ON CONFLICT (doc_group, doc_num)" not in src
|
||||
), "старый ключ всё ещё в запросе — разрешение и изменения схлопнутся"
|
||||
assert "ON CONFLICT (doc_group, doc_num)" not in src, (
|
||||
"старый ключ всё ещё в запросе — разрешение и изменения схлопнутся"
|
||||
)
|
||||
|
||||
|
||||
# ── Механизм и миграция: нужен живой Postgres ────────────────────────────────
|
||||
|
|
@ -184,9 +184,9 @@ def test_old_key_collapses_permit_and_its_amendment() -> None:
|
|||
s.execute(text(_OLD_UPSERT), _ИЗМЕНЕНИЕ)
|
||||
rows = s.execute(text("SELECT doc_name, source_key FROM gisogd_permits")).all()
|
||||
assert len(rows) == 1, f"ожидали схлопывание, получили {len(rows)} строк"
|
||||
assert (
|
||||
"Изменения" in rows[0][0]
|
||||
), f"вытеснено не то: осталось {rows[0][0]!r} — на проде остаётся именно изменение"
|
||||
assert "Изменения" in rows[0][0], (
|
||||
f"вытеснено не то: осталось {rows[0][0]!r} — на проде остаётся именно изменение"
|
||||
)
|
||||
finally:
|
||||
s.rollback()
|
||||
s.close()
|
||||
|
|
|
|||
|
|
@ -141,9 +141,9 @@ def test_schema_01_alone_is_red_for_the_publishable_quarter(sandbox) -> None:
|
|||
conn, schema = sandbox
|
||||
have = _partition_starts(conn, schema)
|
||||
assert have, "песочница пуста — 01_schema не применилась"
|
||||
assert max(have) == date(
|
||||
2026, 1, 1
|
||||
), f"горизонт 01-схемы ожидался 2026q1, есть {sorted(have)[-2:]}"
|
||||
assert max(have) == date(2026, 1, 1), (
|
||||
f"горизонт 01-схемы ожидался 2026q1, есть {sorted(have)[-2:]}"
|
||||
)
|
||||
need = date(2026, 4, 1) # публикуемый квартал на дату инцидента
|
||||
assert need not in have, "красная сторона не состоялась: 01-схема уже знает Q2 2026"
|
||||
|
||||
|
|
|
|||
|
|
@ -115,9 +115,9 @@ def test_migrations_are_transactional() -> None:
|
|||
]
|
||||
if not statements or statements[0] != "BEGIN;" or statements[-1] != "COMMIT;":
|
||||
broken.append(path.name)
|
||||
assert (
|
||||
not broken
|
||||
), f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
|
||||
assert not broken, (
|
||||
f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)."
|
||||
)
|
||||
|
||||
|
||||
def test_no_concurrent_index_in_migrations() -> None:
|
||||
|
|
@ -177,6 +177,6 @@ def test_deploy_workflow_applies_auth_migrations() -> None:
|
|||
f"В {_DEPLOY_WORKFLOW.name} нет цикла по data/sql/auth/*.sql — миграции БД auth "
|
||||
"не применяются на деплое."
|
||||
)
|
||||
assert (
|
||||
"ops/db-bootstrap/create_auth_db.sql" in workflow
|
||||
), f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."
|
||||
assert "ops/db-bootstrap/create_auth_db.sql" in workflow, (
|
||||
f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,8 +40,7 @@ _DB_OK, _DB_ERR = _db_reachable()
|
|||
pytestmark = pytest.mark.skipif(
|
||||
not _DB_OK,
|
||||
reason=(
|
||||
"Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — "
|
||||
f"тест #99 пропущен: {_DB_ERR}"
|
||||
f"Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — тест #99 пропущен: {_DB_ERR}"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -107,10 +106,7 @@ def conn():
|
|||
|
||||
def _insert_quarter(cur, q_start, bucket_area, price_m2, n) -> None:
|
||||
"""Insert n single-flat ДДУ rows at given per-unit area + price/m²."""
|
||||
rows = [
|
||||
("002001003000", "ДДУ", 66, q_start, bucket_area, 1, price_m2)
|
||||
for _ in range(n)
|
||||
]
|
||||
rows = [("002001003000", "ДДУ", 66, q_start, bucket_area, 1, price_m2) for _ in range(n)]
|
||||
cur.executemany(
|
||||
"INSERT INTO rd (realestate_type_code, doc_type, region_code, "
|
||||
"period_start_date, area, deal_count, price_per_sqm) "
|
||||
|
|
@ -136,7 +132,7 @@ def _setup(cur: psycopg.Cursor) -> None:
|
|||
# Bucket 4 (60-80 m²): 2025-Q3 present, 2025-Q4 SPARSE (<10 → filtered),
|
||||
# 2026-Q1 present. index_previous for 2026-Q1 must compare to 2025-Q3.
|
||||
_insert_quarter(cur, "2025-07-01", 70, 150000, 11)
|
||||
_insert_quarter(cur, "2025-10-01", 70, 999999, 3) # below min_deals → dropped
|
||||
_insert_quarter(cur, "2025-10-01", 70, 999999, 3) # below min_deals → dropped
|
||||
_insert_quarter(cur, "2026-01-01", 70, 165000, 11)
|
||||
# Packaged-deal trap: one row area=350 deal_count=7 → per-unit 50 m² (bucket 3),
|
||||
# NOT bucket 6. Price chosen mid-range so it doesn't move the median much.
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ _DB_OK, _DB_ERR = _db_reachable()
|
|||
pytestmark = pytest.mark.skipif(
|
||||
not _DB_OK,
|
||||
reason=(
|
||||
"Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — "
|
||||
f"тест #17 пропущен: {_DB_ERR}"
|
||||
f"Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — тест #17 пропущен: {_DB_ERR}"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -107,8 +106,15 @@ def _setup(cur: psycopg.Cursor) -> None:
|
|||
)
|
||||
snap = "2026-04-28" # stale scrape date; data months end 2025-12 (4-mo gap)
|
||||
months = [
|
||||
"2025-04-01", "2025-05-01", "2025-06-01", "2025-07-01", "2025-08-01",
|
||||
"2025-09-01", "2025-10-01", "2025-11-01", "2025-12-01",
|
||||
"2025-04-01",
|
||||
"2025-05-01",
|
||||
"2025-06-01",
|
||||
"2025-07-01",
|
||||
"2025-08-01",
|
||||
"2025-09-01",
|
||||
"2025-10-01",
|
||||
"2025-11-01",
|
||||
"2025-12-01",
|
||||
]
|
||||
# obj 1 — sharp drop: prior ~15/mo, recent ~4/mo -> alert
|
||||
dropper = [16, 14, 15, 17, 13, 14, 5, 4, 3]
|
||||
|
|
|
|||
|
|
@ -153,9 +153,9 @@ def test_session_usable_after_close_for_thumb_update(
|
|||
|
||||
resp = photos.get_photo(db=_session_with_photo_row, obj_id=1, file_id="f1", size="thumb")
|
||||
|
||||
assert (
|
||||
seen.get("in_transaction") is False
|
||||
), "миниатюра генерируется при открытой транзакции — соединение пула занято"
|
||||
assert seen.get("in_transaction") is False, (
|
||||
"миниатюра генерируется при открытой транзакции — соединение пула занято"
|
||||
)
|
||||
assert getattr(resp, "path", None) == str(generated)
|
||||
|
||||
# Главное: запись ПОСЛЕ close() действительно доехала до БД — читаем ОТДЕЛЬНЫМ
|
||||
|
|
|
|||
|
|
@ -74,9 +74,9 @@ def test_row_assembly_keeps_none_not_zero() -> None:
|
|||
|
||||
src = inspect.getsource(m)
|
||||
assert 'if r["avg_area_m2"] is not None else None' in src, "в сборке ряда None → 0.0"
|
||||
assert (
|
||||
'round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None' in src
|
||||
), "round(row['avg_area_m2']) без проверки на None"
|
||||
assert 'round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None' in src, (
|
||||
"round(row['avg_area_m2']) без проверки на None"
|
||||
)
|
||||
|
||||
|
||||
def test_mix_weighted_area_excludes_rows_without_area() -> None:
|
||||
|
|
@ -89,9 +89,9 @@ def test_mix_weighted_area_excludes_rows_without_area() -> None:
|
|||
|
||||
src = inspect.getsource(m)
|
||||
assert "rb_area_total_deals" in src, "нет отдельного знаменателя для площади"
|
||||
assert re.search(
|
||||
r"rb_area_weighted\[rb\]\s*/\s*rb_area_total_deals\[rb\]", src
|
||||
), "площадь по-прежнему делится на все сделки (rb_deals), а не на ряды с площадью"
|
||||
assert re.search(r"rb_area_weighted\[rb\]\s*/\s*rb_area_total_deals\[rb\]", src), (
|
||||
"площадь по-прежнему делится на все сделки (rb_deals), а не на ряды с площадью"
|
||||
)
|
||||
|
||||
|
||||
def test_pdf_renders_dash_for_missing_area() -> None:
|
||||
|
|
|
|||
|
|
@ -87,9 +87,10 @@ def test_classify_path_forecast() -> None:
|
|||
|
||||
def test_classify_path_forecast_export_not_confused_with_forecast() -> None:
|
||||
"""forecast/export должен дать action='export', НЕ 'forecast'."""
|
||||
assert audit_mod.classify_path(
|
||||
"/api/v1/parcels/66:41:0204016:10/forecast/export"
|
||||
) == ("export", "66:41:0204016:10")
|
||||
assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10/forecast/export") == (
|
||||
"export",
|
||||
"66:41:0204016:10",
|
||||
)
|
||||
|
||||
|
||||
def test_classify_path_unmatched_returns_none() -> None:
|
||||
|
|
@ -134,9 +135,10 @@ def test_classify_path_insight_nested_path_not_matched() -> None:
|
|||
|
||||
def test_classify_path_parcels_method_ignored() -> None:
|
||||
"""Parcels-паттерны не зависят от method — analyze матчится при любом методе."""
|
||||
assert audit_mod.classify_path(
|
||||
"/api/v1/parcels/66:41:0204016:10/analyze", "GET"
|
||||
) == ("analyze", "66:41:0204016:10")
|
||||
assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10/analyze", "GET") == (
|
||||
"analyze",
|
||||
"66:41:0204016:10",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -167,8 +167,7 @@ def test_parse_no_table_returns_empty() -> None:
|
|||
def test_parse_no_header_row_returns_empty() -> None:
|
||||
"""Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive)."""
|
||||
html = (
|
||||
"<table><tr><th>Колонка А</th><th>Колонка Б</th></tr>"
|
||||
"<tr><td>1</td><td>2</td></tr></table>"
|
||||
"<table><tr><th>Колонка А</th><th>Колонка Б</th></tr><tr><td>1</td><td>2</td></tr></table>"
|
||||
)
|
||||
assert gg.parse_grs_table(html) == []
|
||||
|
||||
|
|
|
|||
|
|
@ -129,9 +129,7 @@ def test_residential_main_vri_zh5_with_mkd_true():
|
|||
|
||||
def test_residential_main_vri_overrides_subcategory():
|
||||
"""main_vri ИЖС перебивает даже raw_props subcategory=2 (authoritative wins)."""
|
||||
assert (
|
||||
is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False
|
||||
)
|
||||
assert is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False
|
||||
|
||||
|
||||
def test_residential_empty_main_vri_falls_back_to_regex():
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def test_category_weight_metro():
|
|||
metro_w = _category_weight("metro_stop")
|
||||
for cat in CATEGORY_WEIGHTS:
|
||||
if cat != "metro_stop" and cat != "default":
|
||||
assert metro_w >= _category_weight(
|
||||
cat
|
||||
), f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}"
|
||||
assert metro_w >= _category_weight(cat), (
|
||||
f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}"
|
||||
)
|
||||
|
||||
|
||||
def test_category_weight_unknown_returns_default():
|
||||
|
|
@ -130,9 +130,9 @@ def test_metro_beats_school_at_equal_distance():
|
|||
]
|
||||
db = _MockDb(rows)
|
||||
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
|
||||
assert (
|
||||
result.top_poi[0].category == "metro_stop"
|
||||
), "При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)"
|
||||
assert result.top_poi[0].category == "metro_stop", (
|
||||
"При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)"
|
||||
)
|
||||
|
||||
|
||||
def test_metro_first_when_close():
|
||||
|
|
@ -145,7 +145,7 @@ def test_metro_first_when_close():
|
|||
result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605)
|
||||
assert result.top_poi[0].category == "metro_stop", (
|
||||
"Метро (weight=6.0) в 50м должно быть впереди школы (weight=5.0) в 300м — "
|
||||
f"metro_weight={(1/(50+100))*6:.5f} vs school_weight={(1/(300+100))*5:.5f}"
|
||||
f"metro_weight={(1 / (50 + 100)) * 6:.5f} vs school_weight={(1 / (300 + 100)) * 5:.5f}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -194,9 +194,9 @@ def test_score_contribution_in_range():
|
|||
db = _MockDb(rows)
|
||||
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
|
||||
for item in result.top_poi:
|
||||
assert (
|
||||
0.0 <= item.score_contribution <= 100.0
|
||||
), f"{item.category} score_contribution={item.score_contribution} вне 0..100"
|
||||
assert 0.0 <= item.score_contribution <= 100.0, (
|
||||
f"{item.category} score_contribution={item.score_contribution} вне 0..100"
|
||||
)
|
||||
|
||||
|
||||
def test_metro_at_zero_distance_scores_high():
|
||||
|
|
@ -205,9 +205,9 @@ def test_metro_at_zero_distance_scores_high():
|
|||
rows = [_make_row("Метро у дома", "metro_stop", 0.0)]
|
||||
db = _MockDb(rows)
|
||||
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
|
||||
assert (
|
||||
result.poi_weighted_score >= 19.0
|
||||
), f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"
|
||||
assert result.poi_weighted_score >= 19.0, (
|
||||
f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"
|
||||
)
|
||||
|
||||
|
||||
def test_score_contribution_sum_equals_total():
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ def _make_zouit_row(
|
|||
def test_cad_zouit_overlaps_includes_geom_geojson() -> None:
|
||||
"""#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON)."""
|
||||
geojson_str = (
|
||||
'{"type":"Polygon","coordinates":' "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}"
|
||||
'{"type":"Polygon","coordinates":[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}'
|
||||
)
|
||||
rows = [
|
||||
_make_zouit_row(
|
||||
|
|
@ -568,9 +568,9 @@ def test_empty_result_reports_unknown_risk_coverage() -> None:
|
|||
"""
|
||||
result = make_empty_result()
|
||||
|
||||
assert (
|
||||
"risks_count" in result["nspd_dump"]
|
||||
), "признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»"
|
||||
assert "risks_count" in result["nspd_dump"], (
|
||||
"признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»"
|
||||
)
|
||||
assert result["nspd_dump"]["risks_count"] is None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -476,9 +476,9 @@ def test_local_variables_never_reach_transport(module: str) -> None:
|
|||
assert probe["counts"]["exception"] == 1
|
||||
assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало"
|
||||
|
||||
assert (
|
||||
probe["markers"]["local_var"] not in payload
|
||||
), f"{module}: значение локальной переменной ушло в мониторинг"
|
||||
assert probe["markers"]["local_var"] not in payload, (
|
||||
f"{module}: значение локальной переменной ушло в мониторинг"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"])
|
||||
|
|
@ -493,7 +493,7 @@ def test_scrub_failure_does_not_spawn_second_event(module: str) -> None:
|
|||
"""
|
||||
probe = json.loads(_probe(module))
|
||||
|
||||
assert (
|
||||
probe["scrub_handler_entries"] == 1
|
||||
), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг"
|
||||
assert probe["scrub_handler_entries"] == 1, (
|
||||
"сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг"
|
||||
)
|
||||
assert probe["counts"]["scrub_failure"] == 1
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ def test_beat_schedule_includes_gas_outlet_sync_weekly_tuesday() -> None:
|
|||
with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
|
||||
schedule = beat_schedule.build_beat_schedule()
|
||||
|
||||
assert (
|
||||
"gas-outlet-points-sync-weekly" in schedule
|
||||
), f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}"
|
||||
assert "gas-outlet-points-sync-weekly" in schedule, (
|
||||
f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}"
|
||||
)
|
||||
entry = schedule["gas-outlet-points-sync-weekly"]
|
||||
assert entry["task"] == "tasks.connection_capacity_sync.sync_gas_outlet_points"
|
||||
# crontab day_of_week = вторник (2).
|
||||
|
|
|
|||
|
|
@ -86,9 +86,9 @@ def test_extract_coords_inverted_would_fail_bbox() -> None:
|
|||
inverted_lon = feature["geometry"]["coordinates"][0] # 56.83 → не в bbox [58–63]
|
||||
inverted_lat = feature["geometry"]["coordinates"][1] # 60.61 → не в bbox [55–58.5]
|
||||
inverted_inside = (55.0 <= inverted_lat <= 58.5) and (58.0 <= inverted_lon <= 63.0)
|
||||
assert (
|
||||
not inverted_inside
|
||||
), "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap"
|
||||
assert not inverted_inside, (
|
||||
"Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap"
|
||||
)
|
||||
|
||||
# Проверяем что реальный _extract_coords возвращает КОРРЕКТНЫЕ (lon, lat)
|
||||
result = _extract_coords(feature)
|
||||
|
|
@ -141,9 +141,9 @@ def test_address_passes_include_ekb_districts() -> None:
|
|||
"Верх-Исетский",
|
||||
"Железнодорожный",
|
||||
}
|
||||
assert expected_districts <= set(
|
||||
_ADDRESS_PASSES
|
||||
), f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}"
|
||||
assert expected_districts <= set(_ADDRESS_PASSES), (
|
||||
f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}"
|
||||
)
|
||||
|
||||
|
||||
def test_address_passes_order_city_before_district() -> None:
|
||||
|
|
|
|||
|
|
@ -119,17 +119,17 @@ def test_finished_at_is_last_sign_of_life_not_now() -> None:
|
|||
"""
|
||||
sql = _objective_update(_run())
|
||||
assert sql is not None
|
||||
assert (
|
||||
"COALESCE(heartbeat_at, started_at)" in sql
|
||||
), f"finished_at ставится не по последнему признаку жизни:\n{sql}"
|
||||
assert (
|
||||
"finished_at = NOW()" not in sql
|
||||
), f"finished_at = NOW() — время завершения соврано:\n{sql}"
|
||||
assert "COALESCE(heartbeat_at, started_at)" in sql, (
|
||||
f"finished_at ставится не по последнему признаку жизни:\n{sql}"
|
||||
)
|
||||
assert "finished_at = NOW()" not in sql, (
|
||||
f"finished_at = NOW() — время завершения соврано:\n{sql}"
|
||||
)
|
||||
|
||||
|
||||
def test_kn_sweep_still_runs() -> None:
|
||||
"""Контроль от регресса: добавление Объектива не сломало подметание kn."""
|
||||
db = _run()
|
||||
assert any(
|
||||
"kn_scrape_runs" in s for s in db.sql
|
||||
), f"подметание kn_scrape_runs пропало; выполнено: {db.sql}"
|
||||
assert any("kn_scrape_runs" in s for s in db.sql), (
|
||||
f"подметание kn_scrape_runs пропало; выполнено: {db.sql}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -569,9 +569,9 @@ def test_soft_time_limit_exceeded_flushes_heartbeat(monkeypatch: Any) -> None:
|
|||
for sql, params in captured
|
||||
if "heartbeat_at = NOW()" in sql and "targets_done" in sql
|
||||
]
|
||||
assert (
|
||||
heartbeat_updates
|
||||
), "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
|
||||
assert heartbeat_updates, (
|
||||
"SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise"
|
||||
)
|
||||
|
||||
|
||||
def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed(
|
||||
|
|
|
|||
|
|
@ -24,8 +24,11 @@ def _source(
|
|||
|
||||
|
||||
def _payload(sources: list[dict[str, Any]], overall: str) -> dict[str, Any]:
|
||||
return {"generated_at": "2026-06-25T09:00:00+00:00", "overall_status": overall,
|
||||
"sources": sources}
|
||||
return {
|
||||
"generated_at": "2026-06-25T09:00:00+00:00",
|
||||
"overall_status": overall,
|
||||
"sources": sources,
|
||||
}
|
||||
|
||||
|
||||
def _run_with(payload_or_exc: Any) -> tuple[dict[str, Any], MagicMock]:
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ async def search(
|
|||
if cached is not None:
|
||||
logger.info(
|
||||
"search cache HIT key=%s page=%d size=%d",
|
||||
cache_key[:24], params.page, params.page_size,
|
||||
cache_key[:24],
|
||||
params.page,
|
||||
params.page_size,
|
||||
)
|
||||
resp = SearchResponse.model_validate(cached)
|
||||
resp.cache_hit = True
|
||||
|
|
@ -73,6 +75,10 @@ async def search(
|
|||
await cache.set(cache_key, response.model_dump(mode="json"), ttl=cache.TTL_SEARCH)
|
||||
logger.info(
|
||||
"search MISS page=%d size=%d total=%d items=%d elapsed=%.1fms",
|
||||
params.page, params.page_size, total, len(items), elapsed_ms,
|
||||
params.page,
|
||||
params.page_size,
|
||||
total,
|
||||
len(items),
|
||||
elapsed_ms,
|
||||
)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -48,9 +48,7 @@ class SearchCache:
|
|||
|
||||
async def set(self, key: str, value: dict[str, Any], ttl: int) -> None:
|
||||
try:
|
||||
await self._client.set(
|
||||
key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl
|
||||
)
|
||||
await self._client.set(key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl)
|
||||
except Exception as e:
|
||||
logger.warning("redis SET failed key=%s: %s", key[:24], e)
|
||||
|
||||
|
|
|
|||
|
|
@ -1091,7 +1091,7 @@ def _save_yandex_history_items(
|
|||
|
||||
if skipped_area > 0:
|
||||
logger.info(
|
||||
"yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None" " (addr=%r)",
|
||||
"yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None (addr=%r)",
|
||||
skipped_area,
|
||||
len(result.history_items),
|
||||
result.address,
|
||||
|
|
@ -3328,8 +3328,7 @@ def _price_from_inputs(
|
|||
)
|
||||
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
|
||||
logger.info(
|
||||
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f)"
|
||||
" for %s",
|
||||
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
||||
same_quarter_ratio,
|
||||
settings.estimate_quarter_match_skip_ratio,
|
||||
target_quarter,
|
||||
|
|
@ -6980,7 +6979,7 @@ def _enforce_zero_analog_low(
|
|||
"""
|
||||
if n_analogs == 0 and confidence != "low":
|
||||
logger.warning(
|
||||
"ghost_anchor_guard #1871: forcing confidence 'low' (was %s, " "median=%s, sources=%s)",
|
||||
"ghost_anchor_guard #1871: forcing confidence 'low' (was %s, median=%s, sources=%s)",
|
||||
confidence,
|
||||
median_price,
|
||||
sources_used,
|
||||
|
|
|
|||
|
|
@ -1665,9 +1665,12 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
|
||||
{f'''<tr><td style="padding:4pt 0;">Сделки</td>
|
||||
{
|
||||
f'''<tr><td style="padding:4pt 0;">Сделки</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(deals_as_of)}</td></tr>'''
|
||||
if deals_as_of else ""}
|
||||
if deals_as_of
|
||||
else ""
|
||||
}
|
||||
</table>
|
||||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue