From a64119ad8f3b75dda3878e4d490a89d56b91d790 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 19 Aug 2026 21:05:51 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=BF=D1=83=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=81=D0=BB=D0=BE=D0=B9=20=D1=80=D0=B8=D1=81=D0=BA-?= =?UTF-8?q?=D0=B7=D0=BE=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=91=D1=82=20=D0=BE=D0=B7=D0=BD=D0=B0=D1=87=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=C2=AB=D1=80=D0=B8=D1=81=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=82=C2=BB=20(#2934)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд ВСЕГДА присылает nspd_risk_zones — пустым списком, если ничего нет. Условие показа в LandTab (`!== undefined`) поэтому истинно всегда, а ветка zones.length === 0 рисовала ЗЕЛЁНУЮ плашку «Риски не обнаружены» с текстом «Риск-зоны НСПД на участке не выявлены». Пустой список неоднозначен: «слой опрошен, на участке чисто» и «слой не дал ничего вообще» выглядят одинаково. Замер на проде 19.08: risks_count = 0 у ВСЕХ 669 дампов (для сравнения zouit_count > 0 у 581) — одиннадцать слоёв природного риска ни разу не вернули ни одного объекта. То есть плашка показывалась всегда и всегда без основания. Провёл наружу risks_count из строки дампа (он уже вычитывался, в ответ не попадал). Три состояния вместо двух: risks_count > 0 -> «Риски не обнаружены» — законно, слой отработал risks_count == 0 -> «Не проверено», нейтральным серым risks_count is None -> «Не проверено» (дампа нет; None, а не 0, потому что ноль означал бы «спросили и не нашли») layers_fetched наружу НЕ отдаю: append в nspd_client.py:956 стоит ДО запроса, поэтому как признак «слой опрошен» он лжёт. Записано в коде, чтобы следующий читатель не подхватил его как готовый признак. Заодно счётчик слоя в тумблере карты: при пустом слое «н/д» вместо «0». Ноль в одном ряду с измеренными «Конкуренты 14» и «Красные линии 2» читался как измеренный ноль. Тесты двусторонние: на origin/main обе проверки падают с «Unable to find an element with the text: Не проверено»; два контроля (слой отработал → зелёная плашка законна; зоны найдены → карточки) зелёные с обеих сторон. Большой дифф CpLayerControlPanel.tsx (267 строк при правке в две) — работа pre-commit prettier: файл НЕ проходил prettier уже на origin/main (проверено отдельно), хук нормализовал его целиком, когда я его тронул. Тот же класс, что расхождение ruff в #2864: хук гоняется только по staged-файлам, поэтому файлы, которых давно не касались, остаются нестандартизованными. tsc --noEmit rc=0 · vitest 35 файлов / 275 тестов rc=0 (перепрогнано ПОСЛЕ хуков) pytest tests/test_quarter_dump_lookup.py: 23 passed pytest tests/api/v1 (контракт ответа): 354 passed, 1 skipped, rc=0 Refs #2934 --- .../site_finder/quarter_dump_lookup.py | 17 ++ backend/tests/test_quarter_dump_lookup.py | 35 ++- .../site-finder/CpLayerControlPanel.tsx | 277 +++++++++--------- .../src/components/site-finder/LandTab.tsx | 5 +- .../site-finder/NspdRiskZonesBlock.tsx | 53 +++- .../src/components/site-finder/SiteMap.tsx | 7 +- .../__tests__/NspdRiskZonesBlock.test.tsx | 53 ++++ frontend/src/types/nspd.ts | 10 + 8 files changed, 318 insertions(+), 139 deletions(-) create mode 100644 frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index bb853221..bc25a927 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -160,6 +160,9 @@ def make_empty_result( "harvest_triggered": harvest_triggered, "total_features": total_features, "harvest_eta_seconds": harvest_eta_seconds, + # Дампа нет — про риск-слои не известно ничего. None, а не 0: + # ноль означал бы «спросили и не нашли». + "risks_count": None, }, } @@ -297,6 +300,20 @@ def get_quarter_dump_data( "harvest_triggered": False, "total_features": total_features, "harvest_eta_seconds": None, + # #2934: сколько объектов риск-слоёв лежит в дампе КВАРТАЛА. Нужен, чтобы + # отличить «слой опрошен, на участке чисто» от «слой не дал ничего вообще». + # Пустой `nspd_risk_zones` сам по себе этих случаев не различает, и фронт + # рисовал по нему зелёное «Риски не обнаружены». + # + # Замер 19.08: risks_count = 0 у ВСЕХ 669 дампов (для сравнения zouit_count > 0 + # у 581), то есть сегодня признак всегда говорит «не подтверждено». Это и есть + # правда: одиннадцать слоёв природного риска ни разу не вернули ни одного + # объекта. + # + # Намеренно НЕ отдаём layers_fetched: `layers_fetched.append(...)` в + # nspd_client.py:956 стоит ДО запроса, поэтому как признак «слой опрошен» он + # лжёт. Пока append не перенесён после успешного ответа, наружу его нельзя. + "risks_count": risks_count, } if parcel_wkt is None: diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py index 7563d8a9..15d1fdc1 100644 --- a/backend/tests/test_quarter_dump_lookup.py +++ b/backend/tests/test_quarter_dump_lookup.py @@ -399,8 +399,7 @@ def _make_zouit_row( def test_cad_zouit_overlaps_includes_geom_geojson() -> None: """#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON).""" geojson_str = ( - '{"type":"Polygon","coordinates":' - "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}" + '{"type":"Polygon","coordinates":' "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}" ) rows = [ _make_zouit_row( @@ -549,3 +548,35 @@ def test_early_exit_partial_counts() -> None: assert result["nspd_zoning"]["zone_code"] == "66:41-7.2" assert result["nspd_zouit_overlaps"] == [] assert result["nspd_engineering_nearby"] == [] + + +# ── #2934: признак покрытия риск-слоёв доезжает до ответа ────────────────────── +# +# Пустой `nspd_risk_zones` неоднозначен: «слой опрошен, на участке чисто» и «слой +# не дал ничего вообще» выглядят одинаково. Фронт рисовал по нему зелёную плашку +# «Риски не обнаружены», то есть утверждал результат проверки, которой не было. +# +# Замер на проде 19.08.2026: risks_count = 0 у ВСЕХ 669 дампов (для сравнения +# zouit_count > 0 у 581). Одиннадцать слоёв природного риска ни разу не вернули +# ни одного объекта. + + +def test_empty_result_reports_unknown_risk_coverage() -> None: + """Дампа нет → про риск-слои неизвестно НИЧЕГО. + + Именно None, а не 0: ноль означал бы «спросили и не нашли». + """ + result = make_empty_result() + + assert ( + "risks_count" in result["nspd_dump"] + ), "признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»" + assert result["nspd_dump"]["risks_count"] is None + + +def test_empty_result_stays_independent_between_calls() -> None: + """Контроль: новое поле не сломало mutation-safety (см. шапку файла).""" + a = make_empty_result() + a["nspd_dump"]["risks_count"] = 42 + + assert make_empty_result()["nspd_dump"]["risks_count"] is None diff --git a/frontend/src/components/site-finder/CpLayerControlPanel.tsx b/frontend/src/components/site-finder/CpLayerControlPanel.tsx index e461ab9b..a39afb32 100644 --- a/frontend/src/components/site-finder/CpLayerControlPanel.tsx +++ b/frontend/src/components/site-finder/CpLayerControlPanel.tsx @@ -20,7 +20,11 @@ export interface MarketLayerToggle { key: MarketLayerKey; label: string; color: string; - count: number; + /** + * Число объектов слоя. `null` — слой ничего не отдал и «0» рядом с измеренными + * счётчиками соседей читалось бы как измеренный ноль (#2934); показываем «н/д». + */ + count: number | null; } interface Props { @@ -96,158 +100,161 @@ export function CpLayerControlPanel({ {/* ── Точки подключения (CP) ─────────────────────────────── */} {data && ( <> - {/* No dump */} - {!data.dump_available && ( -
- Дамп квартала не загружен — 0 точек подключения -
- )} + {/* No dump */} + {!data.dump_available && ( +
+ Дамп квартала не загружен — 0 точек подключения +
+ )} - {/* Empty state */} - {data.dump_available && totalCount === 0 && ( -
- 0 точек подключения в этом квартале -
- )} + {/* Empty state */} + {data.dump_available && totalCount === 0 && ( +
+ 0 точек подключения в этом квартале +
+ )} - {/* Toggle-all */} - {totalCount > 0 && ( - - )} - - {/* Per-category */} -
- {CP_ALL_CATEGORIES.map((cat) => { - const structs = grouped.get(cat) ?? []; - const style = CP_CATEGORY_STYLES[cat]; - if (structs.length === 0) return null; - const active = visibleCategories.has(cat); - return ( + {/* Toggle-all */} + {totalCount > 0 && ( - ); - })} -
+ )} - {/* Summary */} - {data.dump_available && totalCount > 0 && ( -
- {data.summary.nearest_structure_distance_m !== null && ( - + {CP_ALL_CATEGORIES.map((cat) => { + const structs = grouped.get(cat) ?? []; + const style = CP_CATEGORY_STYLES[cat]; + if (structs.length === 0) return null; + const active = visibleCategories.has(cat); + return ( + + ); + })} +
+ + {/* Summary */} + {data.dump_available && totalCount > 0 && ( +
- Ближайший:{" "} - {Math.round(data.summary.nearest_structure_distance_m)} м - + {data.summary.nearest_structure_distance_m !== null && ( + + Ближайший:{" "} + {Math.round(data.summary.nearest_structure_distance_m)} м + + )} + {data.summary.in_protection_zone && ( + + В охранной зоне + + )} + {data.summary.protection_zones_intersecting > 0 && + !data.summary.in_protection_zone && ( + + Охранных зон:{" "} + {data.summary.protection_zones_intersecting} + + )} +
)} - {data.summary.in_protection_zone && ( - - В охранной зоне - - )} - {data.summary.protection_zones_intersecting > 0 && - !data.summary.in_protection_zone && ( - - Охранных зон: {data.summary.protection_zones_intersecting} - - )} - - )} )} @@ -308,7 +315,9 @@ export function CpLayerControlPanel({ }} /> {ml.label} - {ml.count} + + {ml.count ?? "н/д"} + ); })} diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 27cae5de..fd20992f 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -107,7 +107,10 @@ export function LandTab({ data }: Props) { Природные риски (НСПД) - + )} diff --git a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx index 2c45c1bf..0dfdc058 100644 --- a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx +++ b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx @@ -5,6 +5,15 @@ import type { RiskZone } from "@/types/nspd"; interface Props { riskZones: RiskZone[] | null | undefined; parcelAreaSqm?: number | null; + /** + * #2934: сколько объектов риск-слоёв в дампе квартала. Пустой `riskZones` без + * этого признака неоднозначен, и блок рисовал по нему зелёное «Риски не + * обнаружены» — то есть утверждал результат проверки, которой не было. + * + * `undefined`/`null` — дампа нет; `0` — слой не дал объектов на весь квартал; + * `>0` — слой отработал, и отсутствие пересечений с участком уже настоящий факт. + */ + quarterRisksCount?: number | null; } // Severity mapping: layer key suffix → severity tier @@ -45,10 +54,52 @@ function formatArea(sqm: number | null): string | null { return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; } -export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) { +export function NspdRiskZonesBlock({ + riskZones, + parcelAreaSqm, + quarterRisksCount, +}: Props) { const zones = riskZones ?? []; if (zones.length === 0) { + // Зелёный success-стиль оставляем ТОЛЬКО когда слой реально что-то отдал по + // кварталу: тогда «на участке ничего не пересекается» — измеренный факт. + // Замер 19.08: risks_count = 0 у всех 669 дампов, слои risk_* не вернули ни + // одного объекта, — то есть сегодня показывается нейтральная ветка. + const layerAnswered = (quarterRisksCount ?? 0) > 0; + if (!layerAnswered) { + return ( +
+ + Не проверено + + + Слои природного риска НСПД не вернули объектов по этому кварталу — + отсутствие риска не подтверждено + +
+ ); + } + return (
0 ? riskZoneList.length : null, }, { key: "opportunity", diff --git a/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx b/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx new file mode 100644 index 00000000..9f4bfd14 --- /dev/null +++ b/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx @@ -0,0 +1,53 @@ +/** + * #2934: блок природных рисков не должен утверждать «рисков нет», когда слой + * ничего не отдал. + * + * Бэкенд ВСЕГДА присылает `nspd_risk_zones` (пустым списком), поэтому условие + * показа в LandTab (`!== undefined`) истинно всегда, а ветка `zones.length === 0` + * рисовала зелёную плашку «Риски не обнаружены» с текстом «Риск-зоны НСПД на + * участке не выявлены». + * + * Замер на проде 19.08.2026: risks_count = 0 у всех 669 дампов, слои risk_* НСПД + * не вернули ни одного объекта — то есть эта плашка показывалась всегда и всегда + * без основания. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { NspdRiskZonesBlock } from "../NspdRiskZonesBlock"; +import type { RiskZone } from "@/types/nspd"; + +const ZONE: RiskZone = { + layer: "risk_flooding", + subtype: "затопление", + geom_wkt: null, + intersection_area_sqm: 1234, +} as unknown as RiskZone; + +describe("NspdRiskZonesBlock (#2934)", () => { + it("слой не дал объектов по кварталу → «не проверено», а не «рисков нет»", () => { + render(); + + expect(screen.getByText("Не проверено")).toBeInTheDocument(); + expect(screen.queryByText("Риски не обнаружены")).not.toBeInTheDocument(); + }); + + it("дампа нет → тоже «не проверено» (признак null)", () => { + render(); + + expect(screen.getByText("Не проверено")).toBeInTheDocument(); + }); + + it("слой отработал по кварталу, но участок не пересекает → «рисков не обнаружено» законно", () => { + render(); + + expect(screen.getByText("Риски не обнаружены")).toBeInTheDocument(); + }); + + it("контроль: найденные зоны рисуются как прежде", () => { + render(); + + expect(screen.queryByText("Не проверено")).not.toBeInTheDocument(); + expect(screen.queryByText("Риски не обнаружены")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index dd772f6f..a1190263 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -65,6 +65,16 @@ export interface NspdDumpMeta { // auto-stop re-poll после ETA*1.5. null когда dump уже available или harvest // не запущен (lock уже взят другим запросом). harvest_eta_seconds?: number | null; + /** + * #2934: сколько объектов риск-слоёв лежит в дампе КВАРТАЛА. + * + * Отличает «слой опрошен, на участке чисто» (>0) от «слой не дал ничего вообще» + * (0). Пустой `nspd_risk_zones` сам по себе этих случаев не различает, а UI + * рисовал по нему зелёное «Риски не обнаружены». + * + * `null` — дампа нет, про риск-слои не известно ничего. + */ + risks_count?: number | null; } // Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field