fix(frontend): нейтральный fallback ещё в 4 enum-lookup-местах
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-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 1m4s
CI / openapi-codegen-check (pull_request) Successful in 2m7s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-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 1m4s
CI / openapi-codegen-check (pull_request) Successful in 2m7s
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
This commit is contained in:
parent
c2d890ff4e
commit
abe76ca980
8 changed files with 251 additions and 7 deletions
|
|
@ -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 гарантирует
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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] ?? "—");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue