fix(mera/v2): форма принимала этаж больше этажности дома
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Failing after 51s

Прод-инцидент 6d0c268e (пр-кт Академика Сахарова 81): оценка ушла в
расчёт с floor=17 при total_floors=9. Ни форма, ни бэкенд не отбили
взаимно противоречивый ввод — мусор дошёл до этажных коэффициентов.

ParamsPanel v2 теперь блокирует сабмит по тому же паттерну, что уже
используется для адреса и площади (fieldErrors + aria-invalid +
aria-describedby + текст под инпутом):

- floor > total_floors (когда заполнены оба)
- нулевой этаж / нулевая этажность
- этаж или этажность > 100

Оба поля остаются опциональными — пустые значения не блокируют.
Правка любого из двух полей сбрасывает обе ошибки: floor > total_floors
— ошибка про пару, а не про одно поле.

Бэкенд не трогали: серверная проверка — отдельным заходом.
This commit is contained in:
bot-backend 2026-08-29 16:03:03 +03:00
parent deddef80ad
commit ac5f16abfd
2 changed files with 174 additions and 6 deletions

View file

@ -728,6 +728,8 @@ export default function ParamsPanel({
const [fieldErrors, setFieldErrors] = useState<{
address?: string;
area?: string;
floor?: string;
totalFloors?: string;
}>({});
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
@ -928,7 +930,12 @@ export default function ParamsPanel({
const areaNum = Number(area.replace(",", "."));
// Collect ALL blockers (don't early-return) so every offending field shows
// its own message at once.
const errs: { address?: string; area?: string } = {};
const errs: {
address?: string;
area?: string;
floor?: string;
totalFloors?: string;
} = {};
if (trimmedAddress.length < 3) {
errs.address = "Укажите адрес квартиры — минимум 3 символа";
}
@ -937,6 +944,29 @@ export default function ParamsPanel({
} else if (!Number.isFinite(areaNum) || areaNum <= 10) {
errs.area = "Площадь должна быть больше 10 м²";
}
// Floor / total-floors are both optional, but if filled must be
// mutually consistent (prod incident 6d0c268e: floor=17, total_floors=9
// was accepted silently — form + backend both let it through).
const floorNum = floor.trim() ? Number(floor) : null;
const totalFloorsNum = totalFloors.trim() ? Number(totalFloors) : null;
if (floorNum === 0) {
errs.floor = "Этаж не может быть нулевым";
} else if (floorNum != null && floorNum > 100) {
errs.floor = "Проверьте число этажей";
}
if (totalFloorsNum === 0) {
errs.totalFloors = "Этажей в доме не может быть 0";
} else if (totalFloorsNum != null && totalFloorsNum > 100) {
errs.totalFloors = "Проверьте число этажей";
}
if (
floorNum != null &&
totalFloorsNum != null &&
floorNum > totalFloorsNum &&
!errs.floor
) {
errs.floor = "Этаж не может быть больше числа этажей в доме";
}
if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
return;
@ -1801,13 +1831,36 @@ export default function ParamsPanel({
type="text"
inputMode="numeric"
value={floor}
onChange={(e) => setFloor(e.target.value.replace(/\D/g, ""))}
aria-invalid={fieldErrors.floor ? true : undefined}
aria-describedby={fieldErrors.floor ? "pp-floor-err" : undefined}
onChange={(e) => {
setFloor(e.target.value.replace(/\D/g, ""));
if (fieldErrors.floor || fieldErrors.totalFloors)
setFieldErrors((prev) => ({
...prev,
floor: undefined,
totalFloors: undefined,
}));
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
style={inputField}
style={
fieldErrors.floor
? { ...inputField, border: `1px solid ${tokens.danger}` }
: inputField
}
/>
{fieldErrors.floor && (
<div
id="pp-floor-err"
style={{ ...errorText, marginTop: 4 }}
role="alert"
>
{fieldErrors.floor}
</div>
)}
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
@ -1822,15 +1875,38 @@ export default function ParamsPanel({
type="text"
inputMode="numeric"
value={totalFloors}
onChange={(e) =>
setTotalFloors(e.target.value.replace(/\D/g, ""))
aria-invalid={fieldErrors.totalFloors ? true : undefined}
aria-describedby={
fieldErrors.totalFloors ? "pp-total-floors-err" : undefined
}
onChange={(e) => {
setTotalFloors(e.target.value.replace(/\D/g, ""));
if (fieldErrors.floor || fieldErrors.totalFloors)
setFieldErrors((prev) => ({
...prev,
floor: undefined,
totalFloors: undefined,
}));
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
style={inputField}
style={
fieldErrors.totalFloors
? { ...inputField, border: `1px solid ${tokens.danger}` }
: inputField
}
/>
{fieldErrors.totalFloors && (
<div
id="pp-total-floors-err"
style={{ ...errorText, marginTop: 4 }}
role="alert"
>
{fieldErrors.totalFloors}
</div>
)}
</div>
</div>

View file

@ -0,0 +1,92 @@
// Клиентская валидация этажности — прод-инцидент 6d0c268e: оценка была принята
// с floor=17 при total_floors=9 (пр-кт Академика Сахарова 81, 2023 г.п.), форма
// пропустила взаимно противоречивый ввод, бэкенд тоже не отбил. Здесь закреплена
// блокирующая проверка во фронте: сабмит не уходит в onSubmit и показывает
// текст ошибки под полем «ЭТАЖ», пока floor > total_floors.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ParamsPanel from "../ParamsPanel";
function renderPanel(onSubmit: (input: unknown) => void) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<ParamsPanel onSubmit={onSubmit} />
</QueryClientProvider>,
);
}
// Валидные адрес/площадь заполняются в каждом кейсе, чтобы изолировать проверку
// именно этажности — иначе сабмит блокировался бы этими полями первым.
function fillRequiredFields(container: HTMLElement) {
const address = container.querySelector("#pp-address") as HTMLInputElement;
const area = container.querySelector("#pp-area") as HTMLInputElement;
fireEvent.change(address, {
target: { value: "Екатеринбург, пр-кт Академика Сахарова 81" },
});
fireEvent.change(area, { target: { value: "54" } });
}
describe("ParamsPanel — валидация этаж / всего этажей", () => {
it("floor=17, total_floors=9 — блокирует сабмит и показывает ошибку на поле ЭТАЖ", () => {
const onSubmit = vi.fn();
const { container } = renderPanel(onSubmit);
fillRequiredFields(container);
fireEvent.change(container.querySelector("#pp-floor") as HTMLInputElement, {
target: { value: "17" },
});
fireEvent.change(
container.querySelector("#pp-total-floors") as HTMLInputElement,
{ target: { value: "9" } },
);
fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
expect(onSubmit).not.toHaveBeenCalled();
expect(
screen.getByText("Этаж не может быть больше числа этажей в доме"),
).toBeInTheDocument();
});
it("floor=9, total_floors=17 — проходит валидацию и вызывает onSubmit", () => {
const onSubmit = vi.fn();
const { container } = renderPanel(onSubmit);
fillRequiredFields(container);
fireEvent.change(container.querySelector("#pp-floor") as HTMLInputElement, {
target: { value: "9" },
});
fireEvent.change(
container.querySelector("#pp-total-floors") as HTMLInputElement,
{ target: { value: "17" } },
);
fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(
screen.queryByText("Этаж не может быть больше числа этажей в доме"),
).not.toBeInTheDocument();
});
it("оба поля этажности пустые — не ошибка, сабмит проходит", () => {
const onSubmit = vi.fn();
const { container } = renderPanel(onSubmit);
fillRequiredFields(container);
fireEvent.click(screen.getByText("ОЦЕНИТЬ КВАРТИРУ"));
expect(onSubmit).toHaveBeenCalledTimes(1);
const [payload] = onSubmit.mock.calls[0] as [
{ floor: unknown; total_floors: unknown },
];
expect(payload.floor).toBeNull();
expect(payload.total_floors).toBeNull();
});
});