From f31cb56081f5b7de128e6e7b6046eab310f7aaec Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 16:07:41 +0500 Subject: [PATCH 1/9] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B4:=20=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3=20=D1=85=D0=BE=D0=B4=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=B2=20=D1=87=D1=83=D0=B6=D0=BE=D0=B9=20=D0=B1=D1=8D=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=20=E2=80=94=20=D0=B8=D0=BC=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=B0=20=D0=B4=D0=B2=D0=BE=D0=B8?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D1=83=D0=BA=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичный лендинг отдавался БЕЗ витрины: без ленты сделок, без строк сверки «прогноз против факта», без подписи разброса. Страница про точность — без единого доказательства. Отдавалось 106 КБ вместо 241 КБ. Причина. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свой сервис backend и оба подключены к общей сети gendesign_shared. Изнутри фронта: backend → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА) tradein-backend → 172.18.0.6 BACKEND_URL=http://backend:8000 уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал 401 no authenticated user, и страница рендерилась пустой. Отказ тихий вдвойне. fetch не бросает — приходит валидный HTTP-ответ, просто чужой. И имя двоится, поэтому часть перегенераций попадала в правильный адрес: утром страница была с данными, к обеду без них, и это выглядело случайной поломкой, а не ошибкой конфигурации. DATABASE_URL болен тем же: @postgres:5432 мог уйти в базу ПТИЦЫ. Там спасало лишь несовпадение кредов — отказ вместо тихого чтения не тех данных. Полагаться на это нельзя: защита держится на том, что у чужой базы нет пользователя с нашим паролем. Переведён на однозначное имя во всех трёх сервисах. Гейт check-compose-ambiguous-hosts.py: пересечение имён сервисов обоих compose и запрет ссылаться на них как на хост. Селфтест по конвенции соседних гейтов — он провалился дважды на моих же фикстурах (в них не было общего имени, то есть ловить было нечего), и это ровно то, ради чего селфтест и нужен. Фальсификация на настоящем файле: возврат backend:8000 даёт точную строку 479, возврат @postgres — все четыре ссылки. --- .forgejo/workflows/ci.yml | 16 +++ scripts/check-compose-ambiguous-hosts.py | 162 +++++++++++++++++++++++ tradein-mvp/docker-compose.prod.yml | 16 ++- 3 files changed, 190 insertions(+), 4 deletions(-) create mode 100755 scripts/check-compose-ambiguous-hosts.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 66b80da9..baf5c736 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -82,6 +82,22 @@ jobs: python3 scripts/check-caddy-snippet-mounts.py --selftest python3 scripts/check-caddy-snippet-mounts.py + - name: "Guard: сервисы МЕРЫ не ссылаются на двоящееся имя" + # ПТИЦА и МЕРА — разные compose-проекты, но оба назвали сервисы + # `backend`/`postgres`/`frontend` и оба сидят в общей сети + # gendesign_shared. Docker отдаёт на такое имя ДВА адреса, клиент + # берёт любой. + # + # 30.08.2026 это уронило публичный лендинг: BACKEND_URL вёл в бэкенд + # ПТИЦЫ, тот отвечал 401, и страница про точность рендерилась БЕЗ + # ленты сделок, без строк сверки и без подписи разброса — то есть без + # единого доказательства. Отказ тихий: fetch не бросает, приходит + # валидный чужой ответ; а из-за двоения часть перегенераций попадала + # в правильный адрес, и поломка выглядела случайной. + run: | + python3 scripts/check-compose-ambiguous-hosts.py --selftest + python3 scripts/check-compose-ambiguous-hosts.py + - name: "Guard: Caddyfile синтаксически валиден" # Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит # на КАЖДОМ PR, стоит секунды, падение блокирует merge. diff --git a/scripts/check-compose-ambiguous-hosts.py b/scripts/check-compose-ambiguous-hosts.py new file mode 100755 index 00000000..669a55ff --- /dev/null +++ b/scripts/check-compose-ambiguous-hosts.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Гейт: сервисы МЕРЫ не ссылаются друг на друга именем, которое двоится (#3282). + +ПОЧЕМУ. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свои сервисы +`backend`, `postgres`, `frontend`, и оба подключены к общей сети +`gendesign_shared`. Docker отдаёт ОБА адреса на такое имя, клиент берёт любой: + + изнутри tradein-frontend: + backend → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА) + tradein-backend → 172.18.0.6 + +30.08.2026 это уронило публичный лендинг. `BACKEND_URL=http://backend:8000` +уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал `401 no authenticated +user`, и страница рендерилась БЕЗ витрины: без ленты сделок, без строк сверки +«прогноз против факта» и без подписи разброса. Страница про точность — без +единого доказательства. + +Отказ был ТИХИЙ вдвойне. Во-первых, `fetch` не бросает: приходит валидный HTTP- +ответ, просто чужой. Во-вторых, имя двоится, поэтому часть перегенераций +попадала в правильный адрес — утром страница отдавалась с данными, к обеду без +них, и выглядело это как случайная поломка, а не как ошибка конфигурации. + +У DSN та же болезнь и худшая цена: `@postgres:5432/tradein` мог уйти в базу +ПТИЦЫ. Там спасало лишь несовпадение кредов — то есть отказ вместо тихого +чтения не тех данных. На такую защиту полагаться нельзя: она держится на том, +что у чужой базы нет пользователя с нашим паролем. + +ЧТО ДЕЛАЕТ. Берёт имена сервисов ОБОИХ compose, находит пересечение (это и есть +двоящиеся имена) и проверяет, что в compose МЕРЫ нет ссылок на них как на хост +— ни `http://<имя>:порт`, ни `@<имя>:порт` в DSN. Лечение всегда одно: +подставить `container_name` (`tradein-backend`, `tradein-postgres`), он +однозначен по построению. + +ЧЕГО НЕ ЛОВИТ. Ссылки, собираемые в рантайме из кусков, и имена, попадающие в +контейнер иным путём (`.env` на хосте, секреты CI). Гейт читает только оба +compose-файла. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +MERA_COMPOSE = REPO / "tradein-mvp" / "docker-compose.prod.yml" +PTICA_COMPOSE = REPO / "docker-compose.prod.yml" + +# Имя сервиса верхнего уровня: ровно два пробела, затем имя и двоеточие. +_SERVICE_RE = re.compile(r"^ ([a-z][a-z0-9_-]*):\s*$", re.M) +# Ссылка на хост: `//имя:порт` (URL) либо `@имя:порт` (DSN). +_HOST_RE = re.compile(r"(?://|@)([a-z][a-z0-9_-]*):\d+") + + +def service_names(text: str) -> set[str]: + """Имена сервисов верхнего уровня. Тома и сети сюда не попадают: они + объявляются в своих секциях с тем же отступом, поэтому список заведомо шире + нужного — и это безопасная сторона, лишнее имя даст лишнюю проверку, а не + пропуск.""" + return set(_SERVICE_RE.findall(text)) + + +def violations(mera: str, ptica: str) -> list[str]: + ambiguous = service_names(mera) & service_names(ptica) + if not ambiguous: + return [] + found: list[str] = [] + for num, line in enumerate(mera.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + for host in _HOST_RE.findall(line): + if host in ambiguous: + found.append(f"{MERA_COMPOSE.name}:{num}: хост «{host}» двоится → {stripped[:90]}") + return found + + +def selftest() -> int: + """Гейт обязан уметь краснеть — иначе зелёный ничего не значит.""" + ptica = "services:\n backend:\n image: x\n postgres:\n image: y\n" + + # У фикстуры МЕРЫ обязан быть сервис с ИМЕНЕМ ИЗ ПТИЦЫ — иначе пересечение + # пусто, проверять нечего, и «не поймал» означало бы «нечего было ловить». + # На этом селфтест и провалился в первой редакции. + bad = ( + "services:\n backend:\n image: x\n" + " frontend:\n environment:\n URL: \"http://backend:8000\"\n" + ) + if not violations(bad, ptica): + print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в URL не пойман", file=sys.stderr) + return 1 + + bad_dsn = ( + "services:\n postgres:\n image: y\n" + " backend:\n environment:\n DSN: \"pg://u:p@postgres:5432/db\"\n" + ) + if not violations(bad_dsn, ptica): + print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в DSN не пойман", file=sys.stderr) + return 1 + + ok = ( + "services:\n backend:\n image: x\n" + " frontend:\n environment:\n URL: \"http://tradein-backend:8000\"\n" + ) + if violations(ok, ptica): + print("СЕЛФТЕСТ ПРОВАЛЕН: однозначное имя признано нарушением", file=sys.stderr) + return 1 + + # Закомментированная строка — не конфигурация. + commented = ( + "services:\n backend:\n image: x\n" + " frontend:\n environment:\n # URL: \"http://backend:8000\"\n" + ) + if violations(commented, ptica): + print("СЕЛФТЕСТ ПРОВАЛЕН: комментарий принят за ссылку", file=sys.stderr) + return 1 + + print("селфтест пройден: гейт краснеет на URL и на DSN, молчит на однозначном имени") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--selftest", action="store_true", help="проверить, что гейт умеет краснеть") + args = ap.parse_args() + + if args.selftest: + return selftest() + + for path in (MERA_COMPOSE, PTICA_COMPOSE): + if not path.exists(): + print(f"НЕ НАЙДЕН: {path}", file=sys.stderr) + return 1 + + mera = MERA_COMPOSE.read_text(encoding="utf-8") + ptica = PTICA_COMPOSE.read_text(encoding="utf-8") + + shared = service_names(mera) & service_names(ptica) + if not shared: + print("пересекающихся имён сервисов нет — проверять нечего") + return 0 + + found = violations(mera, ptica) + if not found: + print(f"ok: двоятся имена {sorted(shared)}, но ссылок на них как на хост нет") + return 0 + + print("Ссылка на ДВОЯЩЕЕСЯ имя сервиса:\n", file=sys.stderr) + for line in found: + print(f" {line}", file=sys.stderr) + print( + "\nОба продукта делят сеть gendesign_shared, поэтому такое имя резолвится\n" + "в два адреса и клиент берёт любой. Подставьте container_name\n" + "(tradein-backend, tradein-postgres) — он однозначен.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 9e229983..d7ef9bb6 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -294,7 +294,7 @@ services: # Деградация источника в None — существующее состояние ответа (так же # ведёт себя таймаут), контракт API не меняется. ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true" - DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" + DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein" PUBLIC_URL: "https://gendsgn.ru/trade-in" CORS_ORIGINS: '["https://gendsgn.ru"]' ENVIRONMENT: "production" @@ -373,7 +373,7 @@ services: - path: ./backend/.env.runtime required: false environment: - DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" + DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein" ENVIRONMENT: "production" GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}" GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}" @@ -432,7 +432,7 @@ services: - path: ./backend/.env.runtime required: false environment: - DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" + DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein" ENVIRONMENT: "production" GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}" # Этот процесс — не scheduler_main; false на всякий случай, если общий @@ -468,7 +468,15 @@ services: # только свой hostname-интерфейс и недоступен как tradein-frontend:3000. # Принудительно 0.0.0.0 чтобы Caddy достучался. HOSTNAME: "0.0.0.0" - BACKEND_URL: "http://backend:8000" # internal SSR + # ИМЯ ОБЯЗАНО БЫТЬ ОДНОЗНАЧНЫМ. Оба продукта назвали свой сервис + # `backend`, и оба подключены к общей сети gendesign_shared — + # изнутри фронта `backend` резолвится в ДВА адреса (tradein 172.18.0.6 + # и ПТИЦА 172.18.0.9), клиент берёт любой. Попав в ПТИЦУ, SSR получает + # 401 и рендерит лендинг БЕЗ витрины — без ленты, строк сверки и + # подписи разброса, то есть страница про точность без доказательств. + # Отказ тихий: fetch не бросает, приходит валидный 401. + # `tradein-backend` — container_name, он однозначен. + BACKEND_URL: "http://tradein-backend:8000" # internal SSR depends_on: [backend] restart: unless-stopped networks: From e45b05845e053d1f2b0a512faad640d6e58435ee Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 11:22:39 +0500 Subject: [PATCH 2/9] =?UTF-8?q?fix(mera-landing):=20=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=B8=D0=B5=20=D1=81=D1=85=D0=B5=D0=BC=D1=8B=20?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D1=86=D1=8B=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=20=D0=B2=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=82=D1=83=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=B2=D0=B8=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первой строкой витрины и первым раундом игры «Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо схемы улиц рисовался полигон района. Причина — `completeness()` считала район, этаж и этажность, но не улицу, хотя именно она решает, будет ли у строки карта. Схема улицы — такое же ВИДИМОЕ поле, как район: строка, которой нечем нарисовать карту, полнее строки с картой быть не может. Признак берётся из уже загружаемого индекса улиц (`StreetIndex.lookup`, поиск в памяти); индекс поднят выше отбора, дорогие пространственные запросы остались в `_schemes_for` и по-прежнему считаются только для показанных строк. Отбор по ВЕЛИЧИНЕ ОШИБКИ не введён и введён быть не может: строка с отклонением +75,7% остаётся в витрине, просто больше не открывает её. Прежнее правило «наличие схемы на отбор не влияет» в докстринге `_schemes_for` заменено с разбором, почему оно давало этот дефект. Тест двусторонний: при прочих равных строка со схемой выше строки без неё, а строка без улицы остаётся в витрине. Фальсифицирован — снятие `row.has_street` из `completeness` даёт красное ПО ЗНАЧЕНИЮ ([9, 8] вместо [8, 9]), не по исключению. Co-Authored-By: Claude Opus 5 --- .../app/tasks/landing_showcase_deals.py | 59 ++++++++++++++----- .../tests/test_landing_showcase_deals.py | 27 +++++++++ 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index ec69b54a..881f3728 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -30,8 +30,8 @@ самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет квартала, нет площади) — «число некрасивое» причиной не является. -Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность), -у строки заполнено. Свежесть — порядок квартала сделки. +Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность, +схема улицы), у строки заполнено. Свежесть — порядок квартала сделки. ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт) ---------------------------------------------------------- @@ -69,7 +69,10 @@ нет ни координат окна, ни констант проекции: точку дома по ней нельзя поставить даже случайно. Название сматчилось с OSM у 550 из 654 названий — 92.3% сделок; остальным `street_scheme` = NULL, и это штатно: фронт - показывает район. Наличие схемы НА ОТБОР НЕ ВЛИЯЕТ (см. `_schemes_for`). + показывает район. Наличие схемы ВХОДИТ В ПОЛНОТУ (см. `completeness`): + строка, которой нечем нарисовать карту, полнее строки с картой быть не + может. Это признак «поле заполнено», как район и этаж, а не величина + ошибки, — правило отбора выше не нарушено. * СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных строк» неотличимо от «столько и было», пока рядом не написано, сколько сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог @@ -97,7 +100,7 @@ from typing import Any from sqlalchemy import text from sqlalchemy.orm import Session -from app.services.street_scheme import build_street_scheme, load_street_index +from app.services.street_scheme import StreetIndex, build_street_scheme, load_street_index logger = logging.getLogger(__name__) @@ -158,15 +161,26 @@ class ShowcaseRow: # None штатно: у части сделок координаты нет, подставлять туда нечего. lat: float | None = None lon: float | None = None + # Есть ли чем нарисовать схему улицы (название сделки нашлось в OSM). + # Саму схему строим только для показанных строк — см. `_schemes_for`. + has_street: bool = False def completeness(row: ShowcaseRow) -> int: - """Сколько ВИДИМЫХ посетителю полей заполнено (0..3). + """Сколько ВИДИМЫХ посетителю полей заполнено (0..4). - Считаем район/этаж/этажность: комнаты и площадь есть у всех кандидатов по - построению выборки, поэтому в оценке полноты они бесполезны. + Считаем район/этаж/этажность и схему улицы: комнаты и площадь есть у всех + кандидатов по построению выборки, поэтому в оценке полноты они бесполезны. + + СХЕМА УЛИЦЫ — ТАКОЕ ЖЕ ВИДИМОЕ ПОЛЕ, как район. Строка без улицы рисует на + фронте полигон РАЙОНА вместо схемы улиц, то есть этого поля у неё просто + нет, и полнее строки с картой она быть не может. Это признак наличия + данных, а не величина ошибки: строка с большим отклонением, но со схемой, + показывается как есть. """ - return sum(x is not None for x in (row.district, row.floor, row.total_floors)) + return int(row.has_street) + sum( + x is not None for x in (row.district, row.floor, row.total_floors) + ) def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]: @@ -197,6 +211,7 @@ def build_row( n_analogs: int, lat: float | None = None, lon: float | None = None, + has_street: bool = False, ) -> ShowcaseRow | None: """Кандидат → строка витрины, либо None если считать не из чего. @@ -251,6 +266,7 @@ def build_row( n_analogs=n_analogs, lat=lat, lon=lon, + has_street=has_street, ) @@ -320,19 +336,27 @@ _INSERT_SQL = text( ) -def _schemes_for(db: Session, chosen: list[ShowcaseRow], addresses: dict[int, str | None]) -> dict: +def _schemes_for( + db: Session, + index: StreetIndex, + chosen: list[ShowcaseRow], + addresses: dict[int, str | None], +) -> dict: """Схемы улиц ТОЛЬКО для показанных строк: id сделки → схема. Считаем после отбора, а не до: схема — это два пространственных запроса на сделку, и на двухстах кандидатах ради двадцати показанных это четыреста лишних запросов в чужую базу. - НА ОТБОР НАЛИЧИЕ СХЕМЫ НЕ ВЛИЯЕТ — и это то же правило, что запрещает отбор - по величине ошибки. Ранжируй мы строки со сматчившейся улицей выше, витрина - показывала бы не работу оценщика, а те 92% адресов, которые удобно легли на - OSM. Схема — украшение строки, а не причина её показать. + НА ОТБОР ВЛИЯЕТ НЕ ЭТОТ ШАГ, А ПОЛНОТА (`completeness`), куда наличие улицы + входит наравне с районом и этажом. До 2026-08-31 здесь было записано + обратное — «схема на отбор не влияет», — и первой строкой витрины и первым + раундом игры стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо + схемы улиц рисовался полигон района. Отсутствие видимого поля не может + делать строку самой полной. Запрет отбора по ВЕЛИЧИНЕ ОШИБКИ этим не + затронут: строка с большим отклонением, но со схемой, стоит в витрине как + есть. """ - index = load_street_index(db) out = {} for row in chosen: scheme = build_street_scheme(db, index, addresses.get(row.deal_id)) @@ -377,6 +401,10 @@ def refresh_landing_showcase_deals( logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since) districts = _fetch_districts(db, [d.id for d in deals]) + # Индекс улиц нужен ДО отбора: сматчился ли адрес с OSM — это признак + # полноты строки. Поиск по индексу идёт в памяти, запрос ровно один на + # прогон, дорогие пространственные запросы остались в `_schemes_for`. + street_index = load_street_index(db) candidates: list[ShowcaseRow] = [] n_priced = 0 @@ -408,6 +436,7 @@ def refresh_landing_showcase_deals( # тип этого не поймает: обе величины float. lat=deal.lat, lon=deal.lon, + has_street=street_index.lookup(deal.address) is not None, ) if row is None: n_incomplete += 1 @@ -415,7 +444,7 @@ def refresh_landing_showcase_deals( candidates.append(row) chosen = select_rows(candidates, limit) - schemes = _schemes_for(db, chosen, {d.id: d.address for d in deals}) + schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals}) db.execute(_DELETE_SQL) db.execute(_DELETE_RUNS_SQL) diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py index 702e42f7..80bd3920 100644 --- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -31,6 +31,7 @@ def _row( total_floors: int | None = 9, deal_date: date = date(2026, 1, 1), err_pct: float = 10.0, + has_street: bool = True, ) -> ShowcaseRow: return ShowcaseRow( deal_id=deal_id, @@ -45,6 +46,7 @@ def _row( fact_rub=5_500_000, err_pct=err_pct, n_analogs=40, + has_street=has_street, ) @@ -72,6 +74,31 @@ def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None: assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2] +def test_selection_prefers_row_with_street_scheme() -> None: + """Строка без улицы не может быть самой полной: ей нечем нарисовать карту. + + Так дефект и выглядел в проде: первой строкой витрины и первым раундом игры + «Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы, и вместо + схемы улиц у неё рисовался полигон РАЙОНА. + + Всё остальное здесь одинаково, а id подобраны так, что без учёта улицы + выиграл бы разрыв ключа по id. Ломать так: убрать `row.has_street` из + `completeness` — тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8]. + + Вторая сторона проверки — строка без улицы ОСТАЁТСЯ в витрине: она не + первая, но и не выброшена. Прятать промахи по-прежнему нельзя. + """ + no_street = _row(9, has_street=False, err_pct=75.7) + with_street = _row(8, err_pct=3.0) + + chosen = select_rows([no_street, with_street], limit=2) + + assert [r.deal_id for r in chosen] == [8, 9], ( + "строка, которой нечем нарисовать карту, оказалась полнее строки со " + "схемой улиц — витрина открывается полигоном района" + ) + + def test_selection_is_deterministic_on_full_ties() -> None: """Полные совпадения ключа разводятся id — иначе витрина «мерцает».""" rows = [_row(7), _row(9), _row(8)] From 28bf3a82d79d234d24d02733bb26ab0201255da3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 11:24:11 +0500 Subject: [PATCH 3/9] =?UTF-8?q?fix(mera-public):=20=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D1=80=D1=8F=D0=B4?= =?UTF-8?q?,=20=C2=AB=D0=A0=D0=B0=D1=81=D1=85=D0=BE=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=C2=BB=20=D0=BF=D0=BE=20=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=D1=83=20=D0=BA=D1=80=D0=B0=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пятая плитка (тёмная, из /stats) не влезала в грид: контейнер 1168px, minmax(220px) + gap 20px требуют 1180px — плитка падала на вторую строку одна и узкая, читалось как обрыв вёрстки. Заменил грид на flex-wrap с общим базисом 200px: пять плиток встают в ряд, четыре — тоже, а на узких ширинах остаток растягивается на всю строку вместо огрызка слева. Значения столбца «Расхождение» стояли по левому краю ячейки, а заголовок — по правому, из-за чего столбец читался пустым. Выровнял значения вправо, по краю заголовка; на мобильном брейке text-align по-прежнему сбрасывается в left (карточки с data-label не тронуты). Co-Authored-By: Claude Opus 5 --- .../src/app/mera-public/landing-v3.module.css | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index 77568bc0..ee875cf8 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -983,9 +983,14 @@ text-wrap: pretty; } +/* Плиток бывает четыре или пять (пятая — тёмная, приходит из /stats и может + не прийти). Грид с фиксированной minmax-колонкой ронял пятую на вторую + строку одну и узкую — читалось как обрыв. Flex-wrap с общим базисом делает + строку цельной при любом числе плиток: что помещается — стоит в ряд, + остаток растягивается на всю ширину, а не висит слева огрызком. */ .accKpiGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + display: flex; + flex-wrap: wrap; gap: 20px; margin: 0 0 32px; padding: 0; @@ -993,6 +998,8 @@ } .accKpiTile { + flex: 1 1 200px; + min-width: 0; background: var(--b2c-page-bg); border: 1px solid var(--b2c-line); border-radius: 10px; @@ -1100,10 +1107,14 @@ color: var(--b2c-ink); } +/* Правый край — общий с заголовком столбца (.accTableHeadCellRight); + иначе значения стоят левее шапки и столбец читается пустым. + На мобильном брейке ниже text-align сбрасывается в left. */ .accCellErr { font-family: var(--b2c-font-mono); font-size: 12px; color: var(--b2c-muted); + text-align: right; } .accCellGain { From 884a6b9cfe45623b9819cef573dfbac5859f4c3a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 11:28:11 +0500 Subject: [PATCH 4/9] =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82:?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=B2=D1=8B=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20=D0=B2=D1=80=D0=B0=D0=B7=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20=E2=80=94=20ORDER=20BY=20id=20DESC=20=D1=81=D0=B0?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=83=D0=BB=D0=B8?= =?UTF-8?q?=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ORDER BY id DESC берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр грузится пачками по домам: соседние id это один дом и одна улица. Замер на проде 31.08.2026 по Екатеринбургу, выборка 200: последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы случайные 200 : 129 разных улиц, максимум 6 с одной На этой выборке стоит витрина лэндинга: 20 строк с ЧЕТЫРЁХ улиц, 16 из них с двух. Владелец заметил это как «почти все сделки из одного района». ЧТО ЗАМЕР ПОКАЗАЛ, а что нет. Пять прогонов по 200 сделок: recent MAPE 15.18% покрытие 88.75% scattered mera MAPE 14.96% покрытие 83.13% scattered alpha MAPE 15.97% покрытие 88.34% scattered bravo MAPE 13.69% покрытие 83.85% scattered charlie MAPE 14.36% покрытие 86.23% Заголовочная точность УСТОЯЛА — опубликованные 14,5% лежат внутри разброса представительной выборки. Кластеризация её не раздувала. А покрытие коридором опубликовано как 88% — это верх диапазона: при пересборке выборки величина гуляет 83-88 при медиане около 86. Публикуется лучший прогон из пяти как единственный. Правка самих чисел лэндинга — отдельным заходом. УМОЛЧАНИЕ НЕ ТРОНУТО. Докстринг _sample_sql обещает дефолтному пути побайтово тот же SQL ради замороженного регресс-гейта; смена умолчания молча обнулила бы сравнимость всей истории замеров. Режим выбирается флагом --spread. Порядок по md5(id||seed), а не random(): нужен ВОСПРОИЗВОДИМЫЙ порядок, иначе два прогона отличаются и из-за правки, и из-за состава выборки, и разделить вклады нечем. Тест держит обе стороны и проверен фальсификацией: сделать вразброс умолчанием — падает identity-проверка (сравнение текстов её бы пропустило), заменить md5 на random() — падает проверка воспроизводимости. --- .../backend/scripts/backtest_estimator.py | 128 +++++++++++++++++- .../tests/test_backtest_sampling_spread.py | 88 ++++++++++++ 2 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_backtest_sampling_spread.py diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index b2e39d47..41a5b555 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1129,6 +1129,88 @@ _SAMPLE_SQL = text( """ ) +# ── ВЫБОРКА ВРАЗБРОС (--spread scattered) ──────────────────────────────────── +# +# ЗАЧЕМ. `ORDER BY id DESC` выше — это последние ВСТАВЛЕННЫЕ строки, а Росреестр +# грузится пачками по домам: соседние id это один дом и одна улица. Замер на +# проде 31.08.2026 по Екатеринбургу, выборка 200: +# +# последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы +# случайные 200 : 129 разных улиц, максимум 6 с одной +# +# То есть заголовочная точность и вся витрина лэндинга считались по выборке +# втрое беднее случайной. Ошибка при этом не «шумит», а СМЕЩЕНА: пара новостроек +# ведёт себя иначе, чем город. +# +# ПОЧЕМУ НЕ СТАЛО УМОЛЧАНИЕМ. Замороженный регресс-гейт сравнивает прогоны между +# собой и требует от дефолтного пути побайтово того же SQL (см. докстринг +# `_sample_sql`). Менять умолчание значило бы обнулить сравнимость всей истории +# замеров. Режим выбирается явно. +# +# ПОЧЕМУ md5, А НЕ random(). `random()` даёт разную выборку на каждом прогоне — +# два замера подряд отличались бы и из-за правки, и из-за выборки, и разделить +# эти вклады было бы нечем. md5(id || seed) — псевдослучайный, но +# ВОСПРОИЗВОДИМЫЙ порядок: тот же seed → та же выборка. +_SAMPLE_SQL_SCATTERED = text( + """ + SELECT + id, + ST_X(geom::geometry) AS lon, + ST_Y(geom::geometry) AS lat, + rooms, + price_per_m2 AS sold_ppm2, + price_rub, + deal_date, + area_m2, + address, + floor, + total_floors, + year_built, + house_type + FROM deals + WHERE source = 'rosreestr' + AND geom IS NOT NULL + AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) + AND rooms IS NOT NULL + AND area_m2 IS NOT NULL + AND area_m2 > 0 + AND deal_date >= CAST(:since AS date) + ORDER BY md5(CAST(id AS text) || CAST(:seed AS text)) + LIMIT CAST(:sample AS integer) + """ +) + +_SAMPLE_SQL_SCATTERED_CITY = text( + """ + SELECT + id, + ST_X(geom::geometry) AS lon, + ST_Y(geom::geometry) AS lat, + rooms, + price_per_m2 AS sold_ppm2, + price_rub, + deal_date, + area_m2, + address, + floor, + total_floors, + year_built, + house_type + FROM deals + WHERE source = 'rosreestr' + AND geom IS NOT NULL + AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) + AND rooms IS NOT NULL + AND area_m2 IS NOT NULL + AND area_m2 > 0 + AND deal_date >= CAST(:since AS date) + AND city = CAST(:city AS text) + ORDER BY md5(CAST(id AS text) || CAST(:seed AS text)) + LIMIT CAST(:sample AS integer) + """ +) + + # Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi # (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers # is applied in Python for byte-for-byte fidelity with the estimator. @@ -1163,7 +1245,7 @@ _CITY_PPM2_BAND_SQL = text( ) -def _sample_sql(city: str | None) -> Any: +def _sample_sql(city: str | None, scattered: bool = False) -> Any: """ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D). ``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used @@ -1177,6 +1259,8 @@ def _sample_sql(city: str | None) -> Any: Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a slug — see the ``--city`` CLI help for the naming decision. """ + if scattered: + return _SAMPLE_SQL_SCATTERED_CITY if city is not None else _SAMPLE_SQL_SCATTERED if city is None: return _SAMPLE_SQL return text( @@ -1235,7 +1319,13 @@ def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float def _load_sample( - db: Session, *, sample: int, since: str, city: str | None = None + db: Session, + *, + sample: int, + since: str, + city: str | None = None, + scattered: bool = False, + seed: str = "mera", ) -> list[DealSample]: """Run the held-out ДКП deal sampling SELECT → list[DealSample]. @@ -1259,7 +1349,9 @@ def _load_sample( } if city is not None: params["city"] = city - rows = db.execute(_sample_sql(city), params).mappings().all() + if scattered: + params["seed"] = seed + rows = db.execute(_sample_sql(city, scattered), params).mappings().all() out: list[DealSample] = [] for r in rows: if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None: @@ -2025,6 +2117,8 @@ def run_backtest( rooms_tolerance: int, holdout_split: bool = False, city: str | None = None, + scattered: bool = False, + seed: str = "mera", ) -> dict[str, Any]: """Drive the full read-only backtest and return a metrics dict. @@ -2044,7 +2138,7 @@ def run_backtest( ``deals.city`` value — see ``_load_sample``. Default None is unscoped (byte-identical to the pre-oblast-D behaviour). """ - deals = _load_sample(db, sample=sample, since=since, city=city) + deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed) logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city) matched_rows: list[tuple[float, float, int]] = [] @@ -2118,6 +2212,8 @@ def run_backtest_full( dump_fixture: str | None = None, resolve_house_id: bool = False, city: str | None = None, + scattered: bool = False, + seed: str = "mera", ) -> dict[str, Any]: """Drive the FULL-spine read-only backtest and return a metrics dict (#1966). @@ -2150,7 +2246,7 @@ def run_backtest_full( ``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring. """ est = _import_estimator_full() - deals = _load_sample(db, sample=sample, since=since, city=city) + deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed) logger.info( "loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city ) @@ -2318,6 +2414,24 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: default="2025-06-01", help="Only deals with deal_date >= this ISO date (default 2025-06-01).", ) + p.add_argument( + "--spread", + choices=("recent", "scattered"), + default="recent", + help="Как берётся выборка сделок. 'recent' (умолчание) — ORDER BY id DESC, " + "т.е. последние ВСТАВЛЕННЫЕ строки: Росреестр грузится пачками по домам, " + "поэтому такая выборка садится на несколько улиц (замер 31.08.2026 по ЕКБ: " + "38 улиц, до 22 сделок с одной). 'scattered' — воспроизводимый " + "псевдослучайный порядок по md5(id||seed): 129 улиц, максимум 6 с одной. " + "Умолчание оставлено 'recent' ради сравнимости с историей замеров — " + "замороженный регресс-гейт требует от дефолтного пути того же SQL.", + ) + p.add_argument( + "--seed", + default="mera", + help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два " + "прогона подряд отличаются только правкой, а не составом выборки.", + ) p.add_argument( "--city", default=None, @@ -2458,6 +2572,8 @@ def main(argv: list[str] | None = None) -> int: dump_fixture=args.dump_fixture, resolve_house_id=args.resolve_house_id, city=args.city, + scattered=(args.spread == "scattered"), + seed=args.seed, ) else: metrics = run_backtest( @@ -2468,6 +2584,8 @@ def main(argv: list[str] | None = None) -> int: rooms_tolerance=args.rooms_tolerance, holdout_split=args.holdout_split, city=args.city, + scattered=(args.spread == "scattered"), + seed=args.seed, ) finally: db.close() diff --git a/tradein-mvp/backend/tests/test_backtest_sampling_spread.py b/tradein-mvp/backend/tests/test_backtest_sampling_spread.py new file mode 100644 index 00000000..b4cbeea0 --- /dev/null +++ b/tradein-mvp/backend/tests/test_backtest_sampling_spread.py @@ -0,0 +1,88 @@ +"""Выборка бэктеста: режим «вразброс» и неприкосновенность умолчания. + +ЗАЧЕМ. `ORDER BY id DESC` берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр +грузится пачками по домам: соседние id — это один дом и одна улица. Замер на +проде 31.08.2026 по Екатеринбургу, выборка 200: + + последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы + случайные 200 : 129 разных улиц, максимум 6 с одной + +Выборка втрое беднее случайной — а на ней стояла витрина лэндинга (20 строк с +ЧЕТЫРЁХ улиц, 16 из них с двух). + +ЧТО ЭТОТ ТЕСТ ДЕРЖИТ. Две вещи, и вторая не менее важна первой: + +1. Режим «вразброс» действительно меняет порядок и воспроизводим по seed. +2. УМОЛЧАНИЕ НЕ ТРОНУТО — дефолтный путь обязан отдавать ТОТ ЖЕ объект SQL. + Замороженный регресс-гейт сравнивает прогоны между собой; сменить умолчание + значило бы молча обнулить сравнимость всей истории замеров. +""" + +from __future__ import annotations + +import re + +from scripts.backtest_estimator import ( + _SAMPLE_SQL, + _SAMPLE_SQL_SCATTERED, + _SAMPLE_SQL_SCATTERED_CITY, + _sample_sql, +) + + +def _sql(obj) -> str: + return str(obj.text if hasattr(obj, "text") else obj) + + +class TestУмолчаниеНеТронуто: + def test_дефолтный_путь_отдаёт_тот_же_объект(self): + # Именно identity, а не равенство текста: докстринг `_sample_sql` + # обещает побайтовую неизменность, и «текст совпал» это обещание не + # проверяет — объект мог быть пересобран с другими bind-параметрами. + assert _sample_sql(None) is _SAMPLE_SQL + assert _sample_sql(None, scattered=False) is _SAMPLE_SQL + + def test_умолчание_сортирует_по_id_и_не_знает_про_seed(self): + sql = _sql(_SAMPLE_SQL) + assert "ORDER BY id DESC" in sql + assert ":seed" not in sql, "в умолчание протёк параметр режима вразброс" + + +class TestРежимВразброс: + def test_выбирается_явно_и_меняет_порядок(self): + assert _sample_sql(None, scattered=True) is _SAMPLE_SQL_SCATTERED + assert _sample_sql("Екатеринбург", scattered=True) is _SAMPLE_SQL_SCATTERED_CITY + + def test_порядок_воспроизводим_а_не_случаен(self): + # random() дал бы разную выборку на каждом прогоне, и два замера + # подряд отличались бы и из-за правки, и из-за состава выборки — + # разделить вклады было бы нечем. + for obj in (_SAMPLE_SQL_SCATTERED, _SAMPLE_SQL_SCATTERED_CITY): + sql = _sql(obj) + assert "md5(" in sql, "порядок обязан считаться от id и seed" + assert ":seed" in sql + assert re.search(r"\brandom\s*\(", sql) is None, ( + "random() делает выборку неповторимой между прогонами" + ) + assert "ORDER BY id DESC" not in sql + + def test_городской_вариант_фильтрует_город_а_общий_нет(self): + assert "city = CAST(:city AS text)" in _sql(_SAMPLE_SQL_SCATTERED_CITY) + assert "city = CAST(:city AS text)" not in _sql(_SAMPLE_SQL_SCATTERED) + + def test_оба_режима_отбирают_по_одним_и_тем_же_условиям(self): + # Иначе разница между режимами объяснялась бы не порядком, а другим + # набором сделок — и сравнение двух прогонов ничего бы не значило. + общие = [ + "source = 'rosreestr'", + "geom IS NOT NULL", + "price_per_m2 BETWEEN", + "rooms IS NOT NULL", + "area_m2 > 0", + "deal_date >= CAST(:since AS date)", + ] + база = _sql(_SAMPLE_SQL) + вразброс = _sql(_SAMPLE_SQL_SCATTERED) + for условие in общие: + assert условие in база, условие + assert условие in вразброс, условие From 4d51d19f7c4a5d554239b0664af12a475de7bf49 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 12:40:51 +0500 Subject: [PATCH 5/9] =?UTF-8?q?feat(mera-public):=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20/business=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка «Для бизнеса» в шапке и «МЕРА для бизнеса» в подвале вели на B2B_URL. Реальная цепочка на проде: 308 → /trade-in → 307 → /trade-in/v2 → клиентский редирект → /trade-in/login. Посетитель публичного сайта упирался в форму «Логин / Пароль» от аккаунта, которого у него нет. Заведена страница /business: кому полезен бизнес-контур, чем он отличается от бесплатной проверки, и как связаться (почта и телеграм — те же константы, что в подвале и в документах, не выдуманные для страницы). Ни цен, ни сроков, ни объёмов: этих величин никто не мерил. Ссылка на вход оставлена внутри страницы второстепенной — «уже работаете с нами». Периметр согласован во всех четырёх местах: PUBLIC_ROUTES, три матчера Caddy (@meraPages, @meraShortSlash, @meraLongPages), PUBLIC_SHORT_PATHS в RouteGuard, плюс строка в smoke-mera-perimeter.sh. Гейт периметра проверен фальсификацией: удаление /business из @meraShortSlash роняет public-perimeter.test.ts. noindex — тем же способом, что у /docs (metadata.robots). Co-Authored-By: Claude Opus 5 --- caddy/sites/apps.caddy | 6 +- scripts/smoke-mera-perimeter.sh | 5 + .../mera-public/_components/InnerHeader.tsx | 5 +- .../mera-public/_components/v3/FooterV3.tsx | 18 +- .../mera-public/_components/v3/HeaderV3.tsx | 5 +- .../src/app/mera-public/business/page.tsx | 154 ++++++++++++++++++ .../frontend/src/app/mera-public/content.ts | 10 ++ .../src/components/auth/RouteGuard.tsx | 1 + 8 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/business/page.tsx diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 8010c04b..085146a4 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -347,7 +347,7 @@ meraocenka.ru { # `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант # без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на # «/», и запрос закольцуется. - @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs + @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business handle @meraPages { rewrite * /trade-in/mera-public{path} reverse_proxy tradein-frontend:3000 { @@ -361,7 +361,7 @@ meraocenka.ru { # голый 404 (так было и до этого PR, с момента #2615). Заодно это # замыкает цепочку для длинных адресов со слэшем: они приходят на короткий # со слэшем и здесь нормализуются. - @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs)/$ + @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$ handle @meraShortSlash { redir * /{re.shortslash.1} permanent } @@ -446,7 +446,7 @@ meraocenka.ru { # порядок директив внутри `handle` определяет Caddy, и без неё `redir` # выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу # (бесконечный цикл; поймано на локальном стенде). - @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ + @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/articles /trade-in/mera-public/articles/ /trade-in/mera-public/articles/kak-ocenit-kvartiru /trade-in/mera-public/articles/kak-ocenit-kvartiru/ /trade-in/mera-public/docs /trade-in/mera-public/docs/ /trade-in/mera-public/business /trade-in/mera-public/business/ handle @meraLongPages { route { uri strip_prefix /trade-in/mera-public diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 52357c7b..478adcda 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -90,6 +90,11 @@ check "meraocenka.ru/privacy — public 200" "$BASE_MERA/privacy" 200 # лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404. check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200 +# 1d2. Страница «МЕРА для бизнеса» — короткий адрес, на который ведут пункт +# шапки и подвала. До 31.08.2026 они вели в закрытый контур и приводили +# человека на форму входа; отвалится handle — вернётся 404 вместо неё. +check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200 + # 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один # канонический адрес, а старые ссылки и закладки продолжают работать. # ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx index be973f8f..89a5a042 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/InnerHeader.tsx @@ -1,4 +1,3 @@ -import { B2B_URL } from "../content"; import styles from "../landing-v3.module.css"; import { PublicLink } from "./PublicLink"; @@ -57,9 +56,9 @@ export function InnerHeader() { diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx index 9a313872..be82a909 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx @@ -21,16 +21,20 @@ * а добавляет одну точку входа с тарифами и порядком возврата. * * ССЫЛКИ — КОРОТКИЕ (15.08.2026). «Проверить квартиру» ведёт на `/estimate` — - * отдельный экран проверки, а не на якорь формы в герое. «МЕРА для бизнеса» - * стала абсолютной: относительный `/` на публичном домене вёл на сам лэндинг, - * то есть пункт не работал. Все переходы — обычным `` через `PublicLink`, - * потому что `next/link` подставляет basePath (см. его шапку). + * отдельный экран проверки, а не на якорь формы в герое. Все переходы — + * обычным `` через `PublicLink`, потому что `next/link` подставляет + * basePath (см. его шапку). + * + * «МЕРА ДЛЯ БИЗНЕСА» ВЕДЁТ НА `/business`, А НЕ НА `B2B_URL` (31.08.2026). + * Абсолютный адрес B2B-контура заканчивался формой входа `/trade-in/login` — + * посетителю публичного сайта предлагали залогиниться в аккаунт, которого у + * него нет. Теперь пункт ведёт на публичную страницу связи, а ссылка на вход + * живёт внутри неё, второстепенной. */ import { safeUrl } from "@/lib/safeUrl"; import { - B2B_URL, LEGAL_ENTITY, REGION_NAME, SUPPORT_EMAIL, @@ -70,9 +74,9 @@ export function FooterV3() { Статьи - + МЕРА для бизнеса - + diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx index 88d71e80..1b932173 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/HeaderV3.tsx @@ -49,7 +49,6 @@ * был `CityPicker`). */ -import { B2B_URL } from "../../content"; import styles from "../../landing-v3.module.css"; import { PublicLink } from "../PublicLink"; @@ -87,9 +86,9 @@ export function HeaderV3() {
- + Для бизнеса - + Проверить квартиру diff --git a/tradein-mvp/frontend/src/app/mera-public/business/page.tsx b/tradein-mvp/frontend/src/app/mera-public/business/page.tsx new file mode 100644 index 00000000..38de70c0 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/business/page.tsx @@ -0,0 +1,154 @@ +import type { Metadata } from "next"; + +import { safeUrl } from "@/lib/safeUrl"; + +import { InnerHeader } from "../_components/InnerHeader"; +import { PublicLink } from "../_components/PublicLink"; +import { FooterV3 } from "../_components/v3/FooterV3"; +import { + B2B_URL, + SUPPORT_EMAIL, + SUPPORT_TELEGRAM_LABEL, + SUPPORT_TELEGRAM_URL, +} from "../content"; +import styles from "../landing-v3.module.css"; + +/** + * «МЕРА для бизнеса» — страница связи, а не вход. + * + * ЗАЧЕМ ЗАВЕДЕНА. Кнопка «Для бизнеса» в шапке и «МЕРА для бизнеса» в подвале + * вели на `B2B_URL`. Реальная цепочка на проде: 308 → `/trade-in` → 307 → + * `/trade-in/v2` → клиентский редирект → `/trade-in/login`. Посетитель + * публичного сайта упирался в форму «Логин / Пароль» от аккаунта, которого у + * него нет и завести который на этом экране нельзя. Владелец просил починить + * это дважды. + * + * ЧЕГО ЗДЕСЬ НЕТ И ПОЧЕМУ. Ни цен, ни сроков ответа, ни объёмов, ни числа + * клиентов: таких величин никто не мерил, а на странице они читались бы как + * обязательство (то же решение, что в `docs/page.tsx` про часы поддержки). + * Формы заявки тоже нет — принимать её в продукте нечем: анонимной ручки для + * B2B-лида не существует, и форма, которая никуда не пишет, хуже честной + * ссылки на живой канал. Каналы взяты из `content.ts` — те же, что в подвале и + * в документах, а не заведены для этой страницы отдельно. + * + * ССЫЛКА НА ВХОД ОСТАВЛЕНА, но второстепенной: у кого доступ уже есть, тот + * пришёл именно за ней, и убрать её значило бы чинить один дефект вторым. + */ + +export const metadata: Metadata = { + title: "МЕРА для бизнеса — связаться", + // Тот же noindex, что у остального публичного дерева (см. layout.tsx и + // docs/page.tsx): страница открыта по адресу, но в выдачу не идёт. + robots: { index: false, follow: false }, +}; + +export default function BusinessPage() { + const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); + + return ( + <> + +
+ + +
+
ДЛЯ БИЗНЕСА
+

МЕРА для бизнеса

+

+ Оценка квартиры по живым объявлениям — не для одной сделки, а + потоком: для агентства, застройщика или компании, которая принимает + квартиры в зачёт. +

+
+ +
+

+ Кому это нужно +

+

+ Тем, кто считает не одну квартиру, а много и регулярно: агентствам + недвижимости, отделам продаж застройщиков, компаниям с программами + трейд-ин. Расчёт идёт по тем же источникам, что и бесплатная + проверка на сайте, — но по вашему потоку адресов и под вашими + учётными записями. +

+

+ Если задача разовая — квартира одна, и нужно понять цену, — идти + сюда незачем: бесплатная проверка на сайте отвечает на этот вопрос + без договорённостей и без регистрации. +

+
+ +
+

+ Чем отличается от бесплатной проверки +

+
+ + Бесплатная проверка + + Одна квартира, здесь и сейчас, без регистрации. Картина рынка + вокруг дома: сколько похожих квартир продаётся рядом и как давно + висят объявления + + + + Бизнес-контур + + Закрытый контур с доступом по учётной записи: расчёты по потоку + адресов и история по ним. Доступ выдаётся вручную, после + разговора, — самостоятельной регистрации в нём нет + + +
+

+ Что именно войдёт в работу — обсуждается: задача разная у + агентства, у застройщика и у трейд-ин программы. Поэтому на этой + странице нет ни тарифа, ни срока: назвать их до разговора значило бы + их придумать. +

+
+ +
+

+ Как связаться +

+

+ Напишите в любой из каналов — коротко, что за компания и какая + задача: сколько адресов, как часто, что должно быть на выходе. + Этого хватает, чтобы ответить по существу с первого письма. +

+
+
+ ПОЧТА + + {SUPPORT_EMAIL} + +
+ {telegramHref ? ( + + ) : null} +
+

+ Уже работаете с нами? Вход в бизнес-контур — + по логину и паролю, которые вам выдали. +

+
+
+ + + ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 375dfc94..4164842e 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -195,6 +195,10 @@ export const PUBLIC_ROUTES = { articles: "/articles", articleHowTo: "/articles/kak-ocenit-kvartiru", docs: "/docs", + // Страница B2B-контура: связаться и оставить заявку. Заведена 31.08.2026 + // взамен ссылки на `B2B_URL` в шапке и подвале — та вела в закрытый контур + // и заканчивалась формой входа для аккаунта, которого у посетителя нет. + business: "/business", } as const; /** Канонический публичный origin сервиса — база для canonical-ссылок. */ @@ -209,6 +213,12 @@ export const PUBLIC_ORIGIN = "https://meraocenka.ru"; * осознанно: это ссылка НАРУЖУ, из одного продукта в другой, и подставлять её * из текущего хоста было бы неверно. */ +/** + * ⚠️ ЭТО ВХОД В ЗАКРЫТЫЙ КОНТУР, А НЕ ВИТРИНА. Цепочка редиректов приводит на + * `/trade-in/login` — форму «Логин / Пароль». Человеку с улицы показывать её + * нельзя, поэтому шапка и подвал ведут на `PUBLIC_ROUTES.business`, а этот + * адрес остался ссылкой «у меня уже есть доступ» внутри той страницы. + */ export const B2B_URL = "https://gendsgn.ru/trade-in/"; /** Короткие публичные адреса тех же страниц (то, что напечатано в документах). */ diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx index 229e03ec..6299c473 100644 --- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx +++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx @@ -103,6 +103,7 @@ const PUBLIC_SHORT_PATHS = [ "/articles", "/articles/kak-ocenit-kvartiru", "/docs", + "/business", ]; export function isPublicPath(rawPath: string): boolean { From 2864d80854383e52d741ba9cbd0723c3560dc119 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 12:41:32 +0500 Subject: [PATCH 6/9] =?UTF-8?q?fix(mera-public):=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B5=20=D1=87=D0=B8=D1=81=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B4=D0=B8=D0=B0=D0=BD=D0=B0=20=D1=82=D1=80=D1=91?= =?UTF-8?q?=D1=85=20=D0=BF=D1=80=D0=B5=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B1=D0=BE=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Покрытие 88 % и расхождение 14,5 % получались только на кластеризованной выборке (ORDER BY id DESC: 20 строк на четыре улицы). Замер 31.08.2026 тремя представительными пересборками (engine=full, ЕКБ, три соли) дал покрытие 82,77/84,31/86,02 % и MAPE 14,66/15,28/15,66 %: опубликованные 88 % лежат ВЫШЕ всего диапазона, то есть недостижимы, а 14,5 % — чуть ниже, тот же благоприятный край. - BACKTEST.coverage 88 % -> 84,3 %, priceError 14,5 % -> 15,3 % (медианы), sampleN 327 -> 325 (медианная пересборка); - в оговорки добавлен сам диапазон пересборок (82,8-86,0 % и 14,7-15,7 %): точечное значение без него обещает точность, которой у замера нет; - BACKTEST_SOURCE переписан — представительная выборка, а не последние загруженные строки; дата замера 29.08 -> 31.08.2026; - confidenceLow («325 из 327») на пересборках НЕ пересчитывался: у него отдельный источник с прежней датой и оговорка про свой знаменатель; - квартальный состав представительной выборки не проверен — записан дословно, с критерием снятия, рядом с BACKTEST_PERIOD_LABEL. Тест: backtest-freshness проверяет наличие диапазона в оговорках (+контроль на инструмент) и что AccuracyV3/HeroV3 не содержат самих величин; render-тест перестал сверяться с вписанным «14,5 %» и берёт значение из landing-facts. Co-Authored-By: Claude Opus 5 --- .../__tests__/backtest-freshness.test.ts | 55 ++++++++++++++++ .../__tests__/landing-v3-render.test.tsx | 7 ++- .../src/app/mera-public/landing-facts.ts | 62 ++++++++++++++++--- 3 files changed, 113 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts index 905ed0cf..70c90626 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts @@ -19,6 +19,9 @@ * квартал), значит чаще чем раз в квартал замеру обновляться не от чего. * Срок = квартал плюс запас на загрузку следующей пачки. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { @@ -78,3 +81,55 @@ describe("свежесть ручного замера бэктеста", () => expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4)); }); }); + +/** + * Разброс пересборок и происхождение величин. + * + * ЗАЧЕМ. 31.08.2026 три представительные пересборки одной и той же сверки дали + * попадание 82,8-86,0 % и расхождение 14,7-15,7 %. Опубликованные до этого + * 88 % лежали ВЫШЕ всего диапазона — значение получалось только на + * кластеризованной выборке (`ORDER BY id DESC`, 20 строк на четыре улицы). + * Точечное число без диапазона обещает точность, которой у замера нет, поэтому + * оговорка про разброс — не украшение, а часть величины: снимут её — красное. + * + * ВТОРАЯ ПОЛОВИНА — откуда число берёт компонент. Прежние 88 % пережили бы + * правку `landing-facts.ts`, будь они вписаны в разметку; здесь проверяется, + * что в витринных компонентах нет самих значений, а есть ссылки на факты. + */ +const V3 = join(__dirname, "..", "_components", "v3"); +const SPREAD_RANGES = ["14,7-15,7", "82,8-86,0"]; + +function caveatsText(): string { + return [...BACKTEST.priceError.caveats, ...BACKTEST.coverage.caveats].join(" "); +} + +describe("разброс пересборок назван, а величины не вписаны в компонент", () => { + it.each(SPREAD_RANGES)("оговорка называет диапазон пересборок %s", (range) => { + expect( + caveatsText(), + [ + `в оговорках priceError/coverage нет диапазона ${range}.`, + "Точечное значение разового замера гуляет при пересборке выборки —", + "без диапазона витрина обещает точность, которой у замера нет.", + ].join(" "), + ).toContain(range); + }); + + it("проверка диапазона краснеет на тексте без него — иначе она зелёная по построению", () => { + const withoutRange = "Попадание достигнуто шириной коридора, а не точностью точки."; + for (const range of SPREAD_RANGES) { + expect(withoutRange).not.toContain(range); + } + }); + + it.each(["AccuracyV3.tsx", "HeroV3.tsx"])("%s берёт величины из landing-facts, а не из своей разметки", (file) => { + const src = readFileSync(join(V3, file), "utf8"); + for (const value of [BACKTEST.priceError.text, BACKTEST.coverage.text]) { + expect( + src, + `${file}: величина ${value} вписана в компонент — правка landing-facts её не догонит`, + ).not.toContain(value); + } + expect(src, `${file}: величина берётся не из BACKTEST`).toContain("BACKTEST."); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx index 4a3c7aca..59c4fcc0 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -13,6 +13,7 @@ import { AccuracyV3 } from "../_components/v3/AccuracyV3"; import { DealsTickerV3 } from "../_components/v3/DealsTickerV3"; import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3"; import { HeroV3 } from "../_components/v3/HeroV3"; +import { BACKTEST } from "../landing-facts"; import type { LandingStat, ShowcaseResponse } from "../public-api"; const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({ @@ -77,9 +78,9 @@ const SHOWCASE: ShowcaseResponse = { describe("витрина лэндинга v3 без данных", () => { it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => { render(); - expect(screen.getByText("14,5 %")).toBeTruthy(); + expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy(); expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy(); - expect(screen.getByText("325 из 327")).toBeTruthy(); + expect(screen.getByText(BACKTEST.confidenceLow.text)).toBeTruthy(); expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy(); // Подпись витрины: сколько рассмотрено и по какому правилу отсеяно. expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy(); @@ -92,7 +93,7 @@ describe("витрина лэндинга v3 без данных", () => { expect(container.querySelector('[role="table"]')).toBeNull(); expect(container.textContent).not.toMatch(/[—-]\s*дн\./); // Бэктест не зависит от ручки — он остаётся вместе со своими оговорками. - expect(screen.getByText("14,5 %")).toBeTruthy(); + expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy(); }); it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => { diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index e1ee7300..9b0aef3a 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -42,8 +42,29 @@ export interface MeasuredValue { readonly caveats: readonly string[]; } -/** Общий источник трёх величин ниже — один и тот же прогон сверки. */ +/** + * Источник двух головных величин — ПРЕДСТАВИТЕЛЬНЫЙ прогон 31.08.2026. + * + * ЧТО ПОМЕНЯЛОСЬ ПРОТИВ 29.08. Прежний прогон брал строки `ORDER BY id DESC`, + * то есть последние загруженные: 20 строк выборки приходились на ЧЕТЫРЕ улицы. + * Это не выборка города, а её кластер, и покрытие на нём выходило 88 % — + * ВЫШЕ всего диапазона представительных пересборок (82,8-86,0 %). Значение + * было недостижимым, а не «чуть оптимистичным»; расхождение 14,5 % лежало + * чуть ниже диапазона 14,66-15,66 % — тот же благоприятный край, но на + * порядок слабее. Здесь стоит МЕДИАНА трёх пересборок с разными солями. + */ const BACKTEST_SOURCE = + "Бэктест на боевой базе (31.08.2026, engine=full): прогноз МЕРЫ против цены " + + "ДКП Росреестра по Екатеринбургу, представительная выборка (разнесение по " + + "адресам, три пересборки с разными солями), медиана трёх прогонов"; + +/** + * Источник `confidenceLow` — ПРЕЖНИЙ прогон, он не пересчитывался 31.08. + * Держится отдельной строкой, чтобы дата и выборка не выглядели общими с + * головными числами: там 325 сделок представительной выборки, здесь 327 + * кластеризованных. + */ +const BACKTEST_CONFIDENCE_SOURCE = "Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " + "Росреестра по Екатеринбургу, сделки II квартала 2026 года"; @@ -54,18 +75,21 @@ const BACKTEST_SOURCE = */ export const BACKTEST: Readonly> = { priceError: { - text: "14,5 %", - sampleN: 327, + text: "15,3 %", + sampleN: 325, source: BACKTEST_SOURCE, caveats: [ + "Величина разового замера гуляет при пересборке выборки: на трёх пересборках " + + "расхождение вышло 14,7-15,7 %, попадание в коридор — 82,8-86,0 %. " + + "Точечное значение без этого диапазона обещает точность, которой у замера нет.", "Прогноз считается по объявлениям, активным НА МОМЕНТ РАСЧЁТА, а сделка — прошлая: " + "это не point-in-time замер. Честный будет не раньше конца 2026 года по календарю данных.", "Цена ДКП бывает занижена ради налога — она нижняя граница правды, а не сама правда.", ], }, coverage: { - text: "88 %", - sampleN: 327, + text: "84,3 %", + sampleN: 325, source: BACKTEST_SOURCE, caveats: [ "Попадание достигнуто шириной коридора ±37 %, а не точностью точки: " + @@ -75,8 +99,13 @@ export const BACKTEST: Readonly Date: Mon, 31 Aug 2026 12:59:27 +0500 Subject: [PATCH 7/9] =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82:?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BA=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80=D1=83,=20?= =?UTF-8?q?=D1=83=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=E2=80=94=20=D0=BA=20=D1=82=D0=BE=D0=BC=D1=83=20=D0=B6?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снимает три блокера ревью на этой же ветке. 1. ПОДПИСЬ ОКНА. Числа перевели на представительную выборку, а подпись осталась от прежнего прогона: «сделки II квартала 2026 года». Автор честно записал, что квартальный состав пересборки не смотрел, и оставил критерий снятия — посчитать deal_date по выборке. Посчитал: III кв 2025 — 106 (26,5 %) I кв 2026 — 88 (22,0 %) IV кв 2025 — 110 (27,5 %) II кв 2026 — 96 (24,0 %) Пересборка покрывает ЧЕТЫРЕ квартала, II квартал 2026 — четверть её. Прежняя подпись для этих чисел была бы прямым враньём. 2. ЗНАМЕНАТЕЛЬ. Был 5 954 — сделки только II квартала. Для выборки по четырём кварталам это завышало долю вчетверо: 5,5 % вместо 1,3 %. Замер тем же фильтром, что у выборки: в окне 24 333, из них II квартал 2026 — 5 954. Оба числа одним запросом. 3. УВЕРЕННОСТЬ. Плитка «325 из 327» была из прогона 29.08, а лид секции печатал «замер 31.08» — старому числу приписывалась новая дата, и 325 в лиде совпадало с 325 в плитке, читаясь как одна выборка. Заменено на «400 из 400» из ТОЙ ЖЕ пересборки. Знаменатель 400 — запрошенные сделки, а не 325 сматчившихся: уверенность считается на каждой оценке. 4. Оговорка уверенности не выводилась вовсе: AccuracyV3 собирал оговорки двух величин из трёх. Плитка стояла без единого слова о том, почему уверенность низкая. Добавлена третья. Тесты проверены фальсификацией: возврат знаменатели одного квартала даёт «expected 5954 to be 24333» и «expected 5.458 to be less than 2»; снятие оговорки из вывода — «оговорка не выводится». 157 passed. --- .../__tests__/backtest-window-labels.test.tsx | 68 ++++++++++++++++++ .../mera-public/_components/v3/AccuracyV3.tsx | 5 ++ .../src/app/mera-public/landing-facts.ts | 72 +++++++++++-------- 3 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx new file mode 100644 index 00000000..27235f9f --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx @@ -0,0 +1,68 @@ +/** + * Подписи бэктеста обязаны описывать ТО окно, по которому числа посчитаны. + * + * 31.08.2026 числа перевели на представительную выборку (медиана трёх + * пересборок), но подписи остались от прежнего прогона: «сделки II квартала + * 2026 года» и знаменатель 5 954 (только этот квартал). Замер показал, что + * пересборка покрывает ЧЕТЫРЕ квартала и II квартал 2026 — лишь 24 % её + * сделок. Доля выборки при старом знаменателе выходила 5,5 % вместо 1,3 %, + * то есть вчетверо льстила. + * + * Отдельно: плитка «уверенность низкая» показывалась БЕЗ своей оговорки — + * `AccuracyV3` собирал оговорки только двух величин из трёх. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { AccuracyV3 } from "../_components/v3/AccuracyV3"; +import { + BACKTEST, + BACKTEST_PERIOD_LABEL, + BACKTEST_POPULATION, + BACKTEST_SHARE_LABEL, +} from "../landing-facts"; + +describe("подписи окна бэктеста", () => { + it("окно названо целиком, а не одним кварталом из четырёх", () => { + // Прежняя подпись называла II квартал 2026 — в представительной выборке + // это 24 % сделок, а не все. + expect(BACKTEST_PERIOD_LABEL).not.toMatch(/^сделки II квартала/u); + expect(BACKTEST_PERIOD_LABEL).toMatch(/четыре квартала/u); + }); + + it("знаменатель — того же окна, что и выборка", () => { + // 5 954 — только II квартал 2026; окно целиком — 24 333 (замер 31.08.2026). + expect(BACKTEST_POPULATION).toBe(24_333); + }); + + it("доля выборки выводится и получается около 1,3 %, а не 5,5 %", () => { + const share = (BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100; + expect(share).toBeGreaterThan(1.0); + expect(share).toBeLessThan(2.0); + expect(BACKTEST_SHARE_LABEL).toBe("1,3 %"); + }); + + it("уверенность посчитана на том же прогоне, что и остальные числа", () => { + // «325 из 327» было из прогона 29.08 и под общей подписью «замер 31.08» + // приписывало старому числу новую дату. + expect(BACKTEST.confidenceLow.text).not.toMatch(/327/u); + }); +}); + +describe("оговорки в интерфейсе", () => { + it("на экране есть оговорки ВСЕХ ТРЁХ величин блока", () => { + render(); + for (const набор of [ + BACKTEST.priceError.caveats, + BACKTEST.coverage.caveats, + BACKTEST.confidenceLow.caveats, + ]) { + for (const line of набор) { + expect( + screen.queryByText(line), + `оговорка не выводится: ${line.slice(0, 48)}…`, + ).not.toBeNull(); + } + } + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx index 2be7c7d8..418fff0b 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx @@ -114,9 +114,14 @@ export function AccuracyV3({ }); } + // Оговорки ВСЕХ ТРЁХ величин блока, а не двух. `confidenceLow` показывается + // отдельной плиткой, но её оговорка сюда не попадала — то есть на экране + // стояло «400 из 400 — уверенность низкая» без единого слова о том, почему. + // Плитка без своей оговорки — ровно то, что этот блок и не должен делать. const caveats = [ ...BACKTEST.priceError.caveats, ...BACKTEST.coverage.caveats, + ...BACKTEST.confidenceLow.caveats, ]; const deals = showcase?.deals ?? []; diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index 9b0aef3a..d0688d7c 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -97,14 +97,20 @@ export const BACKTEST: Readonly 0, + * `deal_date >= 2025-06-01`, город Екатеринбург): **24 333** сделки. + * + * ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, ЧТО И ВЫБОРКА — правило не изменилось, + * изменилось окно. Раньше здесь стояло 5 954 (только II квартал 2026), и это + * было верно для прогона 29.08, который в один квартал и укладывался. Для + * пересборок вразброс, покрывающих четыре квартала, тот же знаменатель дал бы + * долю вчетверо больше реальной — 5,5 % вместо 1,3 %. + * + * Обе величины проверены одним запросом: в окне 24 333, из них с + * `deal_date = 2026-04-01` — 5 954. */ -export const BACKTEST_POPULATION = 5_954; +export const BACKTEST_POPULATION = 24_333; /** * Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может. From c4beee29ffa6ee23ff24ae9de9efd585cd57c779 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 13:02:45 +0500 Subject: [PATCH 8/9] =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B1=D0=B8=D0=B7?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D0=B0:=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BE=D0=B1=D0=B5=D1=89=D0=B0=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D1=85=20=D0=BD=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B5;=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9=D1=82=20=D1=87=D0=B8=D1=81=D0=B5=D0=BB=20?= =?UTF-8?q?=D0=BD=D0=B0=D0=BA=D1=80=D1=8B=D0=BB=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снимает два замечания ревью. 1. КОПИЯ ОБЕЩАЛА НЕСУЩЕСТВУЮЩЕЕ. «Расчёты по потоку адресов», «не для одной сделки, а потоком», «по вашему потоку адресов» — всё это подразумевает массовую загрузку, которой в контуре нет: ни CSV, ни импорта, ни пакетной ручки. Проверено grep-ом по src/app/v2. Что там ЕСТЬ на самом деле и теперь названо: учётные записи команды (app/api/v1/team.py), история своих расчётов (useEstimateHistory, оверлей «Предыдущие оценки»), месячный лимит на аккаунт (account_quota_overrides, monthly_limit/unlimited), выдача доступа вручную. Это ровно тот дефект, который на этом лендинге выкорчёвывали весь день: величина или возможность, которой никто не мерил и не делал, в продуктовом голосе. Новая страница не имеет права начинать заново. 2. ГЕЙТ ЧИСЕЛ НЕ ВИДЕЛ НОВУЮ СТРАНИЦУ. Сканер знал два места: корневой page.tsx и каталог _components/v3. Публичная /business под него не попадала — правило держалось на памяти автора. Теперь подстраницы находятся ОБХОДОМ каталога, а не списком: список — снимок, следующую страницу в него снова забудут вписать. Юридические страницы (оферта, политика, возврат, документы) исключены явно и с обоснованием: они состоят из ссылок на законы («Закон РФ от 07.02.1992 № 2300-1», «152-ФЗ», «ст. 18.1»), а по форме это те же числа, что и замеры. Отличить регулярным выражением нельзя, и правило гейта к ним не относится — там числа цитируются из нормативных актов, а не утверждаются о рынке. Расширение проверено: включение этих страниц давало 4 ложных срабатывания. Фальсификация: вписанные в /business «42 700 квартир» роняют гейт по значению с указанием файла. 150 passed, tsc и eslint чисто. --- .../__tests__/landing-numbers-gate.test.ts | 49 +++++++++++++++++++ .../src/app/mera-public/business/page.tsx | 17 ++++--- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts index 369ba1a0..328fbf4d 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts @@ -79,8 +79,57 @@ const V3_DIR = "_components/v3"; const GENERATED = ["ekb-districts.ts"]; const GENERATED_MARK = "СГЕНЕРИРОВАН"; +/** + * ПОДСТРАНИЦЫ ПЕРИМЕТРА тоже под гейтом, а не только корень и v3-секции. + * + * 31.08.2026 завели `/business`, и ревьюер заметил, что новая ПУБЛИЧНАЯ + * страница чисел ничем не сторожится: сканер знал ровно два места — корневой + * `page.tsx` и каталог `_components/v3`. Чисел там тогда не было, но правило + * держалось на том, что автор о нём помнил. + * + * Каталоги перечисляются ОБХОДОМ, а не списком: список — снимок, и следующую + * страницу в него снова забудут вписать. Обход находит её сам. + */ +/** + * ЮРИДИЧЕСКИЕ СТРАНИЦЫ ПОД ЭТОТ ГЕЙТ НЕ ПОПАДАЮТ — и это не послабление. + * + * Гейт ищет ФОРМУ величины: «14,5 %», «42 700», «150 ₽». Тексты оферты, + * политики и возврата состоят из ссылок на законы, и по форме они те же самые: + * + * Закон РФ от 07.02.1992 № 2300-1 «О защите прав потребителей» + * Федеральный закон от 27.07.2006 № 152-ФЗ + * ст. 18.1 152-ФЗ + * + * Отличить номер закона от замера рынка регулярным выражением нельзя, а + * подгонять под них исключения по одному значило бы дырявить сам сканер. + * Правило, которое гейт защищает, к этим страницам и не относится: там числа + * ЦИТИРУЮТСЯ из нормативных актов, а не утверждаются о рынке. + * + * Список ЯВНЫЙ и короткий: молчаливое расширение (например «все страницы с + * длинным текстом») однажды укроет и маркетинговую. + */ +const LEGAL_PAGES = new Set(["oferta", "privacy", "refund", "docs"]); + +const PAGE_DIRS = readdirSync(ROOT, { withFileTypes: true }) + .filter( + (e) => + e.isDirectory() && + e.name !== "__tests__" && + e.name !== "_components" && + !LEGAL_PAGES.has(e.name), + ) + .map((e) => e.name) + .sort(); + +const SUBPAGES = PAGE_DIRS.flatMap((dir) => + readdirSync(join(ROOT, dir)) + .filter((name) => /^page\.tsx?$/u.test(name)) + .map((name) => `${dir}/${name}`), +); + const V3_SOURCES = [ LANDING_ROOT, + ...SUBPAGES, ...readdirSync(join(ROOT, V3_DIR)) .filter((name) => /\.tsx?$/u.test(name) && !GENERATED.includes(name)) .sort() diff --git a/tradein-mvp/frontend/src/app/mera-public/business/page.tsx b/tradein-mvp/frontend/src/app/mera-public/business/page.tsx index 38de70c0..a56fcfcf 100644 --- a/tradein-mvp/frontend/src/app/mera-public/business/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/business/page.tsx @@ -61,9 +61,9 @@ export default function BusinessPage() {
ДЛЯ БИЗНЕСА

МЕРА для бизнеса

- Оценка квартиры по живым объявлениям — не для одной сделки, а - потоком: для агентства, застройщика или компании, которая принимает - квартиры в зачёт. + Оценка квартир по живым объявлениям под своей учётной записью — + для агентства, застройщика или компании, которая принимает квартиры + в зачёт.

@@ -75,8 +75,8 @@ export default function BusinessPage() { Тем, кто считает не одну квартиру, а много и регулярно: агентствам недвижимости, отделам продаж застройщиков, компаниям с программами трейд-ин. Расчёт идёт по тем же источникам, что и бесплатная - проверка на сайте, — но по вашему потоку адресов и под вашими - учётными записями. + проверка на сайте, — но под учётными записями вашей команды, с + сохранением истории и месячным лимитом на аккаунт.

Если задача разовая — квартира одна, и нужно понять цену, — идти @@ -101,9 +101,10 @@ export default function BusinessPage() { Бизнес-контур - Закрытый контур с доступом по учётной записи: расчёты по потоку - адресов и история по ним. Доступ выдаётся вручную, после - разговора, — самостоятельной регистрации в нём нет + Закрытый контур с доступом по учётной записи: история своих + расчётов, учётные записи команды, месячный лимит на аккаунт. + Доступ выдаётся вручную, после разговора, — самостоятельной + регистрации в нём нет

From 47df1a9020934d883e557621c984145f3de94bdf Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 14:13:36 +0500 Subject: [PATCH 9/9] =?UTF-8?q?=D0=BB=D0=B8=D0=BF=D0=BA=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20=D0=BF=D1=80=D1=8F=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D1=81=D1=8F,=20=D0=BF=D0=BE=D0=BA=D0=B0=20=D0=B3?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B9=20=D0=B2=20=D0=BA=D0=B0=D0=B4=D1=80?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Владелец увидел на первом экране, что полоса «Проверить квартиру» лежит поверх абзаца «Мы называем реальную цену вашей квартиры». .barSpacer это НЕ лечит и не может: он добавляет высоту в КОНЕЦ документа, а .barRoot — fixed, то есть накрывает нижние ~67px вьюпорта при ЛЮБОЙ прокрутке, включая scrollTop=0. Разобравший это агент так и написал и не стал чинить молча. Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки, и полоса дублирует призыв, закрывая текст. Уходит герой — панель появляется. ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА. Скрытие включает только клиентский наблюдатель; если скрипт не выполнился, поведение остаётся ровно сегодняшним. Обратный порядок (скрыта, показывает скрипт) в тех же условиях убрал бы призыв со страницы совсем — то есть чинил бы вид ценой работы. Нет секции героя — наблюдать нечего, панель остаётся видимой, а не пропадает. Убирается transform-ом, а не display:none: transform идёт на композиторе и не вызывает перекладку. Страница и так дорога в отрисовке на мобильном (1824 мс в Style & Layout), и лечение перекрытия не должно стоить пересчёта макета на каждом пересечении границы героя. Убранная панель уходит и из дерева доступности (aria-hidden + inert): иначе Tab уводит в кнопку, которой на экране нет. Тест проверяет ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается пересечение. Фальсификация обеих сторон — панель не прячется («expected false to be true») и панель скрыта по умолчанию («до срабатывания панель видима»). 150 passed, eslint чисто. --- .../__tests__/sticky-cta-hero.test.tsx | 92 +++++++++++++++++++ .../_components/v3/StickyCtaV3.tsx | 51 +++++++++- .../src/app/mera-public/landing-v3.module.css | 18 ++++ 3 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/sticky-cta-hero.test.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/sticky-cta-hero.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/sticky-cta-hero.test.tsx new file mode 100644 index 00000000..e14c8459 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/sticky-cta-hero.test.tsx @@ -0,0 +1,92 @@ +/** + * Липкая панель не должна лежать поверх первого экрана. + * + * Владелец увидел на проде, что полоса накрывает абзац «Мы называем реальную + * цену вашей квартиры». `.barSpacer` это не лечит и не может: он добавляет + * высоту в КОНЕЦ документа, а `.barRoot` — `fixed`, то есть накрывает нижние + * ~67px вьюпорта при любой прокрутке, включая самый верх страницы. + * + * Проверяется ПОВЕДЕНИЕ, а не наличие класса в файле: наблюдателю скармливается + * пересечение и проверяется, что панель убралась, затем обратное. + */ +import { render, screen } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { StickyCtaV3 } from "../_components/v3/StickyCtaV3"; + +let срабатывание: ((entries: { isIntersecting: boolean }[]) => void) | null = null; + +beforeEach(() => { + срабатывание = null; + vi.stubGlobal( + "IntersectionObserver", + class { + constructor(cb: (entries: { isIntersecting: boolean }[]) => void) { + срабатывание = cb; + } + observe() {} + disconnect() {} + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** + * Корень панели ищем ПО КОНТЕЙНЕРУ, а не через getByRole: когда панель убрана, + * на ней стоит aria-hidden, и роль «ссылка» из дерева доступности пропадает — + * это и есть желаемое поведение, но локатор по роли на нём слепнет. + */ +function корень(c: HTMLElement): HTMLElement { + const link = c.querySelector('a[href*="estimate"]'); + if (!link?.parentElement) throw new Error("панель не отрендерилась"); + return link.parentElement; +} + +const скрыта = (c: HTMLElement) => /barHidden/u.test(корень(c).className); + +describe("липкая панель против первого экрана", () => { + it("без секции героя панель остаётся видимой, а не пропадает", () => { + // Наблюдать нечего — поведение обязано остаться сегодняшним. + const { container } = render(); + expect(скрыта(container)).toBe(false); + }); + + it("герой в кадре — панель убрана и недоступна с клавиатуры", () => { + const hero = document.createElement("section"); + hero.id = "check"; + document.body.appendChild(hero); + const { container } = render(); + + expect(скрыта(container), "до срабатывания панель видима").toBe(false); + + act(() => срабатывание!([{ isIntersecting: true }])); + expect(скрыта(container)).toBe(true); + expect(корень(container).getAttribute("aria-hidden")).toBe("true"); + + // Убранная панель обязана уйти и из дерева доступности — иначе Tab + // уводит в кнопку, которой на экране нет. + expect(screen.queryByRole("link", { name: /Проверить квартиру/u })).toBeNull(); + + hero.remove(); + }); + + it("герой ушёл — панель возвращается", () => { + const hero = document.createElement("section"); + hero.id = "check"; + document.body.appendChild(hero); + const { container } = render(); + + act(() => срабатывание!([{ isIntersecting: true }])); + expect(скрыта(container)).toBe(true); + + act(() => срабатывание!([{ isIntersecting: false }])); + expect(скрыта(container), "панель не вернулась — призыв потерян навсегда").toBe(false); + expect(screen.getByRole("link", { name: /Проверить квартиру/u })).toBeTruthy(); + + hero.remove(); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx index 21f1be7d..87e8dc4e 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/StickyCtaV3.tsx @@ -1,3 +1,5 @@ +"use client"; + /** * StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521). * @@ -32,17 +34,64 @@ * чаще всего. В тексте панели по той же причине нет и «срока продажи»: * платный отчёт отдаёт медиану экспозиции похожих объявлений, а не прогноз * срока (`_estimate_days_on_market`, см. HeroV3). + * + * ПАНЕЛЬ ПРЯЧЕТСЯ, ПОКА ГЕРОЙ В КАДРЕ (31.08.2026). Владелец увидел на первом + * экране, что полоса лежит поверх абзаца «Мы называем реальную цену вашей + * квартиры». `.barSpacer` это НЕ лечит и не может: он добавляет высоту в + * КОНЕЦ документа, а `.barRoot` — `fixed`, то есть накрывает нижние ~67px + * вьюпорта при ЛЮБОЙ прокрутке, включая `scrollTop = 0`. + * + * Пока герой виден, панель не нужна: прямо в нём стоит та же форма проверки, + * и панель дублирует призыв, закрывая текст. Уходит герой — панель появляется. + * + * ПО УМОЛЧАНИЮ ПАНЕЛЬ ВИДИМА, и это важно. Скрытие включает только клиентский + * наблюдатель; если скрипт не выполнился (ошибка, старый браузер, отключённый + * JS), поведение остаётся ровно сегодняшним — полоса на месте. Обратный + * порядок (по умолчанию скрыта, показывает скрипт) в тех же условиях убрал бы + * призыв со страницы совсем, то есть чинил бы вид ценой работы. + * + * Наблюдатель смотрит на `#check` — секцию героя (`HeroV3`). Если её на + * странице нет (компонент вставили в другую композицию), наблюдать нечего и + * панель просто остаётся видимой: снова сегодняшнее поведение, а не пустой + * экран. */ +import { useEffect, useState } from "react"; + import styles from "../../landing-v3.module.css"; import { PublicLink } from "../PublicLink"; +/** Секция героя: пока она в кадре, панель молчит. */ +const HERO_ID = "check"; + export function StickyCtaV3() { + const [heroVisible, setHeroVisible] = useState(false); + + useEffect(() => { + const hero = document.getElementById(HERO_ID); + if (!hero || typeof IntersectionObserver === "undefined") return; + + const io = new IntersectionObserver( + ([entry]) => setHeroVisible(entry.isIntersecting), + // Панель высотой ~67px снизу: считаем героя «в кадре», пока он заходит + // хотя бы на эту полосу — иначе она успевала бы мигнуть на его хвосте. + { rootMargin: "0px 0px -68px 0px" }, + ); + io.observe(hero); + return () => io.disconnect(); + }, []); + return ( <>