From 9f00fe88191c6f1f3831efe6936a2eba44b91d9f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 6 Jul 2026 00:03:13 +0500 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=D0=BD=D0=B5=D0=B9=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20fallback=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BD=D0=B5=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20gate/velocity=20enum-=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B9=20(#2445-B2/B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GateVerdictBanner и VelocityBlock искали backend-enum строки (verdict_label, confidence) в object-literal картах без runtime-guard. Неожиданное значение (schema drift / partial deploy) → lookup undefined → краш компонента на следующем .bg/.fg/.Icon доступе. Добавлен ?? нейтральный fallback (серый + HelpCircle / «—»), паттерн ParcelDrawer STATUS_COLORS ?? '#73767E'. Refs #2445 --- .../site-finder/GateVerdictBanner.tsx | 13 ++++- .../components/site-finder/VelocityBlock.tsx | 12 +++-- .../__tests__/GateVerdictBanner.test.tsx | 46 ++++++++++++++++++ .../__tests__/VelocityBlock.test.tsx | 47 +++++++++++++++++++ 4 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/site-finder/__tests__/GateVerdictBanner.test.tsx create mode 100644 frontend/src/components/site-finder/__tests__/VelocityBlock.test.tsx diff --git a/frontend/src/components/site-finder/GateVerdictBanner.tsx b/frontend/src/components/site-finder/GateVerdictBanner.tsx index 862e7967..08d377f9 100644 --- a/frontend/src/components/site-finder/GateVerdictBanner.tsx +++ b/frontend/src/components/site-finder/GateVerdictBanner.tsx @@ -51,6 +51,17 @@ const COLOR_BY_LABEL: Record = { }, }; +// Нейтральный fallback для нераспознанного verdict_label (schema drift / partial +// deploy — backend отдал значение вне TS-union). Не додумываем семантику +// (никаких "зелёный = можно") — тот же серый нейтральный стиль, что и +// "Нужна проверка". +const NEUTRAL_COLOR: VerdictColor = { + bg: "#f9fafb", + border: "#9ca3af", + Icon: HelpCircle, + iconBg: "#6b7280", +}; + const SOURCE_LABEL: Record = { nspd_dump: "данные NSPD актуальны", nspd_dump_partial: "данные NSPD устарели (>180 дней)", @@ -139,7 +150,7 @@ export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) { if (!verdict) return null; - const color = COLOR_BY_LABEL[verdict.verdict_label]; + const color = COLOR_BY_LABEL[verdict.verdict_label] ?? NEUTRAL_COLOR; const Icon = color.Icon; const hasDetails = verdict.blockers.length > 0 || verdict.warnings.length > 0; diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx index 09b4c323..6b1526e1 100644 --- a/frontend/src/components/site-finder/VelocityBlock.tsx +++ b/frontend/src/components/site-finder/VelocityBlock.tsx @@ -34,6 +34,10 @@ const CONFIDENCE_LABEL: Record = { low: "Низкая", }; +// Нейтральный fallback для нераспознанного velocity.confidence (schema drift / +// partial deploy — backend отдал значение вне TS-union "high"|"medium"|"low"). +const NEUTRAL_CONFIDENCE_COLOR = { bg: "#f3f4f6", fg: "#73767E" }; + function formatPercent(score: number): string { return `${Math.round(score * 100)}%`; } @@ -110,7 +114,9 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { const dataAvailable = velocity.velocity_data_available !== false; const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback"; const isObjectiveSource = velocity.velocity_source === "objective"; - const confColor = CONFIDENCE_COLOR[velocity.confidence]; + const confColor = + CONFIDENCE_COLOR[velocity.confidence] ?? NEUTRAL_CONFIDENCE_COLOR; + const confLabel = CONFIDENCE_LABEL[velocity.confidence] ?? "—"; const scorePct = formatPercent(velocity.velocity_score); const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm; const band = paceBand(velocity.velocity_score); @@ -181,9 +187,9 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { fontSize: 11, fontWeight: 600, }} - title={`Уверенность: ${CONFIDENCE_LABEL[velocity.confidence]}`} + title={`Уверенность: ${confLabel}`} > - {CONFIDENCE_LABEL[velocity.confidence]} + {confLabel} diff --git a/frontend/src/components/site-finder/__tests__/GateVerdictBanner.test.tsx b/frontend/src/components/site-finder/__tests__/GateVerdictBanner.test.tsx new file mode 100644 index 00000000..5c21e15e --- /dev/null +++ b/frontend/src/components/site-finder/__tests__/GateVerdictBanner.test.tsx @@ -0,0 +1,46 @@ +/** + * B2 (#2445) — COLOR_BY_LABEL[verdict.verdict_label] had no fallback: an + * unrecognized verdict_label (schema drift / partial deploy — backend ships a + * new enum value before frontend redeploys) made the lookup `undefined`, and + * the next `.Icon`/`.bg` access threw, crashing the whole banner render. + * + * These tests pin the defensive fallback (mirrors ParcelDrawer's + * `STATUS_COLORS[...] ?? '#73767E'` pattern): an unknown verdict_label must + * render a neutral style, not throw. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { GateVerdictBanner } from "../GateVerdictBanner"; +import type { GateVerdict, GateVerdictLabel } from "@/types/site-finder"; + +function makeVerdict(overrides: Partial = {}): GateVerdict { + return { + can_build_mkd: true, + verdict_label: "Можно", + blockers: [], + warnings: [], + checks_performed: [], + source: "nspd_dump", + ...overrides, + }; +} + +describe("GateVerdictBanner", () => { + it("renders known verdict_label without throwing", () => { + render( + , + ); + expect(screen.getByText(/МКД: Можно/)).toBeInTheDocument(); + }); + + it("unrecognized verdict_label renders a neutral fallback instead of throwing", () => { + const drifted = makeVerdict({ + // Simulates schema drift: backend sends a value outside the closed + // GateVerdictLabel union (cast through unknown, not `any`). + verdict_label: "НовыйВердикт" as unknown as GateVerdictLabel, + }); + expect(() => render()).not.toThrow(); + expect(screen.getByText(/МКД: НовыйВердикт/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/site-finder/__tests__/VelocityBlock.test.tsx b/frontend/src/components/site-finder/__tests__/VelocityBlock.test.tsx new file mode 100644 index 00000000..3702ae8f --- /dev/null +++ b/frontend/src/components/site-finder/__tests__/VelocityBlock.test.tsx @@ -0,0 +1,47 @@ +/** + * B3 (#2445) — CONFIDENCE_COLOR[velocity.confidence] / CONFIDENCE_LABEL[...] + * had no fallback: an unrecognized `confidence` (schema drift / partial + * deploy) made the lookup `undefined`, and the next `.bg`/`.fg` access threw, + * crashing the block render. + * + * These tests pin the defensive fallback (mirrors ParcelDrawer's + * `STATUS_COLORS[...] ?? '#73767E'` pattern): an unknown confidence must + * render a neutral color + a neutral "—" label, not throw. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { VelocityBlock } from "../VelocityBlock"; +import type { Velocity } from "@/types/site-finder"; + +function makeVelocity(overrides: Partial = {}): Velocity { + return { + competitors_count: 3, + monthly_velocity_sqm: 500, + ekb_median_sqm: 400, + velocity_score: 0.5, + confidence: "high", + months_observed: 6, + period: { start: "2026-01", end: "2026-06" }, + sample_competitors: [], + ...overrides, + }; +} + +describe("VelocityBlock", () => { + it("renders known confidence without throwing", () => { + render(); + expect(screen.getByText("Высокая")).toBeInTheDocument(); + }); + + it("unrecognized confidence renders neutral fallback color + label, does not throw", () => { + const drifted = makeVelocity({ + // Simulates schema drift: backend sends a value outside the closed + // "high"|"medium"|"low" union (cast through unknown, not `any`). + confidence: "unknown_value" as unknown as Velocity["confidence"], + }); + expect(() => render()).not.toThrow(); + // Neutral "—" fallback label instead of a crash or an invented semantic default. + expect(screen.getByText("—")).toBeInTheDocument(); + }); +});