fix(frontend): нейтральный fallback для нераспознанных gate/velocity enum-значений (#2445-B2/B3)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m3s
CI / openapi-codegen-check (pull_request) Successful in 2m31s

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
This commit is contained in:
bot-backend 2026-07-06 00:03:13 +05:00
parent 82e0b8b0a3
commit 9f00fe8819
4 changed files with 114 additions and 4 deletions

View file

@ -51,6 +51,17 @@ const COLOR_BY_LABEL: Record<GateVerdictLabel, VerdictColor> = {
},
};
// Нейтральный 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<string, string> = {
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;

View file

@ -34,6 +34,10 @@ const CONFIDENCE_LABEL: Record<Velocity["confidence"], string> = {
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}
</span>
</div>
</div>

View file

@ -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> = {}): 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(
<GateVerdictBanner verdict={makeVerdict({ verdict_label: "Можно" })} />,
);
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(<GateVerdictBanner verdict={drifted} />)).not.toThrow();
expect(screen.getByText(/МКД: НовыйВердикт/)).toBeInTheDocument();
});
});

View file

@ -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> = {}): 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(<VelocityBlock velocity={makeVelocity({ confidence: "high" })} />);
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(<VelocityBlock velocity={drifted} />)).not.toThrow();
// Neutral "—" fallback label instead of a crash or an invented semantic default.
expect(screen.getByText("—")).toBeInTheDocument();
});
});