fix(frontend): нейтральный fallback ещё в 4 enum-lookup-местах (#2452)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-worker (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m29s
Deploy / perimeter-smoke (push) Successful in 11s
Deploy / build-backend (push) Has been skipped
Deploy / deploy (push) Successful in 1m4s
Deploy / deploy-status (push) Successful in 1s

This commit is contained in:
bot-backend 2026-08-27 16:39:08 +00:00
commit 737918985b
8 changed files with 251 additions and 7 deletions

View file

@ -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<TimeWindow, string> = {
last_month: "Последний месяц",
last_quarter: "Последний квартал",
@ -62,8 +76,14 @@ const ROOM_BUCKET_LABELS: Record<string, string> = {
// ── 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 гарантирует

View file

@ -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> = {}): 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(<DataQualityCard dq={makeDq({ confidence: "low" })} />);
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(<DataQualityCard dq={drifted} />)).not.toThrow();
expect(screen.getByText("—")).toBeInTheDocument();
// Покрытие продолжает рисоваться — падает только метка, не вся карточка.
expect(screen.getByText(/покрытие 72%/)).toBeInTheDocument();
});
it("fallback не выглядит семантически (не зелёный и не красный)", () => {
const drifted = makeDq({
confidence: "unverified" as unknown as DataQuality["confidence"],
});
render(<DataQualityCard dq={drifted} />);
const badge = screen.getByText("—");
const style = badge.getAttribute("style") ?? "";
expect(style).not.toContain("--success");
expect(style).not.toContain("--danger");
expect(style).not.toContain("--warn");
});
});

View file

@ -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] ?? "—");
});
}

View file

@ -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();
});
});

View file

@ -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}
</span>
) : (
DASH

View file

@ -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> = {}): CompareColumn {
return {
cad: "66:41:0704002:1",
status: "ready",
confidenceLabel: "high",
confidenceValue: 0.73,
...overrides,
};
}
describe("CompareTable — строка «Достоверность» (#2452)", () => {
it("известный уровень печатается словом", () => {
render(<CompareTable columns={[makeColumn()]} onRemove={() => {}} />);
expect(screen.getByText(/высокая/)).toBeInTheDocument();
});
it("значение вне union'а даёт «—», а не обрыв после процента", () => {
const drifted = makeColumn({
// Имитация schema drift: значение вне "high"|"medium"|"low".
confidenceLabel:
"unverified" as unknown as CompareColumn["confidenceLabel"],
});
expect(() =>
render(<CompareTable columns={[drifted]} onRemove={() => {}} />),
).not.toThrow();
// Процент остался на месте, а уровень — нейтральное «—» в той же ячейке.
expect(screen.getByText(/73% · —/)).toBeInTheDocument();
});
});

View file

@ -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

View file

@ -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> = {}): 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();
});
});