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