From abe76ca9809904172327fd578d55c7d983a03d36 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 19:35:56 +0300 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=B5=D1=89?= =?UTF-8?q?=D1=91=20=D0=B2=204=20enum-lookup-=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up #2451 (эпик #2445, B2/B3): тот же класс — object-literal lookup по значению, пришедшему из API, без runtime-guard. Значение вне закрытого union'а (schema drift / частичный деплой) даёт `undefined`, и дальше по-разному плохо: - `BestLayoutsBlock` (DataQualityCard) — обращение `.fg`/`.bg` к undefined РОНЯЕТ рендер всей карточки качества данных; - `ForecastChart.confidenceByHorizon` — подпись уверенности для горизонта пропадает МОЛЧА, при том что прогноз для него есть; - `compare/CompareTable` и `ptica/compare/PticaCompare` — рядом с процентом остаётся «73% · » и обрыв: число выглядит недосказанным, а не неизвестным. Везде нейтральный fallback по образцу VelocityBlock/ParcelDrawer: серый + «—», намеренно НЕ семантический цвет — неизвестное качество не должно читаться ни как хорошее, ни как плохое. Фон бейджа взят из существующего `--border-soft`: список токенов закрыт (ui-tokens.md — «НЕ выдумывать новые»), нейтральной заливки в нём нет. `DataQualityCard`, `confidenceByHorizon` и `METRIC_ROWS` (ptica) экспортированы ради тестов — тем же приёмом, что уже применён к `isDeficitDegenerate`. Тесты: 12 новых в 4 файлах. Проверены на слом — снял все четыре fallback'а, упало ровно 5 проверок out-of-union, остальные 7 (известные значения, отсутствующие данные) остались зелёными. `vitest run src/components/site-finder` — 145 passed, `tsc --noEmit` чисто. Closes #2452 Refs #2445, #2451 --- .../site-finder/BestLayoutsBlock.tsx | 24 ++++++- .../BestLayoutsBlock.dataQuality.test.tsx | 63 +++++++++++++++++++ .../site-finder/analysis/ForecastChart.tsx | 8 ++- .../forecastChart-confidence-fallback.test.ts | 56 +++++++++++++++++ .../site-finder/compare/CompareTable.tsx | 5 +- .../CompareTable.confidence.test.tsx | 44 +++++++++++++ .../ptica/compare/PticaCompare.tsx | 9 ++- .../PticaCompare.confidence.test.tsx | 49 +++++++++++++++ 8 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/site-finder/__tests__/BestLayoutsBlock.dataQuality.test.tsx create mode 100644 frontend/src/components/site-finder/analysis/__tests__/forecastChart-confidence-fallback.test.ts create mode 100644 frontend/src/components/site-finder/compare/__tests__/CompareTable.confidence.test.tsx create mode 100644 frontend/src/components/site-finder/ptica/compare/__tests__/PticaCompare.confidence.test.tsx diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index 620bd97d..6474aa62 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -38,6 +38,20 @@ const CONFIDENCE_STYLES: Record< }, }; +// Нейтральный fallback (#2452, тот же паттерн что VelocityBlock/ParcelDrawer): +// значение вне закрытого union'а (schema drift / частичный деплой) даёт +// `undefined`, и следующее обращение `.fg`/`.bg` роняет рендер всего блока. +// Серый + «—» — намеренно НЕ семантический цвет: неизвестное качество данных +// не должно выглядеть ни хорошим, ни плохим. +// Фон взят из --border-soft, а не из нового токена: список токенов закрыт +// (ui-tokens.md — «НЕ выдумывать новые»), а нейтральной заливки для бейджа в +// нём нет; --border-soft — единственный существующий нейтральный светло-серый. +const NEUTRAL_CONFIDENCE_STYLE = { + fg: "var(--fg-tertiary, #73767E)", + bg: "var(--border-soft, #EEF0F3)", + label: "—", +}; + const TIME_WINDOW_LABELS: Record = { last_month: "Последний месяц", last_quarter: "Последний квартал", @@ -62,8 +76,14 @@ const ROOM_BUCKET_LABELS: Record = { // ── Sub-components ──────────────────────────────────────────────────────────── -function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) { - const conf = CONFIDENCE_STYLES[dq.confidence]; +// Экспорт ради теста (#2452): карточка читает `confidence` из API по ключу +// закрытого union'а, и её нужно проверять на out-of-union напрямую. +export function DataQualityCard({ + dq, +}: { + dq: BestLayoutsResponse["data_quality"]; +}) { + const conf = CONFIDENCE_STYLES[dq.confidence] ?? NEUTRAL_CONFIDENCE_STYLE; return ( // Inline-стили (НЕ Tailwind): flex/gap/bg-* не применяются на этой странице — // спаны текли inline и слипались («НизкоеПокрытие», #1953). Inline гарантирует diff --git a/frontend/src/components/site-finder/__tests__/BestLayoutsBlock.dataQuality.test.tsx b/frontend/src/components/site-finder/__tests__/BestLayoutsBlock.dataQuality.test.tsx new file mode 100644 index 00000000..b20fbeab --- /dev/null +++ b/frontend/src/components/site-finder/__tests__/BestLayoutsBlock.dataQuality.test.tsx @@ -0,0 +1,63 @@ +/** + * #2452 (follow-up #2445-B2/B3) — `CONFIDENCE_STYLES[dq.confidence]` не имел + * fallback'а: значение вне закрытого union'а (schema drift / частичный деплой) + * давало `undefined`, и следующее обращение `.fg`/`.bg` роняло рендер всей + * карточки качества данных. + * + * Тесты фиксируют нейтральный fallback (тот же паттерн, что VelocityBlock и + * ParcelDrawer): неизвестное качество рисуется серым и «—», а не падает и не + * выдаёт себя за хорошее или плохое. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DataQualityCard } from "../BestLayoutsBlock"; +import type { BestLayoutsResponse } from "@/types/best-layouts"; + +type DataQuality = BestLayoutsResponse["data_quality"]; + +function makeDq(overrides: Partial = {}): DataQuality { + return { + objects_with_velocity_data: 18, + objects_total_in_radius: 25, + raw_objects_total: 31, + groups_matched_by_core: 12, + groups_with_supply_only: 3, + velocity_coverage_pct: 72, + confidence: "high", + ...overrides, + }; +} + +describe("DataQualityCard — нейтральный fallback (#2452)", () => { + it("известное значение рисуется своей меткой", () => { + render(); + expect(screen.getByText("Низкое")).toBeInTheDocument(); + }); + + it("значение вне union'а не роняет рендер и даёт «—»", () => { + const drifted = makeDq({ + // Имитация schema drift: backend прислал значение вне + // "high"|"medium"|"low". Приведение через unknown, не через any. + confidence: "unverified" as unknown as DataQuality["confidence"], + }); + + expect(() => render()).not.toThrow(); + expect(screen.getByText("—")).toBeInTheDocument(); + // Покрытие продолжает рисоваться — падает только метка, не вся карточка. + expect(screen.getByText(/покрытие 72%/)).toBeInTheDocument(); + }); + + it("fallback не выглядит семантически (не зелёный и не красный)", () => { + const drifted = makeDq({ + confidence: "unverified" as unknown as DataQuality["confidence"], + }); + render(); + + const badge = screen.getByText("—"); + const style = badge.getAttribute("style") ?? ""; + expect(style).not.toContain("--success"); + expect(style).not.toContain("--danger"); + expect(style).not.toContain("--warn"); + }); +}); diff --git a/frontend/src/components/site-finder/analysis/ForecastChart.tsx b/frontend/src/components/site-finder/analysis/ForecastChart.tsx index 3cdff175..9addeb93 100644 --- a/frontend/src/components/site-finder/analysis/ForecastChart.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastChart.tsx @@ -167,7 +167,8 @@ function bandByHorizon( } /** Per-horizon confidence level for a forecast list (for the band tooltip). */ -function confidenceByHorizon( +// Экспорт ради теста (#2452), как и `isDeficitDegenerate` ниже. +export function confidenceByHorizon( forecasts: DemandSupplyForecast[], horizons: number[], ): (string | null)[] { @@ -175,7 +176,10 @@ function confidenceByHorizon( for (const f of forecasts) byH.set(f.horizon_months, f); return horizons.map((h) => { const f = byH.get(h); - return f == null ? null : CONFIDENCE_RU[f.confidence]; + // `?? "—"` (#2452): значение вне закрытого union'а (schema drift / + // частичный деплой) иначе вернёт undefined и подпись горизонта пропадёт + // молча — при том, что прогноз для него есть. Нейтральное «—» честнее. + return f == null ? null : (CONFIDENCE_RU[f.confidence] ?? "—"); }); } diff --git a/frontend/src/components/site-finder/analysis/__tests__/forecastChart-confidence-fallback.test.ts b/frontend/src/components/site-finder/analysis/__tests__/forecastChart-confidence-fallback.test.ts new file mode 100644 index 00000000..7640f55d --- /dev/null +++ b/frontend/src/components/site-finder/analysis/__tests__/forecastChart-confidence-fallback.test.ts @@ -0,0 +1,56 @@ +/** + * #2452 (follow-up #2445-B2/B3) — `CONFIDENCE_RU[f.confidence]` в + * `confidenceByHorizon` не имел fallback'а. В отличие от соседних мест этого + * класса, здесь промах не роняет рендер: он возвращает `undefined`, и подпись + * уверенности для горизонта пропадает молча — при том, что прогноз для него + * есть. Тихая потеря подписи хуже, чем «—»: на графике не видно, что данные + * были. + */ +import { describe, expect, it } from "vitest"; + +import { confidenceByHorizon } from "../ForecastChart"; +import type { DemandSupplyForecast } from "@/types/forecast"; + +function makeForecast( + horizon: number, + confidence: string, +): DemandSupplyForecast { + return { + horizon_months: horizon, + deficit_index: 0.2, + rate_future: 12.5, + confidence: confidence as DemandSupplyForecast["confidence"], + } as DemandSupplyForecast; +} + +describe("confidenceByHorizon — нейтральный fallback (#2452)", () => { + it("известные уровни переводятся", () => { + const out = confidenceByHorizon( + [makeForecast(12, "high"), makeForecast(24, "low")], + [12, 24], + ); + expect(out).toEqual(["высокая", "низкая"]); + }); + + it("горизонт без прогноза остаётся null (это не то же, что неизвестный уровень)", () => { + const out = confidenceByHorizon([makeForecast(12, "medium")], [12, 36]); + expect(out).toEqual(["средняя", null]); + }); + + it("значение вне union'а даёт «—», а не пропадает молча", () => { + // Имитация schema drift: backend прислал уровень вне "high"|"medium"|"low". + const out = confidenceByHorizon([makeForecast(12, "unverified")], [12]); + expect(out).toEqual(["—"]); + // Именно не undefined: undefined и null означали бы «прогноза нет», а он есть. + expect(out[0]).not.toBeUndefined(); + }); + + it("не бросает на смеси известных, неизвестных и отсутствующих", () => { + expect(() => + confidenceByHorizon( + [makeForecast(12, "high"), makeForecast(24, "странное")], + [12, 24, 36], + ), + ).not.toThrow(); + }); +}); diff --git a/frontend/src/components/site-finder/compare/CompareTable.tsx b/frontend/src/components/site-finder/compare/CompareTable.tsx index 7916c0de..91999d5d 100644 --- a/frontend/src/components/site-finder/compare/CompareTable.tsx +++ b/frontend/src/components/site-finder/compare/CompareTable.tsx @@ -172,7 +172,10 @@ const METRIC_ROWS: MetricRow[] = [ {c.confidenceValue != null ? `${Math.round(c.confidenceValue * 100)}% · ` : ""} - {CONFIDENCE_RU[c.confidenceLabel]} + {/* `?? DASH` (#2452): confidenceLabel — строка из API, а не + выведенный литерал; значение вне union'а (schema drift) + иначе печатает пустоту рядом с процентом. */} + {CONFIDENCE_RU[c.confidenceLabel] ?? DASH} ) : ( DASH diff --git a/frontend/src/components/site-finder/compare/__tests__/CompareTable.confidence.test.tsx b/frontend/src/components/site-finder/compare/__tests__/CompareTable.confidence.test.tsx new file mode 100644 index 00000000..485bd144 --- /dev/null +++ b/frontend/src/components/site-finder/compare/__tests__/CompareTable.confidence.test.tsx @@ -0,0 +1,44 @@ +/** + * #2452 (follow-up #2445-B2/B3) — `CONFIDENCE_RU[c.confidenceLabel]` в строке + * «Достоверность» не имел fallback'а. Промах union'а здесь не роняет рендер + * (в JSX `undefined` печатается пустотой), но даёт хуже: рядом с процентом + * остаётся «73% · » и обрыв — число выглядит недосказанным, а не неизвестным. + * + * Тест фиксирует «—» на значении вне union'а и присутствие процента рядом. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { CompareTable } from "../CompareTable"; +import type { CompareColumn } from "../CompareTable"; + +function makeColumn(overrides: Partial = {}): CompareColumn { + return { + cad: "66:41:0704002:1", + status: "ready", + confidenceLabel: "high", + confidenceValue: 0.73, + ...overrides, + }; +} + +describe("CompareTable — строка «Достоверность» (#2452)", () => { + it("известный уровень печатается словом", () => { + render( {}} />); + expect(screen.getByText(/высокая/)).toBeInTheDocument(); + }); + + it("значение вне union'а даёт «—», а не обрыв после процента", () => { + const drifted = makeColumn({ + // Имитация schema drift: значение вне "high"|"medium"|"low". + confidenceLabel: + "unverified" as unknown as CompareColumn["confidenceLabel"], + }); + + expect(() => + render( {}} />), + ).not.toThrow(); + // Процент остался на месте, а уровень — нейтральное «—» в той же ячейке. + expect(screen.getByText(/73% · —/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/site-finder/ptica/compare/PticaCompare.tsx b/frontend/src/components/site-finder/ptica/compare/PticaCompare.tsx index e56a489b..f90547a6 100644 --- a/frontend/src/components/site-finder/ptica/compare/PticaCompare.tsx +++ b/frontend/src/components/site-finder/ptica/compare/PticaCompare.tsx @@ -79,7 +79,9 @@ function fmtPipeline(v: number | null | undefined): string { : DASH; } -const METRIC_ROWS: MetricRow[] = [ +// Экспорт ради теста (#2452): строка «Достоверность» читает значение из API +// по ключу закрытого union'а, и её нужно проверять на out-of-union прямо. +export const METRIC_ROWS: MetricRow[] = [ { key: "verdict", label: "Вердикт МКД", @@ -148,7 +150,10 @@ const METRIC_ROWS: MetricRow[] = [ {c.confidenceValue != null ? `${Math.round(c.confidenceValue * 100)}% · ` : ""} - {CONFIDENCE_RU[c.confidenceLabel]} + {/* `?? DASH` (#2452): confidenceLabel — строка из API, а не + выведенный литерал; значение вне union'а (schema drift) + иначе печатает пустоту рядом с процентом. */} + {CONFIDENCE_RU[c.confidenceLabel] ?? DASH} ) : ( DASH diff --git a/frontend/src/components/site-finder/ptica/compare/__tests__/PticaCompare.confidence.test.tsx b/frontend/src/components/site-finder/ptica/compare/__tests__/PticaCompare.confidence.test.tsx new file mode 100644 index 00000000..960f69db --- /dev/null +++ b/frontend/src/components/site-finder/ptica/compare/__tests__/PticaCompare.confidence.test.tsx @@ -0,0 +1,49 @@ +/** + * #2452 (follow-up #2445-B2/B3) — тот же незащищённый enum-lookup, что в + * `compare/CompareTable.tsx`: строка «Достоверность» читает + * `CONFIDENCE_RU[c.confidenceLabel]` по значению из API. Промах union'а + * печатал пустоту сразу после процента («73% · »), то есть число выглядело + * недосказанным, а не неизвестным. + * + * Проверяем саму строку METRIC_ROWS, а не всю страницу: `PticaCompare` тянет + * данные по `cad`, и оборачивать его в query-моки ради одной ячейки — лишний + * тестовый вес при том же покрытии. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { METRIC_ROWS } from "../PticaCompare"; +import type { CompareColumn } from "../../../compare/CompareTable"; + +const confidenceRow = METRIC_ROWS.find((r) => r.key === "confidence"); + +function makeColumn(overrides: Partial = {}): CompareColumn { + return { + cad: "66:41:0704002:1", + status: "ready", + confidenceLabel: "medium", + confidenceValue: 0.73, + ...overrides, + }; +} + +describe("PticaCompare — строка «Достоверность» (#2452)", () => { + it("строка на месте (иначе тест ниже зеленел бы впустую)", () => { + expect(confidenceRow).toBeDefined(); + }); + + it("известный уровень печатается словом", () => { + render(<>{confidenceRow!.render(makeColumn())}); + expect(screen.getByText(/средняя/)).toBeInTheDocument(); + }); + + it("значение вне union'а даёт «—», а не обрыв после процента", () => { + const drifted = makeColumn({ + confidenceLabel: + "unverified" as unknown as CompareColumn["confidenceLabel"], + }); + + expect(() => render(<>{confidenceRow!.render(drifted)})).not.toThrow(); + expect(screen.getByText(/73% · —/)).toBeInTheDocument(); + }); +}); -- 2.45.3