fix(frontend): нейтральный fallback для gate/velocity enum-lookup (#2445-B2/B3) #2451
4 changed files with 114 additions and 4 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue