Merge remote-tracking branch 'forgejo/main' into fix/2990-clean-start-initdb
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Successful in 57s
CI Trade-In / frontend-checks (pull_request) Successful in 1m40s
CI / openapi-codegen-check (pull_request) Successful in 2m45s
CI Trade-In / backend-tests (pull_request) Successful in 5m15s
CI / backend-tests (pull_request) Successful in 18m13s

This commit is contained in:
bot-backend 2026-08-21 15:35:32 +03:00
commit 4cb8f32dcc
194 changed files with 3211 additions and 1039 deletions

View file

@ -297,6 +297,8 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
# APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см.
# backend/Dockerfile ARG→ENV) — читает app/core/version.py:
# GET /api/v1/trade-in/version + колонтитул PDF-отчёта.
@ -321,6 +323,8 @@ jobs:
context: ./tradein-mvp
file: ./tradein-mvp/backend/Dockerfile
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
APP_VERSION=${{ needs.changes.outputs.app_version }}
BUILD_SHA=${{ needs.changes.outputs.build_sha }}
@ -425,6 +429,8 @@ jobs:
with:
context: ./tradein-mvp/frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
# basePath=/trade-in baked-in во время build (Next.js)
# NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут
# (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в
@ -454,6 +460,8 @@ jobs:
with:
context: ./tradein-mvp/frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_BASE_PATH=/trade-in
NEXT_PUBLIC_API_BASE_URL=/trade-in
@ -549,6 +557,8 @@ jobs:
with:
context: ./tradein-mvp/browser
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
@ -564,6 +574,8 @@ jobs:
with:
context: ./tradein-mvp/browser
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BROWSER }}:latest
@ -603,6 +615,23 @@ jobs:
needs.build-frontend.result != 'failure' &&
needs.build-browser.result != 'failure'
steps:
# ── #2950: :latest не старше последнего коммита по компоненту ─────────────
# См. комментарий к тому же шагу в deploy.yml и scripts/check-latest-image-revision.sh.
# Пути = фильтры job'а changes (backend/frontend/browser + infra), которые
# приводят к сборке соответствующего образа.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to GHCR — для imagetools inspect гарда (#2950)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Гард свежести :latest (#2950)
run: |
INFRA="tradein-mvp/docker-compose.prod.yml tradein-mvp/deploy .forgejo/workflows/deploy-tradein.yml"
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- tradein-mvp/backend tradein-mvp/packages/scraper-kit tradein-mvp/VERSION $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- tradein-mvp/frontend tradein-mvp/VERSION tradein-mvp/CHANGELOG.md $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_BROWSER" 900 -- tradein-mvp/browser $INFRA
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
env:
@ -689,6 +718,11 @@ jobs:
chmod 600 .env.runtime
set -a; source .env.runtime; set +a
# Re-assert +x на deploy-скриптах (#3005, по образцу deploy.yml ops/*.sh из #71).
# Cron зовёт backup-tradein-db.sh через `bash`, так что бит ему не нужен —
# но любой другой вызов сырым путём не должен зависеть от git-режима файла.
chmod +x deploy/*.sh 2>/dev/null || true
# External network для Caddy (он в основном gendesign-стеке)
docker network inspect gendesign_shared >/dev/null 2>&1 \
|| docker network create gendesign_shared

View file

@ -173,6 +173,8 @@ jobs:
context: ./backend
target: runner
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
@ -194,6 +196,8 @@ jobs:
context: ./backend
target: runner
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max
tags: |
${{ env.IMAGE_BACKEND }}:latest
@ -291,6 +295,8 @@ jobs:
context: ./backend
target: runner-with-chromium
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
@ -308,6 +314,8 @@ jobs:
context: ./backend
target: runner-with-chromium
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max
tags: |
${{ env.IMAGE_WORKER }}:latest
@ -400,6 +408,8 @@ jobs:
with:
context: ./frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
@ -419,6 +429,8 @@ jobs:
with:
context: ./frontend
push: true
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }}
NEXT_PUBLIC_ENVIRONMENT=production
@ -461,6 +473,28 @@ jobs:
needs.build-worker.result != 'failure' &&
needs.build-frontend.result != 'failure'
steps:
# ── #2950: :latest не старше последнего коммита по компоненту ─────────────
# Forgejo отменяет ещё не стартовавший deploy предыдущего run'а этой группы,
# а следующий run (например ops-only, билды пропущены) катит :latest как есть.
# 21.08.2026 10:35 прод получил новый код только потому, что билды
# предшественника успели за 70 с до pull'а. Гард читает метку ревизии из
# образа в registry (labels на build-push выше), ждёт билд предшественника
# до 15 мин и иначе падает громко — вместо тихого отката при зелёной голове.
# Пути = фильтры job'а changes, которые приводят к сборке (caddy_only не
# собирает — Caddyfile/caddy/** намеренно не в списке).
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Login to GHCR — для imagetools inspect гарда (#2950)
env:
GHCR_PAT: ${{ secrets.GHCR_PAT }}
run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin
- name: Гард свежести :latest (#2950)
run: |
INFRA="docker-compose.prod.yml .forgejo/workflows/deploy.yml"
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- backend data/sql $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_WORKER" 900 -- backend data/sql $INFRA
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- frontend $INFRA
- name: Deploy to VM via SSH
uses: appleboy/ssh-action@v1.0.3
env:

View file

@ -26,8 +26,12 @@ repos:
- id: detect-private-key
# Python — ruff (lint + format) on backend/ + tradein-mvp/backend/
# #2864: rev ОБЯЗАН совпадать с версией ruff в backend/uv.lock и tradein-mvp/uv.lock
# (гейт backend/tests/test_2864_ruff_version_alignment.py). Иначе хук и
# `uv run ruff format` форматируют по-разному и играют в пинг-понг на каждом коммите.
# Бампить втроём: rev здесь + `ruff==X` в обоих pyproject.toml + `uv lock` в backend/ и tradein-mvp/.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.4
rev: v0.15.20
hooks:
- id: ruff
args: [--fix]

View file

@ -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)"
)

View file

@ -598,7 +598,8 @@ class TopLayoutRow(BaseModel):
total_sold_in_window: int
velocity_per_month: float
avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj
avg_area_m2: float
# #2867: NULL если сделок за окно нет — средней площади нет; раньше отдавался 0 м².
avg_area_m2: float | None
supply_units_in_radius: int
sold_pct_of_supply: float | None # NULL если supply=0; clamped at 100.0
is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна)

View file

@ -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()

View file

@ -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:

View file

@ -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

View file

@ -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,

View file

@ -50,6 +50,12 @@ def build_layout_tz_html(
return "<td>—</td>"
return f"<td>{val:,.0f}".replace(",", " ") + " ₽</td>"
def _area_cell(val: float | None) -> str:
"""#2867: средняя площадь — None, если сделок за окно нет → «—», а не «0.0»."""
if val is None:
return "<td>—</td>"
return f"<td>{val:.1f}</td>"
def _price_m2_cell(val: float | None) -> str:
"""Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful)."""
if val is None:
@ -69,7 +75,7 @@ def build_layout_tz_html(
f"<td>{_html.escape(r.room_bucket)}</td>"
f"<td>{_html.escape(r.area_bin)}</td>"
f"<td>{r.velocity_per_month:.1f}</td>"
f"<td>{r.avg_area_m2:.1f}</td>"
f"{_area_cell(r.avg_area_m2)}"
f"{_price_cell(r.avg_price_per_m2_rub)}"
f"<td>{r.total_sold_in_window}</td>"
"</tr>"

View file

@ -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 = [

View file

@ -85,8 +85,7 @@ _CONCEPT_FOOTPRINT_STYLE = {
}
_MAP_UNAVAILABLE_HTML = (
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют '
"в отчёте</div>"
'<div class="map-placeholder">Карта недоступна — геоданные участка отсутствуют в отчёте</div>'
)

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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)",

View file

@ -192,15 +192,15 @@ _INLINE_VELOCITY_SQL = text("""
SELECT
a.room_bucket,
SUM(a.deals_window) AS deals_window,
-- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2
-- объявлен как float (не Optional), и NULL ронял бы контракт API.
-- Пустые комнатности получают площадь 0 м², и это тоже неправда но
-- честный NULL требует правки схемы + перегенерации типов фронта
-- и решения, что писать в area_bin. Отдельным заходом: #2867.
COALESCE(
-- #2867: БЕЗ COALESCE(...,0), как у avg_price_per_m2_rub ниже (#2464-B).
-- Сделок за окно нет делитель NULL средней площади нет, и это NULL,
-- а не «0 м²». Замер прода 13.08: 635 пустых пар (проект × комнатность)
-- из 2083, у 80 проектов пусты ВСЕ комнатности ноль выдумывался ровно
-- там, где окрестность беднее замапленными проектами. Схема объявлена
-- float | None, фронт и PDF печатают «».
(
SUM(a.area_weighted_sum)
/ NULLIF(SUM(a.deals_window), 0),
0
/ NULLIF(SUM(a.deals_window), 0)
)::numeric(10, 2) AS avg_area_m2,
-- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL →
-- средней цены нет, и это NULL, а не «0 /м²». Схема так и объявлена
@ -1284,7 +1284,8 @@ def get_best_layouts(
for r in vel_rows:
room_bucket = str(r["room_bucket"])
deals_window = float(r["deals_window"]) if r["deals_window"] is not None else 0.0
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0
# #2867: None остаётся None — «сделок нет» ≠ «0 м²».
avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else None
price_rub = (
float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None
)
@ -1367,7 +1368,10 @@ def get_best_layouts(
total_sold_in_window=int(row["sum_deals"]),
velocity_per_month=row["velocity_per_month"],
avg_price_per_m2_rub=row["avg_price_per_m2_rub"],
avg_area_m2=round(row["avg_area_m2"], 1),
# #2867: None (сделок нет) остаётся None — round(None) ронял бы сборку.
avg_area_m2=(
round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None
),
supply_units_in_radius=row["supply_units_in_radius"],
sold_pct_of_supply=row["sold_pct_of_supply"],
is_oversold=row["is_oversold"],
@ -1499,6 +1503,7 @@ def _build_recommendation(
# Группировка по room_bucket (строки уже могут быть per-bucket из MV GROUP BY)
rb_deals: dict[str, float] = {}
rb_area_weighted: dict[str, float] = {}
rb_area_total_deals: dict[str, float] = {} # #2867: знаменатель только по рядам с площадью
rb_price_weighted: dict[str, float] = {}
rb_price_total_deals: dict[str, float] = {}
all_competitor_ids: set[int] = set()
@ -1507,7 +1512,12 @@ def _build_recommendation(
rb = row["room_bucket"]
sd = float(row["sum_deals"])
rb_deals[rb] = rb_deals.get(rb, 0.0) + sd
rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd
# #2867: ряд без средней площади (сделок за окно нет) не участвует ни в числителе,
# ни в знаменателе взвешенной площади — как у цены ниже. Иначе его sd считался бы
# сделками «с площадью 0» и занижал среднее.
if row["avg_area_m2"] is not None:
rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd
rb_area_total_deals[rb] = rb_area_total_deals.get(rb, 0.0) + sd
all_competitor_ids.update(row["competitor_obj_ids"])
if row["avg_price_per_m2_rub"] is not None:
rb_price_weighted[rb] = rb_price_weighted.get(rb, 0.0) + (
@ -1521,8 +1531,12 @@ def _build_recommendation(
mix: list[LayoutTzMixRow] = []
for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]):
# #2867: делим на сделки рядов С площадью, а не на все — иначе ряды без площади
# занижали бы среднее; нет ни одного ряда с площадью → None, не 0.
avg_area = (
round(rb_area_weighted[rb] / rb_deals[rb], 1) if rb_deals.get(rb, 0) > 0 else None
round(rb_area_weighted[rb] / rb_area_total_deals[rb], 1)
if rb_area_total_deals.get(rb, 0) > 0
else None
)
abs_units: int | None = None
if target_total_flats is not None:

View file

@ -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:

View file

@ -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', '')}"
),
)
)

View file

@ -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,

View file

@ -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]:

View file

@ -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)

View file

@ -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,

View file

@ -47,7 +47,7 @@ dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=5.0.0", # coverage gate в CI (#68): pytest --cov=app --cov-fail-under
"ruff>=0.5.0",
"ruff==0.15.20",
"mypy>=1.10.0",
"types-redis>=4.6.0",
"pre-commit>=3.7.0",

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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:

View file

@ -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"] == "Переименовано"

View file

@ -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 маппинге"

View file

@ -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 ──────────────────────────────────────────────────

View file

@ -0,0 +1,160 @@
"""Regression: verify_dump_integrity() больше не удаляет валидные дампы (#2203).
Что произошло. В обоих бэкап-скриптах (`ops/backup.sh`,
`tradein-mvp/deploy/backup-tradein-db.sh`) проверка трейлера была:
gunzip -c "$file" | tail -5 | grep -qF "$trailer"
`$trailer` это `"-- PostgreSQL database dump complete"` (и `... cluster dump
complete` для globals). Строка начинается с `--`, а GNU grep трактует ведущие
`--` в аргументе как конец списка опций / саму опцию без разделителя `--`
перед паттерном grep падает:
grep: unrecognized option '-- PostgreSQL database dump complete'
Проверка ВСЕГДА возвращала «трейлера нет» не потому что дамп оборван, а
потому что сама grep-команда не может выполниться. Вызывающий код удалял
только что созданный ВАЛИДНЫЙ дамп и завершался с ошибкой; ретеншен не
успевал отработать (ранний exit) свежие бэкапы не создавались никогда,
старые копии оставались молча.
Воспроизведено вручную на проде: `bash
/opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh` удалил свежий дамп с
сообщением «дамп оборван?».
Фикс `grep -qF -- "$trailer"`: `--` явно завершает опции grep, дальше
только позиционные аргументы, ведущие `--` в самом трейлере больше не путают
grep с флагом.
ПОЧЕМУ ЭТОТ КЛАСС БАГОВ НЕ ПОЙМАЛИ РАНЬШЕ: ни один тест не исполнял
verify_dump_integrity() на реальном gzip-потоке только читали/ревьюили
исходник глазами, а `grep -qF "текст, начинающийся с --"` выглядит
безобидно, пока не запущен. Тест ниже исполняет РЕАЛЬНУЮ функцию
verify_dump_integrity(), извлечённую из обоих скриптов (не копию, не
пересказ), через ту же связку `gunzip -c | tail -5 | grep`, что и в проде
регресс (пропажа `--`) уронит его немедленно.
"""
from __future__ import annotations
import gzip
import shutil
import subprocess
from pathlib import Path
import pytest
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = {
"ops/backup.sh": "-- PostgreSQL database dump complete",
"tradein-mvp/deploy/backup-tradein-db.sh": "-- PostgreSQL database dump complete",
}
# `shutil.which`, а не голое "bash" в subprocess.run: на Windows с установленным
# WSL голое имя резолвится Windows-у CreateProcess В СИСТЕМНУЮ ДИРЕКТОРИЮ РАНЬШЕ
# PATH и находит `System32\bash.exe` (лаунчер WSL) вместо Git Bash. Этот лаунчер
# ломает `-c` со скриптом из нескольких `;`-разделённых команд — каждая часть
# выполняется как будто в НОВОЙ оболочке, состояние (переменные, включая
# результат mktemp) между ними не сохраняется. `shutil.which` ищет по PATH как
# обычно и находит настоящий Git Bash, где всё работает штатно.
BASH = shutil.which("bash")
if BASH is None: # pragma: no cover - тестовое окружение без bash не запустит эти тесты
pytest.skip("bash не найден в PATH — тест требует shell-исполнения", allow_module_level=True)
def _extract_function(script_path: Path) -> str:
"""Достаёт тело verify_dump_integrity() из файла — не весь скрипт.
Весь файл source'ить нельзя: ниже функции в обоих скриптах идёт секция
`# --- run ---` / `mkdir -p "$BACKUP_DIR"` и далее реальный
`docker exec ... pg_dump` этого мы не хотим исполнять в тесте.
"""
assert script_path.is_file(), f"нет {script_path} — переехал скрипт, гейт ослеп"
lines = script_path.read_text(encoding="utf-8").splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith("verify_dump_integrity() {"))
end = next(i for i in range(start, len(lines)) if lines[i] == "}")
body = "\n".join(lines[start : end + 1])
assert "grep" in body, f"{script_path}: извлечённое тело не похоже на функцию с grep"
return body
def _run_verify(script_rel: str, trailer: str, gz_content: bytes) -> subprocess.CompletedProcess:
"""Гоняет РЕАЛЬНУЮ verify_dump_integrity() из скрипта на временном .gz.
`mktemp`/`cat > "$tmpfile"` внутри bash не python `tempfile` чтобы не
протаскивать windows-путь через границу python/bash (локальная разработка
идёт под Git Bash на Windows).
"""
func_src = _extract_function(REPO_ROOT / script_rel)
harness = f"""
set -u
log() {{ :; }} # заглушка — сигнатура log() одна и та же в обоих скриптах
{func_src}
tmpfile=$(mktemp --suffix=.sql.gz)
trap 'rm -f "$tmpfile"' EXIT
cat > "$tmpfile"
verify_dump_integrity "$tmpfile" "$1" "test-dump"
"""
return subprocess.run(
[BASH, "-c", harness, "bash", trailer],
input=gz_content,
capture_output=True,
timeout=10,
)
def _gz(text: str) -> bytes:
return gzip.compress(text.encode("utf-8"))
def _stderr(result: subprocess.CompletedProcess) -> str:
return result.stderr.decode("utf-8", errors="replace")
@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items())
def test_verify_dump_integrity_accepts_valid_dump_with_trailer(
script_rel: str, trailer: str
) -> None:
"""Дамп с трейлером последней строкой — валиден (return 0)."""
content = f"CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n{trailer}\n"
result = _run_verify(script_rel, trailer, _gz(content))
assert result.returncode == 0, (
f"{script_rel}: валидный дамп с трейлером в последних 5 строках отклонён "
f"(rc={result.returncode}). stderr:\n{_stderr(result)}"
)
@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items())
def test_verify_dump_integrity_rejects_truncated_dump(script_rel: str, trailer: str) -> None:
"""Дамп без трейлера (оборван на записи) — return 1, не 0."""
content = "CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n" # без трейлера
result = _run_verify(script_rel, trailer, _gz(content))
assert result.returncode == 1, (
f"{script_rel}: оборванный дамп должен быть отклонён (rc=1), получили "
f"rc={result.returncode}. stderr:\n{_stderr(result)}"
)
@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items())
def test_verify_dump_integrity_grep_does_not_choke_on_leading_dashdash(
script_rel: str, trailer: str
) -> None:
"""Регресс-гвоздь #2203: grep не должен падать 'unrecognized option'.
Трейлер начинается с `--`; без `--`-разделителя перед паттерном именно
так и было в проде grep не мог выполниться, и проверка ВСЕГДА
возвращала «трейлера нет» даже на валидном дампе.
"""
content = f"x\n{trailer}\n"
result = _run_verify(script_rel, trailer, _gz(content))
stderr = _stderr(result)
assert "unrecognized option" not in stderr, (
f"{script_rel}: grep споткнулся о ведущие '--' в трейлере — нет "
f"разделителя `--` перед паттерном (#2203). stderr:\n{stderr}"
)
assert result.returncode == 0, (
f"{script_rel}: валидный дамп с '--'-трейлером всё ещё отклоняется "
f"(rc={result.returncode}). stderr:\n{stderr}"
)

View file

@ -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 — отменённый деплой оставляет "
"прод на старом коде ровно так же, как упавший"

View file

@ -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="">'

View file

@ -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:

View file

@ -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)}"
)

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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}"

View file

@ -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

View file

@ -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}: типы ЗОУИТ пропали из сводки"
)

View file

@ -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 держит свою копию метки — форматы разъедутся при следующей правке"
)

View file

@ -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:

View file

@ -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)

View file

@ -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:

View file

@ -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
]

View file

@ -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:

View file

@ -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

View file

@ -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 на часы.
"""

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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}"
)

View file

@ -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 текст, "не назван живой портал ГИСОГД-СО"

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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 формально в диапазоне 5558.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:

View file

@ -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}: запрос вообще не выполнялся"

View file

@ -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}"
)

View file

@ -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, "чужая сессия закрыта — её закрывает вызывающий"

View file

@ -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 не должна меняться от ограничения пятна"
)

View file

@ -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:

View file

@ -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:

View file

@ -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",

View file

@ -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:

View file

@ -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:

View file

@ -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,

View file

@ -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 не возвращается независимо от давности"
)

View file

@ -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"])

View file

@ -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:

View file

@ -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

View file

@ -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:
"""Пр. Космонавтов — попадает в ЕКБ-регион."""

View file

@ -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'ом"
)

View file

@ -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:

View file

@ -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."""

View file

@ -165,3 +165,14 @@ tests/sql/test_2986_permits_source_key.py::test_old_key_collapses_permit_and_its
tests/sql/test_2986_permits_source_key.py::test_after_migration_both_documents_survive
tests/sql/test_2986_permits_source_key.py::test_cross_schema_duplicate_still_merges
tests/sql/test_2986_permits_source_key.py::test_migration_allows_the_izmeneniya_group
# #2998 — горизонт партиций rosreestr_deals. Четыре DB-теста поднимают rosreestr_deals из
# 01_schema в СХЕМЕ-ПЕСОЧНИЦЕ (герметично, прод не трогают), применяют миграцию 193 и
# читают горизонт из pg_inherits: до 193 красно по значению (01-схема кончается на 2026q1),
# после — партиция есть на публикуемый квартал + на следующий; 193 идемпотентна.
# В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. Календарный
# test_calendar_helper_matches_known_publication базы НЕ требует и в список НЕ входит.
tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_for_the_publishable_quarter
tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter
tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead

View file

@ -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():

View file

@ -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)"

View file

@ -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

View file

@ -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"], (
"последняя попытка должна быть новее последнего успеха — иначе провалы " "не видны вообще"
"последняя попытка должна быть новее последнего успеха — иначе провалы не видны вообще"
)

View file

@ -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()

View file

@ -0,0 +1,193 @@
"""У rosreestr_deals есть партиция под каждый квартал, который Росреестр уже мог
опубликовать (#2998).
Таблица партиционирована по period_start_date, и партиции создавались списком в
01_schema_rosreestr_deals.sql «2024 Q3 2026 Q1». Дальше этого горизонта таблица
ничего не знала, и никакой механизм новые партиции не создаёт. Q2 2026 вышел 10.07,
poll заметил его 14.08, а загрузка 21.08 упала:
ERROR: no partition of relation "rosreestr_deals" found for row
DETAIL: Partition key of the failing row contains (period_start_date) = (2026-04-01).
То есть даже оператор, запустив 02_load_all_quarters.sh по подсказке poll, получил
бы отказ. Миграция 193 добавляет Q2Q4 2026; этот тест следит, чтобы горизонт не
отставал снова: партиция обязана существовать на ПОСЛЕДНИЙ квартал, который по
календарю уже мог быть опубликован (публикация отстаёт от конца квартала ~на 10
дней: Q2 2026 вышел 10.07), плюс на следующий чтобы предупреждение приходило за
квартал до отказа, а не в день публикации.
Герметично, как соседние DB-тесты: в своей СХЕМЕ-песочнице поднимается
rosreestr_deals из 01_schema (без индексов), применяется миграция 193, горизонт
читается из pg_inherits по этой схеме. Прод не трогается; в CI идёт на
postgres-сервисе. Без БД skip с причиной.
Красная сторона воспроизводима, а не предполагается: до миграции 193 горизонт
01-схемы кончается на 2026q1, и головная проверка краснеет по значению
(«нет партиции на квартал 2026-04-01»).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import re
import uuid
from datetime import date, timedelta
from pathlib import Path
import pytest
from sqlalchemy import create_engine, text
_SQL_DIR = Path(__file__).resolve().parents[3] / "data" / "sql"
_SCHEMA_01 = _SQL_DIR / "01_schema_rosreestr_deals.sql"
_MIGRATION = _SQL_DIR / "193_partitions_rosreestr_2026_q2_q4.sql"
# Публикация квартала отстаёт от его конца; берём запас, чтобы не требовать партицию
# раньше, чем данные вообще могут появиться. Q2 2026 (конец 30.06) опубликован 10.07.
_PUBLICATION_LAG_DAYS = 20
def _dsn() -> str:
raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get(
"DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign"
)
return raw.replace("postgresql://", "postgresql+psycopg://", 1)
def _quarter_start(d: date) -> date:
return date(d.year, 3 * ((d.month - 1) // 3) + 1, 1)
def _next_quarter(q: date) -> date:
return date(q.year + (q.month == 10), 1 if q.month == 10 else q.month + 3, 1)
def _latest_publishable_quarter(today: date) -> date:
"""Начало последнего квартала, чей дамп по календарю уже мог выйти."""
q = _quarter_start(today)
prev_q = _quarter_start(q - timedelta(days=1))
if (today - q).days >= _PUBLICATION_LAG_DAYS:
return prev_q
return _quarter_start(prev_q - timedelta(days=1))
def _schema_01_without_indexes() -> str:
"""01_schema без CREATE INDEX: индексы тесту не нужны, а часть их — по колонкам,
которых в песочнице проверять нечего. Партиции и сама таблица целиком."""
sql = _SCHEMA_01.read_text(encoding="utf-8")
return re.sub(r"CREATE INDEX[^;]*;", "", sql, flags=re.S)
def _partition_starts(conn, schema: str) -> set[date]:
rows = conn.execute(
text(
"""
SELECT pg_get_expr(c.relpartbound, c.oid) AS bound
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
JOIN pg_namespace n ON n.oid = p.relnamespace
WHERE p.relname = 'rosreestr_deals' AND n.nspname = :schema
"""
),
{"schema": schema},
).scalars()
out: set[date] = set()
for b in rows:
frm = b.split("FROM ('", 1)[1].split("'", 1)[0]
out.add(date.fromisoformat(frm))
return out
@pytest.fixture(scope="module")
def sandbox():
"""Схема-песочница с rosreestr_deals из 01_schema. Удаляется в конце модуля."""
try:
eng = create_engine(_dsn(), future=True)
conn = eng.connect()
conn.execute(text("SELECT 1"))
conn.rollback() # SELECT 1 авто-открыл транзакцию — закрыть, иначе begin() падает
except Exception as e: # pragma: no cover - среда без БД
pytest.skip(f"нет Postgres для проверки партиций: {e}")
schema = f"t2998_{uuid.uuid4().hex[:10]}"
with conn.begin():
conn.execute(text(f"CREATE SCHEMA {schema}"))
conn.execute(text(f"SET LOCAL search_path TO {schema}"))
conn.execute(text(_schema_01_without_indexes()))
try:
yield conn, schema
finally:
conn.rollback()
with conn.begin():
conn.execute(text(f"DROP SCHEMA {schema} CASCADE"))
conn.close()
def _apply_193(conn, schema: str) -> None:
conn.rollback() # предыдущие SELECT авто-открыли транзакцию
with conn.begin():
conn.execute(text(f"SET LOCAL search_path TO {schema}"))
conn.execute(text(_MIGRATION.read_text(encoding="utf-8")))
def test_schema_01_alone_is_red_for_the_publishable_quarter(sandbox) -> None:
"""Красная сторона: ДО миграции 193 партиции на публикуемый квартал нет.
Это ровно состояние origin/main на 21.08.2026, когда загрузка Q2 упала.
Проверка по значению: горизонт 01-схемы кончается на 2026q1.
"""
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:]}"
)
need = date(2026, 4, 1) # публикуемый квартал на дату инцидента
assert need not in have, "красная сторона не состоялась: 01-схема уже знает Q2 2026"
def test_migration_193_is_idempotent_and_closes_the_gap(sandbox) -> None:
"""Миграция применяется дважды без ошибки и добавляет Q2Q4 2026."""
conn, schema = sandbox
_apply_193(conn, schema)
_apply_193(conn, schema)
have = _partition_starts(conn, schema)
assert {date(2026, 4, 1), date(2026, 7, 1), date(2026, 10, 1)} <= have, sorted(have)[-4:]
def test_partition_exists_for_every_publishable_quarter(sandbox) -> None:
"""Головной сторож горизонта: на каждый уже-публикуемый квартал есть партиция.
Сегодня это Q2 2026 (после 193 есть). Когда календарь дойдёт до квартала,
которого миграции ещё не создали, тест покраснеет по значению и это его работа.
"""
conn, schema = sandbox
_apply_193(conn, schema)
have = _partition_starts(conn, schema)
need = _latest_publishable_quarter(date.today())
assert need in have, (
f"нет партиции на квартал {need} — загрузка опубликованного дампа упадёт с "
f"«no partition of relation rosreestr_deals found for row»; есть: {sorted(have)[-3:]}"
)
def test_partition_exists_one_quarter_ahead(sandbox) -> None:
"""Контроль горизонта: партиция на СЛЕДУЮЩИЙ квартал тоже есть — предупреждение
должно приходить за квартал до отказа, а не в день публикации."""
conn, schema = sandbox
_apply_193(conn, schema)
have = _partition_starts(conn, schema)
need = _next_quarter(_latest_publishable_quarter(date.today()))
assert need in have, (
f"нет партиции на следующий квартал {need} — запаса нет, следующая публикация "
f"снова упрётся в отсутствие партиции; есть: {sorted(have)[-3:]}"
)
def test_calendar_helper_matches_known_publication() -> None:
"""Контроль калибровки (без БД): 21.08.2026 → Q2 2026; 05.07 → ещё Q1; 25.07 → Q2."""
assert _latest_publishable_quarter(date(2026, 8, 21)) == date(2026, 4, 1)
assert _latest_publishable_quarter(date(2026, 7, 5)) == date(2026, 1, 1)
assert _latest_publishable_quarter(date(2026, 7, 25)) == date(2026, 4, 1)

View file

@ -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."
)

View file

@ -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.

View file

@ -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]

View file

@ -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() действительно доехала до БД — читаем ОТДЕЛЬНЫМ

View file

@ -0,0 +1,71 @@
"""pre-commit и uv.lock держат ОДНУ версию ruff (#2864).
Хук `ruff-pre-commit` пинился на v0.7.4, а CI и `uv run ruff format` брали ruff из
lock-файлов (0.15.12 в backend/, 0.15.20 в tradein-mvp/). Старый и новый ruff
по-разному форматируют длинные `assert ..., "сообщение"` хук на каждом коммите
переписывал нетронутые строки, следующий `uv run ruff format` возвращал как было.
`ruff format --check` намеренно не в CI-гейте, поэтому дрейф нигде не краснел.
Гейт читает три источника истины и требует равенства. Красный на origin/main по
значению: rev v0.7.4 против 0.15.12 и 0.15.20. Бампить втроём: rev в
.pre-commit-config.yaml + `ruff==X` в обоих pyproject.toml + `uv lock`.
"""
from __future__ import annotations
import re
from pathlib import Path
КОРЕНЬ = Path(__file__).resolve().parents[2]
_REV = re.compile(
r"repo:\s*https://github\.com/astral-sh/ruff-pre-commit\s*\n\s*rev:\s*v?([\w.]+)",
)
_LOCK_RUFF = re.compile(r'^name = "ruff"\nversion = "([\w.]+)"', re.MULTILINE)
_PYPROJECT_RUFF = re.compile(r'"ruff([<>=!~]+[^"]*)"')
def _precommit_rev() -> str:
текст = (КОРЕНЬ / ".pre-commit-config.yaml").read_text(encoding="utf-8")
m = _REV.search(текст)
assert m, "в .pre-commit-config.yaml нет блока ruff-pre-commit с rev"
return m.group(1)
def _lock_version(lock: str) -> str:
m = _LOCK_RUFF.search((КОРЕНЬ / lock).read_text(encoding="utf-8"))
assert m, f"в {lock} нет пакета ruff"
return m.group(1)
def test_precommit_rev_matches_both_locks() -> None:
"""Головной: хук и оба lock'а — одна версия ruff."""
rev = _precommit_rev()
locks = {
"backend/uv.lock": _lock_version("backend/uv.lock"),
"tradein-mvp/uv.lock": _lock_version("tradein-mvp/uv.lock"),
}
расхождения = {k: v for k, v in locks.items() if v != rev}
assert not расхождения, (
f"pre-commit ruff rev=v{rev}, а в lock'ах {расхождения} — хук и "
"`uv run ruff format` будут форматировать по-разному (#2864)"
)
def test_pyproject_pins_exact_ruff() -> None:
"""Контроль от повторного дрейфа: `ruff==X` в обоих pyproject, X = rev.
С `>=` любой `uv lock --upgrade` молча уводит lock от хука ровно так
и разъехались 0.7.4 / 0.15.12 / 0.15.20.
"""
rev = _precommit_rev()
for f in ("backend/pyproject.toml", "tradein-mvp/backend/pyproject.toml"):
m = _PYPROJECT_RUFF.search((КОРЕНЬ / f).read_text(encoding="utf-8"))
assert m, f"в {f} нет зависимости ruff"
assert m.group(1) == f"=={rev}", f"{f}: ruff{m.group(1)}, ожидался ruff=={rev}"
def test_gate_reads_real_values() -> None:
"""Контроль самого гейта: парсеры возвращают версии, а не падают на пустом."""
assert re.fullmatch(r"\d+\.\d+\.\d+", _precommit_rev())
assert re.fullmatch(r"\d+\.\d+\.\d+", _lock_version("backend/uv.lock"))

View file

@ -0,0 +1,130 @@
"""avg_area_m2 в топ-планировках — NULL вместо 0 м², когда сделок за окно нет (#2867).
`_INLINE_VELOCITY_SQL` считал среднюю площадь как
`COALESCE(SUM(area_weighted_sum) / NULLIF(SUM(deals_window), 0), 0)` пустое окно
продаж давало делитель NULL и COALESCE подставлял 0: «средняя площадь проданной
квартиры 0 м²» вместо «сделок не было, средней нет». Замер прода 13.08: 635 пустых
пар (проект × комнатность) из 2083, у 80 проектов пусты все комнатности.
Контракт исправлен насквозь: схема `float | None`, SQL без COALESCE (как у соседнего
avg_price_per_m2_rub, #2464-B), сборка рядов не round'ит None, взвешенная площадь
микса делится на сделки рядов С площадью, PDF печатает «», фронт/типы `number | null`.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import inspect
import re
def test_velocity_sql_does_not_coalesce_area_to_zero() -> None:
"""Головной (SQL): в deals_per_bucket нет COALESCE(... area ..., 0).
На origin/main он есть с комментарием «остаётся намеренно отдельным заходом: #2867».
"""
from app.services.site_finder import best_layouts as m
sql = str(m._INLINE_VELOCITY_SQL)
blk = sql[sql.index("deals_per_bucket AS (") : sql.index("AS avg_area_m2")]
# Только код: SQL-комментарии (--) вырезаем — иначе гейт спорит с собственным
# объяснением правки, где слово COALESCE упомянуто (наступал на это трижды).
code = "\n".join(line for line in blk.splitlines() if not line.strip().startswith("--"))
assert "COALESCE(" not in code, (
"средняя площадь по-прежнему COALESCE'ится в 0 при пустом окне:\n" + code[-300:]
)
def test_schema_declares_avg_area_nullable() -> None:
"""Контракт API: TopLayoutRow.avg_area_m2 допускает None."""
from app.schemas.parcel import TopLayoutRow
ann = TopLayoutRow.model_fields["avg_area_m2"].annotation
assert ann is not float, f"avg_area_m2 объявлен как {ann!r} — None ронял бы контракт"
TopLayoutRow.model_validate(
{
"room_bucket": "1",
"area_bin": "25-40",
"project_name": "x",
"signature": "s",
"competitor_obj_ids": [],
"competitor_count": 0,
"total_sold_in_window": 0,
"velocity_per_month": 0.0,
"avg_price_per_m2_rub": None,
"avg_area_m2": None,
"supply_units_in_radius": 0,
"sold_pct_of_supply": None,
"is_oversold": False,
"rank": 1,
}
)
def test_row_assembly_keeps_none_not_zero() -> None:
"""Сборка рядов: NULL из БД остаётся None, а не превращается в 0.0 / round(None).
На origin/main `else 0.0`: пустое окно отдаёт 0 м². Проверяем по исходнику
двух мест сборки, потому что собрать полный ответ без БД нельзя.
"""
from app.services.site_finder import best_layouts as m
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"
)
def test_mix_weighted_area_excludes_rows_without_area() -> None:
"""Контроль смысла: взвешенная площадь микса делится на сделки рядов С площадью.
Иначе ряд без площади (None) считался бы сделками «с площадью 0» и занижал среднее.
То же правило, что у цены в соседних строках.
"""
from app.services.site_finder import best_layouts as m
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), а не на ряды с площадью"
)
def test_pdf_renders_dash_for_missing_area() -> None:
"""PDF: строка с avg_area_m2=None печатает «—», а не падает на :.1f.
На origin/main `f"{r.avg_area_m2:.1f}"` на None бросает TypeError это отсутствие
возможности, а не неверное значение; поэтому рядом контроль: число печатается как
раньше с одним знаком.
"""
from app.schemas.parcel import TopLayoutRow
from app.services.exporters import layout_tz_pdf as pdf
def row(area):
return TopLayoutRow(
room_bucket="2",
area_bin="40-60",
project_name="ЖК",
signature="sig",
competitor_obj_ids=[1],
competitor_count=1,
total_sold_in_window=3,
velocity_per_month=1.5,
avg_price_per_m2_rub=150000.0,
avg_area_m2=area,
supply_units_in_radius=10,
sold_pct_of_supply=30.0,
is_oversold=False,
rank=1,
)
assert row(None).avg_area_m2 is None and row(38.5).avg_area_m2 == 38.5
src = inspect.getsource(pdf)
assert "_area_cell" in src, "нет ячейки площади с обработкой None"
# прямая проверка самой ячейки через исходник функции — она вложенная в рендер
assert 'return "<td>—</td>"' in src
assert "<td>{val:.1f}</td>" in src, "число перестало печататься с одним знаком"

View file

@ -0,0 +1,145 @@
"""Гард свежести :latest перед деплоем (#2950) — коды выхода по значению.
Сценарий, ради которого гард существует: Forgejo отменил ещё не стартовавший deploy
run'а A, run B (билды пропущены) катит :latest, а :latest несёт ревизию СТАРШЕ
последнего коммита по компоненту билд A не доехал. Гард обязан ждать и, не
дождавшись, падать; а при ревизии не старше последнего коммита пропускать.
`docker` подменяется шимом на PATH: он печатает конфиг образа с меткой ревизии из
переменной окружения FAKE_REV (пусто метки нет; FAKE_DOCKER_FAIL=1 registry не
ответил). Временный git-репозиторий даёт настоящие коммиты для `git log`/merge-base.
"""
from __future__ import annotations
import os
import stat
import subprocess
from pathlib import Path
import pytest
КОРЕНЬ = Path(__file__).resolve().parents[2]
СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh"
_SHIM = """#!/usr/bin/env bash
# подменный docker: только `buildx imagetools inspect <img> --format ...`
if [ "${FAKE_DOCKER_FAIL:-}" = "1" ]; then echo "unauthorized" >&2; exit 1; fi
FMT='{"linux/amd64": {"config": {"Labels": {"org.opencontainers.image.revision": "%s"}}}}'
if [ -n "${FAKE_REV:-}" ]; then
printf "$FMT" "$FAKE_REV"
else
printf '{"linux/amd64": {"config": {"Labels": {}}}}'
fi
"""
def _git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
env={
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
},
).stdout.strip()
def _commit(repo: Path, rel: str, msg: str) -> str:
p = repo / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(msg, encoding="utf-8")
_git(repo, "add", rel)
_git(repo, "commit", "-q", "-m", msg)
return _git(repo, "rev-parse", "HEAD")
@pytest.fixture()
def repo(tmp_path: Path) -> dict[str, object]:
r = tmp_path / "repo"
r.mkdir()
_git(r, "init", "-q")
c1 = _commit(r, "backend/a.py", "c1 backend")
c2 = _commit(r, "frontend/b.ts", "c2 frontend")
c3 = _commit(r, "backend/c.py", "c3 backend")
c4 = _commit(r, "frontend/d.ts", "c4 frontend")
shim_dir = tmp_path / "bin"
shim_dir.mkdir()
shim = shim_dir / "docker"
shim.write_text(_SHIM, encoding="utf-8")
shim.chmod(shim.stat().st_mode | stat.S_IEXEC)
return {"path": r, "c1": c1, "c2": c2, "c3": c3, "c4": c4, "bin": shim_dir}
def _run(
repo: dict[str, object], rev: str | None, *paths: str, fail: bool = False, timeout: str = "1"
) -> subprocess.CompletedProcess[str]:
env = {
**os.environ,
"PATH": f"{repo['bin']}{os.pathsep}{os.environ['PATH']}",
"CHECK_LATEST_SLEEP_S": "0.2",
"FAKE_REV": rev or "",
"FAKE_DOCKER_FAIL": "1" if fail else "",
}
return subprocess.run(
[str(СКРИПТ), "ghcr.io/x/img", timeout, "--", *paths],
cwd=repo["path"],
env=env,
capture_output=True,
text=True,
)
def test_fresh_latest_passes(repo: dict[str, object]) -> None:
""":latest собран из последнего коммита по backend (c3) → 0."""
r = _run(repo, str(repo["c3"]), "backend")
assert r.returncode == 0, r.stdout + r.stderr
assert "содержит последний коммит" in r.stdout
def test_newer_revision_passes(repo: dict[str, object]) -> None:
"""Ревизия новее последнего коммита по компоненту (dispatch собрал голову c4) → 0."""
r = _run(repo, str(repo["c4"]), "backend")
assert r.returncode == 0, r.stdout + r.stderr
def test_stale_latest_fails_after_timeout(repo: dict[str, object]) -> None:
"""Головной: :latest из c1, а по backend последний — c3 → ждёт, затем 1 с ::error."""
# timeout=3: дедлайн считается целыми секундами (`date +%s`), и при 1 с первый
# опрос может прийти уже за дедлайном — гард честно падает сразу, а тест ждёт
# строки ожидания. 3 с при паузе 0.2 с дают её гарантированно.
r = _run(repo, str(repo["c1"]), "backend", timeout="3")
assert r.returncode == 1, r.stdout + r.stderr
assert "::error::" in r.stderr and "отстаёт" in r.stderr
assert "жду билд предшественника" in r.stdout, "гард обязан ЖДАТЬ, а не падать сразу"
def test_revision_from_other_component_does_not_cover(repo: dict[str, object]) -> None:
"""Контроль путей: для frontend последний — c4; :latest из c3 (до c4) → 1."""
r = _run(repo, str(repo["c3"]), "frontend")
assert r.returncode == 1, r.stdout + r.stderr
def test_missing_label_fails(repo: dict[str, object]) -> None:
"""Без метки ревизии нельзя утверждать свежесть → 1, а не молчаливый пропуск."""
r = _run(repo, None, "backend")
assert r.returncode == 1, r.stdout + r.stderr
assert "метки нет" in r.stderr
def test_registry_error_fails(repo: dict[str, object]) -> None:
"""Registry не отвечает → 1 (не «проверить нельзя — значит можно»)."""
r = _run(repo, str(repo["c3"]), "backend", fail=True)
assert r.returncode == 1, r.stdout + r.stderr
def test_usage_errors_are_distinct(repo: dict[str, object]) -> None:
"""Ошибка вызова — код 2, чтобы её не приняли за «отстаёт»."""
r = subprocess.run([str(СКРИПТ), "img"], cwd=repo["path"], capture_output=True, text=True)
assert r.returncode == 2

View file

@ -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",
)
# ---------------------------------------------------------------------------

View file

@ -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) == []

View file

@ -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():

View file

@ -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():

Some files were not shown because too many files have changed in this diff Show more