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> = {
|
const TIME_WINDOW_LABELS: Record<TimeWindow, string> = {
|
||||||
last_month: "Последний месяц",
|
last_month: "Последний месяц",
|
||||||
last_quarter: "Последний квартал",
|
last_quarter: "Последний квартал",
|
||||||
|
|
@ -62,8 +76,14 @@ const ROOM_BUCKET_LABELS: Record<string, string> = {
|
||||||
|
|
||||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
|
// Экспорт ради теста (#2452): карточка читает `confidence` из API по ключу
|
||||||
const conf = CONFIDENCE_STYLES[dq.confidence];
|
// закрытого union'а, и её нужно проверять на out-of-union напрямую.
|
||||||
|
export function DataQualityCard({
|
||||||
|
dq,
|
||||||
|
}: {
|
||||||
|
dq: BestLayoutsResponse["data_quality"];
|
||||||
|
}) {
|
||||||
|
const conf = CONFIDENCE_STYLES[dq.confidence] ?? NEUTRAL_CONFIDENCE_STYLE;
|
||||||
return (
|
return (
|
||||||
// Inline-стили (НЕ Tailwind): flex/gap/bg-* не применяются на этой странице —
|
// Inline-стили (НЕ Tailwind): flex/gap/bg-* не применяются на этой странице —
|
||||||
// спаны текли inline и слипались («НизкоеПокрытие», #1953). Inline гарантирует
|
// спаны текли 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). */
|
/** Per-horizon confidence level for a forecast list (for the band tooltip). */
|
||||||
function confidenceByHorizon(
|
// Экспорт ради теста (#2452), как и `isDeficitDegenerate` ниже.
|
||||||
|
export function confidenceByHorizon(
|
||||||
forecasts: DemandSupplyForecast[],
|
forecasts: DemandSupplyForecast[],
|
||||||
horizons: number[],
|
horizons: number[],
|
||||||
): (string | null)[] {
|
): (string | null)[] {
|
||||||
|
|
@ -175,7 +176,10 @@ function confidenceByHorizon(
|
||||||
for (const f of forecasts) byH.set(f.horizon_months, f);
|
for (const f of forecasts) byH.set(f.horizon_months, f);
|
||||||
return horizons.map((h) => {
|
return horizons.map((h) => {
|
||||||
const f = byH.get(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
|
{c.confidenceValue != null
|
||||||
? `${Math.round(c.confidenceValue * 100)}% · `
|
? `${Math.round(c.confidenceValue * 100)}% · `
|
||||||
: ""}
|
: ""}
|
||||||
{CONFIDENCE_RU[c.confidenceLabel]}
|
{/* `?? DASH` (#2452): confidenceLabel — строка из API, а не
|
||||||
|
выведенный литерал; значение вне union'а (schema drift)
|
||||||
|
иначе печатает пустоту рядом с процентом. */}
|
||||||
|
{CONFIDENCE_RU[c.confidenceLabel] ?? DASH}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
DASH
|
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;
|
: DASH;
|
||||||
}
|
}
|
||||||
|
|
||||||
const METRIC_ROWS: MetricRow[] = [
|
// Экспорт ради теста (#2452): строка «Достоверность» читает значение из API
|
||||||
|
// по ключу закрытого union'а, и её нужно проверять на out-of-union прямо.
|
||||||
|
export const METRIC_ROWS: MetricRow[] = [
|
||||||
{
|
{
|
||||||
key: "verdict",
|
key: "verdict",
|
||||||
label: "Вердикт МКД",
|
label: "Вердикт МКД",
|
||||||
|
|
@ -148,7 +150,10 @@ const METRIC_ROWS: MetricRow[] = [
|
||||||
{c.confidenceValue != null
|
{c.confidenceValue != null
|
||||||
? `${Math.round(c.confidenceValue * 100)}% · `
|
? `${Math.round(c.confidenceValue * 100)}% · `
|
||||||
: ""}
|
: ""}
|
||||||
{CONFIDENCE_RU[c.confidenceLabel]}
|
{/* `?? DASH` (#2452): confidenceLabel — строка из API, а не
|
||||||
|
выведенный литерал; значение вне union'а (schema drift)
|
||||||
|
иначе печатает пустоту рядом с процентом. */}
|
||||||
|
{CONFIDENCE_RU[c.confidenceLabel] ?? DASH}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
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