fix(mera/v2): форма принимала этаж больше этажности дома (#3226)
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / deploy (push) Successful in 1m3s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Failing after 15s
Some checks failed
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m15s
Deploy Trade-In / deploy (push) Successful in 1m3s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Failing after 15s
This commit is contained in:
parent
b241e0145a
commit
ac2633011b
2 changed files with 185 additions and 6 deletions
|
|
@ -728,6 +728,8 @@ export default function ParamsPanel({
|
||||||
const [fieldErrors, setFieldErrors] = useState<{
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
address?: string;
|
address?: string;
|
||||||
area?: string;
|
area?: string;
|
||||||
|
floor?: string;
|
||||||
|
totalFloors?: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
|
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
|
||||||
|
|
@ -756,6 +758,17 @@ export default function ParamsPanel({
|
||||||
initialValues?.target_fias_id ?? null,
|
initialValues?.target_fias_id ?? null,
|
||||||
);
|
);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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).
|
// БАЛКОН radiogroup focus targets (roving tabindex).
|
||||||
const balNoRef = useRef<HTMLButtonElement>(null);
|
const balNoRef = useRef<HTMLButtonElement>(null);
|
||||||
const balYesRef = useRef<HTMLButtonElement>(null);
|
const balYesRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
@ -928,7 +941,12 @@ export default function ParamsPanel({
|
||||||
const areaNum = Number(area.replace(",", "."));
|
const areaNum = Number(area.replace(",", "."));
|
||||||
// Collect ALL blockers (don't early-return) so every offending field shows
|
// Collect ALL blockers (don't early-return) so every offending field shows
|
||||||
// its own message at once.
|
// 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) {
|
if (trimmedAddress.length < 3) {
|
||||||
errs.address = "Укажите адрес квартиры — минимум 3 символа";
|
errs.address = "Укажите адрес квартиры — минимум 3 символа";
|
||||||
}
|
}
|
||||||
|
|
@ -937,6 +955,29 @@ export default function ParamsPanel({
|
||||||
} else if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
} else if (!Number.isFinite(areaNum) || areaNum <= 10) {
|
||||||
errs.area = "Площадь должна быть больше 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) {
|
if (Object.keys(errs).length > 0) {
|
||||||
setFieldErrors(errs);
|
setFieldErrors(errs);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1801,13 +1842,36 @@ export default function ParamsPanel({
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={floor}
|
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) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSubmit();
|
if (e.key === "Enter") handleSubmit();
|
||||||
}}
|
}}
|
||||||
placeholder="—"
|
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>
|
<div>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
|
@ -1822,15 +1886,38 @@ export default function ParamsPanel({
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={totalFloors}
|
value={totalFloors}
|
||||||
onChange={(e) =>
|
aria-invalid={fieldErrors.totalFloors ? true : undefined}
|
||||||
setTotalFloors(e.target.value.replace(/\D/g, ""))
|
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) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleSubmit();
|
if (e.key === "Enter") handleSubmit();
|
||||||
}}
|
}}
|
||||||
placeholder="—"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue