/ структура парсится; маркеры label.
"""
from __future__ import annotations
@@ -16,6 +18,8 @@ from unittest.mock import MagicMock, patch
from app.services.scrapers.okn_egrkn_client import (
_HEADERS,
_build_form_data,
+ _parse_detail_html,
+ fetch_okn_detail,
fetch_okn_points,
)
@@ -114,6 +118,21 @@ def _mock_response(payload: dict[str, Any], status: int = 200) -> MagicMock:
return resp
+def _mock_html_response(html: str, status: int = 200) -> MagicMock:
+ """Создать мок httpx.Response с HTML-содержимым (для detail-эндпойнта)."""
+ resp = MagicMock()
+ resp.status_code = status
+ resp.text = html
+ resp.raise_for_status = MagicMock()
+ if status >= 400:
+ import httpx
+
+ resp.raise_for_status.side_effect = httpx.HTTPStatusError(
+ "error", request=MagicMock(), response=resp
+ )
+ return resp
+
+
def test_fetch_okn_points_returns_features() -> None:
"""fetch_okn_points возвращает список фич из FeatureCollection."""
features = [
@@ -336,3 +355,130 @@ def test_build_form_data_category_type_as_string() -> None:
result = _build_form_data("Свердловская", 4)
values = dict(result)
assert values["data[1][value]"] == "4"
+
+
+# ── fetch_okn_detail / _parse_detail_html (#1159) ────────────────────────────
+
+
+_DETAIL_HTML_DL = """
+
+
+ - Наименование
- Дом Севастьянова
+ - Адрес
- г. Екатеринбург, пр. Ленина, 35
+ - Категория историко-культурного значения
- федерального
+ - Вид объекта
- памятник архитектуры
+ - Датировка
- 1866
+ - Документ о постановке на гос. охрану
- Постановление Правительства РФ № 527
+ - Регистрационный номер
- 661510234190006
+
+
+"""
+
+
+_DETAIL_HTML_TABLE = """
+
+
+ | Наименование | Гостиница «Большой Урал» |
+ | Адрес | г. Екатеринбург, ул. Красноармейская, 1 |
+ | Категория историко-культурного значения | регионального |
+
+
+"""
+
+
+def test_parse_detail_html_dl_extracts_all_fields() -> None:
+ """- /
- структура → все поля извлечены."""
+ result = _parse_detail_html(_DETAIL_HTML_DL)
+ assert result["name"] == "Дом Севастьянова"
+ assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
+ assert result["protection_category"] == "федерального"
+ assert result["object_kind"] == "памятник архитектуры"
+ assert result["dating"] == "1866"
+ assert result["protection_doc"] == "Постановление Правительства РФ № 527"
+ assert result["regnumber"] == "661510234190006"
+
+
+def test_parse_detail_html_table_extracts_name_address() -> None:
+ """
| label | value | → парсится."""
+ result = _parse_detail_html(_DETAIL_HTML_TABLE)
+ assert result["name"] == "Гостиница «Большой Урал»"
+ assert result["address"] == "г. Екатеринбург, ул. Красноармейская, 1"
+ assert result["protection_category"] == "регионального"
+
+
+def test_parse_detail_html_empty_returns_empty_dict() -> None:
+ """Пустой/None HTML → {}."""
+ assert _parse_detail_html("") == {}
+ assert _parse_detail_html(" ") == {}
+
+
+def test_parse_detail_html_no_recognized_fields_returns_empty() -> None:
+ """HTML без полей-маркеров → пустой dict."""
+ html = "404 not found "
+ assert _parse_detail_html(html) == {}
+
+
+def test_fetch_okn_detail_returns_parsed_fields_on_success() -> None:
+ """GET карточки + парсинг → {name, address, …}."""
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
+
+ with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
+ result = fetch_okn_detail("661510234190006")
+
+ assert result["name"] == "Дом Севастьянова"
+ assert result["address"] == "г. Екатеринбург, пр. Ленина, 35"
+ # GET был вызван с params={"id": "..."}.
+ mock_client.get.assert_called_once()
+ call_kwargs = mock_client.get.call_args.kwargs
+ assert call_kwargs.get("params") == {"id": "661510234190006"}
+
+
+def test_fetch_okn_detail_http_error_returns_empty() -> None:
+ """HTTP 500 на detail → пустой dict, не raise."""
+ mock_client = MagicMock()
+ mock_client.__enter__ = MagicMock(return_value=mock_client)
+ mock_client.__exit__ = MagicMock(return_value=False)
+ mock_client.get.return_value = _mock_html_response("", status=500)
+
+ with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", return_value=mock_client):
+ result = fetch_okn_detail("001")
+
+ assert result == {}
+
+
+def test_fetch_okn_detail_empty_source_id_returns_empty() -> None:
+ """Пустой source_id → пустой dict без HTTP-вызова."""
+ with patch("app.services.scrapers.okn_egrkn_client.httpx.Client") as mock_cls:
+ result = fetch_okn_detail("")
+ assert result == {}
+ mock_cls.assert_not_called()
+
+
+def test_fetch_okn_detail_ssl_error_retries_with_verify_false() -> None:
+ """SSL ConnectError на detail → retry с verify=False (как у searchMap)."""
+ import httpx
+
+ clients: list[dict] = []
+
+ def _make_client(**kwargs: Any) -> MagicMock:
+ verify = kwargs.get("verify", True)
+ clients.append({"verify": verify})
+ mc = MagicMock()
+ mc.__enter__ = MagicMock(return_value=mc)
+ mc.__exit__ = MagicMock(return_value=False)
+ if verify:
+ mc.get.side_effect = httpx.ConnectError("ssl cert verify failed")
+ else:
+ mc.get.return_value = _mock_html_response(_DETAIL_HTML_DL)
+ return mc
+
+ with patch("app.services.scrapers.okn_egrkn_client.httpx.Client", side_effect=_make_client):
+ result = fetch_okn_detail("test-id")
+
+ assert result["name"] == "Дом Севастьянова"
+ assert len(clients) == 2
+ assert clients[0]["verify"] is True
+ assert clients[1]["verify"] is False
diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py
index 0720feaa..c88a6dfc 100644
--- a/backend/tests/services/site_finder/test_best_layouts.py
+++ b/backend/tests/services/site_finder/test_best_layouts.py
@@ -342,6 +342,57 @@ def test_total_sold_in_window_matches_deals_window() -> None:
assert resp.top_layouts[0].total_sold_in_window == int(deals)
+# ── Тесты Fix #1229: supply / velocity bucket vocabulary match ────────────────
+
+
+def test_supply_velocity_buckets_match_for_2_rooms() -> None:
+ """Fix #1229: supply bucket для 2-комн матчит velocity '2' независимо от площади.
+
+ Раньше supply отдельно вычислял euro-1/euro-2 для rooms=2 + area<50 → эти
+ строки выпадали из знаменателя bucket '2' → sold_pct/is_oversold двушек
+ были завышены. После фикса вся supply rooms=2 идёт в '2' и sold_pct
+ рассчитывается от полного supply.
+
+ Здесь моделируем что весь supply для '2' уже агрегирован SQL-стороной в
+ один (rb='2', ab='40-60') ряд: 20 deals_window против 50 supply → 40% sold.
+ """
+ deals = 20.0
+ id_rows = [_obj_id_row(7)]
+ vel_rows = [_vel_row("2", deals_window=deals, avg_area=45.0, obj_ids=[7])]
+ supply_rows = [_supply_row("2", "40-60", 50)]
+ db = _make_db(
+ coord=_coord_row(),
+ id_rows=id_rows,
+ vel_rows=vel_rows,
+ supply_rows=supply_rows,
+ )
+ req = _request(time_window="last_quarter")
+ resp = get_best_layouts(db, CAD_NUM, req)
+
+ assert len(resp.top_layouts) == 1
+ row = resp.top_layouts[0]
+ assert row.room_bucket == "2"
+ assert row.supply_units_in_radius == 50
+ # 20 / 50 * 100 = 40.0 (раньше при euro-* разделении знаменатель был меньше
+ # → sold_pct > 40 или is_oversold=True).
+ assert row.sold_pct_of_supply == pytest.approx(40.0, rel=1e-3)
+ assert row.is_oversold is False
+
+
+def test_supply_does_not_emit_euro_buckets() -> None:
+ """Fix #1229: supply SQL больше НЕ содержит литералов 'euro-1' / 'euro-2'.
+
+ Regression guard: если кто-то восстанавливает SF-08 euro-биннинг в supply
+ без согласования с velocity-стороной (objective_corpus_room_month отдаёт
+ {studio,1,2,3,4+}, не euro-*), sold_pct двушек снова поедет.
+ """
+ from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
+
+ sql_text = str(_SUPPLY_BATCH_SQL.text)
+ assert "'euro-1'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-1 — see #1229"
+ assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229"
+
+
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────
diff --git a/backend/tests/services/test_ird_overlay_lookup.py b/backend/tests/services/test_ird_overlay_lookup.py
index e5a0a945..3e9898c6 100644
--- a/backend/tests/services/test_ird_overlay_lookup.py
+++ b/backend/tests/services/test_ird_overlay_lookup.py
@@ -4,7 +4,8 @@ from __future__ import annotations
from typing import Any
-from sqlalchemy.exc import OperationalError
+import pytest
+from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from app.services.site_finder.ird_overlay_lookup import parcel_ird_overlaps
@@ -102,8 +103,32 @@ def test_empty_when_no_overlap() -> None:
assert res == {"ird_overlaps": [], "ird_by_kind": {}}
-def test_graceful_when_table_missing() -> None:
- """ird_overlays ещё не задеплоена → пусто, без падения analyze."""
- db = _FakeDB(raise_exc=OperationalError("stmt", {}, Exception("no such table")))
+@pytest.mark.parametrize(
+ ("exc", "case"),
+ [
+ (
+ OperationalError("stmt", {}, Exception("no such table")),
+ "operational-error: connection drop / БД недоступна",
+ ),
+ (
+ ProgrammingError("stmt", {}, Exception("relation \"ird_overlays\" does not exist")),
+ "programming-error: UndefinedTable — pre-migration / D9b не задеплоен",
+ ),
+ (
+ DataError("stmt", {}, Exception("parse error - invalid geometry")),
+ "data-error: malformed WKT → PostGIS ST_GeomFromText ERROR",
+ ),
+ ],
+)
+def test_graceful_when_db_fails(exc: Exception, case: str) -> None:
+ """ird_overlays недоступна / malformed WKT → пусто, analyze не падает.
+
+ Покрывает:
+ - OperationalError — БД недоступна.
+ - ProgrammingError — таблица ещё не задеплоена (реальный класс при UndefinedTable).
+ - DataError — malformed parcel_wkt (PostGIS ST_GeomFromText бросает ERROR,
+ SQLAlchemy конвертит в DataError).
+ """
+ db = _FakeDB(raise_exc=exc)
res = parcel_ird_overlaps(db, _WKT)
- assert res == {"ird_overlaps": [], "ird_by_kind": {}}
+ assert res == {"ird_overlaps": [], "ird_by_kind": {}}, case
diff --git a/backend/tests/workers/tasks/test_gknspecial_harvest.py b/backend/tests/workers/tasks/test_gknspecial_harvest.py
index 8a70e78e..76efe332 100644
--- a/backend/tests/workers/tasks/test_gknspecial_harvest.py
+++ b/backend/tests/workers/tasks/test_gknspecial_harvest.py
@@ -256,6 +256,71 @@ def test_returns_types_and_features_counts() -> None:
assert result["features"] == 2
+def test_per_prefix_commit_durability() -> None:
+ """db.commit вызван N+1 раз: после каждого из 8 префиксов + финальный (#1116).
+
+ Durability: WorkerLostError в середине прогона не теряет уже записанные префиксы.
+ """
+ feat = _make_feature(zone_state_v="Действующий")
+ # 8 префиксов: первый с фичей, остальные пустые
+ feats_per_prefix: list[list[Any]] = [[feat]] + [[] for _ in range(7)]
+ call_idx: list[int] = [0]
+
+ def fake_by_cql(layer: str, cql_filter: str) -> list[Any]:
+ idx = call_idx[0]
+ call_idx[0] += 1
+ return feats_per_prefix[idx] if idx < len(feats_per_prefix) else []
+
+ mock_db = MagicMock()
+ mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
+ mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
+
+ with (
+ patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls,
+ patch("app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=mock_db),
+ ):
+ mock_client = MagicMock()
+ mock_client.features_by_cql.side_effect = fake_by_cql
+ mock_client_cls.return_value = mock_client
+ harvest_gknspecial_zones()
+
+ # 8 per-prefix commit + 1 финальный (подстраховка) = 9
+ assert mock_db.commit.call_count == 9
+
+
+def test_per_prefix_commit_skipped_on_wfs_failure() -> None:
+ """Если WFS падает на префиксе — commit за этот префикс не делается (continue).
+
+ Проверяем что commit'ов столько же, сколько успешно обработанных префиксов + 1 финальный.
+ """
+ feat = _make_feature(zone_state_v="Действующий")
+ # 8 префиксов: первый успех, второй WFS-fail (sentinel), остальные успех
+ call_idx: list[int] = [0]
+
+ def fake_by_cql(layer: str, cql_filter: str) -> list[Any]:
+ idx = call_idx[0]
+ call_idx[0] += 1
+ if idx == 1:
+ raise RuntimeError("WFS timeout")
+ return [feat] if idx == 0 else []
+
+ mock_db = MagicMock()
+ mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
+ mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
+
+ with (
+ patch("app.workers.tasks.gknspecial_harvest.EKBGeoportalClient") as mock_client_cls,
+ patch("app.workers.tasks.gknspecial_harvest.SessionLocal", return_value=mock_db),
+ ):
+ mock_client = MagicMock()
+ mock_client.features_by_cql.side_effect = fake_by_cql
+ mock_client_cls.return_value = mock_client
+ harvest_gknspecial_zones()
+
+ # 7 успешных префиксов × commit + 1 финальный = 8 (failed prefix через continue → нет commit)
+ assert mock_db.commit.call_count == 8
+
+
def test_unmatched_feature_written_to_sentinel() -> None:
"""Фича с full_name не из _PREFIX_MAP → пишется в unmatched (source_layer_id=990099)."""
feat = _make_feature(
diff --git a/backend/tests/workers/tasks/test_okn_objects_sync.py b/backend/tests/workers/tasks/test_okn_objects_sync.py
index e345c9a1..e12d7d91 100644
--- a/backend/tests/workers/tasks/test_okn_objects_sync.py
+++ b/backend/tests/workers/tasks/test_okn_objects_sync.py
@@ -1,19 +1,31 @@
-"""Тесты воркера okn_objects_sync (#1066) — без живой сети.
+"""Тесты воркера okn_objects_sync (#1066, #1159) — без живой сети.
Покрывает:
-- upsert идемпотентность: два вызова с одними source_id → один insert.
-- дедуп source_id across проходов: category берётся из первого (более приоритетного).
- _extract_coords: правильный порядок [lat, lon] → (lon, lat) для PostGIS.
- _extract_coords: невалидные / выходящие за bbox координаты → None.
-- sync_okn_objects: мок client + мок DB, проверка UPSERT-вызовов.
+- _PASSES: расширенный список адресных подстрок + декартово с категориями.
+- sync_okn_objects: дедуп source_id across проходов (federal wins).
+- sync_okn_objects: coverage — фичи из district-passes добавляются к city-pass.
+- sync_okn_objects: upsert идемпотентность.
+- _enrich_with_detail: merge name/address в raw_props; пустой detail → noop.
+- _enrich_with_detail: detail-фейл (raise) не валит прогон.
+- _enrich_with_detail: limit ограничивает число fetch'ей.
+- parcel_okn_objects: name/address возвращаются из БД-строки.
"""
from __future__ import annotations
+import json
from typing import Any
from unittest.mock import MagicMock, patch
-from app.workers.tasks.okn_objects_sync import _extract_coords
+from app.workers.tasks.okn_objects_sync import (
+ _ADDRESS_PASSES,
+ _CATEGORY_ORDER,
+ _PASSES,
+ _enrich_with_detail,
+ _extract_coords,
+)
# ── _extract_coords ───────────────────────────────────────────────────────────
@@ -109,6 +121,63 @@ def test_extract_coords_single_coordinate_returns_none() -> None:
assert _extract_coords(feature) is None
+# ── _PASSES / _ADDRESS_PASSES структура (#1159 coverage) ──────────────────────
+
+
+def test_address_passes_include_ekb_districts() -> None:
+ """Поверх «Екатеринбург»/«Свердловская» добавлены районы ЕКБ.
+
+ Defect 1 (#1159): searchMap не возвращает объекты с адресом без слова
+ «Екатеринбург». Запросы по районам ловят такие записи.
+ """
+ expected_districts = {
+ "Кировский",
+ "Ленинский",
+ "Октябрьский",
+ "Орджоникидзевский",
+ "Чкаловский",
+ "Верх-Исетский",
+ "Железнодорожный",
+ }
+ assert expected_districts <= set(_ADDRESS_PASSES), (
+ f"Не все районы ЕКБ в _ADDRESS_PASSES: missing="
+ f"{expected_districts - set(_ADDRESS_PASSES)}"
+ )
+
+
+def test_address_passes_order_city_before_district() -> None:
+ """Городские запросы идут раньше районных.
+
+ При дедупе source_id first-wins: если объект всплыл в проходе «Екатеринбург»
+ как federal, проход «Кировский» не должен перетереть его category.
+ """
+ city_idx = _ADDRESS_PASSES.index("Екатеринбург")
+ district_idx = _ADDRESS_PASSES.index("Кировский")
+ assert city_idx < district_idx
+
+
+def test_passes_cartesian_product_address_x_category() -> None:
+ """_PASSES = декартово (address × category_order)."""
+ expected_size = len(_ADDRESS_PASSES) * len(_CATEGORY_ORDER)
+ assert len(_PASSES) == expected_size
+ # Каждый адрес покрыт всеми тремя категориями (federal/regional/local).
+ for addr in _ADDRESS_PASSES:
+ cats = [cat for a, cat in _PASSES if a == addr]
+ assert sorted(cats) == sorted(_CATEGORY_ORDER), f"{addr}: {cats}"
+
+
+def test_passes_federal_before_local_per_address() -> None:
+ """Для каждого address federal появляется раньше regional раньше local.
+
+ Это гарантирует что при конфликте source_id в одной address-страте
+ более «высокая» категория выигрывает.
+ """
+ for addr in _ADDRESS_PASSES:
+ positions = [i for i, (a, _c) in enumerate(_PASSES) if a == addr]
+ cats_for_addr = [_PASSES[i][1] for i in positions]
+ assert cats_for_addr == _CATEGORY_ORDER, f"{addr}: {cats_for_addr}"
+
+
# ── sync_okn_objects: дедуп source_id ────────────────────────────────────────
@@ -120,155 +189,272 @@ def _make_feature_full(
return {
"id": feature_id,
"geometry": {"type": "Point", "coordinates": [lat, lon]},
- "properties": {"name": f"ОКН-{feature_id}"},
+ "properties": {"id": feature_id},
}
+def _build_mock_db(captured: list[dict[str, Any]]) -> MagicMock:
+ mock_db = MagicMock()
+ mock_db.__enter__ = MagicMock(return_value=mock_db)
+ mock_db.__exit__ = MagicMock(return_value=False)
+ mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
+ mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
+
+ def _exec(sql: Any, row: Any = None) -> MagicMock:
+ if row:
+ captured.append(dict(row))
+ return MagicMock()
+
+ mock_db.execute = _exec
+ return mock_db
+
+
def test_sync_okn_objects_dedup_source_id_across_passes() -> None:
"""Один source_id из нескольких проходов → category из первого прохода (federal wins)."""
from app.workers.tasks.okn_objects_sync import sync_okn_objects
# «001» встречается в проходе (Екатеринбург, 1) = federal
- # и в проходе (Екатеринбург, 2) = regional
- # category должна быть federal
- pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
- ("Екатеринбург", 1): [_make_feature_full("001")],
- ("Екатеринбург", 2): [_make_feature_full("001")], # дубль
- ("Екатеринбург", 4): [],
- ("Свердловская", 1): [],
- ("Свердловская", 2): [],
- ("Свердловская", 4): [],
- }
+ # и в проходе (Екатеринбург, 2) = regional → category должна быть federal.
+ feat = _make_feature_full("001")
def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
- return pass_results.get((address, category_type), [])
+ if address == "Екатеринбург" and category_type in (1, 2):
+ return [feat]
+ return []
upserted_rows: list[dict[str, Any]] = []
-
- mock_db = MagicMock()
- mock_db.__enter__ = MagicMock(return_value=mock_db)
- mock_db.__exit__ = MagicMock(return_value=False)
- mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
- mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
-
- def _capture_execute(sql: Any, row: Any = None) -> MagicMock:
- if row:
- upserted_rows.append(dict(row))
- return MagicMock()
-
- mock_db.execute = _capture_execute
+ mock_db = _build_mock_db(upserted_rows)
with (
patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch),
- patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
- ):
- result = sync_okn_objects()
-
- # Один объект, один upsert
- assert result == {"okn": 1}
- assert len(upserted_rows) == 1
- assert upserted_rows[0]["source_id"] == "001"
- assert upserted_rows[0]["category"] == "federal"
-
-
-def test_sync_okn_objects_upsert_idempotent() -> None:
- """Два вызова sync_okn_objects с одними source_id → два upsert (ON CONFLICT DO UPDATE в БД).
-
- Тест проверяет что воркер передаёт все source_id для UPSERT при каждом прогоне —
- идемпотентность гарантирует SQL ON CONFLICT, не фильтрация в Python.
- """
- from app.workers.tasks.okn_objects_sync import sync_okn_objects
-
- features = [_make_feature_full("001"), _make_feature_full("002")]
- pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
- ("Екатеринбург", 1): features,
- ("Екатеринбург", 2): [],
- ("Екатеринбург", 4): [],
- ("Свердловская", 1): [],
- ("Свердловская", 2): [],
- ("Свердловская", 4): [],
- }
-
- upsert_counts: list[int] = []
-
- def _run_sync() -> dict[str, int]:
- upserted: list[dict[str, Any]] = []
- mock_db = MagicMock()
- mock_db.__enter__ = MagicMock(return_value=mock_db)
- mock_db.__exit__ = MagicMock(return_value=False)
- mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
- mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
-
- def _exec(sql: Any, row: Any = None) -> MagicMock:
- if row:
- upserted.append(dict(row))
- return MagicMock()
-
- mock_db.execute = _exec
-
- with (
- patch(
- "app.workers.tasks.okn_objects_sync.fetch_okn_points",
- side_effect=lambda a, c: pass_results.get((a, c), []),
- ),
- patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
- ):
- res = sync_okn_objects()
-
- upsert_counts.append(len(upserted))
- return res
-
- r1 = _run_sync()
- r2 = _run_sync()
-
- assert r1 == {"okn": 2}
- assert r2 == {"okn": 2}
- # Оба прогона делают UPSERT (идемпотентность обеспечена SQL ON CONFLICT)
- assert upsert_counts == [2, 2]
-
-
-def test_sync_okn_objects_returns_total_unique_count() -> None:
- """sync_okn_objects возвращает {"okn": N} — число уникальных source_id после дедупа."""
- from app.workers.tasks.okn_objects_sync import sync_okn_objects
-
- # 3 уникальных + 1 дубль = 3 upsert
- pass_results: dict[tuple[str, int], list[dict[str, Any]]] = {
- ("Екатеринбург", 1): [_make_feature_full("A"), _make_feature_full("B")],
- ("Екатеринбург", 2): [_make_feature_full("C"), _make_feature_full("A")], # A дубль
- ("Екатеринбург", 4): [],
- ("Свердловская", 1): [],
- ("Свердловская", 2): [],
- ("Свердловская", 4): [],
- }
-
- mock_db = MagicMock()
- mock_db.__enter__ = MagicMock(return_value=mock_db)
- mock_db.__exit__ = MagicMock(return_value=False)
- mock_db.begin_nested.return_value.__enter__ = MagicMock(return_value=None)
- mock_db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
-
- with (
patch(
- "app.workers.tasks.okn_objects_sync.fetch_okn_points",
- side_effect=lambda a, c: pass_results.get((a, c), []),
+ "app.workers.tasks.okn_objects_sync.fetch_okn_detail",
+ return_value={},
),
patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
):
result = sync_okn_objects()
- assert result == {"okn": 3}
+ # Один объект, один upsert
+ assert result == {"okn": 1, "enriched": 0}
+ assert len(upserted_rows) == 1
+ assert upserted_rows[0]["source_id"] == "001"
+ assert upserted_rows[0]["category"] == "federal"
-# ── parcel_okn_objects lookup ─────────────────────────────────────────────────
+def test_sync_okn_objects_coverage_collects_from_multiple_address_passes() -> None:
+ """Фичи, появляющиеся только в district-проходе, добавляются к city-проходу (#1159).
+
+ Симулируем реальный кейс: 1 объект ловится «Екатеринбург», 1 — только
+ «Кировский» (улицный адрес без слова Екатеринбург), 1 — только
+ «Свердловская» (за городом). После прогона все три попадают в upsert.
+ """
+ from app.workers.tasks.okn_objects_sync import sync_okn_objects
+
+ city_feat = _make_feature_full("city-001")
+ district_feat = _make_feature_full("district-002")
+ region_feat = _make_feature_full("region-003")
+
+ def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
+ if category_type != 2: # для краткости — все «региональные»
+ return []
+ if address == "Екатеринбург":
+ return [city_feat]
+ if address == "Кировский":
+ return [city_feat, district_feat] # дубль city-001 + новый
+ if address == "Свердловская":
+ return [region_feat]
+ return []
+
+ upserted_rows: list[dict[str, Any]] = []
+ mock_db = _build_mock_db(upserted_rows)
+
+ with (
+ patch("app.workers.tasks.okn_objects_sync.fetch_okn_points", side_effect=_mock_fetch),
+ patch(
+ "app.workers.tasks.okn_objects_sync.fetch_okn_detail",
+ return_value={},
+ ),
+ patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
+ ):
+ result = sync_okn_objects()
+
+ source_ids = {r["source_id"] for r in upserted_rows}
+ assert source_ids == {"city-001", "district-002", "region-003"}, source_ids
+ assert result["okn"] == 3
-def test_parcel_okn_objects_returns_nearby() -> None:
- """parcel_okn_objects: мок DB → список [{source_id, category, distance_m}]."""
+def test_sync_okn_objects_upsert_idempotent() -> None:
+ """Два вызова с одними source_id → два upsert (ON CONFLICT в БД, не Python-фильтр)."""
+ from app.workers.tasks.okn_objects_sync import sync_okn_objects
+
+ features = [_make_feature_full("001"), _make_feature_full("002")]
+
+ def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
+ if address == "Екатеринбург" and category_type == 1:
+ return features
+ return []
+
+ counts: list[int] = []
+
+ def _run() -> dict[str, int]:
+ upserted: list[dict[str, Any]] = []
+ mock_db = _build_mock_db(upserted)
+ with (
+ patch(
+ "app.workers.tasks.okn_objects_sync.fetch_okn_points",
+ side_effect=_mock_fetch,
+ ),
+ patch(
+ "app.workers.tasks.okn_objects_sync.fetch_okn_detail",
+ return_value={},
+ ),
+ patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
+ ):
+ res = sync_okn_objects()
+ counts.append(len(upserted))
+ return res
+
+ r1 = _run()
+ r2 = _run()
+ assert r1["okn"] == 2
+ assert r2["okn"] == 2
+ assert counts == [2, 2]
+
+
+def test_sync_okn_objects_returns_total_unique_count() -> None:
+ """Возвращает {"okn": N, "enriched": M}: N уникальных, M обогащённых."""
+ from app.workers.tasks.okn_objects_sync import sync_okn_objects
+
+ feats_1 = [_make_feature_full("A"), _make_feature_full("B")]
+ feats_2 = [_make_feature_full("C"), _make_feature_full("A")] # A дубль
+
+ def _mock_fetch(address: str, category_type: int) -> list[dict[str, Any]]:
+ if address == "Екатеринбург" and category_type == 1:
+ return feats_1
+ if address == "Екатеринбург" and category_type == 2:
+ return feats_2
+ return []
+
+ upserted_rows: list[dict[str, Any]] = []
+ mock_db = _build_mock_db(upserted_rows)
+
+ with (
+ patch(
+ "app.workers.tasks.okn_objects_sync.fetch_okn_points",
+ side_effect=_mock_fetch,
+ ),
+ patch(
+ "app.workers.tasks.okn_objects_sync.fetch_okn_detail",
+ return_value={},
+ ),
+ patch("app.workers.tasks.okn_objects_sync.SessionLocal", return_value=mock_db),
+ ):
+ result = sync_okn_objects()
+
+ assert result["okn"] == 3
+ assert result["enriched"] == 0
+
+
+# ── _enrich_with_detail (#1159 acceptance 2) ─────────────────────────────────
+
+
+def _row(source_id: str, base_props: dict[str, Any] | None = None) -> dict[str, Any]:
+ return {
+ "source_id": source_id,
+ "category": "regional",
+ "lon": 60.61,
+ "lat": 56.83,
+ "raw_props": json.dumps(base_props or {"id": source_id}, ensure_ascii=False),
+ }
+
+
+def test_enrich_merges_name_address_into_raw_props() -> None:
+ """detail (name, address, …) подмёрджен в raw_props; baseline id сохранён."""
+ rows = [_row("001"), _row("002")]
+ details = {
+ "001": {"name": "Дом Севастьянова", "address": "пр. Ленина, 35"},
+ "002": {"name": "Гостиница «Большой Урал»", "address": "ул. Красноармейская, 1"},
+ }
+
+ enriched = _enrich_with_detail(rows, detail_fetcher=lambda sid: details[sid])
+ assert enriched == 2
+
+ parsed_0 = json.loads(rows[0]["raw_props"])
+ assert parsed_0 == {"id": "001", "name": "Дом Севастьянова", "address": "пр. Ленина, 35"}
+ parsed_1 = json.loads(rows[1]["raw_props"])
+ assert parsed_1["address"] == "ул. Красноармейская, 1"
+ assert parsed_1["id"] == "002"
+
+
+def test_enrich_with_empty_detail_keeps_baseline() -> None:
+ """Пустой detail → raw_props не меняется, enriched=0."""
+ rows = [_row("001")]
+ original = rows[0]["raw_props"]
+
+ enriched = _enrich_with_detail(rows, detail_fetcher=lambda _sid: {})
+ assert enriched == 0
+ assert rows[0]["raw_props"] == original
+
+
+def test_enrich_tolerates_detail_exception() -> None:
+ """Detail-fetcher raises → строка пропускается, прогон не падает."""
+ rows = [_row("001"), _row("002")]
+
+ def _fetcher(source_id: str) -> dict[str, Any]:
+ if source_id == "001":
+ raise RuntimeError("network down")
+ return {"name": "OK", "address": "Х"}
+
+ enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher)
+ assert enriched == 1
+ # 001 baseline, 002 enriched
+ parsed_1 = json.loads(rows[1]["raw_props"])
+ assert parsed_1["name"] == "OK"
+
+
+def test_enrich_respects_limit() -> None:
+ """limit=N → только первые N source_id запрошены."""
+ rows = [_row(f"{i:03d}") for i in range(10)]
+ calls: list[str] = []
+
+ def _fetcher(source_id: str) -> dict[str, Any]:
+ calls.append(source_id)
+ return {"name": f"N-{source_id}"}
+
+ enriched = _enrich_with_detail(rows, detail_fetcher=_fetcher, limit=3)
+ assert enriched == 3
+ assert len(calls) == 3
+ assert calls == ["000", "001", "002"]
+
+
+def test_enrich_handles_non_dict_base_props() -> None:
+ """raw_props с битым JSON (не dict) → пересоздаётся как пустой dict + merge detail."""
+ row = {
+ "source_id": "001",
+ "category": "regional",
+ "lon": 60.61,
+ "lat": 56.83,
+ "raw_props": json.dumps(["broken", "list"]), # не dict
+ }
+ enriched = _enrich_with_detail(
+ [row], detail_fetcher=lambda _sid: {"name": "X", "address": "Y"}
+ )
+ assert enriched == 1
+ parsed = json.loads(row["raw_props"])
+ assert parsed == {"name": "X", "address": "Y"}
+
+
+# ── parcel_okn_objects lookup (#1159 acceptance 2: name/address) ─────────────
+
+
+def test_parcel_okn_objects_returns_nearby_with_name_address() -> None:
+ """parcel_okn_objects: мок DB → [{source_id, category, distance_m, name, address}]."""
from app.services.site_finder.okn_lookup import parcel_okn_objects
rows = [
- ("ekb-001", "federal", 42),
- ("ekb-002", "regional", 95),
+ ("ekb-001", "federal", 42, "Дом Севастьянова", "пр. Ленина, 35"),
+ ("ekb-002", "regional", 95, None, None), # detail не догружен
]
mock_result = MagicMock()
@@ -280,8 +466,16 @@ def test_parcel_okn_objects_returns_nearby() -> None:
result = parcel_okn_objects(db, "POLYGON((60 56,61 56,61 57,60 57,60 56))", radius_m=100)
assert len(result) == 2
- assert result[0] == {"source_id": "ekb-001", "category": "federal", "distance_m": 42}
- assert result[1] == {"source_id": "ekb-002", "category": "regional", "distance_m": 95}
+ assert result[0] == {
+ "source_id": "ekb-001",
+ "category": "federal",
+ "distance_m": 42,
+ "name": "Дом Севастьянова",
+ "address": "пр. Ленина, 35",
+ }
+ assert result[1]["name"] is None
+ assert result[1]["address"] is None
+ assert result[1]["source_id"] == "ekb-002"
def test_parcel_okn_objects_empty_wkt_returns_empty() -> None:
@@ -345,10 +539,10 @@ def test_parcel_okn_objects_no_results_returns_empty() -> None:
def test_parcel_okn_objects_distance_mapping() -> None:
- """distance_m берётся из колонки [2] (третий элемент row-кортежа)."""
+ """distance_m / name / address берутся из колонок [2..4] (порядок SELECT)."""
from app.services.site_finder.okn_lookup import parcel_okn_objects
- rows = [("id-x", "local", 7)]
+ rows = [("id-x", "local", 7, "Памятник Х", "ул. Y")]
mock_result = MagicMock()
mock_result.fetchall.return_value = rows
@@ -359,3 +553,5 @@ def test_parcel_okn_objects_distance_mapping() -> None:
assert result[0]["distance_m"] == 7
assert result[0]["source_id"] == "id-x"
assert result[0]["category"] == "local"
+ assert result[0]["name"] == "Памятник Х"
+ assert result[0]["address"] == "ул. Y"
diff --git a/frontend/src/components/auth/UserMenu.tsx b/frontend/src/components/auth/UserMenu.tsx
index 2a37dbc3..abea2f62 100644
--- a/frontend/src/components/auth/UserMenu.tsx
+++ b/frontend/src/components/auth/UserMenu.tsx
@@ -5,7 +5,7 @@
*
* Avatar-кнопка с инициалом из username + dropdown:
* - текущий username
- * - role badge («Админ» / «Пилот»)
+ * - role badge («Админ» / «Пилот» / «Аналитик»)
* - кнопка «Выйти» — invalidate Caddy basic_auth cached creds в browser
*
* Используется в `TopNav.rightSlot`. Если /me грузится или 401 (dev без
@@ -24,7 +24,18 @@ import { useEffect, useRef, useState } from "react";
import { LogOut } from "lucide-react";
-import { useMe } from "@/lib/useMe";
+import { useMe, type Role } from "@/lib/useMe";
+
+/**
+ * Локализация ролей для badge. Должен покрывать все варианты `Role` из
+ * `lib/useMe.ts` — TypeScript-проверка через `Record`
+ * сломает build, если backend добавит новую роль без обновления map.
+ */
+const ROLE_LABELS: Record = {
+ admin: "Админ",
+ pilot: "Пилот",
+ analyst: "Аналитик",
+};
/**
* Logout — invalidate basic_auth cache + reload.
@@ -89,7 +100,7 @@ export function UserMenu() {
if (isLoading || error || !data) return null;
const initial = data.username[0]?.toUpperCase() ?? "?";
- const roleLabel = data.role === "admin" ? "Админ" : "Пилот";
+ const roleLabel = ROLE_LABELS[data.role];
return (
diff --git a/frontend/src/components/landing/PilotRequestModal.tsx b/frontend/src/components/landing/PilotRequestModal.tsx
index d8710d64..aab73461 100644
--- a/frontend/src/components/landing/PilotRequestModal.tsx
+++ b/frontend/src/components/landing/PilotRequestModal.tsx
@@ -25,6 +25,25 @@ interface FormState {
message: string;
}
+type FieldName = keyof FormState;
+type FieldErrors = Partial>;
+
+// ── Schema mirror ──────────────────────────────────────────────────────────
+// Зеркалирует backend Pydantic-схему `PilotRequestInput`
+// (`backend/app/api/v1/pilot.py:25-34`). При изменении на бэкенде —
+// синхронизировать здесь, иначе лид получит 422 и сырой JSON в модалке.
+export const PILOT_LIMITS = {
+ name: { min: 2, max: 200 },
+ phone: { max: 50 },
+ email: { max: 200 },
+ company: { max: 200 },
+ message: { max: 2000 },
+} as const;
+
+// Тот же паттерн, что и в Pydantic Field(pattern=...) на бэкенде
+// (`backend/app/api/v1/pilot.py:31`).
+export const PILOT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
+
// ── Helpers ────────────────────────────────────────────────────────────────
/** Derives a human-readable tracking ID from the UUID string returned by backend.
@@ -34,6 +53,85 @@ function deriveTrackingId(id: string): string {
return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase();
}
+/**
+ * Валидирует одно поле против ограничений схемы. Возвращает текст ошибки
+ * (понятный пользователю) или пустую строку, если поле валидно.
+ *
+ * Экспортится для unit-тестов и потенциального переиспользования.
+ */
+export function validatePilotField(name: FieldName, value: string): string {
+ const trimmed = value.trim();
+ switch (name) {
+ case "name":
+ if (!trimmed) return "Укажите имя";
+ if (trimmed.length < PILOT_LIMITS.name.min)
+ return `Минимум ${PILOT_LIMITS.name.min} символа`;
+ if (trimmed.length > PILOT_LIMITS.name.max)
+ return `Максимум ${PILOT_LIMITS.name.max} символов`;
+ return "";
+ case "phone":
+ if (trimmed.length > PILOT_LIMITS.phone.max)
+ return `Максимум ${PILOT_LIMITS.phone.max} символов`;
+ return "";
+ case "email":
+ if (!trimmed) return "";
+ if (trimmed.length > PILOT_LIMITS.email.max)
+ return `Максимум ${PILOT_LIMITS.email.max} символов`;
+ if (!PILOT_EMAIL_RE.test(trimmed)) return "Некорректный email";
+ return "";
+ case "company":
+ if (trimmed.length > PILOT_LIMITS.company.max)
+ return `Максимум ${PILOT_LIMITS.company.max} символов`;
+ return "";
+ case "message":
+ if (trimmed.length > PILOT_LIMITS.message.max)
+ return `Максимум ${PILOT_LIMITS.message.max} символов`;
+ return "";
+ default:
+ return "";
+ }
+}
+
+/** Валидирует все поля разом. Пустой объект — форма валидна. */
+export function validatePilotForm(form: FormState): FieldErrors {
+ const errors: FieldErrors = {};
+ (Object.keys(form) as FieldName[]).forEach((key) => {
+ const err = validatePilotField(key, form[key]);
+ if (err) errors[key] = err;
+ });
+ return errors;
+}
+
+/**
+ * Дружелюбное сообщение об ошибке отправки.
+ * apiFetch (`lib/api.ts:39`) кладёт сырой response.text() в Error.message
+ * как «API error 422: {"detail":[...]}». На публичной лид-форме сырой JSON
+ * показывать нельзя — поэтому мапим коды в человеческий текст. На 422 это
+ * fallback при рассинхроне фронт/бэк валидации; клиент-сайд должен ловить
+ * это до отправки.
+ */
+export function formatSubmitError(err: unknown): string {
+ const fallback = "Не удалось отправить заявку. Попробуйте позже.";
+ if (!(err instanceof Error)) return fallback;
+ const match = err.message.match(/^API error (\d{3}):\s*(.*)$/s);
+ if (!match) return fallback;
+ const status = Number(match[1]);
+ const body = match[2];
+ if (status === 422) {
+ return "Проверьте корректность заполнения полей.";
+ }
+ if (status >= 500) {
+ return "Сервер недоступен. Попробуйте позже.";
+ }
+ try {
+ const parsed = JSON.parse(body) as { detail?: unknown };
+ if (typeof parsed.detail === "string") return parsed.detail;
+ } catch {
+ // not JSON — fall through to fallback
+ }
+ return fallback;
+}
+
// ── Component ──────────────────────────────────────────────────────────────
export function PilotRequestModal({ open, onClose }: Props) {
@@ -44,28 +142,48 @@ export function PilotRequestModal({ open, onClose }: Props) {
company: "",
message: "",
});
- const [emailError, setEmailError] = useState("");
+ const [errors, setErrors] = useState({});
const [trackingId, setTrackingId] = useState(null);
const mutation = useSubmitPilotRequest();
- const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
-
function handleChange(
e: React.ChangeEvent,
) {
const { name, value } = e.target;
- setForm((prev) => ({ ...prev, [name]: value }));
- if (name === "email") {
- setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : "");
- }
+ const fieldName = name as FieldName;
+ setForm((prev) => ({ ...prev, [fieldName]: value }));
+ // Live re-validate только для полей где ошибка уже показана (клиент сразу
+ // видит, что фикс «помог»), плюс email — исторически на онлайн-проверке.
+ setErrors((prev) => {
+ if (fieldName !== "email" && !prev[fieldName]) return prev;
+ const err = validatePilotField(fieldName, value);
+ const next: FieldErrors = { ...prev };
+ if (err) next[fieldName] = err;
+ else delete next[fieldName];
+ return next;
+ });
+ }
+
+ function handleBlur(
+ e: React.FocusEvent,
+ ) {
+ const { name, value } = e.target;
+ const fieldName = name as FieldName;
+ const err = validatePilotField(fieldName, value);
+ setErrors((prev) => {
+ const next: FieldErrors = { ...prev };
+ if (err) next[fieldName] = err;
+ else delete next[fieldName];
+ return next;
+ });
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
- if (!form.name.trim()) return;
- if (form.email && !EMAIL_RE.test(form.email)) {
- setEmailError("Некорректный email");
+ const validation = validatePilotForm(form);
+ if (Object.keys(validation).length > 0) {
+ setErrors(validation);
return;
}
@@ -76,7 +194,7 @@ export function PilotRequestModal({ open, onClose }: Props) {
...(form.email.trim() ? { email: form.email.trim() } : {}),
...(form.company.trim() ? { company: form.company.trim() } : {}),
...(form.message.trim()
- ? { message: form.message.trim().slice(0, 2000) }
+ ? { message: form.message.trim().slice(0, PILOT_LIMITS.message.max) }
: {}),
};
@@ -89,12 +207,15 @@ export function PilotRequestModal({ open, onClose }: Props) {
function handleClose() {
setForm({ name: "", phone: "", email: "", company: "", message: "" });
- setEmailError("");
+ setErrors({});
setTrackingId(null);
mutation.reset();
onClose();
}
+ const hasBlockingErrors = Object.keys(validatePilotForm(form)).length > 0;
+ const submitDisabled = mutation.isPending || hasBlockingErrors;
+
return (
{/* Header */}
@@ -191,10 +312,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
name="name"
value={form.name}
onChange={handleChange}
+ onBlur={handleBlur}
required
+ minLength={PILOT_LIMITS.name.min}
+ maxLength={PILOT_LIMITS.name.max}
placeholder="Алексей Кириллов"
- style={inputStyle}
+ aria-invalid={errors.name ? "true" : "false"}
+ aria-describedby={errors.name ? "pilot-name-error" : undefined}
+ style={{
+ ...inputStyle,
+ borderColor: errors.name
+ ? "var(--danger)"
+ : "var(--border-strong)",
+ }}
/>
+ {errors.name ? (
+
+ {errors.name}
+
+ ) : null}
{/* Company */}
@@ -207,9 +343,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
name="company"
value={form.company}
onChange={handleChange}
+ onBlur={handleBlur}
+ maxLength={PILOT_LIMITS.company.max}
placeholder="PRINZIP"
- style={inputStyle}
+ aria-invalid={errors.company ? "true" : "false"}
+ aria-describedby={
+ errors.company ? "pilot-company-error" : undefined
+ }
+ style={{
+ ...inputStyle,
+ borderColor: errors.company
+ ? "var(--danger)"
+ : "var(--border-strong)",
+ }}
/>
+ {errors.company ? (
+
+ {errors.company}
+
+ ) : null}
{/* Phone */}
@@ -223,9 +375,25 @@ export function PilotRequestModal({ open, onClose }: Props) {
type="tel"
value={form.phone}
onChange={handleChange}
+ onBlur={handleBlur}
+ maxLength={PILOT_LIMITS.phone.max}
placeholder="+7 900 000-00-00"
- style={inputStyle}
+ aria-invalid={errors.phone ? "true" : "false"}
+ aria-describedby={
+ errors.phone ? "pilot-phone-error" : undefined
+ }
+ style={{
+ ...inputStyle,
+ borderColor: errors.phone
+ ? "var(--danger)"
+ : "var(--border-strong)",
+ }}
/>
+ {errors.phone ? (
+
+ {errors.phone}
+
+ ) : null}
{/* Email */}
@@ -239,24 +407,23 @@ export function PilotRequestModal({ open, onClose }: Props) {
type="email"
value={form.email}
onChange={handleChange}
+ onBlur={handleBlur}
+ maxLength={PILOT_LIMITS.email.max}
placeholder="alex@prinzip.ru"
+ aria-invalid={errors.email ? "true" : "false"}
+ aria-describedby={
+ errors.email ? "pilot-email-error" : undefined
+ }
style={{
...inputStyle,
- borderColor: emailError
+ borderColor: errors.email
? "var(--danger)"
: "var(--border-strong)",
}}
/>
- {emailError ? (
-
- {emailError}
+ {errors.email ? (
+
+ {errors.email}
) : null}
@@ -271,14 +438,22 @@ export function PilotRequestModal({ open, onClose }: Props) {
name="message"
value={form.message}
onChange={handleChange}
+ onBlur={handleBlur}
placeholder="Расскажите о вашем проекте или вопросе..."
rows={4}
- maxLength={2000}
+ maxLength={PILOT_LIMITS.message.max}
+ aria-invalid={errors.message ? "true" : "false"}
+ aria-describedby={
+ errors.message ? "pilot-message-error" : undefined
+ }
style={{
...inputStyle,
resize: "vertical",
minHeight: 96,
fontFamily: "inherit",
+ borderColor: errors.message
+ ? "var(--danger)"
+ : "var(--border-strong)",
}}
/>
- {form.message.length} / 2000
+ {form.message.length} / {PILOT_LIMITS.message.max}
+ {errors.message ? (
+
+ {errors.message}
+
+ ) : null}
{/* Error */}
{mutation.isError ? (
- {mutation.error instanceof Error
- ? mutation.error.message
- : "Ошибка отправки. Попробуйте позже."}
+ {formatSubmitError(mutation.error)}
) : null}
{/* Submit */}
|