diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index fef908ce..d0332980 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index fd04b561..95df986a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3175e0db..0c1d39f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/backend/app/schemas/own_project.py b/backend/app/schemas/own_project.py index 3d878d7e..e3e2c661 100644 --- a/backend/app/schemas/own_project.py +++ b/backend/app/schemas/own_project.py @@ -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)" ) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 118f527a..078dde50 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -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% (несопоставимые окна) diff --git a/backend/app/services/analytics/ddu_price_indicator.py b/backend/app/services/analytics/ddu_price_indicator.py index cf7268e8..df503d0e 100644 --- a/backend/app/services/analytics/ddu_price_indicator.py +++ b/backend/app/services/analytics/ddu_price_indicator.py @@ -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() diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 9e6ef0ab..8393b48e 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -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: diff --git a/backend/app/services/chat/intents.py b/backend/app/services/chat/intents.py index d821c7e9..7d66c009 100644 --- a/backend/app/services/chat/intents.py +++ b/backend/app/services/chat/intents.py @@ -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 diff --git a/backend/app/services/etl/newbuilding_crossload.py b/backend/app/services/etl/newbuilding_crossload.py index ab5ed376..ad5ef66d 100644 --- a/backend/app/services/etl/newbuilding_crossload.py +++ b/backend/app/services/etl/newbuilding_crossload.py @@ -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, diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py index 4061dabd..581f200d 100644 --- a/backend/app/services/exporters/layout_tz_pdf.py +++ b/backend/app/services/exporters/layout_tz_pdf.py @@ -50,6 +50,12 @@ def build_layout_tz_html( return "—" return f"{val:,.0f}".replace(",", " ") + " ₽" + def _area_cell(val: float | None) -> str: + """#2867: средняя площадь — None, если сделок за окно нет → «—», а не «0.0».""" + if val is None: + return "—" + return f"{val:.1f}" + def _price_m2_cell(val: float | None) -> str: """Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful).""" if val is None: @@ -69,7 +75,7 @@ def build_layout_tz_html( f"{_html.escape(r.room_bucket)}" f"{_html.escape(r.area_bin)}" f"{r.velocity_per_month:.1f}" - f"{r.avg_area_m2:.1f}" + f"{_area_cell(r.avg_area_m2)}" f"{_price_cell(r.avg_price_per_m2_rub)}" f"{r.total_sold_in_window}" "" diff --git a/backend/app/services/exporters/report_docx.py b/backend/app/services/exporters/report_docx.py index 96663941..6c47930f 100644 --- a/backend/app/services/exporters/report_docx.py +++ b/backend/app/services/exporters/report_docx.py @@ -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 = [ diff --git a/backend/app/services/exporters/report_maps.py b/backend/app/services/exporters/report_maps.py index 44fe62db..3f9e3cbd 100644 --- a/backend/app/services/exporters/report_maps.py +++ b/backend/app/services/exporters/report_maps.py @@ -85,8 +85,7 @@ _CONCEPT_FOOTPRINT_STYLE = { } _MAP_UNAVAILABLE_HTML = ( - '
Карта недоступна — геоданные участка отсутствуют ' - "в отчёте
" + '
Карта недоступна — геоданные участка отсутствуют в отчёте
' ) diff --git a/backend/app/services/forecasting/affordability.py b/backend/app/services/forecasting/affordability.py index 04235f7d..ac865265 100644 --- a/backend/app/services/forecasting/affordability.py +++ b/backend/app/services/forecasting/affordability.py @@ -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: diff --git a/backend/app/services/generative/catalog.py b/backend/app/services/generative/catalog.py index 2ed0c21c..45f0fb93 100644 --- a/backend/app/services/generative/catalog.py +++ b/backend/app/services/generative/catalog.py @@ -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 diff --git a/backend/app/services/generative/geometry.py b/backend/app/services/generative/geometry.py index 930ce619..c4052623 100644 --- a/backend/app/services/generative/geometry.py +++ b/backend/app/services/generative/geometry.py @@ -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) diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index 772db0b3..c27fdec8 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -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)", diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index f7567a49..ea7045e5 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -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: diff --git a/backend/app/services/site_finder/eias_heat_loader.py b/backend/app/services/site_finder/eias_heat_loader.py index 6b340fa9..02febce0 100644 --- a/backend/app/services/site_finder/eias_heat_loader.py +++ b/backend/app/services/site_finder/eias_heat_loader.py @@ -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: diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 4b3a8726..bff1dd4c 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -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', '')}" ), ) ) diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index d6347644..8ead9006 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -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, diff --git a/backend/app/services/site_finder/noise_loader.py b/backend/app/services/site_finder/noise_loader.py index 32755c83..627997a4 100644 --- a/backend/app/services/site_finder/noise_loader.py +++ b/backend/app/services/site_finder/noise_loader.py @@ -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]: diff --git a/backend/app/services/site_finder/pzz_loader.py b/backend/app/services/site_finder/pzz_loader.py index 7d3c3a12..87e25c29 100644 --- a/backend/app/services/site_finder/pzz_loader.py +++ b/backend/app/services/site_finder/pzz_loader.py @@ -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) diff --git a/backend/app/workers/tasks/scrape_objective.py b/backend/app/workers/tasks/scrape_objective.py index 7ab5506d..76270c40 100644 --- a/backend/app/workers/tasks/scrape_objective.py +++ b/backend/app/workers/tasks/scrape_objective.py @@ -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, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0411d8d3..f41dd24c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", diff --git a/backend/scripts/spike_plan_vectorize.py b/backend/scripts/spike_plan_vectorize.py index 2ffd2a77..1f4b3cee 100644 --- a/backend/scripts/spike_plan_vectorize.py +++ b/backend/scripts/spike_plan_vectorize.py @@ -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: diff --git a/backend/tests/api/v1/test_2464_confidence_zoning_source.py b/backend/tests/api/v1/test_2464_confidence_zoning_source.py index 6878d6e4..71cd130a 100644 --- a/backend/tests/api/v1/test_2464_confidence_zoning_source.py +++ b/backend/tests/api/v1/test_2464_confidence_zoning_source.py @@ -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 diff --git a/backend/tests/api/v1/test_2464g_noise_source_filter.py b/backend/tests/api/v1/test_2464g_noise_source_filter.py index af948cdc..cfd60e80 100644 --- a/backend/tests/api/v1/test_2464g_noise_source_filter.py +++ b/backend/tests/api/v1/test_2464g_noise_source_filter.py @@ -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: diff --git a/backend/tests/api/v1/test_2934_no_fake_geology_label.py b/backend/tests/api/v1/test_2934_no_fake_geology_label.py index 96f49925..17ccd476 100644 --- a/backend/tests/api/v1/test_2934_no_fake_geology_label.py +++ b/backend/tests/api/v1/test_2934_no_fake_geology_label.py @@ -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: diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index 055a2d34..7174b6dd 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -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: diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index cc61f0ef..35ffb40e 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -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() diff --git a/backend/tests/api/v1/test_insights.py b/backend/tests/api/v1/test_insights.py index ad202afd..c84cac60 100644 --- a/backend/tests/api/v1/test_insights.py +++ b/backend/tests/api/v1/test_insights.py @@ -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 diff --git a/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py b/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py index f435dc5a..ae211c57 100644 --- a/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py +++ b/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py @@ -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: diff --git a/backend/tests/api/v1/test_own_projects.py b/backend/tests/api/v1/test_own_projects.py index 06e67314..1a791839 100644 --- a/backend/tests/api/v1/test_own_projects.py +++ b/backend/tests/api/v1/test_own_projects.py @@ -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"] == "Переименовано" diff --git a/backend/tests/api/v1/test_parcel_competitors.py b/backend/tests/api/v1/test_parcel_competitors.py index d49138ab..1e3c0bfd 100644 --- a/backend/tests/api/v1/test_parcel_competitors.py +++ b/backend/tests/api/v1/test_parcel_competitors.py @@ -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 маппинге" diff --git a/backend/tests/integration/test_analyze_parcels_sql.py b/backend/tests/integration/test_analyze_parcels_sql.py index b50d0577..49dde2cc 100644 --- a/backend/tests/integration/test_analyze_parcels_sql.py +++ b/backend/tests/integration/test_analyze_parcels_sql.py @@ -104,9 +104,9 @@ class TestNeighborsSummarySql: for kw in forbidden_aliases: # ищем паттерн ``WITH AS (`` или ``, 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 ────────────────────────────────────────────────── diff --git a/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py b/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py new file mode 100644 index 00000000..b944536b --- /dev/null +++ b/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py @@ -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}" + ) diff --git a/backend/tests/ops/test_2950_deploy_concurrency_group.py b/backend/tests/ops/test_2950_deploy_concurrency_group.py index b8fa59b2..a2e8b519 100644 --- a/backend/tests/ops/test_2950_deploy_concurrency_group.py +++ b/backend/tests/ops/test_2950_deploy_concurrency_group.py @@ -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 — отменённый деплой оставляет " "прод на старом коде ровно так же, как упавший" diff --git a/backend/tests/scrapers/test_cbr_macro.py b/backend/tests/scrapers/test_cbr_macro.py index 5b03f0c3..ffffc1e3 100644 --- a/backend/tests/scrapers/test_cbr_macro.py +++ b/backend/tests/scrapers/test_cbr_macro.py @@ -41,7 +41,7 @@ FIXTURE_KEYRATE_XML = ( # где угодно), несмотря на schema-блок и namespace на KR. FIXTURE_KEYRATE_DIFFGRAM = ( '' - "" + '' '' '' diff --git a/backend/tests/scrapers/test_emiss_sdmx.py b/backend/tests/scrapers/test_emiss_sdmx.py index 8b356e1d..6cba33ba 100644 --- a/backend/tests/scrapers/test_emiss_sdmx.py +++ b/backend/tests/scrapers/test_emiss_sdmx.py @@ -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: diff --git a/backend/tests/scrapers/test_nspd_bulk_client.py b/backend/tests/scrapers/test_nspd_bulk_client.py index bc57857a..ef93c852 100644 --- a/backend/tests/scrapers/test_nspd_bulk_client.py +++ b/backend/tests/scrapers/test_nspd_bulk_client.py @@ -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)}" + ) diff --git a/backend/tests/scrapers/test_nspd_grid_walk.py b/backend/tests/scrapers/test_nspd_grid_walk.py index 26d8e625..a34bceac 100644 --- a/backend/tests/scrapers/test_nspd_grid_walk.py +++ b/backend/tests/scrapers/test_nspd_grid_walk.py @@ -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" diff --git a/backend/tests/scrapers/test_rosstat_emiss.py b/backend/tests/scrapers/test_rosstat_emiss.py index bb3c2c30..525a2c13 100644 --- a/backend/tests/scrapers/test_rosstat_emiss.py +++ b/backend/tests/scrapers/test_rosstat_emiss.py @@ -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" diff --git a/backend/tests/services/analysis_runs/test_repository_run_history.py b/backend/tests/services/analysis_runs/test_repository_run_history.py index f5201e01..fe54cb16 100644 --- a/backend/tests/services/analysis_runs/test_repository_run_history.py +++ b/backend/tests/services/analysis_runs/test_repository_run_history.py @@ -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 diff --git a/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py b/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py index e9d7a146..733ca4df 100644 --- a/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py +++ b/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py @@ -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}" diff --git a/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py b/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py index 7104d93a..711dec1b 100644 --- a/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py +++ b/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py @@ -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 diff --git a/backend/tests/services/exporters/test_2464_zouit_count_label.py b/backend/tests/services/exporters/test_2464_zouit_count_label.py index ec17ce91..f2c46189 100644 --- a/backend/tests/services/exporters/test_2464_zouit_count_label.py +++ b/backend/tests/services/exporters/test_2464_zouit_count_label.py @@ -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}: типы ЗОУИТ пропали из сводки" + ) diff --git a/backend/tests/services/exporters/test_2934_flood_row_honesty.py b/backend/tests/services/exporters/test_2934_flood_row_honesty.py index 03fda10c..3ae8b247 100644 --- a/backend/tests/services/exporters/test_2934_flood_row_honesty.py +++ b/backend/tests/services/exporters/test_2934_flood_row_honesty.py @@ -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 держит свою копию метки — форматы разъедутся при следующей правке" + ) diff --git a/backend/tests/services/exporters/test_excel.py b/backend/tests/services/exporters/test_excel.py index b639775f..549a075d 100644 --- a/backend/tests/services/exporters/test_excel.py +++ b/backend/tests/services/exporters/test_excel.py @@ -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: diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py index b946e28f..572f0566 100644 --- a/backend/tests/services/forecasting/test_confidence_engine.py +++ b/backend/tests/services/forecasting/test_confidence_engine.py @@ -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) diff --git a/backend/tests/services/forecasting/test_normalize.py b/backend/tests/services/forecasting/test_normalize.py index f85229b3..be5cc9d7 100644 --- a/backend/tests/services/forecasting/test_normalize.py +++ b/backend/tests/services/forecasting/test_normalize.py @@ -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: diff --git a/backend/tests/services/forecasting/test_scenarios.py b/backend/tests/services/forecasting/test_scenarios.py index 94999a10..2588cb99 100644 --- a/backend/tests/services/forecasting/test_scenarios.py +++ b/backend/tests/services/forecasting/test_scenarios.py @@ -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 ] diff --git a/backend/tests/services/generative/test_market_price_lookup.py b/backend/tests/services/generative/test_market_price_lookup.py index 5667f38e..abdd5fe3 100644 --- a/backend/tests/services/generative/test_market_price_lookup.py +++ b/backend/tests/services/generative/test_market_price_lookup.py @@ -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: diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py index a8757ff4..0909f1f9 100644 --- a/backend/tests/services/generative/test_teap_financial.py +++ b/backend/tests/services/generative/test_teap_financial.py @@ -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 diff --git a/backend/tests/services/llm/test_client.py b/backend/tests/services/llm/test_client.py index 838165a2..63c56f60 100644 --- a/backend/tests/services/llm/test_client.py +++ b/backend/tests/services/llm/test_client.py @@ -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 на часы. """ diff --git a/backend/tests/services/llm/test_provider.py b/backend/tests/services/llm/test_provider.py index eccf3434..18ba9f5b 100644 --- a/backend/tests/services/llm/test_provider.py +++ b/backend/tests/services/llm/test_provider.py @@ -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 diff --git a/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py b/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py index ad58d072..151be295 100644 --- a/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py +++ b/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py @@ -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 diff --git a/backend/tests/services/scrapers/test_2464_detect_kind_position.py b/backend/tests/services/scrapers/test_2464_detect_kind_position.py index 647ea28f..2fb124a1 100644 --- a/backend/tests/services/scrapers/test_2464_detect_kind_position.py +++ b/backend/tests/services/scrapers/test_2464_detect_kind_position.py @@ -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: diff --git a/backend/tests/services/scrapers/test_2464_status_negation.py b/backend/tests/services/scrapers/test_2464_status_negation.py index c2a6fbcb..d54591ce 100644 --- a/backend/tests/services/scrapers/test_2464_status_negation.py +++ b/backend/tests/services/scrapers/test_2464_status_negation.py @@ -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}" + ) diff --git a/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py b/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py index 1394c84b..fc809698 100644 --- a/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py +++ b/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py @@ -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 текст, "не назван живой портал ГИСОГД-СО" diff --git a/backend/tests/services/scrapers/test_domrf_catalog_object.py b/backend/tests/services/scrapers/test_domrf_catalog_object.py index 34012edd..30da2a2d 100644 --- a/backend/tests/services/scrapers/test_domrf_catalog_object.py +++ b/backend/tests/services/scrapers/test_domrf_catalog_object.py @@ -42,7 +42,7 @@ def test_extract_next_data_from_html() -> None: def test_extract_next_data_single_quotes() -> None: """Тег с одинарными кавычками тоже должен парситься.""" - html = "" + html = '' result = extract_next_data(html) assert "props" in result diff --git a/backend/tests/services/scrapers/test_domrf_catalog_parse.py b/backend/tests/services/scrapers/test_domrf_catalog_parse.py index a9214814..786aec07 100644 --- a/backend/tests/services/scrapers/test_domrf_catalog_parse.py +++ b/backend/tests/services/scrapers/test_domrf_catalog_parse.py @@ -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"{nav_text}" if nav_text else "" - return ( - "" - f"{nav}" - f"{label}" - f"{value}" - "" - ) + return f"{nav}{label}{value}" def _html_blocks_only(blocks: list[tuple[str, str]]) -> str: diff --git a/backend/tests/services/scrapers/test_domrf_flat_plan_url.py b/backend/tests/services/scrapers/test_domrf_flat_plan_url.py index ef613b7b..acdb2e0f 100644 --- a/backend/tests/services/scrapers/test_domrf_flat_plan_url.py +++ b/backend/tests/services/scrapers/test_domrf_flat_plan_url.py @@ -115,10 +115,7 @@ def test_plan_from_img_proximity_to_label() -> None: """ без plan-hint в атрибутах, но рядом с блоком «Планировка».""" plain = f"{BASE_URL}/api/ext/file/imgabc.png" html = ( - "" - "
Планировка
" - f'' - "" + f'
Планировка
' ) got = extract_plan_image_url(html, _collector_for(html)) assert got == plain diff --git a/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py b/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py index c0f7de00..f31b97bf 100644 --- a/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py +++ b/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py @@ -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 diff --git a/backend/tests/services/scrapers/test_okn_egrkn_client.py b/backend/tests/services/scrapers/test_okn_egrkn_client.py index 6d1740c6..fb7a6d87 100644 --- a/backend/tests/services/scrapers/test_okn_egrkn_client.py +++ b/backend/tests/services/scrapers/test_okn_egrkn_client.py @@ -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: diff --git a/backend/tests/services/site_finder/test_2464_capacity_savepoints.py b/backend/tests/services/site_finder/test_2464_capacity_savepoints.py index c43e1d77..760d448e 100644 --- a/backend/tests/services/site_finder/test_2464_capacity_savepoints.py +++ b/backend/tests/services/site_finder/test_2464_capacity_savepoints.py @@ -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}: запрос вообще не выполнялся" diff --git a/backend/tests/services/site_finder/test_2464_default_profile_order.py b/backend/tests/services/site_finder/test_2464_default_profile_order.py index b01e3352..6d23bb7a 100644 --- a/backend/tests/services/site_finder/test_2464_default_profile_order.py +++ b/backend/tests/services/site_finder/test_2464_default_profile_order.py @@ -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}" + ) diff --git a/backend/tests/services/site_finder/test_2464_heat_loader_tx.py b/backend/tests/services/site_finder/test_2464_heat_loader_tx.py index 304e6218..cb4cfad1 100644 --- a/backend/tests/services/site_finder/test_2464_heat_loader_tx.py +++ b/backend/tests/services/site_finder/test_2464_heat_loader_tx.py @@ -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, "чужая сессия закрыта — её закрывает вызывающий" diff --git a/backend/tests/services/site_finder/test_2464_teap_input_sanity.py b/backend/tests/services/site_finder/test_2464_teap_input_sanity.py index 16a86ff1..e885f4a8 100644 --- a/backend/tests/services/site_finder/test_2464_teap_input_sanity.py +++ b/backend/tests/services/site_finder/test_2464_teap_input_sanity.py @@ -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 не должна меняться от ограничения пятна" + ) diff --git a/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py b/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py index 31c08f46..18f17a18 100644 --- a/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py +++ b/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py @@ -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: diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 8825c9c5..6ddc3906 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -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: diff --git a/backend/tests/services/site_finder/test_competitors_parking.py b/backend/tests/services/site_finder/test_competitors_parking.py index 58fb795d..fecc42eb 100644 --- a/backend/tests/services/site_finder/test_competitors_parking.py +++ b/backend/tests/services/site_finder/test_competitors_parking.py @@ -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", diff --git a/backend/tests/services/site_finder/test_pat_subzones.py b/backend/tests/services/site_finder/test_pat_subzones.py index 826f8ef0..61b79e38 100644 --- a/backend/tests/services/site_finder/test_pat_subzones.py +++ b/backend/tests/services/site_finder/test_pat_subzones.py @@ -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: diff --git a/backend/tests/services/site_finder/test_permits_nearby.py b/backend/tests/services/site_finder/test_permits_nearby.py index 36f82ae5..f39f8832 100644 --- a/backend/tests/services/site_finder/test_permits_nearby.py +++ b/backend/tests/services/site_finder/test_permits_nearby.py @@ -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: diff --git a/backend/tests/services/site_finder/test_supply_layers.py b/backend/tests/services/site_finder/test_supply_layers.py index 0e11e83f..7a10105f 100644 --- a/backend/tests/services/site_finder/test_supply_layers.py +++ b/backend/tests/services/site_finder/test_supply_layers.py @@ -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, diff --git a/backend/tests/services/test_2464_docstring_matches_code.py b/backend/tests/services/test_2464_docstring_matches_code.py index 136bc1f5..b14e07cc 100644 --- a/backend/tests/services/test_2464_docstring_matches_code.py +++ b/backend/tests/services/test_2464_docstring_matches_code.py @@ -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 не возвращается независимо от давности" + ) diff --git a/backend/tests/services/test_2464_three_small_leaks.py b/backend/tests/services/test_2464_three_small_leaks.py index 57479248..a3a5279f 100644 --- a/backend/tests/services/test_2464_three_small_leaks.py +++ b/backend/tests/services/test_2464_three_small_leaks.py @@ -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"]) diff --git a/backend/tests/services/test_2464a_job_settings_savepoint.py b/backend/tests/services/test_2464a_job_settings_savepoint.py index 5539f82c..c842536f 100644 --- a/backend/tests/services/test_2464a_job_settings_savepoint.py +++ b/backend/tests/services/test_2464a_job_settings_savepoint.py @@ -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: diff --git a/backend/tests/services/test_analytics_queries_domrf_dedup.py b/backend/tests/services/test_analytics_queries_domrf_dedup.py index a6db1805..77e0edf1 100644 --- a/backend/tests/services/test_analytics_queries_domrf_dedup.py +++ b/backend/tests/services/test_analytics_queries_domrf_dedup.py @@ -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 diff --git a/backend/tests/services/test_ekburg_permits.py b/backend/tests/services/test_ekburg_permits.py index 51c47cd2..1bd8f8b9 100644 --- a/backend/tests/services/test_ekburg_permits.py +++ b/backend/tests/services/test_ekburg_permits.py @@ -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: """Пр. Космонавтов — попадает в ЕКБ-регион.""" diff --git a/backend/tests/services/test_newbuilding_crossload.py b/backend/tests/services/test_newbuilding_crossload.py index 232cc7f1..fecd061d 100644 --- a/backend/tests/services/test_newbuilding_crossload.py +++ b/backend/tests/services/test_newbuilding_crossload.py @@ -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'ом" + ) diff --git a/backend/tests/services/test_recommend_mix_velocity.py b/backend/tests/services/test_recommend_mix_velocity.py index 73ea6e0a..38804a2c 100644 --- a/backend/tests/services/test_recommend_mix_velocity.py +++ b/backend/tests/services/test_recommend_mix_velocity.py @@ -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: diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index ed5b5f71..8e3f6a7c 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -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.""" diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index d1c28220..0c08811c 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -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 diff --git a/backend/tests/sql/test_2464_act_date_backfill.py b/backend/tests/sql/test_2464_act_date_backfill.py index bc6a0311..9442753f 100644 --- a/backend/tests/sql/test_2464_act_date_backfill.py +++ b/backend/tests/sql/test_2464_act_date_backfill.py @@ -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(): diff --git a/backend/tests/sql/test_2464_land_reservation_dedup.py b/backend/tests/sql/test_2464_land_reservation_dedup.py index f9e923ee..067302af 100644 --- a/backend/tests/sql/test_2464_land_reservation_dedup.py +++ b/backend/tests/sql/test_2464_land_reservation_dedup.py @@ -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)" diff --git a/backend/tests/sql/test_2464_leads_stats_suffix_contract.py b/backend/tests/sql/test_2464_leads_stats_suffix_contract.py index fc13f048..f83a868f 100644 --- a/backend/tests/sql/test_2464_leads_stats_suffix_contract.py +++ b/backend/tests/sql/test_2464_leads_stats_suffix_contract.py @@ -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 diff --git a/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py b/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py index 7193158c..c30077ec 100644 --- a/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py +++ b/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py @@ -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"], ( - "последняя попытка должна быть новее последнего успеха — иначе провалы " "не видны вообще" + "последняя попытка должна быть новее последнего успеха — иначе провалы не видны вообще" ) diff --git a/backend/tests/sql/test_2986_permits_source_key.py b/backend/tests/sql/test_2986_permits_source_key.py index b4b2d675..1214708c 100644 --- a/backend/tests/sql/test_2986_permits_source_key.py +++ b/backend/tests/sql/test_2986_permits_source_key.py @@ -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() diff --git a/backend/tests/sql/test_2998_rosreestr_partition_horizon.py b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py new file mode 100644 index 00000000..1236bade --- /dev/null +++ b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py @@ -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) diff --git a/backend/tests/sql/test_auth_sql_migrations.py b/backend/tests/sql/test_auth_sql_migrations.py index 29a14105..cd446547 100644 --- a/backend/tests/sql/test_auth_sql_migrations.py +++ b/backend/tests/sql/test_auth_sql_migrations.py @@ -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." + ) diff --git a/backend/tests/sql/test_ddu_price_indicator.py b/backend/tests/sql/test_ddu_price_indicator.py index df9bd034..9033a404 100644 --- a/backend/tests/sql/test_ddu_price_indicator.py +++ b/backend/tests/sql/test_ddu_price_indicator.py @@ -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. diff --git a/backend/tests/sql/test_velocity_alerts.py b/backend/tests/sql/test_velocity_alerts.py index 44b34bfe..7baf7631 100644 --- a/backend/tests/sql/test_velocity_alerts.py +++ b/backend/tests/sql/test_velocity_alerts.py @@ -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] diff --git a/backend/tests/test_2464c_photos_session_release.py b/backend/tests/test_2464c_photos_session_release.py index 10b2a9db..dae3c691 100644 --- a/backend/tests/test_2464c_photos_session_release.py +++ b/backend/tests/test_2464c_photos_session_release.py @@ -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() действительно доехала до БД — читаем ОТДЕЛЬНЫМ diff --git a/backend/tests/test_2864_ruff_version_alignment.py b/backend/tests/test_2864_ruff_version_alignment.py new file mode 100644 index 00000000..fb463f97 --- /dev/null +++ b/backend/tests/test_2864_ruff_version_alignment.py @@ -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")) diff --git a/backend/tests/test_2867_avg_area_nullable.py b/backend/tests/test_2867_avg_area_nullable.py new file mode 100644 index 00000000..2f95a392 --- /dev/null +++ b/backend/tests/test_2867_avg_area_nullable.py @@ -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 "—"' in src + assert "{val:.1f}" in src, "число перестало печататься с одним знаком" diff --git a/backend/tests/test_2950_latest_image_revision_gate.py b/backend/tests/test_2950_latest_image_revision_gate.py new file mode 100644 index 00000000..ad75bc28 --- /dev/null +++ b/backend/tests/test_2950_latest_image_revision_gate.py @@ -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 --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 diff --git a/backend/tests/test_audit_middleware.py b/backend/tests/test_audit_middleware.py index c02e684f..e046d1a0 100644 --- a/backend/tests/test_audit_middleware.py +++ b/backend/tests/test_audit_middleware.py @@ -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", + ) # --------------------------------------------------------------------------- diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py index 54687e72..4725dc3a 100644 --- a/backend/tests/test_gas_grs_loader.py +++ b/backend/tests/test_gas_grs_loader.py @@ -167,8 +167,7 @@ def test_parse_no_table_returns_empty() -> None: def test_parse_no_header_row_returns_empty() -> None: """Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive).""" html = ( - "" - "
Колонка АКолонка Б
12
" + "
Колонка АКолонка Б
12
" ) assert gg.parse_grs_table(html) == [] diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index bc5bd67c..f0dfed29 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -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(): diff --git a/backend/tests/test_poi_score.py b/backend/tests/test_poi_score.py index ae5aeae3..97cfcffb 100644 --- a/backend/tests/test_poi_score.py +++ b/backend/tests/test_poi_score.py @@ -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(): diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py index 15d1fdc1..bf58373b 100644 --- a/backend/tests/test_quarter_dump_lookup.py +++ b/backend/tests/test_quarter_dump_lookup.py @@ -399,7 +399,7 @@ def _make_zouit_row( def test_cad_zouit_overlaps_includes_geom_geojson() -> None: """#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON).""" geojson_str = ( - '{"type":"Polygon","coordinates":' "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}" + '{"type":"Polygon","coordinates":[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}' ) rows = [ _make_zouit_row( @@ -568,9 +568,9 @@ def test_empty_result_reports_unknown_risk_coverage() -> None: """ result = make_empty_result() - assert ( - "risks_count" in result["nspd_dump"] - ), "признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»" + assert "risks_count" in result["nspd_dump"], ( + "признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»" + ) assert result["nspd_dump"]["risks_count"] is None diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index 19ff11d1..6abc2b9c 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -476,9 +476,9 @@ def test_local_variables_never_reach_transport(module: str) -> None: assert probe["counts"]["exception"] == 1 assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало" - assert ( - probe["markers"]["local_var"] not in payload - ), f"{module}: значение локальной переменной ушло в мониторинг" + assert probe["markers"]["local_var"] not in payload, ( + f"{module}: значение локальной переменной ушло в мониторинг" + ) @pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) @@ -493,7 +493,7 @@ def test_scrub_failure_does_not_spawn_second_event(module: str) -> None: """ probe = json.loads(_probe(module)) - assert ( - probe["scrub_handler_entries"] == 1 - ), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг" + assert probe["scrub_handler_entries"] == 1, ( + "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг" + ) assert probe["counts"]["scrub_failure"] == 1 diff --git a/backend/tests/workers/tasks/test_gas_outlet_sync.py b/backend/tests/workers/tasks/test_gas_outlet_sync.py index 4cd5c056..2da47ad4 100644 --- a/backend/tests/workers/tasks/test_gas_outlet_sync.py +++ b/backend/tests/workers/tasks/test_gas_outlet_sync.py @@ -27,9 +27,9 @@ def test_beat_schedule_includes_gas_outlet_sync_weekly_tuesday() -> None: with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}): schedule = beat_schedule.build_beat_schedule() - assert ( - "gas-outlet-points-sync-weekly" in schedule - ), f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}" + assert "gas-outlet-points-sync-weekly" in schedule, ( + f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}" + ) entry = schedule["gas-outlet-points-sync-weekly"] assert entry["task"] == "tasks.connection_capacity_sync.sync_gas_outlet_points" # crontab day_of_week = вторник (2). diff --git a/backend/tests/workers/tasks/test_okn_objects_sync.py b/backend/tests/workers/tasks/test_okn_objects_sync.py index 466ed6dd..5984ca3d 100644 --- a/backend/tests/workers/tasks/test_okn_objects_sync.py +++ b/backend/tests/workers/tasks/test_okn_objects_sync.py @@ -86,9 +86,9 @@ def test_extract_coords_inverted_would_fail_bbox() -> None: inverted_lon = feature["geometry"]["coordinates"][0] # 56.83 → не в bbox [58–63] inverted_lat = feature["geometry"]["coordinates"][1] # 60.61 → не в bbox [55–58.5] inverted_inside = (55.0 <= inverted_lat <= 58.5) and (58.0 <= inverted_lon <= 63.0) - assert ( - not inverted_inside - ), "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap" + assert not inverted_inside, ( + "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap" + ) # Проверяем что реальный _extract_coords возвращает КОРРЕКТНЫЕ (lon, lat) result = _extract_coords(feature) @@ -141,9 +141,9 @@ def test_address_passes_include_ekb_districts() -> None: "Верх-Исетский", "Железнодорожный", } - assert expected_districts <= set( - _ADDRESS_PASSES - ), f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}" + assert expected_districts <= set(_ADDRESS_PASSES), ( + f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}" + ) def test_address_passes_order_city_before_district() -> None: diff --git a/backend/tests/workers/test_2464_objective_zombie_sweep.py b/backend/tests/workers/test_2464_objective_zombie_sweep.py index 25ce10d3..1291ea35 100644 --- a/backend/tests/workers/test_2464_objective_zombie_sweep.py +++ b/backend/tests/workers/test_2464_objective_zombie_sweep.py @@ -119,17 +119,17 @@ def test_finished_at_is_last_sign_of_life_not_now() -> None: """ sql = _objective_update(_run()) assert sql is not None - assert ( - "COALESCE(heartbeat_at, started_at)" in sql - ), f"finished_at ставится не по последнему признаку жизни:\n{sql}" - assert ( - "finished_at = NOW()" not in sql - ), f"finished_at = NOW() — время завершения соврано:\n{sql}" + assert "COALESCE(heartbeat_at, started_at)" in sql, ( + f"finished_at ставится не по последнему признаку жизни:\n{sql}" + ) + assert "finished_at = NOW()" not in sql, ( + f"finished_at = NOW() — время завершения соврано:\n{sql}" + ) def test_kn_sweep_still_runs() -> None: """Контроль от регресса: добавление Объектива не сломало подметание kn.""" db = _run() - assert any( - "kn_scrape_runs" in s for s in db.sql - ), f"подметание kn_scrape_runs пропало; выполнено: {db.sql}" + assert any("kn_scrape_runs" in s for s in db.sql), ( + f"подметание kn_scrape_runs пропало; выполнено: {db.sql}" + ) diff --git a/backend/tests/workers/test_nspd_geo.py b/backend/tests/workers/test_nspd_geo.py index 8ee58bfb..77e3381a 100644 --- a/backend/tests/workers/test_nspd_geo.py +++ b/backend/tests/workers/test_nspd_geo.py @@ -569,9 +569,9 @@ def test_soft_time_limit_exceeded_flushes_heartbeat(monkeypatch: Any) -> None: for sql, params in captured if "heartbeat_at = NOW()" in sql and "targets_done" in sql ] - assert ( - heartbeat_updates - ), "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise" + assert heartbeat_updates, ( + "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise" + ) def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed( diff --git a/backend/tests/workers/test_scrape_freshness_check.py b/backend/tests/workers/test_scrape_freshness_check.py index 4e05af2e..b014daf9 100644 --- a/backend/tests/workers/test_scrape_freshness_check.py +++ b/backend/tests/workers/test_scrape_freshness_check.py @@ -24,8 +24,11 @@ def _source( def _payload(sources: list[dict[str, Any]], overall: str) -> dict[str, Any]: - return {"generated_at": "2026-06-25T09:00:00+00:00", "overall_status": overall, - "sources": sources} + return { + "generated_at": "2026-06-25T09:00:00+00:00", + "overall_status": overall, + "sources": sources, + } def _run_with(payload_or_exc: Any) -> tuple[dict[str, Any], MagicMock]: diff --git a/backend/uv.lock b/backend/uv.lock index 1b93d369..edc9d2f7 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -877,7 +877,7 @@ dev = [ { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, - { name = "ruff", specifier = ">=0.5.0" }, + { name = "ruff", specifier = "==0.15.20" }, { name = "types-redis", specifier = ">=4.6.0" }, ] @@ -2515,27 +2515,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" +version = "0.15.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] diff --git a/data/sql/02_load_all_quarters.sh b/data/sql/02_load_all_quarters.sh index 69326c0c..51f63383 100644 --- a/data/sql/02_load_all_quarters.sh +++ b/data/sql/02_load_all_quarters.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Loads all 7 quarters of dataset_СДЕЛКИ into rosreestr_deals via staging. +# Loads quarters of dataset_СДЕЛКИ into rosreestr_deals via staging (see JOBS below). # Q3 2024 uses ';' separator, all later quarters use '~'. # # Usage: @@ -42,6 +42,12 @@ declare -a JOBS=( "2025Q3:2025-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_3.csv:~" "2025Q4:2025-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_4.csv:~" "2026Q1:2026-01-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv:~" + # #2998: партиции под Q2–Q4 2026 — миграция 193. Загрузчик скипает квартал, для + # которого нет CSV в data/raw/, поэтому строки на ещё не опубликованные кварталы + # безопасны: они ждут файла, а не падают. + "2026Q2:2026-04-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv:~" + "2026Q3:2026-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_3.csv:~" + "2026Q4:2026-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_4.csv:~" ) # Возможные имена zip-ов в data/raw/ (можно класть как есть, скрипт распакует). @@ -82,7 +88,12 @@ resolve_csv() { for job in "${JOBS[@]}"; do IFS=':' read -r SRC_Q PERIOD FILE_CANDIDATES SEP <<< "$job" - FILE=$(resolve_csv "$FILE_CANDIDATES" "$SRC_Q") + # `|| true` обязателен (#2998): resolve_csv сигналит «файла нет» кодом 1, а скрипт + # идёт под `set -e` — без этого первый же квартал без CSV в data/raw/ молча ронял + # ВЕСЬ прогон до строки SKIP, и до реально лежащего файла (например, одного Q2 на + # VPS) загрузчик не доходил никогда. Проверено на проде 21.08: с одним Q2-файлом в + # каталоге скрипт завершался с rc=0, не напечатав ни одной строки. + FILE=$(resolve_csv "$FILE_CANDIDATES" "$SRC_Q" || true) if [[ -z "$FILE" ]]; then echo "=== $SRC_Q SKIP — нет CSV в data/raw/ (искал: $FILE_CANDIDATES; sdelki_${SRC_Q,,}.csv.zip)" continue diff --git a/data/sql/193_partitions_rosreestr_2026_q2_q4.sql b/data/sql/193_partitions_rosreestr_2026_q2_q4.sql new file mode 100644 index 00000000..d0a26ef8 --- /dev/null +++ b/data/sql/193_partitions_rosreestr_2026_q2_q4.sql @@ -0,0 +1,29 @@ +-- Партиции rosreestr_deals на Q2–Q4 2026 (#2998). +-- +-- Почему это понадобилось. Схема 01_schema_rosreestr_deals.sql создавала партиции +-- списком «2024 Q3 — 2026 Q1» — дальше этого горизонта таблица ничего не знала, и +-- никакой механизм новые партиции не создаёт. Q2 2026 опубликован Росреестром +-- 10.07, poll заметил его 14.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) +-- — ровно это и случилось при первой попытке загрузить 21.08. То есть даже оператор, +-- запустив 02_load_all_quarters.sh по подсказке poll, получил бы отказ. +-- +-- Создаём с запасом на три квартала вперёд, чтобы каждый новый квартал не начинался +-- с этой же ошибки. Q1 2027 и далее — следующая такая же миграция (или автосоздание, +-- если до него дойдут руки; пока его нет нигде — проверено grep'ом по app/ и data/sql/). +-- +-- Индексы НЕ перечисляем: партиции наследуют четыре индекса родителя автоматически +-- (проверено по rosreestr_deals_2026q1: pkey(id, period_start_date), doc_type partial, +-- realestate_type_code, (region_code, quarter_cad_number)). +-- +-- Range FROM inclusive, TO exclusive — [start_q, start_next_q). Идемпотентно. + +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q2 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'); +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q3 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-07-01') TO ('2026-10-01'); +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q4 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-10-01') TO ('2027-01-01'); diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index c42d70fa..620bd97d 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -348,7 +348,9 @@ function TopLayoutsTable({ fontVariantNumeric: "tabular-nums", }} > - {row.avg_area_m2.toFixed(1)} + {row.avg_area_m2 != null + ? row.avg_area_m2.toFixed(1) + : "—"} 100% (incompatible time windows) diff --git a/ops/backup.sh b/ops/backup.sh index cd0d49f5..997c9558 100755 --- a/ops/backup.sh +++ b/ops/backup.sh @@ -71,7 +71,7 @@ verify_dump_integrity() { log "ERROR: ${label} failed gzip integrity check -> $file" >&2 return 1 fi - if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF "$trailer"; then + if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then log "ERROR: ${label} missing trailer '${trailer}' -> $file (truncated dump?)" >&2 return 1 fi @@ -168,7 +168,7 @@ if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" && -v "$LOCAL_BACKUP_DIR":/backup:ro \ amazon/aws-cli:latest \ --endpoint-url "$S3_ENDPOINT" \ - s3 cp "/backup/$(basename "$f")" "s3://${S3_BUCKET}/" + s3 cp --no-progress "/backup/$(basename "$f")" "s3://${S3_BUCKET}/" done log "S3 upload OK" else diff --git a/scripts/check-latest-image-revision.sh b/scripts/check-latest-image-revision.sh new file mode 100755 index 00000000..d69b2084 --- /dev/null +++ b/scripts/check-latest-image-revision.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Гард свежести образа :latest перед деплоем (#2950). +# +# ЗАЧЕМ. Деплой катит IMAGE_TAG=latest. Forgejo отменяет ещё не стартовавший +# `deploy` предыдущего run'а той же concurrency-группы, когда приходит новый push +# (cancel-in-progress: false объявлен, но на pending-job не действует — 21.08.2026 +# 10:35:13 «Has been cancelled» через 4 с после старта следующего run'а). Следующий +# run — например ops-only, с пропущенными билдами — катит :latest как есть. 21.08 +# прод получил новый код только потому, что билды предшественника успели за 70 с +# до pull'а; иначе голова main была бы зелёной, а прод — на старом образе. +# +# ЧТО ПРОВЕРЯЕТ. Образ :latest в registry несёт метку org.opencontainers.image.revision +# (ставится build-push-action в deploy.yml / deploy-tradein.yml). Гард требует, чтобы +# эта ревизия СОДЕРЖАЛА последний коммит, трогавший пути компонента (git log по +# checkout'у run'а): то есть :latest не старше того, что обязано было собраться. +# Ревизия новее — нормально (workflow_dispatch собирает всё на голове). +# Пока условие не выполнено — ждёт (билд предшественника ещё пушит) до таймаута, +# затем падает громко: лучше красный деплой, чем тихий откат. +# +# Использование: check-latest-image-revision.sh -- ... +# CHECK_LATEST_SLEEP_S — пауза между опросами (по умолчанию 20; тесты ставят меньше). +# Коды: 0 — свежий; 1 — отстаёт/метки нет/registry не ответил до таймаута; 2 — usage. +set -euo pipefail + +IMAGE="${1:-}"; TIMEOUT="${2:-}" +if [ -z "$IMAGE" ] || [ -z "$TIMEOUT" ] || [ "${3:-}" != "--" ] || [ $# -lt 4 ]; then + echo "usage: $0 -- ..." >&2; exit 2 +fi +shift 3 +SLEEP="${CHECK_LATEST_SLEEP_S:-20}" + +LAST="$(git log -1 --format=%H -- "$@" || true)" +if [ -z "$LAST" ]; then + echo "::error::$IMAGE — нет ни одного коммита по путям: $*" >&2; exit 2 +fi + +read_revision() { + # Печатает ревизию из метки образа или пустую строку. .Image для multi-arch — + # карта по платформам, для single-arch — сам конфиг; разбираем оба вида. + docker buildx imagetools inspect "$IMAGE:latest" --format '{{json .Image}}' 2>/dev/null \ + | python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +cands = [d] if isinstance(d, dict) and "config" in d else [v for v in d.values() if isinstance(v, dict)] +for c in cands: + rev = ((c.get("config") or {}).get("Labels") or {}).get("org.opencontainers.image.revision") + if rev: + print(rev); break +' || true +} + +deadline=$(( $(date +%s) + TIMEOUT )) +while :; do + REV="$(read_revision)" + if [ -n "$REV" ] && git cat-file -e "${REV}^{commit}" 2>/dev/null \ + && git merge-base --is-ancestor "$LAST" "$REV"; then + echo "✓ $IMAGE:latest — ревизия ${REV:0:10} содержит последний коммит по компоненту ${LAST:0:10}" + exit 0 + fi + now=$(date +%s) + if [ "$now" -ge "$deadline" ]; then + echo "::error::$IMAGE:latest несёт ревизию '${REV:-<метки нет / registry не ответил>}', а последний коммит, трогавший [$*] — ${LAST:0:10}. :latest отстаёт (билд предшественника не доехал или отменён) — деплой остановлен, иначе прод получил бы старый образ при зелёной голове (#2950). Если билд того коммита упал — чини его или запусти workflow_dispatch полного деплоя." >&2 + exit 1 + fi + echo "… $IMAGE:latest — ревизия '${REV:-<метки нет>}' ещё не содержит ${LAST:0:10}; жду билд предшественника ($(( deadline - now )) с)" + sleep "$SLEEP" +done diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index aa251ffe..cfb63fd3 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -1288,6 +1288,15 @@ class CianFullLoadRequest(BaseModel): "cian_full_load прогона для resume. Без этого поля — full walk с нуля." ), ) + secondary_only: bool = Field( + default=True, + description=( + "True (дефолт, поведение без изменений) — новостройки отбрасываются " + "после разбора. False — сохраняются вместе со вторичкой. Дополнительных " + "HTTP-запросов не стоит: страницы с ними всё равно скачиваются, " + "totalOffers бакета считает обе категории (#1781)." + ), + ) @router.post("/scrape/cian-full-load", response_model=CitySweepStartResponse) @@ -1326,6 +1335,7 @@ async def start_cian_full_load( enrich_detail=payload.enrich_detail, detail_top_n=payload.detail_top_n, resume_run_id=payload.resume_run_id, + secondary_only=payload.secondary_only, ) except Exception: logger.exception("cian-full-load background task run_id=%d crashed", run_id) @@ -2654,6 +2664,113 @@ async def update_scraper_pacing( ) from exc +# ── Дома, уехавшие от собственных объявлений (GET /scraper/mislocated-houses) ─ +# +# Отдельная ручка ПО ТРЕБОВАНИЮ, а не поле в /scraper/data-quality (#2996). +# Замер прода 21.08.2026: запрос стоит ~445 мс на тёплом кэше, тогда как обе +# существующие выборки data-quality вместе — 27 мс, а фронт опрашивает её каждые +# 120 с. Класть сюда 17-кратное удорожание ради числа, которое меняется раз в +# месяцы, неправильно; поэтому проверка живёт отдельно и вызывается руками. +# +# Что ловим. Прод: 23 дома стоят вне Свердловской области, к ним привязано 591 +# объявление; адреса екатеринбургские, координаты — Варшава, Белград, Таллин, +# Владивосток, Ижевск. 22 из 23 несут в raw_payload след разового бэкфилла +# `full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без резолва +# города. Живой путь чист: 0 записей вне региона в geocode_cache из 10 448. +# +# 17 из 22 при этом помечены геокодером `precision: "exact"` — точность отвечает +# на «нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть +# не может. Поэтому проверяется ПРИНАДЛЕЖНОСТЬ, а не «точность». +# +# Инвариант нарочно НЕ географический: «дом рядом со своими объявлениями», а не +# «дом внутри рамки области». Рамка сломалась бы при выходе в Москву (#2996) — +# этот инвариант расширение переживёт. Он же строго сильнее: ловит 25 домов +# против 23 у рамки, и оба лишних при проверке оказались настоящими +# («Ул. Белинского/Фурманова» за 207 км, 34 объявления). +# +# Порог не подобран на глаз. Замер по 9 052 домам с объявлениями: +# ближе 1 км 8 914 (98.5 %) · 1-5 км 64 · 5-25 км 40 +# 25-100 км 9 — настоящие пригороды (Сарапулка, Кедровка, Чусовское +# Озеро, Ревда, Первоуральск), самый дальний 46.7 км +# дальше 100 км 25 — ближайший 119.2 км, максимум 5 079 км +# Между 46.7 и 119.2 км нет НИ ОДНОГО дома: 100 лежит в середине пустого +# промежутка, а не на краю распределения. +_MISLOCATED_KM = 100.0 + +_MISLOCATED_SQL = text(""" + WITH pairs AS ( + SELECT h.id AS house_id, + h.address, + h.source, + ST_Distance( + h.geom::geography, + ST_SetSRID(ST_MakePoint( + percentile_disc(0.5) WITHIN GROUP (ORDER BY l.lon), + percentile_disc(0.5) WITHIN GROUP (ORDER BY l.lat) + ), 4326)::geography + ) / 1000.0 AS km, + count(*) AS listings_count + FROM houses h + JOIN listings l ON l.house_id_fk = h.id + WHERE h.geom IS NOT NULL AND l.lat IS NOT NULL AND l.lon IS NOT NULL + GROUP BY h.id, h.geom, h.address, h.source + ) + SELECT house_id, address, source, round(km::numeric, 1) AS km, listings_count + FROM pairs + WHERE km >= CAST(:km AS double precision) + ORDER BY km DESC +""") + + +class MislocatedHouse(BaseModel): + """Дом, стоящий дальше порога от медианы координат своих объявлений.""" + + house_id: int + address: str | None + source: str | None + km: float + listings_count: int + + +class MislocatedHousesResponse(BaseModel): + threshold_km: float + houses_count: int + listings_count: int + houses: list[MislocatedHouse] + + +@router.get("/scraper/mislocated-houses", response_model=MislocatedHousesResponse) +def get_mislocated_houses( + db: Annotated[Session, Depends(get_db)], +) -> MislocatedHousesResponse: + """Дома, уехавшие от собственных объявлений дальше порога (#2996). + + Ноль — ожидаемое значение. Ненулевое означает, что чьи-то координаты пришли + из геокода в чужой город: живой путь резолвит город, но разовые скрипты + ходят в API напрямую и эту защиту обходят. + + Запрос тяжёлый (~445 мс на проде) — поэтому ручка отдельная и вызывается + руками, а не подмешана в опрашиваемую каждые 2 минуты /scraper/data-quality. + """ + rows = db.execute(_MISLOCATED_SQL, {"km": _MISLOCATED_KM}).mappings().all() + houses = [ + MislocatedHouse( + house_id=int(r["house_id"]), + address=r["address"], + source=r["source"], + km=float(r["km"]), + listings_count=int(r["listings_count"]), + ) + for r in rows + ] + return MislocatedHousesResponse( + threshold_km=_MISLOCATED_KM, + houses_count=len(houses), + listings_count=sum(h.listings_count for h in houses), + houses=houses, + ) + + # ── Data-quality coverage (GET /scraper/data-quality) ──────────────────────── diff --git a/tradein-mvp/backend/app/api/v1/search.py b/tradein-mvp/backend/app/api/v1/search.py index 9580c2af..26d34ba3 100644 --- a/tradein-mvp/backend/app/api/v1/search.py +++ b/tradein-mvp/backend/app/api/v1/search.py @@ -47,7 +47,9 @@ async def search( if cached is not None: logger.info( "search cache HIT key=%s page=%d size=%d", - cache_key[:24], params.page, params.page_size, + cache_key[:24], + params.page, + params.page_size, ) resp = SearchResponse.model_validate(cached) resp.cache_hit = True @@ -73,6 +75,10 @@ async def search( await cache.set(cache_key, response.model_dump(mode="json"), ttl=cache.TTL_SEARCH) logger.info( "search MISS page=%d size=%d total=%d items=%d elapsed=%.1fms", - params.page, params.page_size, total, len(items), elapsed_ms, + params.page, + params.page_size, + total, + len(items), + elapsed_ms, ) return response diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index cac916e8..42067d74 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -562,6 +562,33 @@ class Settings(BaseSettings): # sparse-coverage выше). estimate_kitchen_ceiling_signal_min_n: int = 5 # + # ── #2936: штраф за НЕИЗВЕСТНЫЙ year_built / house_type (default OFF) ────── + # В SQL-формуле relevance_score кандидат без year_built получает штраф 0 — + # столько же, сколько точное попадание в год, и ЛУЧШЕ, чем кандидат с + # известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие + # данных выигрывает у знания, и это возвышает источник с худшей полнотой: + # замер 19.08 — avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 % + # доли в пуле, yandex (год 99 %) — 9 % при 37 %. + # + # Влияние на цену измерено дважды (#2936): систематического смещения НЕТ + # (медиана сдвига 0.29 %), но у 39 % целей медиана сопоставимых уходит >5 % + # в зависимости от того, как оценено незнание — шум от полноты сбора. + # Чего замеры НЕ говорят: какой из двух отборов ТОЧНЕЕ. Поэтому флаг, а не + # правка формулы: включать — только после бэктеста на сделках (MAPE), как + # требует тот же контракт, что у kitchen/ceiling (#2012) выше. + # + # Механизм: НЕ наказание, но и не награда — кандидат с NULL получает + # МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов, у которых он + # известен (self-referential, как kitchen/ceiling). Пул = кандидаты тира + # после SQL (до 300), ДО сортировки и LIMIT 50. Известное ограничение: + # per-address cap (rn_addr ≤ MAX_ANALOGS_PER_ADDRESS) в SQL уже отработал + # без штрафа — Python-слой переранжирует то, что SQL оставил, как и #2012. + # Sparse-safe: если известных значений меньше min_n — сигнал пропускается + # целиком (медиана по трём строкам — не «типичный штраф», а шум). + # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED. + estimate_unknown_attr_penalty_enabled: bool = False + estimate_unknown_attr_penalty_min_n: int = 5 + # # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard # #1186 (`listing_segment`) в _COMMON_WHERE. Апартаменты — юридически иной diff --git a/tradein-mvp/backend/app/services/cache.py b/tradein-mvp/backend/app/services/cache.py index 7de08ac0..80b2465e 100644 --- a/tradein-mvp/backend/app/services/cache.py +++ b/tradein-mvp/backend/app/services/cache.py @@ -48,9 +48,7 @@ class SearchCache: async def set(self, key: str, value: dict[str, Any], ttl: int) -> None: try: - await self._client.set( - key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl - ) + await self._client.set(key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl) except Exception as e: logger.warning("redis SET failed key=%s: %s", key[:24], e) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 5d42d82f..73880ec1 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1091,7 +1091,7 @@ def _save_yandex_history_items( if skipped_area > 0: logger.info( - "yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None" " (addr=%r)", + "yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None (addr=%r)", skipped_area, len(result.history_items), result.address, @@ -3328,8 +3328,7 @@ def _price_from_inputs( ) if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio: logger.info( - "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f)" - " for %s", + "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s", same_quarter_ratio, settings.estimate_quarter_match_skip_ratio, target_quarter, @@ -5473,6 +5472,54 @@ def _adjust_relevance_by_pool_deviation( c["relevance_score"] = (c.get("relevance_score") or 0.0) + penalty +def _apply_unknown_attr_penalty( + candidates: list[dict[str, Any]], + *, + target_year: int | None, + target_house_type: str | None, + min_n: int, +) -> None: + """#2936: неизвестный year_built / house_type получает МЕДИАННЫЙ по пулу штраф. + + SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько + же, сколько точному попаданию в год, и лучше, чем известному году с разницей + 24 (2.0). Отсутствие данных выигрывает у знания и возвышает источник с худшей + полнотой (см. комментарий к estimate_unknown_attr_penalty_enabled в config). + + Штраф за NULL = медиана штрафов того же признака среди кандидатов пула, у + которых он известен: не наказание и не награда. Считается ТОЛЬКО по тем + членам пула, где SQL реально начислил штраф, — то есть воспроизводит ровно + SQL-термы abs(year_built - target)/12.0 и 1.5-за-несовпадение, без своего + понимания «насколько далеко». Если target неизвестен, SQL штраф не начисляет + никому — и здесь тоже (симметрия: CASE WHEN :target_... IS NOT NULL). + + Sparse-safe: известных значений меньше ``min_n`` → сигнал пропущен целиком. + Мутирует relevance_score IN PLACE — та же конвенция, что у kitchen/ceiling. + """ + if target_year is not None: + known = [ + abs(int(c["year_built"]) - int(target_year)) / 12.0 + for c in candidates + if c.get("year_built") is not None + ] + if len(known) >= min_n: + median_pen = statistics.median(known) + for c in candidates: + if c.get("year_built") is None: + c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen + if target_house_type is not None: + known_ht = [ + 1.5 if str(c["house_type"]) != str(target_house_type) else 0.0 + for c in candidates + if c.get("house_type") is not None + ] + if len(known_ht) >= min_n: + median_pen = statistics.median(known_ht) + for c in candidates: + if c.get("house_type") is None: + c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen + + def _apply_kitchen_ceiling_signal(candidates: list[dict[str, Any]]) -> None: """#2012: apply the kitchen_area_m2 / ceiling_height_m comp-scoring signals. @@ -5511,7 +5558,16 @@ _ANALOG_SELECT_COLS = """ -- Tier H/W применяют их (см. _apply_kitchen_ceiling_signal) — Tier S -- (same building) не трогают, симметрично house_type/year_built, которые -- тоже не участвуют в Tier S relevance (там фиксированный 0.0). - kitchen_area_m2, ceiling_height_m + kitchen_area_m2, ceiling_height_m, + -- #2936: year_built / house_type ВЫБИРАЮТСЯ, а не только участвуют в SQL-CASE. + -- До этого SQL считал по ним relevance_score, но в словарь кандидата колонки не + -- попадали: Python-слой видел None у ВСЕХ и штрафовать «неизвестное» не мог по + -- построению — прогон A/B с флагом дал 0 из 5 915 сменённых слотов при 55.7 % + -- avito в топ-50. Замечено probe-логом в оверлее: pool=30 null_year=30 + -- null_type=30. Для путей, которые эти ключи не читают, добавление байт- + -- идентично; фикстура бэктеста реплеится с замороженными listings и гейта не + -- трогает. + year_built, house_type """ _COMMON_WHERE = """ @@ -5879,7 +5935,10 @@ def _fetch_analogs( listing_date, days_on_market, photo_urls, scraped_at, distance_m, relevance_score, building_cadastral_number, - kitchen_area_m2, ceiling_height_m + kitchen_area_m2, ceiling_height_m, + -- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует + -- внутренний, без этого Python-слой не видит год и тип дома. + year_built, house_type FROM base WHERE rn_addr <= :max_per_addr {dup_filter} @@ -5912,6 +5971,13 @@ def _fetch_analogs( tier_h = [dict(r) for r in tier_h_rows] _apply_kitchen_ceiling_signal(tier_h) + if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF + _apply_unknown_attr_penalty( + tier_h, + target_year=year_built, + target_house_type=house_type, + min_n=settings.estimate_unknown_attr_penalty_min_n, + ) tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0) if len(tier_h) >= 5: logger.info( @@ -5945,6 +6011,9 @@ def _fetch_analogs( scraped_at, building_cadastral_number, kitchen_area_m2, ceiling_height_m, + -- #2936: Tier W строит base явным списком (не _ANALOG_SELECT_COLS), + -- поэтому год и тип дома добавляются здесь отдельно. + year_built, house_type, id, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m, @@ -6039,6 +6108,9 @@ def _fetch_analogs( scraped_at, building_cadastral_number, kitchen_area_m2, ceiling_height_m, + -- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует + -- внутренний, без этого Python-слой не видит год и тип дома. + year_built, house_type, distance_m, relevance_score FROM base @@ -6076,6 +6148,13 @@ def _fetch_analogs( candidates: list[dict[str, Any]] = [dict(r) for r in tier_w_rows] _apply_kitchen_ceiling_signal(candidates) + if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF + _apply_unknown_attr_penalty( + candidates, + target_year=year_built, + target_house_type=house_type, + min_n=settings.estimate_unknown_attr_penalty_min_n, + ) candidates.sort(key=lambda r: r.get("relevance_score") or 0.0) logger.info("analogs tier=W radius=%dm → %d candidates", radius_m, len(candidates)) return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W" @@ -6900,7 +6979,7 @@ def _enforce_zero_analog_low( """ if n_analogs == 0 and confidence != "low": logger.warning( - "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, " "median=%s, sources=%s)", + "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, median=%s, sources=%s)", confidence, median_price, sources_used, diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index ef896560..ca2aff59 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -1665,9 +1665,12 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) - {f''' + { + f'''''' - if deals_as_of else ""} + if deals_as_of + else "" + }
Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Сделки
Сделки {_mono(deals_as_of)}
Источники данных
diff --git a/tradein-mvp/backend/app/services/house_metadata.py b/tradein-mvp/backend/app/services/house_metadata.py index ff2d1f87..c69844c8 100644 --- a/tradein-mvp/backend/app/services/house_metadata.py +++ b/tradein-mvp/backend/app/services/house_metadata.py @@ -27,9 +27,9 @@ from app.core.config import settings logger = logging.getLogger(__name__) OVERPASS_URL = "https://overpass-api.de/api/interpreter" -_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же -_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки -_CAD_RADIUS_M = 60 # радиус поиска здания в cad_buildings (#393) +_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же +_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки +_CAD_RADIUS_M = 60 # радиус поиска здания в cad_buildings (#393) @dataclass(frozen=True, slots=True) @@ -38,15 +38,18 @@ class HouseMetadata: lon: float year_built: int | None total_floors: int | None - house_type: str | None # panel / brick / monolith / monolith_brick / other + house_type: str | None # panel / brick / monolith / monolith_brick / other total_units: int | None - source: str # 'osm' / 'cache' + source: str # 'osm' / 'cache' # ── Парсинг тегов OSM ──────────────────────────────────────────────────────── _YEAR_KEYS = ( - "start_date", "construction_date", "building:year", - "year_of_construction", "building:start_date", + "start_date", + "construction_date", + "building:year", + "year_of_construction", + "building:start_date", ) @@ -110,9 +113,12 @@ def _cache_get(db: Session, lat: float, lon: float) -> HouseMetadata | None: if row is None: return None return HouseMetadata( - lat=row.lat, lon=row.lon, - year_built=row.year_built, total_floors=row.total_floors, - house_type=row.house_type, total_units=row.total_units, + lat=row.lat, + lon=row.lon, + year_built=row.year_built, + total_floors=row.total_floors, + house_type=row.house_type, + total_units=row.total_units, source="cache", ) @@ -129,9 +135,12 @@ def _cache_put(db: Session, meta: HouseMetadata, raw: dict) -> None: """ ), { - "lat": meta.lat, "lon": meta.lon, - "year": meta.year_built, "floors": meta.total_floors, - "htype": meta.house_type, "units": meta.total_units, + "lat": meta.lat, + "lon": meta.lon, + "year": meta.year_built, + "floors": meta.total_floors, + "htype": meta.house_type, + "units": meta.total_units, "raw": json.dumps(raw, ensure_ascii=False), }, ) @@ -184,10 +193,11 @@ def _cad_buildings_get(db: Session, lat: float, lon: float) -> HouseMetadata | N if row is None: return None return HouseMetadata( - lat=lat, lon=lon, + lat=lat, + lon=lon, year_built=row.year_built, total_floors=row.floors, - house_type=None, # purpose не маппится в panel/brick + house_type=None, # purpose не маппится в panel/brick total_units=None, source="cadastre", ) @@ -219,7 +229,8 @@ async def _overpass_lookup(lat: float, lon: float) -> tuple[HouseMetadata, dict] best = min(elements, key=_dist2) tags = best.get("tags", {}) meta = HouseMetadata( - lat=lat, lon=lon, + lat=lat, + lon=lon, year_built=_parse_year(tags), total_floors=_parse_int(tags, "building:levels"), house_type=_parse_house_type(tags), @@ -242,7 +253,10 @@ async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetada if cad is not None: logger.info( "house_metadata: cad_buildings (%.5f, %.5f) → year=%s floors=%s", - lat, lon, cad.year_built, cad.total_floors, + lat, + lon, + cad.year_built, + cad.total_floors, ) return cad @@ -264,13 +278,15 @@ async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetada _cache_put(db, meta, raw) logger.info( "house_metadata OSM: (%.5f, %.5f) → year=%s floors=%s type=%s", - lat, lon, meta.year_built, meta.total_floors, meta.house_type, + lat, + lon, + meta.year_built, + meta.total_floors, + meta.house_type, ) return meta except Exception: - logger.warning( - "house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True - ) + logger.warning("house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True) try: db.rollback() except Exception: diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 1effa723..531cb1fc 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -191,8 +191,7 @@ def match_or_create_house( row = ( db.execute( text( - "SELECT house_id FROM house_sources " - "WHERE ext_source = :s AND ext_id = :e LIMIT 1" + "SELECT house_id FROM house_sources WHERE ext_source = :s AND ext_id = :e LIMIT 1" ), {"s": ext_source, "e": str(ext_id)}, ) diff --git a/tradein-mvp/backend/app/services/matching/listings.py b/tradein-mvp/backend/app/services/matching/listings.py index ceaae20c..398bbefa 100644 --- a/tradein-mvp/backend/app/services/matching/listings.py +++ b/tradein-mvp/backend/app/services/matching/listings.py @@ -287,6 +287,26 @@ def _upsert_listing_source( floor = COALESCE(EXCLUDED.floor, listing_sources.floor), rooms_count = COALESCE(EXCLUDED.rooms_count, listing_sources.rooms_count), raw_payload = COALESCE(EXCLUDED.raw_payload, listing_sources.raw_payload) + -- #2992: симметричный гейт к listings-апсерту (scraper_kit/base.py). Вторая + -- по нагрузке таблица базы: 10.27 млн апдейтов на 102 тыс. строк, HOT 0.10 %. + -- Апдейт идёт, если итоговое значение хоть одной колонки отличается ИЛИ + -- last_seen_at ещё не сегодняшний по МСК. Тот же МСК-критерий, что у listings, + -- — иначе разъехались бы listings.last_seen_at и listing_sources.last_seen_at, + -- равные сегодня у 100 % пар (см. докстринг выше). + WHERE ( + listing_sources.confidence, listing_sources.price_rub, + listing_sources.area_m2, listing_sources.floor, + listing_sources.rooms_count, listing_sources.raw_payload + ) IS DISTINCT FROM ( + GREATEST(EXCLUDED.confidence, listing_sources.confidence), + COALESCE(EXCLUDED.price_rub, listing_sources.price_rub), + COALESCE(EXCLUDED.area_m2, listing_sources.area_m2), + COALESCE(EXCLUDED.floor, listing_sources.floor), + COALESCE(EXCLUDED.rooms_count, listing_sources.rooms_count), + COALESCE(EXCLUDED.raw_payload, listing_sources.raw_payload) + ) + OR (listing_sources.last_seen_at AT TIME ZONE 'Europe/Moscow')::date + IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date """), { "lid": listing_id, diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 00d91cf9..affa088b 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -125,8 +125,7 @@ def _alert_domclick_cookies(db: Session, run_id: int) -> None: detail = "кук DomClick нет в БД" elif expires_at <= now: detail = ( - f"куки DomClick протухли {expires_at:%Y-%m-%d} " - f"({(now - expires_at).days} дн. назад)" + f"куки DomClick протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)" ) else: detail = "куки DomClick помечены невалидными (last_invalid_at)" @@ -292,7 +291,7 @@ async def run_domclick_detail_backfill( # #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper). if shutdown_requested(): logger.info( - "domclick_detail_backfill: run_id=%d SIGTERM-drain — stopping at " "#%d/%d", + "domclick_detail_backfill: run_id=%d SIGTERM-drain — stopping at #%d/%d", run_id, idx, len(snapshot), @@ -331,8 +330,7 @@ async def run_domclick_detail_backfill( consecutive_blocks += 1 counters.blocked += 1 logger.warning( - "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d " - "(consecutive=%d): %s", + "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", run_id, idx + 1, len(snapshot), diff --git a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py index ba1c8d73..f54537c8 100644 --- a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py +++ b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py @@ -129,10 +129,30 @@ _SELECT_PENDING_HOUSES = """ SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id ) ) - ORDER BY h.id, hs.ext_id NULLS LAST + -- #2924: дом, у которого резолв уже не удался, не берётся повторно раньше чем + -- через :retry_days (образец — geocode_tried_at, задача geocode_missing). + AND ( + CAST(:force AS boolean) = TRUE + OR h.yandex_jk_resolve_tried_at IS NULL + OR h.yandex_jk_resolve_tried_at + < NOW() - make_interval(days => CAST(:retry_days AS integer)) + ) + -- #2924: неудавшиеся — в КОНЕЦ очереди, а не в начало: иначе пять первых по h.id + -- занимали все пять слотов каждую неделю. + ORDER BY h.id, h.yandex_jk_resolve_tried_at NULLS FIRST, hs.ext_id NULLS LAST LIMIT :lim """ +# #2924: сколько дней не повторять резолв дома, у которого он не удался. Семь — как у +# geocode_tried_at: разметка Яндекса могла поменяться, дом мог появиться в SERP позже. +RESOLVE_RETRY_DAYS = 7 + +_MARK_RESOLVE_TRIED = """ + UPDATE houses + SET yandex_jk_resolve_tried_at = NOW() + WHERE id = CAST(:hid AS bigint) +""" + _UPDATE_SLUG = """ UPDATE houses SET yandex_jk_slug = CAST(:slug AS text) @@ -251,7 +271,14 @@ async def enrich_yandex_newbuilding_sweep( result.fetchable = sizing["fetchable"] result.pending = sizing["pending"] - rows = db.execute(text(_SELECT_PENDING_HOUSES), {"force": force, "lim": limit}).mappings().all() + rows = ( + db.execute( + text(_SELECT_PENDING_HOUSES), + {"force": force, "lim": limit, "retry_days": RESOLVE_RETRY_DAYS}, + ) + .mappings() + .all() + ) logger.info( "yandex-nb-sweep: total=%d fetchable=%d pending=%d; " @@ -321,6 +348,7 @@ async def enrich_yandex_newbuilding_sweep( if not resolved: logger.warning("slug unresolved house_id=%s ext_id=%s — skip", house_id, ext_id) result.failed_resolve += 1 + _mark_resolve_tried(db, house_id) continue # Persist slug под SAVEPOINT @@ -335,6 +363,7 @@ async def enrich_yandex_newbuilding_sweep( "persist slug failed house_id=%s ext_id=%s: %s", house_id, ext_id, exc ) result.failed_resolve += 1 + _mark_resolve_tried(db, house_id) continue jk_slug = resolved @@ -448,6 +477,22 @@ async def enrich_yandex_newbuilding_sweep( return result +def _mark_resolve_tried(db: Session, house_id: int) -> None: + """#2924: зафиксировать неудачную попытку резолва — иначе дом снова первый в очереди. + + Отдельный SAVEPOINT: отметка не должна ронять прогон, а её потеря — не фатальна + (дом просто вернётся в очередь на следующем прогоне, как и раньше). + """ + sp = db.begin_nested() + try: + db.execute(text(_MARK_RESOLVE_TRIED), {"hid": house_id}) + sp.commit() + db.commit() + except Exception as exc: + sp.rollback() + logger.warning("mark resolve tried failed house_id=%s: %s", house_id, exc) + + async def _sleep_with_jitter(delay: float, idx: int, total: int, *, force: bool = False) -> None: """Polite anti-bot sleep с ±20% jitter. diff --git a/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql b/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql new file mode 100644 index 00000000..b990d581 --- /dev/null +++ b/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql @@ -0,0 +1,21 @@ +-- #2924: неудача резолва yandex_jk_slug ничего не помечала — выборка yandex_newbuilding_sweep +-- каждый прогон брала те же дома в том же порядке (ORDER BY h.id), и пять первых +-- занимали все пять слотов (limit 5) неделю за неделей. Замер 19.08: 397 ожидающих, +-- первые пять по h.id — 6706, 6741, 6757, 6774, 6785 — одни и те же в каждом прогоне. +-- +-- Маркер «пробовали резолвить» — по образцу listings.geocode_tried_at (миграция 005): +-- выборка повторяет попытку не раньше чем через N дней и ставит неудавшиеся в конец +-- очереди (ORDER BY tried_at NULLS FIRST), а не в начало. +-- +-- Идемпотентно. Колонка nullable, без дефолта — существующие строки = «не пробовали». +-- lock_timeout (#2752): ALTER TABLE берёт ACCESS EXCLUSIVE на houses — при живом +-- писателе (свип) ждал бы его бесконечно и копил очередь за собой; 5с — и миграция +-- падает честно, деплой перезапускается, а не вешает базу. +-- SET LOCAL действует только внутри транзакции — вне BEGIN он молча ничего не делает +-- (гейт check-migration-lock-timeout это проверяет; на этом я и споткнулся). +BEGIN; +SET LOCAL lock_timeout = '5s'; +ALTER TABLE houses ADD COLUMN IF NOT EXISTS yandex_jk_resolve_tried_at timestamptz; +COMMENT ON COLUMN houses.yandex_jk_resolve_tried_at IS + '#2924: когда yandex_newbuilding_sweep последний раз пытался разрезолвить yandex_jk_slug и не смог; NULL = не пробовали или резолв удался'; +COMMIT; diff --git a/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql b/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql new file mode 100644 index 00000000..0b869139 --- /dev/null +++ b/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql @@ -0,0 +1,27 @@ +-- 270_houses_geog_gist_idx.sql +-- #2997: у houses нет индекса под geography. Весь матчинг ходит через +-- ST_DWithin(geom::geography, …) (app/services/matching/houses.py:300, :352, :554), +-- а живой houses_geom_idx построен по geometry. Планировщик берёт его только как +-- bitmap по `geom IS NOT NULL` и дальше фильтрует все ~9.4k строк с координатами. +-- Замер на проде 21.08.2026 (EXPLAIN ANALYZE, точка в центре ЕКБ, 150 м): +-- Bitmap Index Scan on houses_geom_idx, Index Cond: (geom IS NOT NULL) +-- Rows Removed by Filter: 4718 на воркер, Execution Time: 70.7 ms +-- pg_stat_statements за 13 ч: 66 вызовов × 39.9 мс и 5 × 53.3 мс на этот класс запросов. +-- +-- Функциональный GiST по (geom::geography) совпадает с выражением в запросах +-- (h.geom::geography ≡ (geom)::geography), и ST_DWithin на geography начинает +-- идти по индексу. houses_geom_idx НЕ трогаем: его дроп — отдельное решение по +-- эпику #2989 (см. #2997, там же — почему дубль на listings тоже пока не снят). +-- +-- CONCURRENTLY и без BEGIN/COMMIT — по образцу 225: раннер деплоя исполняет файл +-- через psql autocommit по операторам; CIC нельзя внутри транзакции, а +-- lock_timeout ему вреден (гейт scripts/check-migration-lock-timeout.py +-- CONCURRENTLY-формы не требует и не терпит). Таблица маленькая (10 111 строк), +-- но её читает матчинг каждого объявления — ACCESS EXCLUSIVE даже на секунду не +-- нужен. Если CIC оборвётся, останется INVALID-индекс — его ловит шаг проверки +-- невалидных индексов в deploy-tradein.yml, и деплой краснеет, а не молчит. +CREATE INDEX CONCURRENTLY IF NOT EXISTS houses_geog_gist_idx + ON houses USING gist ((geom::geography)); + +COMMENT ON INDEX houses_geog_gist_idx IS + '#2997: GiST по (geom::geography) под ST_DWithin(geom::geography, …) матчинга (matching/houses.py). До него планировщик шёл через houses_geom_idx как bitmap по geom IS NOT NULL и фильтровал все строки (~70 мс на запрос, прод 21.08.2026).'; diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index 29d7c22c..ee679c0b 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -40,7 +40,7 @@ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "pytest-timeout>=2.3.0", - "ruff>=0.5.0", + "ruff==0.15.20", ] [tool.pytest.ini_options] diff --git a/tradein-mvp/backend/scripts/backfill_houses_dadata.py b/tradein-mvp/backend/scripts/backfill_houses_dadata.py index ec7dd2be..cabbd302 100644 --- a/tradein-mvp/backend/scripts/backfill_houses_dadata.py +++ b/tradein-mvp/backend/scripts/backfill_houses_dadata.py @@ -158,14 +158,11 @@ class Stats: # для `--priority listings`. Correlated EXISTS бьёт по partial-индексу # `listings_house_id_fk_idx (house_id_fk) WHERE house_id_fk IS NOT NULL`. _HAS_ACTIVE_LISTINGS_EXPR = ( - "EXISTS (SELECT 1 FROM listings l " - "WHERE l.house_id_fk = houses.id AND l.is_active)" + "EXISTS (SELECT 1 FROM listings l WHERE l.house_id_fk = houses.id AND l.is_active)" ) -def _select_candidates( - db: Session, *, priority: str, limit: int -) -> list[tuple[HouseRow, str]]: +def _select_candidates(db: Session, *, priority: str, limit: int) -> list[tuple[HouseRow, str]]: """Возвращает список (HouseRow, priority_bucket) для enrichment. priority: @@ -477,9 +474,7 @@ async def _run_backfill( db.rollback() stats.failed += 1 stats.bump(priority, "failed") - logger.warning( - "db_write failed for house_id=%s: %s", row.id, exc - ) + logger.warning("db_write failed for house_id=%s: %s", row.id, exc) if i % _LOG_EVERY == 0: logger.info( @@ -568,9 +563,7 @@ async def main(argv: list[str] | None = None) -> int: db = SessionLocal() try: candidates = _select_candidates(db, priority=args.priority, limit=args.limit) - logger.info( - "loaded candidates: %d (priority=%s)", len(candidates), args.priority - ) + logger.info("loaded candidates: %d (priority=%s)", len(candidates), args.priority) if not candidates: logger.info( "nothing to do — нет rows с dadata_enriched_at IS NULL для priority=%s", @@ -578,9 +571,7 @@ async def main(argv: list[str] | None = None) -> int: ) return 0 - stats = await _run_backfill( - db, candidates, batch=args.batch, dry_run=args.dry_run - ) + stats = await _run_backfill(db, candidates, batch=args.batch, dry_run=args.dry_run) logger.info( "done: batch=%s processed=%d enriched=%d no_change=%d " diff --git a/tradein-mvp/backend/scripts/geocode_deals_from_houses.py b/tradein-mvp/backend/scripts/geocode_deals_from_houses.py index fc1acc15..d924e67d 100644 --- a/tradein-mvp/backend/scripts/geocode_deals_from_houses.py +++ b/tradein-mvp/backend/scripts/geocode_deals_from_houses.py @@ -293,17 +293,21 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]: would require duplicating the regex logic in plpgsql and risk drift. 8,600 rows is trivial to hold in memory. """ - rows = db.execute( - text( - "SELECT address, lat, lon " - "FROM houses " - "WHERE geom IS NOT NULL " - " AND lat IS NOT NULL " - " AND lon IS NOT NULL " - " AND address IS NOT NULL " - " AND length(trim(address)) > 0" + rows = ( + db.execute( + text( + "SELECT address, lat, lon " + "FROM houses " + "WHERE geom IS NOT NULL " + " AND lat IS NOT NULL " + " AND lon IS NOT NULL " + " AND address IS NOT NULL " + " AND length(trim(address)) > 0" + ) ) - ).mappings().all() + .mappings() + .all() + ) # street_key → running [lat_sum, lon_sum, n] acc: dict[str, list[float]] = {} @@ -328,18 +332,22 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]: Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful UPDATE sets lat NOT NULL, dropping the row out on the next run. """ - rows = db.execute( - text( - "SELECT id, address " - "FROM deals " - "WHERE lat IS NULL " - " AND address IS NOT NULL " - " AND length(trim(address)) > 0 " - "ORDER BY id " - "LIMIT CAST(:lim AS int)" - ), - {"lim": limit}, - ).mappings().all() + rows = ( + db.execute( + text( + "SELECT id, address " + "FROM deals " + "WHERE lat IS NULL " + " AND address IS NOT NULL " + " AND length(trim(address)) > 0 " + "ORDER BY id " + "LIMIT CAST(:lim AS int)" + ), + {"lim": limit}, + ) + .mappings() + .all() + ) return [DealRow(id=r["id"], address=r["address"]) for r in rows] @@ -415,9 +423,7 @@ def _run_backfill( else: try: with db.begin_nested(): - _update_deal_coords( - db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon - ) + _update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon) # Per-row commit so resume picks up exactly where we crashed. db.commit() stats.geocoded += 1 @@ -470,9 +476,7 @@ def _report_dry_run( logger.info("─" * 60) logger.info("DRY-RUN SUMMARY (no DB writes)") logger.info("distinct streets with a house centroid: %d", distinct_streets) - logger.info( - "deals scanned this run (lat IS NULL, capped by --limit): %d", scanned - ) + logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned) logger.info("deals matched to a centroid: %d", matched) logger.info("deals with no street match: %d", stats.no_street_match) logger.info("match rate on scanned slice: %.1f%%", match_rate * 100.0) @@ -550,9 +554,7 @@ def main(argv: list[str] | None = None) -> int: centroids = _build_centroid_map(db) logger.info("built centroid map: %d distinct streets", len(centroids)) if not centroids: - logger.warning( - "no house centroids — houses table has no geocoded rows; nothing to do" - ) + logger.warning("no house centroids — houses table has no geocoded rows; nothing to do") return 0 deals = _select_deals_without_coords(db, args.limit) @@ -561,9 +563,7 @@ def main(argv: list[str] | None = None) -> int: logger.info("nothing to do — no deals with lat IS NULL and an address") return 0 - stats = _run_backfill( - db, deals, centroids, batch=args.batch, dry_run=args.dry_run - ) + stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run) if args.dry_run: total_null = _count_deals_null(db) diff --git a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py index c6876d83..8bdeda9a 100644 --- a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py +++ b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py @@ -461,8 +461,7 @@ def _maybe_log_progress(i: int, groups: list[AddressGroup], batch: str, stats: S """Emit a progress line every `_LOG_EVERY` distinct addresses.""" if i % _LOG_EVERY == 0: logger.info( - "batch=%s progress %d/%d geocoded=%d failed=%d deals_updated=%d " - "cache=(hit=%d miss=%d)", + "batch=%s progress %d/%d geocoded=%d failed=%d deals_updated=%d cache=(hit=%d miss=%d)", batch, i, len(groups), diff --git a/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py b/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py index ea4711bf..a1633511 100644 --- a/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py +++ b/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py @@ -251,8 +251,12 @@ def test_update_house_enriched_passes_full_payload(): """UPDATE houses содержит lat/lon/cadnum/fias/qc-codes и dadata_enriched_at = NOW().""" db = MagicMock() res = _make_dadata_result( - qc_geo=0, qc_house=2, lat=56.838, lon=60.586, - cadnum="66:41:0704045:350", fias="fias-uuid-here", + qc_geo=0, + qc_house=2, + lat=56.838, + lon=60.586, + cadnum="66:41:0704045:350", + fias="fias-uuid-here", ) _update_house_enriched(db, house_id=42, result=res) @@ -304,9 +308,7 @@ async def test_run_backfill_qc_geo_0_writes_enriched_update(): "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): - stats = await _run_backfill( - db, [(row, "coords")], batch="b1", dry_run=False - ) + stats = await _run_backfill(db, [(row, "coords")], batch="b1", dry_run=False) assert stats.enriched == 1 assert stats.no_change == 0 @@ -338,9 +340,7 @@ async def test_run_backfill_qc_geo_3_records_attempt_only(): "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): - stats = await _run_backfill( - db, [(row, "coords")], batch="b2", dry_run=False - ) + stats = await _run_backfill(db, [(row, "coords")], batch="b2", dry_run=False) assert stats.enriched == 0 assert stats.no_change == 1 @@ -366,9 +366,7 @@ async def test_run_backfill_dadata_returns_none_records_attempt(): "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=None), ): - stats = await _run_backfill( - db, [(row, "coords")], batch="b3", dry_run=False - ) + stats = await _run_backfill(db, [(row, "coords")], batch="b3", dry_run=False) assert stats.no_change == 1 assert stats.enriched == 0 @@ -425,9 +423,7 @@ async def test_run_backfill_dry_run_skips_db_writes(): "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): - stats = await _run_backfill( - db, [(row, "coords")], batch="dry", dry_run=True - ) + stats = await _run_backfill(db, [(row, "coords")], batch="dry", dry_run=True) assert stats.processed == 1 assert stats.enriched == 1 diff --git a/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py b/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py index b94e1202..a7c05e8e 100644 --- a/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py +++ b/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py @@ -105,10 +105,7 @@ def test_street_key_three_spec_variants_collapse_to_same_key(): def test_street_key_strips_region_and_house_number(): - assert ( - _street_key("Свердловская обл., Екатеринбург, ул. Большакова, 17") - == "большакова" - ) + assert _street_key("Свердловская обл., Екатеринбург, ул. Большакова, 17") == "большакова" def test_street_key_strips_district_marker(): @@ -139,10 +136,7 @@ def test_street_key_does_not_eat_name_starting_like_type_token(): def test_street_key_strips_korpus_and_kv_suffix(): assert _street_key("Екатеринбург, ул. Крауля, 48, корп. 2") == "крауля" - assert ( - _street_key("РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12") - == "ленина" - ) + assert _street_key("РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12") == "ленина" def test_street_key_does_not_eat_names_starting_like_apt_suffix(): diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index cee70c8c..9fa62103 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -173,9 +173,9 @@ def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None: from app.services import estimator - assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource( - lc - ), "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator" + assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource(lc), ( + "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator" + ) assert lc.LISTINGS_FRESH_DAYS == estimator.LISTINGS_FRESH_DAYS diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py index a311b7c9..b4fb5b0b 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_rotation.py +++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py @@ -538,9 +538,9 @@ async def test_token_never_appears_in_log_messages_or_sentry_text( await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] for record in caplog.records: - assert ( - SECRET_TOKEN not in record.getMessage() - ), f"scenario={name}: token leaked into log message args" + assert SECRET_TOKEN not in record.getMessage(), ( + f"scenario={name}: token leaked into log message args" + ) assert sentry_texts, "expected at least one Sentry capture (401 scenario)" assert all(SECRET_TOKEN not in text for text in sentry_texts) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index ee3c6e89..4883fa72 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -93,3 +93,15 @@ tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_a # `days_on_market <= :max_age_days` встречается в SQL дважды). На мок-лэйне # (deploy-tradein.yml, DSN-заглушка) реальной БД нет — self-skip. tests/test_coverage_probe_endpoint.py::test_max_age_outlier_excluded_from_median_live + +# #2992 — гейт неизменной строки в апсертах listings / listing_sources. Шесть live-тестов +# идут через реальный save_listings / upsert_listing_source на живом Postgres и меряют, +# переписана ли строка, по ctid (save_listings коммитит — транзакционные счётчики +# бесполезны). Без БД — skip; в CI Trade-In идут на postgres-сервисе (#2745). Свои +# строки t2992-* тесты удаляют явно в finally. +tests/test_2992_upsert_unchanged_gate.py::test_unchanged_rescrape_same_day_does_not_update_the_row +tests/test_2992_upsert_unchanged_gate.py::test_changed_price_still_updates +tests/test_2992_upsert_unchanged_gate.py::test_coalesce_backfill_still_updates +tests/test_2992_upsert_unchanged_gate.py::test_next_day_rescrape_updates_even_if_unchanged +tests/test_2992_upsert_unchanged_gate.py::test_skipped_row_still_yields_listing_id_for_downstream +tests/test_2992_upsert_unchanged_gate.py::test_listing_sources_unchanged_rescrape_same_day_does_not_update diff --git a/tradein-mvp/backend/tests/support/identity_modes.py b/tradein-mvp/backend/tests/support/identity_modes.py index 73c5672a..66d885ec 100644 --- a/tradein-mvp/backend/tests/support/identity_modes.py +++ b/tradein-mvp/backend/tests/support/identity_modes.py @@ -111,8 +111,7 @@ def assert_reads_access_state(sql: str, names: SqlNames) -> None: expected = f"{names.access_state_column} AS access_state" if expected not in sql: raise AssertionError( - f"запрос к реестру не читает колонку состояния текущего режима " - f"({expected!r}): {sql!r}" + f"запрос к реестру не читает колонку состояния текущего режима ({expected!r}): {sql!r}" ) diff --git a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py index 0e786ffb..fe362774 100644 --- a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py +++ b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py @@ -680,9 +680,9 @@ def test_estimator_fetch_analogs_includes_avito() -> None: from app.services import estimator source = inspect.getsource(estimator._fetch_analogs) - assert ( - "source <> 'avito'" not in source - ), "Avito exclusion должен быть удалён из estimator._fetch_analogs" + assert "source <> 'avito'" not in source, ( + "Avito exclusion должен быть удалён из estimator._fetch_analogs" + ) # ── Admin endpoint smoke (schema-only, no real DB) ──────────────────────────── diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py index 2e59eb54..ff51444b 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py @@ -38,9 +38,9 @@ def test_extract_address_standard_title() -> None: ) addr = _extract_address_from_title(html) assert addr is not None, "Should extract address from standard title" - assert _RE_HAS_HOUSE_NUMBER.search( - addr - ), f"Extracted address should contain house number: {addr!r}" + assert _RE_HAS_HOUSE_NUMBER.search(addr), ( + f"Extracted address should contain house number: {addr!r}" + ) assert "Екатеринбург" in addr assert "Горького" in addr assert "36" in addr @@ -54,9 +54,9 @@ def test_extract_address_with_zhk_prefix() -> None: ) addr = _extract_address_from_title(html) assert addr is not None, "Should extract address when ЖК prefix is present" - assert _RE_HAS_HOUSE_NUMBER.search( - addr - ), f"Extracted address should contain house number: {addr!r}" + assert _RE_HAS_HOUSE_NUMBER.search(addr), ( + f"Extracted address should contain house number: {addr!r}" + ) assert "Екатеринбург" in addr @@ -97,7 +97,7 @@ def test_extract_address_beryozovsky_with_zhk() -> None: def test_extract_address_ekb_no_zhk_still_matches() -> None: """EKB variant from docstring (no ЖК prefix) still matches after city-agnostic change.""" - html = "Продажа квартиры — Екатеринбург, улица Горького, 36" " — id 7654321" + html = "Продажа квартиры — Екатеринбург, улица Горького, 36 — id 7654321" addr = _extract_address_from_title(html) assert addr is not None, "EKB no-ЖК title should still match" assert _RE_HAS_HOUSE_NUMBER.search(addr), f"Should contain house number: {addr!r}" @@ -116,9 +116,9 @@ def test_extract_address_no_house_number_guard() -> None: addr = _extract_address_from_title(html) # If the regex matches at all, the extracted value must not contain a house number. if addr is not None: - assert not _RE_HAS_HOUSE_NUMBER.search( - addr - ), f"Street-only addr must fail house-number guard: {addr!r}" + assert not _RE_HAS_HOUSE_NUMBER.search(addr), ( + f"Street-only addr must fail house-number guard: {addr!r}" + ) def test_extract_address_nbsp_replaced() -> None: @@ -129,9 +129,9 @@ def test_extract_address_nbsp_replaced() -> None: ) addr = _extract_address_from_title(html) assert addr is not None, "\\xa0 should be replaced before regex" - assert _RE_HAS_HOUSE_NUMBER.search( - addr - ), f"Extracted address should contain house number: {addr!r}" + assert _RE_HAS_HOUSE_NUMBER.search(addr), ( + f"Extracted address should contain house number: {addr!r}" + ) def test_extract_address_no_title_returns_none() -> None: diff --git a/tradein-mvp/backend/tests/test_1781_secondary_only_param.py b/tradein-mvp/backend/tests/test_1781_secondary_only_param.py new file mode 100644 index 00000000..c4d474ab --- /dev/null +++ b/tradein-mvp/backend/tests/test_1781_secondary_only_param.py @@ -0,0 +1,123 @@ +"""`secondary_only` — параметр расписания, а не хардкод; выброшенное считается (#1781). + +`run_cian_full_load` передавал `secondary_only=True` жёстко (`pipeline.py:3328`), поэтому +включить новостройки в полный обход можно было только деплоем. При этом новостройки +НЕ пропускаются при запросе — они скачиваются, разбираются и выбрасываются последним +шагом (`cian/serp.py:671`), потому что SERP-параметр `object_type=1` у Cian ненадёжен +(~5 % выдачи) и фильтруют по authoritative `listing_segment` уже после парсинга. + +Следствие, проверенное по коду: проба бакета берёт `totalOffers` из Redux-состояния +SERP и считает `pages_needed = ceil(totalOffers / offers_per_page)`; `totalOffers` +включает обе категории. То есть страницы с новостройками уже скачаны, лимит страниц и +антибан-бюджет за них уже заплачены — включение стоит НОЛЬ дополнительных запросов. + +Дефолт НЕ меняется: `True`, поведение прода прежнее. Меняется только то, что решение +становится правкой одной ячейки `scrape_schedules.default_params`, а не деплоем. + +Замер прода 21.08.2026, ради которого это и делается: месячный охват свипа +cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и avito/novostroyki; +11 993 активные строки, медианный возраст 81 сутки. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import inspect + + +def test_full_load_no_longer_hardcodes_secondary_only() -> None: + """Головной: значение берётся из параметра, а не из литерала. + + На origin/main в теле стоит `secondary_only=True` — включить новостройки + можно только правкой кода. + """ + from scraper_kit.orchestration import pipeline + + src = inspect.getsource(pipeline.run_cian_full_load) + assert "secondary_only=secondary_only" in src, "значение не пробрасывается из параметра" + assert "secondary_only=True," not in src, ( + "в теле остался хардкод — расписание на него повлиять не сможет" + ) + + +def test_default_is_unchanged() -> None: + """Контроль: дефолт остаётся True — поведение прода не меняется этим PR. + + Без этого контроля правка «сделать параметром» могла бы тихо включить сбор + новостроек на проде, а это отдельное решение с замером (#1781, #2994). + """ + from scraper_kit.orchestration.pipeline import run_cian_full_load + + params = inspect.signature(run_cian_full_load).parameters + assert "secondary_only" in params, ( + "параметра нет в сигнатуре — значение задано в теле, расписание на него " + f"повлиять не может; параметры: {list(params)}" + ) + default = params["secondary_only"].default + assert default is True, f"дефолт изменён на {default!r} — это не входило в правку" + + +def test_admin_request_exposes_the_flag_with_same_default() -> None: + """Параметр доезжает до расписания: у эндпоинта есть поле с тем же дефолтом.""" + from app.api.v1.admin import CianFullLoadRequest + + поля = CianFullLoadRequest.model_fields + assert "secondary_only" in поля, ( + "поля нет в запросе — параметр не доедет из scrape_schedules.default_params; " + f"поля: {sorted(поля)}" + ) + assert поля["secondary_only"].default is True + req = CianFullLoadRequest() + assert req.secondary_only is True + assert CianFullLoadRequest(secondary_only=False).secondary_only is False + + +def test_counters_persist_dropped_novostroyki() -> None: + """Выброшенное обязано сохраняться, а не только логироваться. + + Счётчик `dropped_nb` печатался в лог, но в `scrape_runs.counters` не попадал, и + ответить «сколько инвентаря выбрасывает полный обход» задним числом было нечем: + логи за нужную дату уже ротировались. Тот же довод, по которому рядом заведён + `partial_buckets`. + """ + from scraper_kit.orchestration.pipeline import CianFullLoadCounters + + c = CianFullLoadCounters() + assert "dropped_novostroyki" in c.to_dict(), c.to_dict() + c.dropped_novostroyki = 7 + assert c.to_dict()["dropped_novostroyki"] == 7 + + +def test_scraper_resets_the_counter_per_run() -> None: + """Контроль: счётчик сбрасывается на каждый прогон. + + Инстанс скрапера переиспользуется; без сброса второй прогон унаследовал бы + число первого и записал бы в counters завышенное значение. + """ + from scraper_kit.providers.cian.serp import CianScraper + + src = inspect.getsource(CianScraper.fetch_all_secondary) + assert "self.last_dropped_nb = 0" in src, "нет сброса в начале прогона" + assert "self.last_dropped_nb += " in inspect.getsource(CianScraper._paginate_leaf_bucket), ( + "накопление не там, где считается dropped_nb" + ) + assert getattr(CianScraper, "last_dropped_nb", None) == 0, ( + "нет класс-дефолта: атрибут не прочитается, если прогон упал до первого бакета" + ) + + +def test_filter_still_drops_when_flag_is_on() -> None: + """Контроль от переусердствования: при secondary_only=True фильтр остаётся. + + Правка делает флаг управляемым, а не отменяет его. + """ + from scraper_kit.providers.cian.serp import CianScraper + + # Фильтр лежит в _paginate_leaf_bucket — том методе, что собирает страницы + # бакета; fetch_all_secondary только раздаёт флаг вниз. + src = inspect.getsource(CianScraper._paginate_leaf_bucket) + assert 'lot.listing_segment != "novostroyki"' in src, "фильтр пропал" + assert "if secondary_only:" in src, "фильтр перестал зависеть от флага" diff --git a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py index 188b185d..0a9c69aa 100644 --- a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py +++ b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py @@ -142,9 +142,9 @@ def test_house_suggestions_insert_covers_every_declared_column() -> None: (_SQL_DIR / "064_house_imv_phase_c.sql").read_text("utf-8"), "house_suggestions" ) written = _insert_columns(inspect.getsource(hib.save_imv_result), "house_suggestions") - assert ( - declared - {"id"} <= written - ), f"колонки без писателя: {sorted(declared - {'id'} - written)}" + assert declared - {"id"} <= written, ( + f"колонки без писателя: {sorted(declared - {'id'} - written)}" + ) def test_save_imv_result_binds_image_link_and_metrics() -> None: diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py index a6fb3820..8384a28a 100644 --- a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py +++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py @@ -147,9 +147,9 @@ async def test_price_history_takes_pool_node_despite_flag_off() -> None: """ from app.core.config import settings - assert ( - settings.use_proxy_pool_curl is False - ), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL" + assert settings.use_proxy_pool_curl is False, ( + "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL" + ) pool = _SpyPool() await _run_price_history(pool, status_code=200) assert pool.acquire_calls == ["cian"] diff --git a/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py b/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py new file mode 100644 index 00000000..fbb26d18 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py @@ -0,0 +1,180 @@ +"""Неудача резолва yandex_jk_slug помечает дом, а очередь не упирается в те же пять (#2924). + +`_SELECT_PENDING_HOUSES` брал ожидающие дома `ORDER BY h.id` и уходил из очереди только +попавший в витрину. Неудачная попытка не писала ничего — ни времени, ни счётчика, — и +следующий прогон брал те же дома в том же порядке. Замер 19.08: 397 ожидающих, первые +пять по h.id (6706, 6741, 6757, 6774, 6785) занимали все пять слотов каждый прогон. + +Маркер — `houses.yandex_jk_resolve_tried_at` (миграция 269), по образцу +`listings.geocode_tried_at`: выборка не берёт дом раньше чем через RESOLVE_RETRY_DAYS, +а неудавшиеся идут в КОНЕЦ очереди (`NULLS FIRST`). + +Тесты идут через реальный `enrich_yandex_newbuilding_sweep` с двойником сессии: он отдаёт +заданные строки на SELECT и запоминает весь выполненный SQL. Резолвер патчится на None — +это «неудача», ради которой маркер и нужен. На origin/main маркер не пишется ни разу. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from contextlib import contextmanager +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class _Result: + def __init__(self, rows: list[dict], scalar: Any = 0) -> None: + self._rows = rows + self._scalar = scalar + + def mappings(self) -> _Result: + return self + + def all(self) -> list[dict]: + return self._rows + + def fetchone(self) -> Any: + return self._rows[0] if self._rows else None + + def first(self) -> Any: + return self._rows[0] if self._rows else None + + def scalar(self) -> Any: + return self._scalar + + def scalar_one(self) -> Any: + return self._scalar + + def scalar_one_or_none(self) -> Any: + return self._scalar + + +class _Session: + """Отдаёт pending-строки на SELECT и запоминает все (sql, params).""" + + def __init__(self, pending: list[dict]) -> None: + self._pending = pending + self.sql: list[tuple[str, dict]] = [] + + def execute(self, statement: Any = None, params: Any = None, *a: Any, **kw: Any) -> _Result: + text_ = str(statement) + self.sql.append((text_, dict(params or {}))) + # Выборка ожидающих — единственный запрос с DISTINCT ON (h.id); COUNT-запросы + # тоже читают houses h + yandex_realty_nb, их путать с выборкой нельзя. + if "DISTINCT ON (h.id)" in text_: + return _Result(self._pending) + return _Result([]) + + def begin_nested(self) -> Any: + sp = MagicMock() + + @contextmanager + def _cm(): + yield sp + + # код зовёт и `sp = db.begin_nested(); sp.commit()` и `with db.begin_nested():` + sp.__enter__ = lambda *_: sp + sp.__exit__ = lambda *_: False + return sp + + def commit(self) -> None: + pass + + def rollback(self) -> None: + pass + + +async def _run(pending: list[dict], *, force: bool = False, resolved: str | None = None): + from app.tasks import yandex_newbuilding_sweep as mod + + db = _Session(pending) + # Резолвер и конфиг импортируются ВНУТРИ функции (late import) — патчим модуль-источник. + with ( + patch( + "scraper_kit.providers.yandex.newbuilding.resolve_yandex_jk_slug", + AsyncMock(return_value=resolved), + ), + patch.object(mod, "_sleep_with_jitter", AsyncMock(return_value=None)), + patch("app.services.scraper_adapters.RealScraperConfig", MagicMock()), + ): + await mod.enrich_yandex_newbuilding_sweep( + db, city="ekaterinburg", limit=5, force=force, request_delay_sec=0 + ) + return db + + +def _tried_updates(db: _Session) -> list[int]: + return [ + int(p["hid"]) + for s, p in db.sql + if "UPDATE houses" in s and "yandex_jk_resolve_tried_at" in s and "hid" in p + ] + + +def _select_sql(db: _Session) -> str: + for s, _ in db.sql: + if "DISTINCT ON (h.id)" in s: + return s + raise AssertionError("выборка ожидающих домов не выполнялась") + + +def _select_params(db: _Session) -> dict: + for s, p in db.sql: + if "DISTINCT ON (h.id)" in s: + return p + raise AssertionError("выборка ожидающих домов не выполнялась") + + +@pytest.mark.asyncio +async def test_failed_resolve_marks_the_house() -> None: + """Головной: резолв не удался → дом получает yandex_jk_resolve_tried_at. + + На origin/main такого UPDATE нет вовсе — дом останется первым в очереди навсегда. + """ + db = await _run( + [{"house_id": 6706, "yandex_jk_slug": None, "yandex_jk_id": None, "ext_id": "286394"}] + ) + assert _tried_updates(db) == [6706], ( + f"неудача резолва не помечена; выполненный SQL: {[s[:50] for s, _ in db.sql]}" + ) + + +@pytest.mark.asyncio +async def test_successful_resolve_does_not_mark() -> None: + """Контроль: удачный резолв маркер НЕ ставит — иначе удачные дома тоже уходили + бы в конец очереди и ждали бы неделю до обогащения.""" + db = await _run( + [{"house_id": 6706, "yandex_jk_slug": None, "yandex_jk_id": None, "ext_id": "286394"}], + resolved="uspenskij", + ) + assert _tried_updates(db) == [], "маркер поставлен при удачном резолве" + + +@pytest.mark.asyncio +async def test_selection_skips_recently_tried_and_pushes_tried_to_the_end() -> None: + """Выборка: не берёт дом, у которого попытка свежее RESOLVE_RETRY_DAYS, и ставит + пробованные в конец (NULLS FIRST). Проверяется по тексту запроса, который реально + ушёл в БД, и по параметрам — retry_days доезжает, а не захардкожен в строке.""" + db = await _run([]) + sql = _select_sql(db) + assert "yandex_jk_resolve_tried_at IS NULL" in sql, "нет фильтра по маркеру" + assert ":retry_days" in sql, "срок повтора не параметризован" + assert "yandex_jk_resolve_tried_at NULLS FIRST" in sql, "пробованные не уходят в конец очереди" + assert _select_params(db).get("retry_days") == 7, _select_params(db) + + +@pytest.mark.asyncio +async def test_force_bypasses_the_marker() -> None: + """Контроль: force=True игнорирует маркер — ручной полный проход остаётся возможным.""" + db = await _run([], force=True) + sql = _select_sql(db) + # Тот же OR-блок, что и у гейта «уже обогащён»: CAST(:force AS boolean) = TRUE OR … + assert sql.count("CAST(:force AS boolean) = TRUE") >= 2, ( + "маркер не обходится через force — второго OR-блока с :force нет" + ) + assert _select_params(db).get("force") is True diff --git a/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py b/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py new file mode 100644 index 00000000..db4cd3f4 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py @@ -0,0 +1,139 @@ +"""Неизвестный year_built / house_type перестаёт считаться идеальным совпадением (#2936). + +SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько же, +сколько точному попаданию, и ЛУЧШЕ, чем известному году с разницей 24 (2.0). +Отсутствие данных выигрывает у знания, и это возвышает источник с худшей +полнотой: avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 % доли в пуле. + +Флаг, а не правка формулы: влияние на цену измерено дважды — смещения нет, есть +шум (39 % целей сдвигаются >5 %). Какой отбор ТОЧНЕЕ — отвечает только бэктест на +сделках, и включать можно только по его итогу (тот же контракт, что у #2012). + +Механизм: NULL получает МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов, +у которых он известен, — не наказание и не награда. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.estimator import _apply_unknown_attr_penalty + + +def _pool() -> list[dict]: + # Штрафы за год при target=2000: 0.0 / 1.0 / 2.0 / 3.0 / 4.0 → медиана 2.0. + # Типы при target='panel': известные — 3 совпадения (0) и 2 несовпадения (1.5) → + # медиана 0.0; добавим ещё одно несовпадение, чтобы медиана стала ненулевой. + return [ + {"id": 1, "year_built": 2000, "house_type": "panel", "relevance_score": 0.5}, + {"id": 2, "year_built": 2012, "house_type": "panel", "relevance_score": 0.5}, + {"id": 3, "year_built": 2024, "house_type": "brick", "relevance_score": 0.5}, + {"id": 4, "year_built": 1964, "house_type": "brick", "relevance_score": 0.5}, + {"id": 5, "year_built": 1952, "house_type": "brick", "relevance_score": 0.5}, + {"id": 6, "year_built": None, "house_type": None, "relevance_score": 0.5}, # NULL + {"id": 7, "year_built": 2000, "house_type": None, "relevance_score": 0.5}, # NULL тип + ] + + +def test_null_year_gets_pool_median_penalty() -> None: + """Головной: NULL-год получает медиану штрафов известных, а не 0. + + На origin/main функции нет — кандидат 6 остаётся с 0.5 и обгоняет + кандидатов 3–5 с известным годом. + """ + pool = _pool() + _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type=None, min_n=5) + by = {c["id"]: c["relevance_score"] for c in pool} + # Известных годов ШЕСТЬ (ид. 1,2,3,4,5 и 7): штрафы 0,1,2,3,4 и 0 → медиана 1.5. + # (Первая редакция теста считала пятерых и ждала 2.0 — арифметика, не код.) + assert by[6] == 0.5 + 1.5, ( + f"NULL-год должен получить медианный штраф 1.5, получил {by[6] - 0.5}" + ) + assert by[1] == 0.5 and by[3] == 0.5, ( + "известный год не должен трогаться — его уже оштрафовал SQL" + ) + + +def test_null_house_type_gets_pool_median_penalty() -> None: + """NULL-тип: медиана штрафов известных типов (3×0, 3×1.5 → медиана 0.75).""" + pool = _pool() + # Делаем 3 совпадения и 3 несовпадения: 1,2 panel; 3,4,5 brick; добавим ещё panel. + pool.append({"id": 8, "year_built": 2000, "house_type": "panel", "relevance_score": 0.5}) + _apply_unknown_attr_penalty(pool, target_year=None, target_house_type="panel", min_n=5) + by = {c["id"]: c["relevance_score"] for c in pool} + assert by[6] == 0.5 + 0.75 and by[7] == 0.5 + 0.75, f"NULL-тип: {by[6] - 0.5}, {by[7] - 0.5}" + assert by[3] == 0.5, "известный несовпадающий тип уже оштрафован SQL — не трогаем" + + +def test_unknown_target_penalizes_nobody() -> None: + """Симметрия с SQL: если target неизвестен, SQL не штрафует никого — и мы тоже.""" + pool = _pool() + _apply_unknown_attr_penalty(pool, target_year=None, target_house_type=None, min_n=5) + assert all(c["relevance_score"] == 0.5 for c in pool) + + +def test_sparse_pool_is_skipped() -> None: + """Контроль от шума: меньше min_n известных значений → сигнал пропущен целиком.""" + pool = [*_pool()[:3], {"id": 9, "year_built": None, "house_type": None, "relevance_score": 0.5}] + _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type="panel", min_n=5) + assert all(c["relevance_score"] == 0.5 for c in pool), "медиана по трём строкам — не сигнал" + + +def test_flag_default_off_means_no_call() -> None: + """Контроль: дефолт флага OFF — поведение прода не меняется этим PR.""" + from app.core.config import settings + + assert settings.estimate_unknown_attr_penalty_enabled is False + + +def test_penalty_reproduces_sql_terms_not_its_own_idea_of_distance() -> None: + """Штраф считается по ТЕМ ЖЕ термам, что SQL: abs(Δyear)/12.0 и 1.5 за тип. + + Ловит «починку», где NULL получал бы произвольную константу вместо медианы + реально начисленных SQL-штрафов. + """ + pool = [ + {"id": i, "year_built": 2000 + 12 * i, "house_type": "x", "relevance_score": 0.0} + for i in range(1, 6) + ] + [{"id": 99, "year_built": None, "house_type": "x", "relevance_score": 0.0}] + # штрафы 1,2,3,4,5 → медиана 3.0 (= 36 лет / 12) + _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type=None, min_n=5) + assert next(c for c in pool if c["id"] == 99)["relevance_score"] == 3.0 + + +def test_tier_h_and_w_outer_selects_carry_year_and_house_type() -> None: + """Сторож на «мёртвый флаг»: внешние SELECT тиров H и W обязаны выбирать + year_built и house_type — иначе Python-слой видит None у ВСЕХ и не штрафует + никого по построению. + + Именно так флаг и был мёртв в первой редакции: _ANALOG_SELECT_COLS дополнили, + а внешние списки — нет; A/B на 300 целях ЕКБ дал 0 из 5 915 сменённых слотов, + probe-лог показал pool=30 null_year=30. Проверка по исходнику запроса, а не по + живой БД: это инвариант текста SQL. + """ + import inspect + import re + + from app.services import estimator as m + + src = inspect.getsource(m) + # Внешний SELECT тира — блок между `\n<отступ>SELECT\n` и `\n<отступ>FROM base` с ТЕМ + # ЖЕ отступом (иначе rfind цепляет SELECT внутри оконной функции/CASE). + outer_blocks = [] + for mt in re.finditer(r"\n(\s+)FROM base\n", src): + i = mt.start() + j = src.rfind("\n" + mt.group(1) + "SELECT\n", 0, i) + if j >= 0: + outer_blocks.append(src[j:i]) + assert len(outer_blocks) >= 3, f"найдено внешних SELECT: {len(outer_blocks)}" + # Штрафуемые тиры — ровно те, куда #2012 добавил kitchen/ceiling: H и W. + # Tier S (same building) и anchor-тир штрафов не применяют — их внешние + # списки kitchen_area_m2 не содержат, и гейт их не трогает. + penalized = [b for b in outer_blocks if "kitchen_area_m2" in b] + assert len(penalized) == 2, f"ожидал 2 внешних SELECT тиров H и W, нашёл {len(penalized)}" + for b in penalized: + assert "year_built" in b and "house_type" in b, ( + "внешний SELECT тира не выбирает year_built/house_type — флаг #2936 мёртв:\n" + b[-400:] + ) diff --git a/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py b/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py index cbe363a7..49c6bd60 100644 --- a/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py +++ b/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py @@ -84,8 +84,7 @@ async def test_consecutive_queries_are_spaced(throttle_reset: None) -> None: assert len(stamps) == 3, f"ожидали 3 запроса, ушло {len(stamps)}" gaps = _gaps(stamps) assert all(g >= _TEST_INTERVAL * 0.9 for g in gaps), ( - f"запросы идут вплотную: зазоры {[round(g, 4) for g in gaps]}, " - f"ожидалось ≥ {_TEST_INTERVAL}" + f"запросы идут вплотную: зазоры {[round(g, 4) for g in gaps]}, ожидалось ≥ {_TEST_INTERVAL}" ) @@ -108,8 +107,7 @@ async def test_failed_lookup_does_not_reset_the_pace(throttle_reset: None) -> No await geocoder._nominatim_lookup("ненаходимый адрес два") assert boundary_index > 0 and len(stamps) > boundary_index, ( - f"оба поиска должны были сходить в сеть: {len(stamps)} запросов, " - f"граница {boundary_index}" + f"оба поиска должны были сходить в сеть: {len(stamps)} запросов, граница {boundary_index}" ) boundary_gap = stamps[boundary_index] - stamps[boundary_index - 1] assert boundary_gap >= _TEST_INTERVAL * 0.9, ( diff --git a/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py b/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py new file mode 100644 index 00000000..dbe3c0ca --- /dev/null +++ b/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py @@ -0,0 +1,258 @@ +"""Повторный скрейп неизменившегося объявления не переписывает строку (#2992). + +Апсерт listings безусловно присваивал 41 колонку при каждом повторном скрейпе — +заново тостил description и плодил TOAST-чанки. Прод 2026-08-20: n_tup_upd +20.87 млн на 107 тыс. строк (198 апдейтов на строку за 91 день), HOT 0.44 %, +TOAST 15 ГБ при ~230 МБ полезного, WAL 7 ГБ/сутки. + +Гейт в `ON CONFLICT DO UPDATE … WHERE`: апдейт идёт, если итоговое +(post-COALESCE) значение хоть одной контентной колонки отличается ИЛИ +last_seen_at ещё не сегодняшний по МСК. Второе условие обязательно: метка +живости должна сдвигаться хотя бы раз в сутки — на неё завязаны деактиватор, +снапшоты, эстиматор (#2206), монитор свежести. + +Проверяется на ЖИВОМ Postgres через реальный save_listings, в транзакции с +откатом. Счётчик апдейтов — pg_stat_xact_user_tables (текущая транзакция), +то есть ровно «повторный скрейп не увеличивает n_tup_upd» из acceptance. +Без БД — skip (как соседние live-тесты; в CI Trade-In есть postgres-сервис). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import uuid +from typing import Any +from unittest.mock import MagicMock + +import pytest +from scraper_kit.base import ScrapedLot, save_listings +from sqlalchemy import text + + +def _live_session() -> Any | None: + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _matcher() -> MagicMock: + m = MagicMock() + m.match_or_create_house.return_value = (None, 0.0, "no_address") + m.upsert_listing_source.return_value = None + return m + + +def _lot( + src_id: str, + price: int = 5_000_000, + description: str | None = "тестовое описание", + address: str | None = None, +) -> ScrapedLot: + return ScrapedLot( + source="cian", + source_url=f"https://ekb.cian.ru/sale/flat/t2992-{src_id}/", + source_id=f"t2992-{src_id}", + price_rub=price, + description=description, + address=address, + ) + + +def _row(db: Any, src_id: str) -> Any: + """Строка + её физическая версия (ctid). save_listings КОММИТИТ, поэтому + транзакционные счётчики pg_stat_xact_* тут бесполезны (сбрасываются). UPDATE + всегда создаёт новую версию строки → ctid меняется; пропущенный апдейт → ctid + тот же. Это и есть прямой замер «переписана ли строка».""" + return db.execute( + text( + "SELECT id, ctid::text AS ctid, price_rub, city, description, last_seen_at " + "FROM listings WHERE source = 'cian' AND source_id = :sid" + ), + {"sid": f"t2992-{src_id}"}, + ).fetchone() + + +def _cleanup(db: Any) -> None: + """save_listings коммитит — rollback не спасает. Убираем свои строки явно.""" + try: + db.rollback() + db.execute( + text( + "DELETE FROM listings_snapshots WHERE listing_id IN " + "(SELECT id FROM listings WHERE source='cian' AND source_id LIKE 't2992-%')" + ) + ) + db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't2992-%'")) + db.execute(text("DELETE FROM listings WHERE source='cian' AND source_id LIKE 't2992-%'")) + db.commit() + finally: + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_unchanged_rescrape_same_day_does_not_update_the_row() -> None: + """Головной: второй апсерт неизменного лота в тот же МСК-день — n_tup_upd не растёт. + + На origin/main (без гейта) второй save_listings переписывает строку — счётчик +1. + """ + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + first = _row(db, sid) + assert first is not None, "первая вставка не прошла" + ins, upd = save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + second = _row(db, sid) + assert second.ctid == first.ctid, ( + f"повторный скрейп неизменного лота переписал строку: ctid {first.ctid}→{second.ctid}" + ) + assert (ins, upd) == (0, 0), f"счётчики: inserted={ins} updated={upd}, ждали 0/0" + assert second.last_seen_at == first.last_seen_at, "last_seen_at сдвинулся без нужды" + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_changed_price_still_updates() -> None: + """Контроль: изменение контента проходит — гейт не глушит настоящие изменения.""" + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid, price=5_000_000)], matcher=_matcher(), region_code=66) + first = _row(db, sid) + save_listings(db, [_lot(sid, price=5_100_000)], matcher=_matcher(), region_code=66) + second = _row(db, sid) + assert second.ctid != first.ctid, "изменение цены не записалось (строка не переписана)" + assert second.price_rub == 5_100_000 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_coalesce_backfill_still_updates() -> None: + """Контроль: COALESCE-дозаполнение (город NULL → значение) проходит. + + Именно за это забраковали гейт по card_hash: он бы блокировал дозаполнение. + Сравнение идёт по ИТОГОВЫМ post-COALESCE значениям — если дописал, строка + отличается. + """ + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + # city приходит ПАРАМЕТРОМ save_listings (развёртка города), не полем лота. + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66, city=None) + first = _row(db, sid) + assert first.city is None + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66, city="Екатеринбург") + second = _row(db, sid) + assert second.ctid != first.ctid, "дозаполнение города заблокировано гейтом" + assert second.city == "Екатеринбург" + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_next_day_rescrape_updates_even_if_unchanged() -> None: + """Живость: если last_seen_at вчерашний — апдейт идёт и без изменений контента. + + Иначе деактиватор/снапшоты/эстиматор потеряли бы живой инвентарь. Симулируем + «вчера», отодвинув last_seen_at у только что вставленной строки. + """ + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + db.execute( + text( + "UPDATE listings SET last_seen_at = last_seen_at - interval '1 day' " + "WHERE source='cian' AND source_id = :sid" + ), + {"sid": f"t2992-{sid}"}, + ) + db.commit() + first = _row(db, sid) + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + second = _row(db, sid) + assert second.ctid != first.ctid, "суточное ре-подтверждение живости заблокировано" + assert second.last_seen_at > first.last_seen_at, "last_seen_at не сдвинулся" + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_skipped_row_still_yields_listing_id_for_downstream() -> None: + """При пропуске апдейта listing_id всё равно известен — downstream (снапшот, + матчинг, listing_sources) идёт; иначе гейт молча отключил бы сопоставление.""" + db = _live_session() + sid = uuid.uuid4().hex[:8] + m = _matcher() + lot = _lot(sid, address="Екатеринбург, ул. Тестовая, 1") # матчинг идёт только с адресом/гео + try: + save_listings(db, [lot], matcher=m, region_code=66) + calls_before = m.match_or_create_house.call_count + assert calls_before == 1, ( + "контроль сконструирован неверно: матчинг не зовётся и в первый раз" + ) + save_listings(db, [lot], matcher=m, region_code=66) + assert m.match_or_create_house.call_count == calls_before + 1, ( + "при пропущенном апдейте матчинг не вызван — listing_id потерян" + ) + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_listing_sources_unchanged_rescrape_same_day_does_not_update() -> None: + """Симметричный гейт на listing_sources — второй по нагрузке таблице (10.27 млн + апдейтов, HOT 0.10 %). Повторный upsert_listing_source того же лота в тот же + МСК-день не переписывает строку; изменение цены — переписывает.""" + from app.services.matching.listings import upsert_listing_source + + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + lid = int(_row(db, sid).id) + + def ls_row() -> Any: + return db.execute( + text( + "SELECT ctid::text AS ctid, price_rub, last_seen_at FROM listing_sources " + "WHERE ext_source='cian' AND ext_id = :e" + ), + {"e": f"t2992-{sid}"}, + ).fetchone() + + kw = dict(ext_source="cian", ext_id=f"t2992-{sid}", method="test", confidence=0.9) + upsert_listing_source(db, listing_id=lid, price_rub=5_000_000, **kw) + db.commit() + first = ls_row() + assert first is not None + upsert_listing_source(db, listing_id=lid, price_rub=5_000_000, **kw) + db.commit() + second = ls_row() + assert second.ctid == first.ctid, ( + f"listing_sources переписана без изменений: ctid {first.ctid}→{second.ctid}" + ) + upsert_listing_source(db, listing_id=lid, price_rub=5_100_000, **kw) + db.commit() + third = ls_row() + assert third.ctid != second.ctid and third.price_rub == 5_100_000, ( + "изменение цены не записалось" + ) + finally: + _cleanup(db) diff --git a/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py b/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py new file mode 100644 index 00000000..a216f3c8 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py @@ -0,0 +1,112 @@ +"""Сторож на дома, уехавшие от собственных объявлений (#2996). + +Прод 21.08.2026: 23 дома стоят вне Свердловской области, к ним привязано 591 +объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»), +координаты — Варшава, Белград, Таллин, Владивосток, Ижевск. + +Причина найдена и она НЕ в живом пути: 22 из 23 несут в `raw_payload` след +разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер +напрямую, без резолва города. Живой путь чист — 0 записей вне региона в +`geocode_cache` из 10 448 и 2 из 88 544 у `listings`. + +**17 из 22 помечены геокодером `precision: "exact"`.** Точность отвечает на +«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не +может. Поэтому сторож проверяет ПРИНАДЛЕЖНОСТЬ, а не «точность». + +Инвариант нарочно не географический — «дом рядом со своими объявлениями», а не +«дом внутри рамки области». Рамка сломалась бы при выходе в Москву; этот +инвариант переживёт расширение. + +Порог 100 км не подобран на глаз. Замер по 9 052 домам с объявлениями: + + ближе 1 км 8 914 (98.5 %) + 1–5 км 64 + 5–25 км 40 + 25–100 км 9 ← настоящие пригороды: Сарапулка, Кедровка, + Чусовское Озеро, Ревда, Первоуральск (макс. 46.7) + дальше 100 км 25 ← ближайший 119.2, максимум 5 079 + +Между 46.7 и 119.2 км нет НИ ОДНОГО дома. 100 лежит в середине пустого +промежутка, и ловит на два дома больше, чем рамка по области (оба проверены — +настоящие, «Ул. Белинского/Фурманова» за 207 км с 34 объявлениями). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import inspect +import re + + +def test_threshold_sits_in_the_measured_gap() -> None: + """Головной: порог задан и лежит между пригородами и патологией. + + 46.7 км — самый дальний настоящий пригород, 119.2 — ближайший дефект. + Порог вне этого промежутка либо ловит пригороды, либо пропускает дефекты. + """ + from app.api.v1 import admin + + порог = getattr(admin, "_MISLOCATED_KM", None) + assert порог is not None, ( + "порога нет вовсе — сторож не задан, дома в Варшаве и Таллине никем не считаются" + ) + assert 46.7 < порог < 119.2, ( + f"порог {порог} вне измеренного промежутка (46.7, 119.2): ниже — ловит " + "настоящие пригороды, выше — пропускает дефекты" + ) + + +def test_endpoint_exists_and_reports_scale() -> None: + """Ручка обязана отдавать не только счётчик, но и МАСШТАБ. + + Одно число «25» не даёт принять решение об очистке: нужно, сколько + объявлений привязано к этим домам (на проде 591) и какие это дома. + """ + from app.api.v1 import admin + + ручка = getattr(admin, "get_mislocated_houses", None) + модель = getattr(admin, "MislocatedHousesResponse", None) + assert ручка is not None and модель is not None, ( + "ручки нет — дома в Варшаве и Таллине не перечисляет никто; " + f"есть ли обработчик: {ручка is not None}, есть ли модель: {модель is not None}" + ) + поля = модель.model_fields + for нужное in ("threshold_km", "houses_count", "listings_count", "houses"): + assert нужное in поля, f"нет поля {нужное}; есть: {sorted(поля)}" + + +def test_query_compares_house_to_its_own_listings() -> None: + """Контроль смысла: инвариант «рядом со своими», а не «внутри рамки». + + Рамка по области сломалась бы при выходе в Москву — ровно то, ради чего + заведена #2996. Ловит «починку» через координатный BETWEEN. + """ + from app.api.v1 import admin + + запрос = getattr(admin, "_MISLOCATED_SQL", None) + assert запрос is not None, "запроса нет — проверять принадлежность нечем" + src = str(запрос) + assert "l.house_id_fk = h.id" in src, "дом не сравнивается со СВОИМИ объявлениями" + assert "percentile_disc(0.5)" in src, "берётся не медиана координат объявлений" + assert not re.search(r"ST_Y\(.*BETWEEN", src), ( + "появилась географическая рамка — она не переживёт расширение региона" + ) + + +def test_check_stays_out_of_the_polled_endpoint() -> None: + """Контроль цены: тяжёлая проверка не должна попасть в опрашиваемую ручку. + + Замер прода: этот запрос ~445 мс, обе выборки /scraper/data-quality вместе — + 27 мс, а фронт опрашивает её каждые 120 с. Первая редакция этой правки клала + проверку туда и давала 17-кратное удорожание; замер это и остановил. + """ + from app.api.v1 import admin + + src = inspect.getsource(admin.get_data_quality) + assert "percentile_disc" not in src, ( + "тяжёлый агрегат вернулся в опрашиваемую каждые 2 минуты ручку" + ) + assert "mislocated" not in src.lower(), "проверка снова подмешана в data-quality" diff --git a/tradein-mvp/backend/tests/test_781_quality_gate.py b/tradein-mvp/backend/tests/test_781_quality_gate.py index ac689067..26452d64 100644 --- a/tradein-mvp/backend/tests/test_781_quality_gate.py +++ b/tradein-mvp/backend/tests/test_781_quality_gate.py @@ -166,9 +166,9 @@ def test_755_anchor_n2_does_not_fire_headline_stays_radius() -> None: "anchor with n=2 comps must NOT fire (min_comps=4 post-#755)" ) # Confidence from 5 radius analogs must not be "high" (unique_addr < 7 threshold). - assert ( - est.confidence != "high" - ), f"Confidence should not be 'high' with 5 radius analogs, got {est.confidence!r}" + assert est.confidence != "high", ( + f"Confidence should not be 'high' with 5 radius analogs, got {est.confidence!r}" + ) def test_755_anchor_n2_pure_unit_confidence_never_high() -> None: @@ -250,9 +250,9 @@ def test_753_dedup_hash_stable_across_reprice_with_source_id() -> None: lot_original = _make_scraped_lot(source_url=url, source_id=sid, price_rub=5_000_000) lot_repriced = _make_scraped_lot(source_url=url, source_id=sid, price_rub=4_900_000) - assert ( - lot_original.compute_dedup_hash() == lot_repriced.compute_dedup_hash() - ), "dedup_hash must be stable across reprice when source_id is present (#753)" + assert lot_original.compute_dedup_hash() == lot_repriced.compute_dedup_hash(), ( + "dedup_hash must be stable across reprice when source_id is present (#753)" + ) def test_753_dedup_hash_stable_across_context_query_no_source_id() -> None: @@ -275,9 +275,9 @@ def test_753_dedup_hash_stable_across_context_query_no_source_id() -> None: source_id=None, price_rub=5_000_000, ) - assert ( - lot_v1.compute_dedup_hash() == lot_v2.compute_dedup_hash() - ), "dedup_hash must strip ?context= query and match for same listing URL (#753)" + assert lot_v1.compute_dedup_hash() == lot_v2.compute_dedup_hash(), ( + "dedup_hash must strip ?context= query and match for same listing URL (#753)" + ) def test_753_dedup_hash_differs_for_distinct_listings() -> None: @@ -312,9 +312,9 @@ def test_753_dedup_hash_source_id_takes_priority_over_url() -> None: source_url="https://www.avito.ru/ru/offer/9999?some=other", source_id="9999", ) - assert ( - lot_canonical.compute_dedup_hash() == lot_redirect.compute_dedup_hash() - ), "source_id takes priority: same source_id -> same hash regardless of URL (#753)" + assert lot_canonical.compute_dedup_hash() == lot_redirect.compute_dedup_hash(), ( + "source_id takes priority: same source_id -> same hash regardless of URL (#753)" + ) # --------------------------------------------------------------------------- @@ -353,12 +353,12 @@ def test_773_expected_sold_positive_on_anchor_only_path( # Anchor gave a valid headline. assert est.median_price_rub > 0, "anchor must produce non-zero headline" # expected_sold must also be computed (not skipped by empty listings_clean). - assert ( - est.expected_sold_price_rub is not None - ), "expected_sold_price_rub must not be None on anchor-only path when ratio present (#773)" - assert ( - est.expected_sold_price_rub > 0 - ), f"expected_sold_price_rub must be > 0, got {est.expected_sold_price_rub} (#773)" + assert est.expected_sold_price_rub is not None, ( + "expected_sold_price_rub must not be None on anchor-only path when ratio present (#773)" + ) + assert est.expected_sold_price_rub > 0, ( + f"expected_sold_price_rub must be > 0, got {est.expected_sold_price_rub} (#773)" + ) # Consistency: sold = asking * ratio. assert est.expected_sold_price_rub == round(est.median_price_rub * 0.92) @@ -376,9 +376,9 @@ def test_773_expected_sold_null_when_no_ratio_anchor_only() -> None: ratio_tuple=(None, None), ) assert est.median_price_rub > 0 # anchor headline is valid - assert ( - est.expected_sold_price_rub is None - ), "expected_sold must be None when ratio is None, even on anchor-only path (#773)" + assert est.expected_sold_price_rub is None, ( + "expected_sold must be None when ratio is None, even on anchor-only path (#773)" + ) # --------------------------------------------------------------------------- @@ -437,9 +437,9 @@ async def test_754_block_page_http200_page_gt1_graceful_return() -> None: result = await scraper.fetch_around(56.838, 60.605, radius_m=1000, pages=2) # The important invariant: no exception raised, result is a list. - assert isinstance( - result, list - ), "fetch_around must return a list, not raise, when page>1 returns 0 cards (#754)" + assert isinstance(result, list), ( + "fetch_around must return a list, not raise, when page>1 returns 0 cards (#754)" + ) # --------------------------------------------------------------------------- @@ -476,9 +476,9 @@ def test_740_median_zero_gives_insufficient_data_true() -> None: actual_deals=[], expires_at=datetime(2026, 6, 1, tzinfo=UTC), ) - assert ( - est.insufficient_data is True - ), "insufficient_data must be True when median_price_rub=0 (#740)" + assert est.insufficient_data is True, ( + "insufficient_data must be True when median_price_rub=0 (#740)" + ) assert est.median_price_rub == 0 @@ -505,9 +505,9 @@ def test_740_positive_median_gives_insufficient_data_false() -> None: actual_deals=[], expires_at=datetime(2026, 6, 1, tzinfo=UTC), ) - assert ( - est.insufficient_data is False - ), "insufficient_data must be False when median_price_rub>0 (#740)" + assert est.insufficient_data is False, ( + "insufficient_data must be False when median_price_rub>0 (#740)" + ) def test_740_insufficient_data_serialized_in_model_dump() -> None: diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py index 22324746..82b0b0f7 100644 --- a/tradein-mvp/backend/tests/test_alerts_become_events.py +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -155,9 +155,9 @@ def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: now=datetime(2026, 8, 6, tzinfo=UTC), ) assert out["alert"] == 1 - assert any( - "sber freshness" in t for t in event_texts(events) - ), "отставание загрузки не стало событием — WARNING до GlitchTip не долетает" + assert any("sber freshness" in t for t in event_texts(events)), ( + "отставание загрузки не стало событием — WARNING до GlitchTip не долетает" + ) def test_sber_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py index 671d8f58..b12d02a5 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -494,13 +494,13 @@ def test_migration_098_band_matches_settings_default() -> None: from app.tasks.asking_to_sold_ratio import _PPM2_MIN # Lower bound — matches the 30000 literal in migration 098. - assert ( - _PPM2_MIN == 30_000 - ), f"_PPM2_MIN changed ({_PPM2_MIN}); update migration 098 seed literals to match" + assert _PPM2_MIN == 30_000, ( + f"_PPM2_MIN changed ({_PPM2_MIN}); update migration 098 seed literals to match" + ) # Upper bound — matches the 1200000 literal in migration 098. - assert ( - Settings().asking_ratio_ppm2_max == 1_200_000 - ), "asking_ratio_ppm2_max default changed; update migration 098 seed literals to match" + assert Settings().asking_ratio_ppm2_max == 1_200_000, ( + "asking_ratio_ppm2_max default changed; update migration 098 seed literals to match" + ) # ── area_bucket() Python twin matches _AREA_ROOMS_BUCKET_SQL (#2620) ───────── diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 9cd7b5cd..9436d1f7 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -826,18 +826,18 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive( # этого вердикта, а не вместо него. assert probe_latencies, "проба не сделала ни одного запроса" probe_latencies.sort() - assert ( - probe_latencies[-1] < 0.5 - ), f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа" + assert probe_latencies[-1] < 0.5, ( + f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа" + ) median_probe = probe_latencies[len(probe_latencies) // 2] assert median_probe < verify_s, ( f"медиана стороннего запроса {median_probe * 1000:.0f}мс ≥ времени одной " f"сверки — цикл занят проверкой пароля, API стоит" ) # Мало проб за секунду — тоже занятый цикл: проба просыпается раз в 10мс. - assert ( - len(probe_latencies) >= 10 - ), f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят" + assert len(probe_latencies) >= 10, ( + f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят" + ) # 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает # потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик). diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 8a386aa4..178986ab 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -59,17 +59,13 @@ class TestYandexTitleExtract: assert not result.endswith(",") def test_nbsp_replaced(self): - html = ( - "— Екатеринбург,\xa0улица Горького, 36 — id 12 " "на Яндекс.Недвижимости" - ) + html = "— Екатеринбург,\xa0улица Горького, 36 — id 12 на Яндекс.Недвижимости" result = self._extract(html) assert result is not None assert "\xa0" not in result def test_case_insensitive_city(self): - html = ( - "— ЕКАТЕРИНБУРГ, улица Горького, 36 — id 777 " "на Яндекс.Недвижимости" - ) + html = "— ЕКАТЕРИНБУРГ, улица Горького, 36 — id 777 на Яндекс.Недвижимости" # regex is case-insensitive; city name retains original case result = self._extract(html) assert result is not None @@ -101,8 +97,7 @@ async def test_backfill_yandex_addresses_saves_enriched(): from app.services.yandex_address_backfill import backfill_yandex_addresses html_with_addr = ( - "Продажа — Екатеринбург, улица Горького, 36 — id 11 " - "на Яндекс.Недвижимости" + "Продажа — Екатеринбург, улица Горького, 36 — id 11 на Яндекс.Недвижимости" ) mock_resp = MagicMock() diff --git a/tradein-mvp/backend/tests/test_backtest_regression_gate.py b/tradein-mvp/backend/tests/test_backtest_regression_gate.py index 51a26de8..0275db49 100644 --- a/tradein-mvp/backend/tests/test_backtest_regression_gate.py +++ b/tradein-mvp/backend/tests/test_backtest_regression_gate.py @@ -46,9 +46,9 @@ _ABS_TOL = 1e-6 def _assert_match(path: str, expected: object, actual: object) -> None: if isinstance(expected, dict): assert isinstance(actual, dict), f"{path}: expected dict, got {type(actual).__name__}" - assert ( - expected.keys() == actual.keys() - ), f"{path}: key set differs\n expected={sorted(expected)}\n actual= {sorted(actual)}" + assert expected.keys() == actual.keys(), ( + f"{path}: key set differs\n expected={sorted(expected)}\n actual= {sorted(actual)}" + ) for k in expected: _assert_match(f"{path}.{k}", expected[k], actual[k]) elif isinstance(expected, list): diff --git a/tradein-mvp/backend/tests/test_cian_state_parser.py b/tradein-mvp/backend/tests/test_cian_state_parser.py index e8d7e4c4..a8665672 100644 --- a/tradein-mvp/backend/tests/test_cian_state_parser.py +++ b/tradein-mvp/backend/tests/test_cian_state_parser.py @@ -28,9 +28,7 @@ def _concat_push(mfe: str, entries: list[tuple[str, str]]) -> str: (не эскейпленных дважды — `.concat([...])` несёт валидный JSON-массив как есть). """ items = ",".join(f'{{"key":"{k}","value":{v},"priority":0}}' for k, v in entries) - return ( - f"window._cianConfig['{mfe}'] = (window._cianConfig['{mfe}'] || [])" f".concat([{items}]);" - ) + return f"window._cianConfig['{mfe}'] = (window._cianConfig['{mfe}'] || []).concat([{items}]);" def _push(mfe: str, key: str, value: str) -> str: diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_avito.py b/tradein-mvp/backend/tests/test_deactivate_stale_avito.py index 9dffac4c..fcac27cf 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_avito.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_avito.py @@ -253,17 +253,17 @@ def test_migration_100_updates_correct_source() -> None: def test_migration_100_sets_enabled_true() -> None: sql = _MIGRATION_100.read_text("utf-8") # UPDATE … SET enabled = true - assert re.search( - r"enabled\s*=\s*true", sql, re.IGNORECASE - ), "migration 100 must SET enabled = true" + assert re.search(r"enabled\s*=\s*true", sql, re.IGNORECASE), ( + "migration 100 must SET enabled = true" + ) def test_migration_100_is_idempotent() -> None: """WHERE enabled = false ensures re-running after fix → 0 rows matched.""" sql = _MIGRATION_100.read_text("utf-8") - assert re.search( - r"enabled\s*=\s*false", sql, re.IGNORECASE - ), "migration 100 must guard with AND enabled = false for idempotency" + assert re.search(r"enabled\s*=\s*false", sql, re.IGNORECASE), ( + "migration 100 must guard with AND enabled = false for idempotency" + ) def test_migration_100_is_transactional() -> None: diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py index fbfc49f5..5ea5fce5 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -157,9 +157,9 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey ) _, update_params = db.update_query assert update_params is not None - assert ( - update_params["ttl_days"] == effective - ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен" + assert update_params["ttl_days"] == effective, ( + f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен" + ) def _read_cap_mult_from_migration(filename: str, *, source: str) -> int: diff --git a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py index 5e6e0129..9e437ef9 100644 --- a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -82,8 +82,7 @@ def test_domrf_window_does_not_collide_with_matview_refresh() -> None: start, end = hours[0], hours[1] matview_start, matview_end = 3, 4 # прод-значение scrape_schedules на 2026-08-06 assert end <= matview_start or start >= matview_end, ( - f"окно {start}-{end} пересекается с refresh_search_matview " - f"{matview_start}-{matview_end}" + f"окно {start}-{end} пересекается с refresh_search_matview {matview_start}-{matview_end}" ) @@ -248,9 +247,9 @@ def test_price_divergence_is_documented_as_structurally_empty() -> None: """Оставленный задел обязан говорить, чем он НЕ является сегодня.""" sql = MIGRATION.read_text(encoding="utf-8") comment = sql.split("COMMENT ON VIEW v_price_divergence IS", 1)[1].split(";", 1)[0] - assert ( - "match_or_create_listing" in comment - ), "комментарий не называет причину пустоты — без неё это просто «пока пусто»" + assert "match_or_create_listing" in comment, ( + "комментарий не называет причину пустоты — без неё это просто «пока пусто»" + ) # ───────────────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_estimator_cohort.py b/tradein-mvp/backend/tests/test_estimator_cohort.py index b6c572fb..0a4682c0 100644 --- a/tradein-mvp/backend/tests/test_estimator_cohort.py +++ b/tradein-mvp/backend/tests/test_estimator_cohort.py @@ -3,6 +3,7 @@ PR 10 (2026-05-24) — covers _target_cohort_range edge cases: None, out-of-range, exact boundaries, first-match semantics. """ + import os # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. @@ -16,26 +17,26 @@ from app.services.estimator import _target_cohort_range @pytest.mark.parametrize( ("year", "expected"), [ - (None, None), # no year → no cohort - (1900, None), # out-of-range below → no cohort - (1954, None), # below khrushchev floor - (1955, (1955, 1969)), # khrushchev floor - (1960, (1955, 1969)), # khrushchev mid - (1969, (1955, 1969)), # khrushchev ceiling - (1970, (1970, 1989)), # brezhnev floor - (1978, (1970, 1989)), # brezhnev (target audit case) - (1988, (1970, 1989)), # brezhnev pre-overlap fix held - (1989, (1970, 1989)), # brezhnev ceiling - (1990, (1990, 1999)), # late_soviet floor (post-PR10 fix) - (1995, (1990, 1999)), # late_soviet mid - (1999, (1990, 1999)), # late_soviet ceiling - (2000, (2000, 2010)), # 2000s floor - (2010, (2000, 2010)), # 2000s ceiling - (2011, (2011, 2100)), # modern floor - (2024, (2011, 2100)), # modern current - (2100, (2011, 2100)), # modern ceiling - (2101, None), # above modern ceiling → no cohort - (2200, None), # far future → no cohort + (None, None), # no year → no cohort + (1900, None), # out-of-range below → no cohort + (1954, None), # below khrushchev floor + (1955, (1955, 1969)), # khrushchev floor + (1960, (1955, 1969)), # khrushchev mid + (1969, (1955, 1969)), # khrushchev ceiling + (1970, (1970, 1989)), # brezhnev floor + (1978, (1970, 1989)), # brezhnev (target audit case) + (1988, (1970, 1989)), # brezhnev pre-overlap fix held + (1989, (1970, 1989)), # brezhnev ceiling + (1990, (1990, 1999)), # late_soviet floor (post-PR10 fix) + (1995, (1990, 1999)), # late_soviet mid + (1999, (1990, 1999)), # late_soviet ceiling + (2000, (2000, 2010)), # 2000s floor + (2010, (2000, 2010)), # 2000s ceiling + (2011, (2011, 2100)), # modern floor + (2024, (2011, 2100)), # modern current + (2100, (2011, 2100)), # modern ceiling + (2101, None), # above modern ceiling → no cohort + (2200, None), # far future → no cohort ], ) def test_target_cohort_range(year, expected): diff --git a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py index 16815488..ccaec2ae 100644 --- a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py +++ b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py @@ -57,9 +57,9 @@ def test_very_low_reliability_forces_confidence_low() -> None: """reliability == 'very_low' → confidence forced to 'low', regardless of what _compute_confidence originally scored.""" for original in ("high", "medium", "low"): - assert ( - _cap_confidence_by_reliability(original, "very_low") == "low" - ), f"original={original!r} must be forced to 'low' under very_low reliability" + assert _cap_confidence_by_reliability(original, "very_low") == "low", ( + f"original={original!r} must be forced to 'low' under very_low reliability" + ) def test_low_reliability_caps_confidence_at_medium() -> None: diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py index 51e8a675..c0dece68 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py @@ -68,9 +68,9 @@ def test_bucket_clamping() -> None: _get_asking_sold_ratio(db, rooms) # First positional bind param is {"b": bucket}. bind = db.execute.call_args_list[0].args[1] - assert ( - bind["b"] == expected_bucket - ), f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}" + assert bind["b"] == expected_bucket, ( + f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}" + ) def test_bucket_keyed_by_area_when_area_known() -> None: diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py index ca8842ac..8cc771d6 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -134,13 +134,13 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1( est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) assert est.median_price_rub > 0, "headline должен быть задан аналогами" assert est.expected_sold_price_rub is not None - assert ( - est.expected_sold_price_rub <= est.median_price_rub - ), f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}" + assert est.expected_sold_price_rub <= est.median_price_rub, ( + f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}" + ) assert est.expected_sold_per_m2 is not None - assert ( - est.expected_sold_per_m2 <= est.median_price_per_m2 - ), "expected_sold_per_m2 превышает median_price_per_m2" + assert est.expected_sold_per_m2 <= est.median_price_per_m2, ( + "expected_sold_per_m2 превышает median_price_per_m2" + ) # #1966: expected_sold range is now a calibrated ~80% PI around the point # (point × [p10, p90] of sold/expected_sold), so it is NO LONGER bounded by the # asking-IQR band — the high arm (point × 1.392) legitimately exceeds range_high. diff --git a/tradein-mvp/backend/tests/test_estimator_floor_optional.py b/tradein-mvp/backend/tests/test_estimator_floor_optional.py index 718cfe79..addcb2d9 100644 --- a/tradein-mvp/backend/tests/test_estimator_floor_optional.py +++ b/tradein-mvp/backend/tests/test_estimator_floor_optional.py @@ -4,6 +4,7 @@ Uses MagicMock for DB (consistent with other test_estimator_* tests — no live The test validates schema construction + that floor=None is accepted without ValueError. The actual NOT NULL guard is covered by migration 065. """ + from __future__ import annotations import os diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py index a4033a0f..d3df789a 100644 --- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py +++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py @@ -298,9 +298,9 @@ def test_repair_coefficient_now_applies_to_thin_sample() -> None: geo=_geo(), dadata_qc_geo=None, ) - assert ( - pr.median_price != pr_no_repair.median_price - ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample" + assert pr.median_price != pr_no_repair.median_price, ( + "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample" + ) # ───────────────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py b/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py index fecc2c63..95ded602 100644 --- a/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py +++ b/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py @@ -77,18 +77,14 @@ def test_estimate_null_floor_skips_imv_and_cian() -> None: async def _run() -> None: with ( - patch("app.services.estimator.geocode", - new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.get_house_metadata", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), patch("app.services.estimator._get_or_fetch_imv_cached", new=imv_mock), - patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", - new=yandex_mock), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock), patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock), - patch("app.services.estimator._get_asking_sold_ratio", - return_value=(None, None)), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) @@ -120,18 +116,14 @@ def test_estimate_full_floor_awaits_imv_and_cian() -> None: async def _run() -> None: with ( - patch("app.services.estimator.geocode", - new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.get_house_metadata", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), patch("app.services.estimator._get_or_fetch_imv_cached", new=imv_mock), - patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", - new=yandex_mock), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock), patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock), - patch("app.services.estimator._get_asking_sold_ratio", - return_value=(None, None)), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) @@ -161,20 +153,19 @@ def test_estimate_yandex_timeout_degrades_no_5xx() -> None: async def _run() -> None: with ( - patch("app.services.estimator.geocode", - new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.get_house_metadata", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), - patch("app.services.estimator._get_or_fetch_imv_cached", - new=AsyncMock(return_value=None)), - patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", - new=yandex_mock), - patch("app.services.estimator.estimate_via_cian_valuation", - new=AsyncMock(return_value=None)), - patch("app.services.estimator._get_asking_sold_ratio", - return_value=(None, None)), + patch( + "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) + ), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) @@ -203,26 +194,23 @@ def test_estimate_yandex_httpx_timeout_at_scraper_degrades_no_5xx() -> None: fake_scraper = MagicMock() fake_scraper.__aenter__ = AsyncMock(return_value=fake_scraper) fake_scraper.__aexit__ = AsyncMock(return_value=False) - fake_scraper.fetch_house_history = AsyncMock( - side_effect=httpx.TimeoutException("read timeout") - ) + fake_scraper.fetch_house_history = AsyncMock(side_effect=httpx.TimeoutException("read timeout")) async def _run() -> None: with ( - patch("app.services.estimator.geocode", - new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.get_house_metadata", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), patch("app.services.estimator._fetch_deals", return_value=[]), - patch("app.services.estimator._get_or_fetch_imv_cached", - new=AsyncMock(return_value=None)), - patch("app.services.estimator.YandexValuationScraper", - return_value=fake_scraper), - patch("app.services.estimator.estimate_via_cian_valuation", - new=AsyncMock(return_value=None)), - patch("app.services.estimator._get_asking_sold_ratio", - return_value=(None, None)), + patch( + "app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) + ), + patch("app.services.estimator.YandexValuationScraper", return_value=fake_scraper), + patch( + "app.services.estimator.estimate_via_cian_valuation", + new=AsyncMock(return_value=None), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index 1551978e..aba07b00 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -135,9 +135,9 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None: est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) floor_ppm2 = 150_000 * 0.8 # 120_000 - assert ( - est.median_price_per_m2 >= floor_ppm2 - ), f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" + assert est.median_price_per_m2 >= floor_ppm2, ( + f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" + ) # ── тест 2: radius median выше floor → no-op ───────────────────────────────── @@ -164,9 +164,9 @@ def test_radius_median_above_dkp_floor_unchanged() -> None: floor_ppm2 = 150_000 * 0.8 # 120_000 assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)" # Медиана соответствует аналогам (~200k), а не floor - assert ( - 180_000 <= est.median_price_per_m2 <= 220_000 - ), f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" + assert 180_000 <= est.median_price_per_m2 <= 220_000, ( + f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" + ) # ── тест 3: dkp_raw is None → no-op ───────────────────────────────────────── @@ -182,6 +182,6 @@ def test_no_dkp_raw_no_floor() -> None: est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8) # median ~80k, без dkp_raw floor не поднимает - assert ( - est.median_price_per_m2 < 100_000 - ), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" + assert est.median_price_per_m2 < 100_000, ( + f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" + ) diff --git a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py index 0e9dab86..2a717175 100644 --- a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py +++ b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py @@ -150,9 +150,9 @@ def _run_estimate_with_ratio_spy( def test_ratio_called_exactly_once() -> None: """_get_asking_sold_ratio должен вызываться ровно один раз — после headline.""" _est, calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (0.80, "per_rooms")) - assert ( - len(calls) == 1 - ), f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз" + assert len(calls) == 1, ( + f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз" + ) # ── тест 2: anchor поднял headline → ratio вызван с финальным (high) ppm² ──── @@ -298,9 +298,9 @@ def test_ratio_tier_uses_final_headline_after_anchor() -> None: ppm2_used = captured_anchor_ppm2[0] assert ppm2_used is not None # Финальный headline должен быть в зоне anchor (~300k), а НЕ в зоне radius (~105k). - assert ( - ppm2_used > 200_000 - ), f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}" + assert ppm2_used > 200_000, ( + f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}" + ) # ── тест 3: graceful — нет ratio → expected_sold_* = None, headline не изменён ── diff --git a/tradein-mvp/backend/tests/test_estimator_repair_coef.py b/tradein-mvp/backend/tests/test_estimator_repair_coef.py index 83393181..886c08a9 100644 --- a/tradein-mvp/backend/tests/test_estimator_repair_coef.py +++ b/tradein-mvp/backend/tests/test_estimator_repair_coef.py @@ -142,9 +142,9 @@ def test_excellent_to_needs_repair_ratio_matches_coef() -> None: actual_ratio = excellent.median_price_rub / needs_repair.median_price_rub # int() truncation on both medians ⇒ allow a small tolerance. - assert ( - abs(actual_ratio - expected_ratio) < 0.005 - ), f"excellent/needs_repair ratio {actual_ratio:.5f} != coef ratio {expected_ratio:.5f}" + assert abs(actual_ratio - expected_ratio) < 0.005, ( + f"excellent/needs_repair ratio {actual_ratio:.5f} != coef ratio {expected_ratio:.5f}" + ) def test_standard_is_baseline_noop() -> None: diff --git a/tradein-mvp/backend/tests/test_estimator_source_quota.py b/tradein-mvp/backend/tests/test_estimator_source_quota.py index 9b02b85c..a1745b7f 100644 --- a/tradein-mvp/backend/tests/test_estimator_source_quota.py +++ b/tradein-mvp/backend/tests/test_estimator_source_quota.py @@ -172,9 +172,9 @@ def test_source_quota_includes_all_when_supply_below_min() -> None: result, _, _ = _fetch_analogs(db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000) cian_count = sum(1 for r in result if r["source"] == "cian") - assert ( - cian_count == 3 - ), f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}" + assert cian_count == 3, ( + f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}" + ) assert len(result) == 8 # 5 avito + 3 cian diff --git a/tradein-mvp/backend/tests/test_extract_short_addr.py b/tradein-mvp/backend/tests/test_extract_short_addr.py index eb8b2b58..198a1810 100644 --- a/tradein-mvp/backend/tests/test_extract_short_addr.py +++ b/tradein-mvp/backend/tests/test_extract_short_addr.py @@ -8,15 +8,14 @@ from app.services.estimator import _extract_short_addr def test_full_admin_chain() -> None: - assert _extract_short_addr( - "Свердловская область, г. Екатеринбург, Склад, ул. Заводская, д. 44-а" - ) == "ул. Заводская, д. 44-а" + assert ( + _extract_short_addr("Свердловская область, г. Екатеринбург, Склад, ул. Заводская, д. 44-а") + == "ул. Заводская, д. 44-а" + ) def test_russia_prefix() -> None: - assert _extract_short_addr( - "Россия, Екатеринбург, ул. Малышева, 1" - ) == "ул. Малышева, 1" + assert _extract_short_addr("Россия, Екатеринбург, ул. Малышева, 1") == "ул. Малышева, 1" def test_apt_stripped() -> None: diff --git a/tradein-mvp/backend/tests/test_geo_precision.py b/tradein-mvp/backend/tests/test_geo_precision.py index f0f2da67..bece66b0 100644 --- a/tradein-mvp/backend/tests/test_geo_precision.py +++ b/tradein-mvp/backend/tests/test_geo_precision.py @@ -76,9 +76,9 @@ async def test_geocode_missing_sets_city_precision_for_bare_city_address() -> No # Inspect the UPDATE call parameters: geo_precision must be 'city'. update_call = db.execute.call_args_list[1] update_params = update_call[0][1] # positional arg[1] = params dict - assert ( - update_params.get("precision") == "city" - ), f"Expected precision='city' for bare city address, got {update_params.get('precision')!r}" + assert update_params.get("precision") == "city", ( + f"Expected precision='city' for bare city address, got {update_params.get('precision')!r}" + ) @pytest.mark.asyncio @@ -107,9 +107,9 @@ async def test_geocode_missing_sets_none_precision_for_precise_address() -> None update_call = db.execute.call_args_list[1] update_params = update_call[0][1] - assert ( - update_params.get("precision") is None - ), f"Expected precision=None for precise address, got {update_params.get('precision')!r}" + assert update_params.get("precision") is None, ( + f"Expected precision=None for precise address, got {update_params.get('precision')!r}" + ) @pytest.mark.asyncio @@ -138,9 +138,9 @@ async def test_geocode_missing_sets_city_precision_for_locality_confidence() -> update_call = db.execute.call_args_list[1] update_params = update_call[0][1] - assert ( - update_params.get("precision") == "city" - ), f"Expected precision='city' for locality confidence, got {update_params.get('precision')!r}" + assert update_params.get("precision") == "city", ( + f"Expected precision='city' for locality confidence, got {update_params.get('precision')!r}" + ) # ── (b) estimator radius-analog SQL contains geo_precision exclusion ────────── diff --git a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py index b414aefc..f6bd4605 100644 --- a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py +++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py @@ -203,9 +203,9 @@ async def test_explicit_only_status_still_takes_exhausted_houses() -> None: {"ids": list(_IDS)}, ) } - assert ( - statuses[_H_EXHAUSTED] != "transient_error" - ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит" + assert statuses[_H_EXHAUSTED] != "transient_error", ( + "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит" + ) # Автоповтора поверх явного запроса нет: pending не тронут. assert statuses[_H_PENDING] == "pending" assert result.retried == 0 diff --git a/tradein-mvp/backend/tests/test_matching.py b/tradein-mvp/backend/tests/test_matching.py index e0066101..a1d7e7ef 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -439,13 +439,13 @@ def test_match_house_advisory_lock_called_first(): sql_obj = first_call[0][0] # TextClause bind = first_call[0][1] # dict - assert "pg_advisory_xact_lock" in str( - sql_obj - ), f"first execute must be advisory lock, got: {sql_obj}" + assert "pg_advisory_xact_lock" in str(sql_obj), ( + f"first execute must be advisory lock, got: {sql_obj}" + ) assert "fp" in bind, f"lock bind must include fp, got: {bind}" - assert ( - isinstance(bind["fp"], str) and len(bind["fp"]) == 32 - ), f"fp must be 32-char sha256 hex, got: {bind.get('fp')!r}" + assert isinstance(bind["fp"], str) and len(bind["fp"]) == 32, ( + f"fp must be 32-char sha256 hex, got: {bind.get('fp')!r}" + ) # --------------------------------------------------------------------------- @@ -484,9 +484,9 @@ def test_geo_match_does_not_register_alias(): db, "n1", "ext-geo", address="улица Новая 3", lat=56.83, lon=60.59 ) assert (house_id, conf, method) == (22, 0.7, "geo_proximity") - assert not any( - "INSERT INTO house_address_aliases" in s for s in _executed_sqls(db) - ), "geo match must not write an alias (P3)" + assert not any("INSERT INTO house_address_aliases" in s for s in _executed_sqls(db)), ( + "geo match must not write an alias (P3)" + ) def test_geo_match_rejected_when_house_number_differs(): @@ -539,18 +539,18 @@ def test_bare_street_numberless_no_house_created(): ) assert (house_id, conf, method) == (None, 0.0, "no_house_number") sqls = _executed_sqls(db) - assert not any( - "normalized_address = :na" in s for s in sqls - ), "bare-street address must not run Tier 2b normalized_address lookup (P1)" - assert not any( - "INSERT INTO houses" in s for s in sqls - ), "numberless address must not create a house (P1 extended)" - assert not any( - "INSERT INTO house_address_aliases" in s for s in sqls - ), "numberless address must not register an alias (P1)" - assert not any( - "INSERT INTO house_sources" in s for s in sqls - ), "numberless refusal must not upsert house_sources" + assert not any("normalized_address = :na" in s for s in sqls), ( + "bare-street address must not run Tier 2b normalized_address lookup (P1)" + ) + assert not any("INSERT INTO houses" in s for s in sqls), ( + "numberless address must not create a house (P1 extended)" + ) + assert not any("INSERT INTO house_address_aliases" in s for s in sqls), ( + "numberless address must not register an alias (P1)" + ) + assert not any("INSERT INTO house_sources" in s for s in sqls), ( + "numberless refusal must not upsert house_sources" + ) def test_numberless_none_address_with_coords_no_house_created(): @@ -575,9 +575,9 @@ def test_numberless_none_address_with_coords_no_house_created(): db, "yandex", "ext-none-addr", address=None, lat=56.83, lon=60.59 ) assert (house_id, conf, method) == (None, 0.0, "no_house_number") - assert not any( - "INSERT INTO houses" in s for s in _executed_sqls(db) - ), "address-NULL coords-only listing must not create a house (P1 extended)" + assert not any("INSERT INTO houses" in s for s in _executed_sqls(db)), ( + "address-NULL coords-only listing must not create a house (P1 extended)" + ) def test_numberless_address_with_cadastral_creates_house(): @@ -611,9 +611,9 @@ def test_numberless_address_with_cadastral_creates_house(): building_cadastral_number="66:41:0000000:12345", ) assert (house_id, conf, method) == (321, 1.0, "new") - assert any( - "INSERT INTO houses" in s for s in _executed_sqls(db) - ), "numberless + cadastral must still create a house (cadastral = identity)" + assert any("INSERT INTO houses" in s for s in _executed_sqls(db)), ( + "numberless + cadastral must still create a house (cadastral = identity)" + ) def test_insert_alias_noop_for_bare_street(): @@ -735,9 +735,9 @@ def test_tier2b_no_coords_no_city_token_skips_and_creates_new(): db, "avito", "ext-2b-bare", address="улица Ленина 100" ) assert (house_id, conf, method) == (910, 1.0, "new") - assert not any( - "normalized_address = :na" in s for s in _executed_sqls(db) - ), "bare common-street with no coords/city must not run a Tier-2b lookup" + assert not any("normalized_address = :na" in s for s in _executed_sqls(db)), ( + "bare common-street with no coords/city must not run a Tier-2b lookup" + ) def test_tier2b_no_coords_with_city_token_matches(): @@ -792,12 +792,12 @@ def test_tier2a_coord_less_non_ekb_city_skips_both_lookups_and_creates_new(): ) assert (house_id, conf, method) == (701, 1.0, "new") sqls = _executed_sqls(db) - assert not any( - "fingerprint = :fp" in s for s in sqls - ), "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup" - assert not any( - "normalized_address = :na" in s for s in sqls - ), "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup" + assert not any("fingerprint = :fp" in s for s in sqls), ( + "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup" + ) + assert not any("normalized_address = :na" in s for s in sqls), ( + "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup" + ) def test_tier2a_coord_less_ekb_city_still_matches(): @@ -818,9 +818,9 @@ def test_tier2a_coord_less_ekb_city_still_matches(): db, "avito", "ext-2a-ekb", address="Екатеринбург, ул. Ленина, 5" ) assert (house_id, conf, method) == (88, 0.9, "fingerprint") - assert any( - "fingerprint = :fp" in s for s in _executed_sqls(db) - ), "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup" + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)), ( + "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup" + ) def test_tier2a_coord_less_bare_street_still_runs_tier2a(): @@ -872,9 +872,9 @@ def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups(): ) assert (house_id, conf, method) == (2777, 1.0, "new") sqls = _executed_sqls(db) - assert not any( - "fingerprint = :fp" in s for s in sqls - ), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»" + assert not any("fingerprint = :fp" in s for s in sqls), ( + "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»" + ) assert not any("normalized_address = :na" in s for s in sqls) @@ -1085,13 +1085,13 @@ def test_field_priority_sources_are_lists_or_known_string_rules(): rules alongside list entries. Both are valid per LISTING_FIELD_PRIORITY type hint. """ for col, sources in HOUSE_FIELD_PRIORITY.items(): - assert ( - isinstance(sources, list) or sources in VALID_STRING_RULES - ), f"HOUSE_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" + assert isinstance(sources, list) or sources in VALID_STRING_RULES, ( + f"HOUSE_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" + ) for col, sources in LISTING_FIELD_PRIORITY.items(): - assert ( - isinstance(sources, list) or sources in VALID_STRING_RULES - ), f"LISTING_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" + assert isinstance(sources, list) or sources in VALID_STRING_RULES, ( + f"LISTING_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" + ) def test_update_canonical_fields_is_callable(): diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py index b16be85b..51107d0c 100644 --- a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -65,9 +65,9 @@ def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None: # Ищем именно литерал method-значения ('"fias_exact"'), а не слово в комментарии: # комментарий-надгробие про удалённый тир остаться должен, ветка — нет. - assert '"fias_exact"' not in inspect.getsource( - match_or_create_house - ), "мёртвая fias-ветка вернулась в путь создания домов" + assert '"fias_exact"' not in inspect.getsource(match_or_create_house), ( + "мёртвая fias-ветка вернулась в путь создания домов" + ) assert '"fias_exact"' in inspect.getsource(match_house_readonly) @@ -104,9 +104,9 @@ def test_house_key_never_accepts_flat_cadastre() -> None: assert "building_cadastral_number" in _params(match_or_create_house) src = inspect.getsource(match_or_create_house) - assert ( - "cad = building_cadastral_number\n" in src - ), "в ключ дома вернулся фолбэк на кадастр квартиры" + assert "cad = building_cadastral_number\n" in src, ( + "в ключ дома вернулся фолбэк на кадастр квартиры" + ) def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None: @@ -128,6 +128,6 @@ def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None: ) # save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать # именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен. - assert "city=lot_city" in inspect.getsource( - save_listings - ), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён" + assert "city=lot_city" in inspect.getsource(save_listings), ( + "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён" + ) diff --git a/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py b/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py index 72500fe3..34af6701 100644 --- a/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py +++ b/tradein-mvp/backend/tests/test_migration_201_purge_dead_mobileproxy_proxies.py @@ -51,9 +51,9 @@ def test_migration_201_deletes_by_domain_not_id() -> None: flat = _flat(_executable_sql()) assert "delete from scrape_proxies" in flat assert "where url like '%mobileproxy.space%'" in flat - assert ( - re.search(r"where\s+id\s*(=|in)", flat) is None - ), "миграция не должна фильтровать по id — id разъезжается между средами" + assert re.search(r"where\s+id\s*(=|in)", flat) is None, ( + "миграция не должна фильтровать по id — id разъезжается между средами" + ) def test_migration_201_is_idempotent_by_construction() -> None: diff --git a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py index 75da91a6..492fdb1c 100644 --- a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py +++ b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py @@ -300,9 +300,9 @@ def test_mikhaylovsk_has_only_cian_row() -> None: РОВНО одну строку (cian), НЕ три.""" rows = _row_sources(_MIGRATION_262) mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"} - assert mikhaylovsk_providers == { - "cian" - }, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}" + assert mikhaylovsk_providers == {"cian"}, ( + f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}" + ) # ── CITY_ANCHORS parity ───────────────────────────────────────────────────── @@ -332,9 +332,9 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None: slug for _p, slug in _row_sources(_MIGRATION_262) } orphaned = set(CITY_ANCHORS) - seeded_slugs - assert ( - not orphaned - ), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}" + assert not orphaned, ( + f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}" + ) def test_city_anchors_wave2_count_and_content() -> None: @@ -398,9 +398,9 @@ def test_migration_262_windows_are_one_hour() -> None: полночь, см. scraper_kit.orchestration.scheduler).""" for provider, slug, start, end in _row_windows(_MIGRATION_262): expected = (start + 1) % 24 - assert ( - end == expected - ), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})" + assert end == expected, ( + f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})" + ) def test_migration_262_window_hours_satisfy_db_check_constraint() -> None: diff --git a/tradein-mvp/backend/tests/test_password.py b/tradein-mvp/backend/tests/test_password.py index a03e5c9f..8d1dd9e1 100644 --- a/tradein-mvp/backend/tests/test_password.py +++ b/tradein-mvp/backend/tests/test_password.py @@ -285,9 +285,9 @@ async def test_one_key_cannot_take_more_than_its_share(monkeypatch: pytest.Monke async def _wait_inflight(n: int) -> None: deadline = time.monotonic() + 5 while password_mod._verify_inflight < n: - assert ( - time.monotonic() < deadline - ), f"слотов занято {password_mod._verify_inflight} < {n}" + assert time.monotonic() < deadline, ( + f"слотов занято {password_mod._verify_inflight} < {n}" + ) await asyncio.sleep(0.005) flood = [ diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index 7d6d21ed..7789b8e0 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -522,9 +522,9 @@ def test_brand_not_taken_from_query_param_docstring() -> None: sig = inspect.signature(estimate_pdf) param_names = list(sig.parameters.keys()) - assert ( - "brand" not in param_names - ), "estimate_pdf should NOT have a 'brand' query param after #7 fix" + assert "brand" not in param_names, ( + "estimate_pdf should NOT have a 'brand' query param after #7 fix" + ) # ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ── diff --git a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py index a49f1a96..f539f67d 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -435,7 +435,7 @@ def test_real_purge_deletes_only_anonymous_expired_estimates() -> None: remaining_ids = { str(r) for r in db.execute( - _t("SELECT id FROM trade_in_estimates " "WHERE id = ANY(CAST(:ids AS uuid[]))"), + _t("SELECT id FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), {"ids": [str(anon_id), str(pilot_id)]}, ) .scalars() @@ -497,9 +497,9 @@ def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None: {"order_id": healthy_order, "id": str(healthy_id)}, ) db.commit() - assert ( - task_mod._preflight_paid_candidates(db) == baseline - ), "healthy paid row (retain_until set) must NOT raise the pre-flight count" + assert task_mod._preflight_paid_candidates(db) == baseline, ( + "healthy paid row (retain_until set) must NOT raise the pre-flight count" + ) # Anomaly: retain_until NULL despite a payments row existing -- exactly # the case the two DELETE safeguards exist for. Must raise by exactly one. diff --git a/tradein-mvp/backend/tests/test_ratelimit.py b/tradein-mvp/backend/tests/test_ratelimit.py index b9b71c1a..fdb75f74 100644 --- a/tradein-mvp/backend/tests/test_ratelimit.py +++ b/tradein-mvp/backend/tests/test_ratelimit.py @@ -183,9 +183,9 @@ def test_notify_path_bypasses_general_limiter_400_requests_zero_429(notify_clien statuses = [ notify_client.post("/api/v1/trade-in/payments/notify").status_code for _ in range(400) ] - assert all( - code == 200 for code in statuses - ), f"notify получил 429 хотя бы раз: {[c for c in statuses if c != 200]}" + assert all(code == 200 for code in statuses), ( + f"notify получил 429 хотя бы раз: {[c for c in statuses if c != 200]}" + ) def test_general_limiter_still_active_for_other_paths(notify_client): diff --git a/tradein-mvp/backend/tests/test_sber_index.py b/tradein-mvp/backend/tests/test_sber_index.py index 61b7f1c7..ae70ad4f 100644 --- a/tradein-mvp/backend/tests/test_sber_index.py +++ b/tradein-mvp/backend/tests/test_sber_index.py @@ -203,8 +203,7 @@ def test_active_dashboards_match_captured_secondary_series() -> None: d = _DASH[slug] encoded = build_sber_route(d.slug, "643", d.extra_filter) assert encoded in captured_b64, ( - f"active dashboard {slug!r} route not in captured-live set — " - f"filter={d.extra_filter}" + f"active dashboard {slug!r} route not in captured-live set — filter={d.extra_filter}" ) @@ -345,15 +344,15 @@ def test_decode_sber_response_real_fixture() -> None: # Verify region label decoded correctly regions = {r["ref_area"] for r in rows} - assert ( - "Свердловская область" in regions - ), f"Expected 'Свердловская область' in ref_area values, got: {regions}" + assert "Свердловская область" in regions, ( + f"Expected 'Свердловская область' in ref_area values, got: {regions}" + ) # Verify secondary-market segment present realty_vals = {r["realty"] for r in rows} - assert any( - "тори" in str(v) or "Вторичн" in str(v) for v in realty_vals - ), f"Expected secondary-market label in realty field, got: {realty_vals}" + assert any("тори" in str(v) or "Вторичн" in str(v) for v in realty_vals), ( + f"Expected secondary-market label in realty field, got: {realty_vals}" + ) # --------------------------------------------------------------------------- @@ -434,9 +433,9 @@ async def test_pull_sber_indices_upsert_on_conflict_idempotent() -> None: # Must NOT contain :: type casts (psycopg v3 rule) import re - assert not re.search( - r":[a-z_]+::[a-z]", upsert_sql - ), "SQL must not contain ::type casts — use CAST(... AS type) instead" + assert not re.search(r":[a-z_]+::[a-z]", upsert_sql), ( + "SQL must not contain ::type casts — use CAST(... AS type) instead" + ) @pytest.mark.asyncio @@ -532,9 +531,9 @@ async def test_pull_sber_indices_asking_benchmark_logged(caplog: pytest.LogCaptu dashboards=[_DASH["dinamika-tsen-obyavlenii"]], ) - assert any( - "benchmark" in record.message for record in caplog.records - ), "Expected a benchmark log line after upserting dinamika-tsen-obyavlenii" - assert any( - "115000" in record.message for record in caplog.records - ), "Benchmark log should include the latest asking index value" + assert any("benchmark" in record.message for record in caplog.records), ( + "Expected a benchmark log line after upserting dinamika-tsen-obyavlenii" + ) + assert any("115000" in record.message for record in caplog.records), ( + "Benchmark log should include the latest asking index value" + ) diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index 813d78a2..ea7f0b5d 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -477,9 +477,9 @@ def test_data_quality_pct_in_range(client: TestClient) -> None: for src in body["sources"]: for field_name, pct in src["fields"].items(): - assert ( - 0.0 <= pct <= 100.0 - ), f"source={src['source']} field={field_name} pct={pct} вне [0,100]" + assert 0.0 <= pct <= 100.0, ( + f"source={src['source']} field={field_name} pct={pct} вне [0,100]" + ) # ── Security: текст исключения не утекает в HTTP-ответ (#2234) ──────────────── diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index b973e19a..781dd7c9 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -164,9 +164,9 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None: real_registry = build_registry(build_product_handlers(ctx=None)) # type: ignore[arg-type] for source in _PRODUCT_SOURCES | _KIT_NATIVE_SOURCES: - assert ( - resolve_handler(source, real_registry) is not None - ), f"real build_product_handlers()/build_registry() misses source={source}" + assert resolve_handler(source, real_registry) is not None, ( + f"real build_product_handlers()/build_registry() misses source={source}" + ) def test_kit_native_handler_set() -> None: diff --git a/tradein-mvp/backend/tests/test_segment_guard_1186.py b/tradein-mvp/backend/tests/test_segment_guard_1186.py index 289ff36d..78f7a465 100644 --- a/tradein-mvp/backend/tests/test_segment_guard_1186.py +++ b/tradein-mvp/backend/tests/test_segment_guard_1186.py @@ -40,9 +40,9 @@ def _norm(s: str) -> str: def test_common_where_has_canonical_guard() -> None: """_COMMON_WHERE используется Tier S и Tier H — должен содержать канон-предикат.""" - assert _GUARD_RE.search( - est_mod._COMMON_WHERE - ), "_COMMON_WHERE lacks novostroyki guard — Tier S/H comp set contaminated" + assert _GUARD_RE.search(est_mod._COMMON_WHERE), ( + "_COMMON_WHERE lacks novostroyki guard — Tier S/H comp set contaminated" + ) def test_common_where_no_old_neq_form() -> None: @@ -95,18 +95,18 @@ _REDERIVE_SQL_TEXT = str(ratio_mod._REDERIVE_SQL.text) def test_ask_side_cte_has_guard() -> None: """ask_side CTE в _REDERIVE_SQL (per-rooms asking медиана) — guard обязателен.""" - assert _GUARD_RE.search( - _REDERIVE_SQL_TEXT - ), "ask_side CTE in _REDERIVE_SQL lacks novostroyki guard" + assert _GUARD_RE.search(_REDERIVE_SQL_TEXT), ( + "ask_side CTE in _REDERIVE_SQL lacks novostroyki guard" + ) def test_ask_global_cte_has_guard() -> None: """ask_global CTE в _REDERIVE_SQL (global fallback asking медиана) — guard обязателен.""" # _REDERIVE_SQL содержит два `ask_global`-блока; ищем оба через count. matches = len(_GUARD_RE.findall(_REDERIVE_SQL_TEXT)) - assert ( - matches >= 2 - ), f"Expected ≥2 guard occurrences in _REDERIVE_SQL (ask_side + ask_global), got {matches}" + assert matches >= 2, ( + f"Expected ≥2 guard occurrences in _REDERIVE_SQL (ask_side + ask_global), got {matches}" + ) # ── Поведенческие тесты: _fetch_analogs (mock DB) ──────────────────────────── @@ -164,9 +164,9 @@ def test_fetch_analogs_sql_guard_present_novostroyki_excluded() -> None: """ src = inspect.getsource(est_mod._fetch_analogs) # Guard должен присутствовать хотя бы один раз в теле функции. - assert _GUARD_RE.search( - src - ), "_fetch_analogs SQL no longer contains novostroyki guard — guard was removed!" + assert _GUARD_RE.search(src), ( + "_fetch_analogs SQL no longer contains novostroyki guard — guard was removed!" + ) def test_null_segment_listing_not_excluded_by_guard() -> None: @@ -185,9 +185,9 @@ def test_null_segment_listing_not_excluded_by_guard() -> None: ) # NULL-segment listing должен присутствовать в результате (не отброшен Python-стороной). - assert any( - r.get("source") == "avito" for r in result - ), "NULL-segment listing was unexpectedly excluded from comp set" + assert any(r.get("source") == "avito" for r in result), ( + "NULL-segment listing was unexpectedly excluded from comp set" + ) def test_vtorichka_segment_listing_included() -> None: @@ -234,12 +234,12 @@ def test_no_neq_novostroyki_form_in_estimator() -> None: Канон: IS NULL OR = 'vtorichka'. """ src = _estimator_full_src() - assert ( - "<> 'novostroyki'" not in src - ), "estimator.py contains deprecated `<> 'novostroyki'` form — use canonical guard" - assert ( - "!= 'novostroyki'" not in src - ), "estimator.py contains deprecated `!= 'novostroyki'` form — use canonical guard" + assert "<> 'novostroyki'" not in src, ( + "estimator.py contains deprecated `<> 'novostroyki'` form — use canonical guard" + ) + assert "!= 'novostroyki'" not in src, ( + "estimator.py contains deprecated `!= 'novostroyki'` form — use canonical guard" + ) def test_anchor_comps_no_dead_listing_segment_param() -> None: @@ -269,6 +269,6 @@ def test_fetch_anchor_comps_tier_c_canonical_guard_not_parametric() -> None: "must be hardcoded canonical guard" ) # И канон-guard на месте (Tier C-блок). - assert _GUARD_RE.search( - src - ), "_fetch_anchor_comps lacks canonical guard after removing parametric form" + assert _GUARD_RE.search(src), ( + "_fetch_anchor_comps lacks canonical guard after removing parametric form" + ) diff --git a/tradein-mvp/backend/tests/test_snapshot_writer.py b/tradein-mvp/backend/tests/test_snapshot_writer.py index a779c98d..6cde5c51 100644 --- a/tradein-mvp/backend/tests/test_snapshot_writer.py +++ b/tradein-mvp/backend/tests/test_snapshot_writer.py @@ -303,9 +303,9 @@ def test_save_detail_enrichment_oph_on_conflict_constraint(): sqls = _get_all_sqls(db) oph_sqls = [s for s in sqls if "offer_price_history" in s] assert oph_sqls, "INSERT offer_price_history не найден" - assert ( - "offer_price_history_listing_change_uq" in oph_sqls[0] - ), "ON CONFLICT должен ссылаться на UNIQUE constraint" + assert "offer_price_history_listing_change_uq" in oph_sqls[0], ( + "ON CONFLICT должен ссылаться на UNIQUE constraint" + ) def test_save_detail_enrichment_skips_price_change_without_change_time(): diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index 003883e1..570711db 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -317,15 +317,15 @@ def test_street_regex_word_boundary_no_false_positive() -> None: # Must NOT match — 'мира' appears inside 'Макарова' substring check skipped # but more critically — 'мира' appears inside 'Адмирала Макарова' false_positive_addr = "улица Адмирала Макарова" - assert not re.search( - pattern, false_positive_addr, re.IGNORECASE - ), f"Pattern {pattern!r} should NOT match {false_positive_addr!r}" + assert not re.search(pattern, false_positive_addr, re.IGNORECASE), ( + f"Pattern {pattern!r} should NOT match {false_positive_addr!r}" + ) # Must match — exact word true_positive_addr = "улица Мира 5" - assert re.search( - pattern, true_positive_addr, re.IGNORECASE - ), f"Pattern {pattern!r} should match {true_positive_addr!r}" + assert re.search(pattern, true_positive_addr, re.IGNORECASE), ( + f"Pattern {pattern!r} should match {true_positive_addr!r}" + ) def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None: @@ -355,6 +355,6 @@ def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None: assert "street_regex" in params, f"street_regex not in params: {params}" regex_val = params["street_regex"] # Must contain word-boundary anchors - assert ( - r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m") - ), f"Expected word-boundary in regex, got: {regex_val!r}" + assert r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m"), ( + f"Expected word-boundary in regex, got: {regex_val!r}" + ) diff --git a/tradein-mvp/backend/tests/test_yandex_city_sweep.py b/tradein-mvp/backend/tests/test_yandex_city_sweep.py index b50729a9..fb3f895a 100644 --- a/tradein-mvp/backend/tests/test_yandex_city_sweep.py +++ b/tradein-mvp/backend/tests/test_yandex_city_sweep.py @@ -219,6 +219,6 @@ def test_combos_sweep_timeout_substantially_larger_than_anchor_timeout() -> None f"combos-mode will still timeout mid-sweep" ) # И намного > ANCHOR_TIMEOUT_SEC (240s) - assert ( - sweep_timeout > ANCHOR_TIMEOUT_SEC * 4 - ), f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s" + assert sweep_timeout > ANCHOR_TIMEOUT_SEC * 4, ( + f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s" + ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py index c6196e84..638c5042 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -416,9 +416,9 @@ def test_area_regex_rejects_year_concat(): item = YandexValuationScraper._parse_item_text(text) # The "202452,2" token has digits jammed before it (no separator), so the # tightened regex must NOT match it. - assert ( - item is None or item.area_m2 is None - ), f"expected no area match for concat token, got {item.area_m2 if item else 'None item'}" + assert item is None or item.area_m2 is None, ( + f"expected no area match for concat token, got {item.area_m2 if item else 'None item'}" + ) def test_area_regex_accepts_isolated_token(): @@ -474,9 +474,9 @@ def test_area_regex_two_komn_chunk(): text = "8 000 000 ₽ за м²2-комнатная 52,2 м² 5 этаж 10.05.2024 8 000 000 ₽ В продаже" item = YandexValuationScraper._parse_item_text(text) assert item is not None - assert ( - item.area_m2 == 52.2 - ), f"2-комн area must parse despite preceding tokens, got {item.area_m2}" + assert item.area_m2 == 52.2, ( + f"2-комн area must parse despite preceding tokens, got {item.area_m2}" + ) def test_area_regex_still_blocks_year_concat(): diff --git a/tradein-mvp/deploy/backup-tradein-db.sh b/tradein-mvp/deploy/backup-tradein-db.sh index 39eb3644..95bfea43 100755 --- a/tradein-mvp/deploy/backup-tradein-db.sh +++ b/tradein-mvp/deploy/backup-tradein-db.sh @@ -5,8 +5,10 @@ # Запускается из cron на прод-хосте. pg_dump идёт через `docker exec` # (локальный сокет внутри контейнера — пароль не нужен). # -# Crontab пример (ежедневно в 04:30 UTC): -# 30 4 * * * /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /tmp/tradein-backup.log 2>&1 +# Crontab (ежедневно в 04:30 по времени хоста = 01:30 UTC; на проде именно так, #3005). +# Вызывать через `bash `, а не сырым путём — тогда потеря бита +x не сломает +# бэкап молча (инцидент #71 у основного бэкапа): +# 30 4 * * * bash /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /tmp/tradein-backup.log 2>&1 # # Restore: # gunzip -c tradein-YYYYMMDD-HHMMSS.sql.gz | \ @@ -56,7 +58,7 @@ verify_dump_integrity() { log "ОШИБКА: ${label} не прошёл проверку gzip -> $file" >&2 return 1 fi - if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF "$trailer"; then + if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then log "ОШИБКА: ${label} без трейлера '${trailer}' -> $file (дамп оборван?)" >&2 return 1 fi @@ -133,7 +135,7 @@ if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" && -v "$BACKUP_DIR":/backup:ro \ amazon/aws-cli:latest \ --endpoint-url "$S3_ENDPOINT" \ - s3 cp "/backup/$(basename "$f")" "s3://${S3_BUCKET}/" + s3 cp --no-progress "/backup/$(basename "$f")" "s3://${S3_BUCKET}/" done log "Выгрузка в S3 ok" else diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index c68d74c2..2f9914bd 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -125,6 +125,70 @@ services: # тяжёлых PostGIS-сортировок бэктеста/эстиматора + autovacuum) mem_limit: 3g memswap_limit: 3g + # ⚠️ При переезде на выделенный сервер (#2989) mem_limit поднимать ДО правки + # shared_buffers, а не после: лимит контейнера срабатывает РАНЬШЕ postgresql.conf, + # и shared_buffers=16GB при mem_limit=3g даёт OOM-kill на старте. Целевое на + # 64 ГБ — 40-48g, а не 64g: нужен запас под page cache ВНЕ контейнера. + shm_size: 512m + # /dev/shm по умолчанию 64 МБ. Параллельные воркеры кладут туда shared memory + # segments; на тяжёлых PostGIS-сортировках это «could not resize shared memory». + # + # ── Конфигурация Postgres (#2991) ───────────────────────────────────────── + # До этого контейнер шёл на ПОЛНОСТЬЮ стоковых настройках. Замер прода 2026-08-20: + # 169 млрд blks_read за 91,75 сут ≈ 175 МБ/с мимо кеша при shared_buffers=128 МБ. + # + # Значения подобраны под ТЕКУЩИЙ сервер и НЕ выходят за mem_limit=3g. + # После переезда пересчитать под 64 ГБ. + command: + - postgres + # Память. 768MB shared_buffers — 6× от стоковых 128MB, с запасом внутри 3g + # (idle-замер контейнера был 292 МБ, work_mem-спайки и autovacuum сверху). + - -c + - shared_buffers=768MB + # effective_cache_size — подсказка планировщику, НЕ аллокация. На хосте + # MemAvailable 6,1 ГБ, поэтому 6GB честно отражает доступный page cache. + - -c + - effective_cache_size=6GB + - -c + - work_mem=16MB + # maintenance_work_mem: autovacuum по listings идёт 193 раза в сутки, + # с 64MB каждый проход перечитывает индексы лишними итерациями. + - -c + - maintenance_work_mem=256MB + # Чекпойнты. ГЛАВНОЕ: 288 чекпойнтов в сутки — это ровно 24ч/288 = 5 минут, + # то есть дефолтный checkpoint_timeout, а НЕ max_wal_size. При WAL 7 ГБ/сут + # лимит в 1 ГБ дал бы 7 чекпойнтов, а не 288. Поэтому поднимаем ИМЕННО + # timeout — иначе правка max_wal_size ничего бы не изменила. + # Реже чекпойнты → реже full-page images (сейчас 55-86% всего объёма WAL). + - -c + - checkpoint_timeout=30min + - -c + - checkpoint_completion_target=0.9 + # max_wal_size=4GB, а не 8GB: на диске сейчас всего 38 ГБ свободно, а pg_wal + # растёт до этого значения. При timeout=30min и 7 ГБ/сут между чекпойнтами + # накапливается ~0,15 ГБ, так что 4GB — потолок с большим запасом. + - -c + - max_wal_size=4GB + - -c + - min_wal_size=1GB + # Самая дешёвая победа при доле FPI 55-86%. zstd доступен с PG15, у нас 16. + - -c + - wal_compression=zstd + # Диск NVMe, а стоковый random_page_cost=4 — настройка под HDD: планировщик + # систематически недооценивает индексные сканы и уходит в Seq Scan. + - -c + - random_page_cost=1.1 + - -c + - effective_io_concurrency=200 + # pg_stat_statements: расширение есть в образе, но не подключено. Без него + # следующий разбор снова придётся вести по косвенным признакам. + # shared_preload_libraries требует рестарта — поэтому именно здесь. + - -c + - shared_preload_libraries=pg_stat_statements + - -c + - pg_stat_statements.max=5000 + - -c + - pg_stat_statements.track=top logging: *default-logging environment: POSTGRES_DB: tradein diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index 58ad9b0d..d250af19 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -382,6 +382,7 @@ def save_listings( updated = 0 skipped = 0 reconciled = 0 # UPDATE by (source,source_id) при dedup_hash-дрейфе + unchanged_today = 0 # #2992: гейт апсерта пропустил неизменную, уже виденную сегодня строку matched = 0 match_failures = 0 geo_guard_dropped = 0 # city NULL'ен из-за geo-guard (лот вне city_radius_km от anchor'а) @@ -423,7 +424,7 @@ def save_listings( # never the old ones, so a lightweight SELECT is the clean way. # None → row did not exist yet (fresh insert) → always process. prior_row = db.execute( - text("SELECT card_hash, last_seen_at FROM listings WHERE dedup_hash = :dedup"), + text("SELECT id, card_hash, last_seen_at FROM listings WHERE dedup_hash = :dedup"), {"dedup": dedup}, ).fetchone() prior_card_hash = prior_row.card_hash if prior_row is not None else None @@ -681,6 +682,81 @@ def save_listings( EXCLUDED.newbuilding_url, listings.newbuilding_url ), card_hash = EXCLUDED.card_hash + -- #2992: гейт неизменной строки. Без него каждый повторный скрейп + -- переписывал 41 колонку, заново тостил description и плодил TOAST-чанки: + -- прод 2026-08-20 — n_tup_upd 20.87 млн на 107 тыс. строк (198 апдейтов на + -- строку за 91 день), HOT 0.44 %, TOAST 15 ГБ при ~230 МБ полезного, WAL + -- 7 ГБ/сутки. Детектор изменений (card_hash) есть рядом, но гейта в апсерте + -- не было. + -- + -- ДВА условия, при любом из которых апдейт ИДЁТ: + -- (1) итоговое (post-COALESCE) значение хотя бы одной контентной колонки + -- отличается от текущего — IS DISTINCT FROM по кортежу, NULL-safe. + -- Сравниваются ИТОГОВЫЕ значения, а не EXCLUDED: так COALESCE- + -- дозаполнение (адрес, город, сегмент…) по-прежнему проходит — если + -- дописал, строка отличается. Гейт по card_hash был забракован именно + -- за то, что ломает дозаполнение. + -- (2) last_seen_at ещё не сегодняшний по МСК — метка живости ОБЯЗАНА + -- сдвигаться хотя бы раз в сутки: на неё завязаны деактиватор (TTL), + -- снапшоты (is_active = last_seen_at > now()-7d), эстиматор + -- (scraped_at > NOW()-14d, #2206), монитор свежести. Это та же + -- семантика, что у skip_seen_today (МСК-день), но в SQL, race-free и + -- для ВСЕХ путей записи, а не трёх full_load. + -- + -- При пропуске RETURNING пуст → listing_id берётся из pre-read (prior_row.id), + -- downstream (снапшот, матчинг, listing_sources) идёт как прежде. + -- listing_sources получает симметричный гейт (matching/listings.py) — иначе + -- разъехались бы listings.last_seen_at и listing_sources.last_seen_at, + -- которые сегодня равны у 100 % пар. + WHERE ( + listings.price_rub, listings.price_per_m2, listings.living_area_m2, + listings.bedrooms_count, listings.balconies_count, listings.loggias_count, + listings.description_minhash, listings.cadastral_number, + listings.building_cadastral_number, listings.phones, listings.is_homeowner, + listings.is_pro_seller, listings.bargain_allowed, listings.sale_type, + listings.metro_stations, listings.listing_date, listings.area_m2, + listings.address, listings.city, listings.kitchen_area_m2, + listings.ceiling_height_m, listings.mortgage_available, + listings.is_apartments, listings.is_rosreestr_checked, + listings.listing_segment, listings.publish_date, listings.days_on_market, + listings.description, listings.agency_name, listings.yandex_offer_id, + listings.predicted_price_rub, listings.predicted_price_min, + listings.predicted_price_max, listings.price_trend, + listings.price_previous_rub, listings.newbuilding_id, + listings.newbuilding_url, listings.card_hash, listings.is_active + ) IS DISTINCT FROM ( + EXCLUDED.price_rub, EXCLUDED.price_per_m2, EXCLUDED.living_area_m2, + EXCLUDED.bedrooms_count, EXCLUDED.balconies_count, EXCLUDED.loggias_count, + EXCLUDED.description_minhash, EXCLUDED.cadastral_number, + EXCLUDED.building_cadastral_number, EXCLUDED.phones, EXCLUDED.is_homeowner, + EXCLUDED.is_pro_seller, EXCLUDED.bargain_allowed, EXCLUDED.sale_type, + EXCLUDED.metro_stations, + COALESCE(EXCLUDED.listing_date, listings.listing_date), + COALESCE(EXCLUDED.area_m2, listings.area_m2), + COALESCE(listings.address, EXCLUDED.address), + COALESCE(EXCLUDED.city, listings.city), + COALESCE(EXCLUDED.kitchen_area_m2, listings.kitchen_area_m2), + COALESCE(EXCLUDED.ceiling_height_m, listings.ceiling_height_m), + COALESCE(EXCLUDED.mortgage_available, listings.mortgage_available), + COALESCE(EXCLUDED.is_apartments, listings.is_apartments), + COALESCE(EXCLUDED.is_rosreestr_checked, listings.is_rosreestr_checked), + COALESCE(EXCLUDED.listing_segment, listings.listing_segment), + COALESCE(EXCLUDED.publish_date, listings.publish_date), + COALESCE(EXCLUDED.days_on_market, listings.days_on_market), + COALESCE(EXCLUDED.description, listings.description), + COALESCE(EXCLUDED.agency_name, listings.agency_name), + COALESCE(EXCLUDED.yandex_offer_id, listings.yandex_offer_id), + COALESCE(EXCLUDED.predicted_price_rub, listings.predicted_price_rub), + COALESCE(EXCLUDED.predicted_price_min, listings.predicted_price_min), + COALESCE(EXCLUDED.predicted_price_max, listings.predicted_price_max), + COALESCE(EXCLUDED.price_trend, listings.price_trend), + COALESCE(EXCLUDED.price_previous_rub, listings.price_previous_rub), + COALESCE(EXCLUDED.newbuilding_id, listings.newbuilding_id), + COALESCE(EXCLUDED.newbuilding_url, listings.newbuilding_url), + EXCLUDED.card_hash, true + ) + OR (listings.last_seen_at AT TIME ZONE 'Europe/Moscow')::date + IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date RETURNING id, (xmax = 0) AS inserted """ ) @@ -809,6 +885,11 @@ def save_listings( inserted += 1 else: updated += 1 + elif prior_row is not None: + # #2992: гейт пропустил апдейт — строка не изменилась и уже видена + # сегодня. listing_id известен из pre-read; downstream идёт как прежде. + listing_id = int(prior_row.id) + unchanged_today += 1 # ── Snapshot: point-in-time observation in listings_snapshots ─── # Fault-tolerant: failure here MUST NOT abort the listings batch. @@ -881,11 +962,12 @@ def save_listings( db.commit() logger.info( - "save_listings: source=%s inserted=%d updated=%d reconciled=%d " + "save_listings: source=%s inserted=%d updated=%d unchanged_today=%d reconciled=%d " "skipped_seen_today=%d matched=%d match_failures=%d geo_guard_dropped=%d (total %d)", lots[0].source if lots else "?", inserted, updated, + unchanged_today, reconciled, skipped, matched, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 0f1bfefb..31647a6b 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -3147,6 +3147,14 @@ class CianFullLoadCounters: # Без этого счётчика «сколько бакетов чекпоинт не покрывает» видно только грепом # логов, которые теряются при редеплое. partial_buckets: int = 0 + # Новостройки, отброшенные фильтром secondary_only ПОСЛЕ скачивания и разбора + # (#1781). Ровно тот же довод, что у partial_buckets выше: счётчик логировался + # (`dropped_nb=` в cian/serp.py), но не сохранялся, и ответить «сколько инвентаря + # выбрасывает полный обход» задним числом было нечем — логи за нужную дату уже + # ротировались. Заодно это цена вопроса при обсуждении secondary_only=False: + # страницы с этими лотами УЖЕ скачаны, лимит страниц и антибан-бюджет за них + # уже заплачены, выбрасывается только результат разбора. + dropped_novostroyki: int = 0 def to_dict(self) -> dict[str, int]: return {f.name: getattr(self, f.name) for f in fields(self)} @@ -3167,6 +3175,7 @@ async def run_cian_full_load( concurrency: int = 5, resume_run_id: int | None = None, region_code: int = DEFAULT_REGION_CODE, + secondary_only: bool = True, ) -> CianFullLoadCounters: """Exhaustive региональный сбор Cian ЕКБ вторички (БЕЗ anchor'ов). @@ -3325,18 +3334,21 @@ async def run_cian_full_load( await scraper.fetch_all_secondary( price_cap_per_bucket=price_cap_per_bucket, concurrency=concurrency, - secondary_only=True, + secondary_only=secondary_only, on_bucket=_on_bucket, on_progress=_on_progress, skip_buckets=skip_set if skip_set else None, ) + counters.dropped_novostroyki = getattr(scraper, "last_dropped_nb", 0) logger.info( - "cian-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d", + "cian-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d " + "dropped_novostroyki=%d", run_id, counters.unique_fetched, counters.saved_inserted, counters.saved_updated, + counters.dropped_novostroyki, ) runs.update_heartbeat(db, run_id, counters.to_dict()) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py index 67b5c302..97cddd8e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py @@ -122,6 +122,10 @@ class CianScraper(BaseScraper): base_url = "https://ekb.cian.ru" # Класс-дефолт; реальное значение загружается из scraper_settings при создании экземпляра. request_delay_sec = 5.0 # консервативно: Cian менее агрессивен чем Avito, но 5s безопасно + # Сколько новостроек отброшено фильтром secondary_only за последний прогон + # fetch_all_secondary (#1781). Класс-дефолт нужен, чтобы атрибут читался даже + # если прогон упал до первого бакета. + last_dropped_nb: int = 0 def __init__( self, @@ -401,6 +405,9 @@ class CianScraper(BaseScraper): """ _buckets = rooms_buckets if rooms_buckets is not None else _DEFAULT_ROOMS_BUCKETS seen: dict[str, ScrapedLot] = {} + # Сброс на КАЖДЫЙ прогон (#1781): инстанс скрапера переиспользуется, и без + # сброса счётчик копился бы между вызовами и врал бы в counters второго. + self.last_dropped_nb = 0 for rooms in _buckets: room_label = f"room{'_'.join(str(r) for r in rooms)}" @@ -671,6 +678,10 @@ class CianScraper(BaseScraper): filtered = [lot for lot in bucket_lots if lot.listing_segment != "novostroyki"] dropped_nb = collected_this_bucket - len(filtered) bucket_lots = filtered + # Копим по всему прогону в атрибут инстанса (#1781): вызывающий кладёт это + # в counters прогона. Атрибут, а не аргумент on_bucket — у колбэка уже есть + # внешние реализации, менять его сигнатуру ради счётчика нельзя. + self.last_dropped_nb += dropped_nb # Дедуп в общий seen for lot in bucket_lots: diff --git a/tradein-mvp/uv.lock b/tradein-mvp/uv.lock index 66012765..7d3380a4 100644 --- a/tradein-mvp/uv.lock +++ b/tradein-mvp/uv.lock @@ -1742,7 +1742,7 @@ dev = [ { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-timeout", specifier = ">=2.3.0" }, - { name = "ruff", specifier = ">=0.5.0" }, + { name = "ruff", specifier = "==0.15.20" }, ] [[package]]