From ac2633011bce9710f2c2eece06bce0736c495140 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 29 Aug 2026 15:19:24 +0000 Subject: [PATCH] =?UTF-8?q?fix(mera/v2):=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=BB=D0=B0=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=B6=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5?= =?UTF-8?q?=20=D1=8D=D1=82=D0=B0=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=D0=B0=20(#3226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/trade-in/v2/ParamsPanel.tsx | 99 +++++++++++++++++-- .../v2/__tests__/ParamsPanel.floors.test.tsx | 92 +++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.floors.test.tsx diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 3a1fa2d2..aef80107 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -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) ── @@ -756,6 +758,17 @@ export default function ParamsPanel({ initialValues?.target_fias_id ?? null, ); const debounceRef = useRef | null>(null); + + // Debounce-таймер адреса переживал размонтирование панели: набранный текст + // ставит таймер на 200 мс, и если панель закрыть раньше, он дёргает setState + // уже мёртвого компонента. В тестах это падает как unhandled + // `ReferenceError: window is not defined` после teardown окружения (#3226). + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, + [], + ); // БАЛКОН radiogroup focus targets (roving tabindex). const balNoRef = useRef(null); const balYesRef = useRef(null); @@ -928,7 +941,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 +955,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 +1842,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 && ( + + )}
@@ -1822,15 +1886,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 && ( + + )}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.floors.test.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.floors.test.tsx new file mode 100644 index 00000000..8dcbef05 --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/ParamsPanel.floors.test.tsx @@ -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( + + + , + ); +} + +// Валидные адрес/площадь заполняются в каждом кейсе, чтобы изолировать проверку +// именно этажности — иначе сабмит блокировался бы этими полями первым. +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(); + }); +});