fix(mera-public): этаж и состояние переживают переход на /estimate
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 58s

Карточка лэндинга требовала этаж и состояние обязательными и клала их в
черновик. Целевая страница на монтировании звала takeDraft, а та стирала
черновик целиком, прочитав только адрес, комнаты, площадь и город: оба
обязательных поля уничтожались непрочитанными. Человек заполнял два поля,
единственным эффектом которых было их же удаление.

takeDraft по-прежнему забирает черновик «на вынос» (адрес не должен
подставляться на следующей неделе в той же вкладке), но перекладывает
непрочитанные floor/condition в отдельный ключ вместе с адресом, к которому
они относятся, — чтобы платный шаг (#2896) не приклеил этаж одной квартиры к
другой. Потребителя у полей пока нет: платного шага нет.

Closes #3321
This commit is contained in:
bot-backend 2026-09-02 14:40:36 +05:00
parent 1c533c8a98
commit 7b61ecb122
3 changed files with 144 additions and 2 deletions

View file

@ -0,0 +1,72 @@
/**
* Этаж и состояние переживают ПЕРЕХОД, а не только запись в хранилище.
*
* Дефект #3321 был ровно в стыке: карточка требовала оба поля обязательными и
* честно клала их в черновик, а `/estimate` на монтировании звала `takeDraft`,
* которая стирала черновик ЦЕЛИКОМ, прочитав из него адрес, комнаты, площадь и
* город. Оба обязательных поля уничтожались непрочитанными человек заполнял
* то, что гарантированно выбрасывалось.
*
* Поэтому здесь обе стороны перехода настоящие: форма заполняется как человеком
* и отправляется, затем монтируется целевая страница. Тест на одном хранилище
* остался бы зелёным при возврате дефекта стирает-то целевая страница.
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EstimateFlow } from "../_components/estimate/EstimateFlow";
import { FreeCheckCard } from "../_components/v3/FreeCheckCard";
import { readDraftExtras } from "../estimate-draft";
beforeEach(() => {
window.sessionStorage.clear();
Object.defineProperty(window, "location", {
configurable: true,
value: { ...window.location, assign: vi.fn() },
});
});
function fill(labelPart: string, value: string) {
const field = screen.getByLabelText(new RegExp(labelPart, "i"));
fireEvent.change(field, { target: { value } });
}
/** Заполняет и отправляет карточку лэндинга; возвращает выбранное состояние. */
function submitLandingCard(): string {
const { unmount } = render(<FreeCheckCard />);
fill("город", "Екатеринбург");
fill("адрес", "Екатеринбург, Ленина 5");
fill("М²", "54");
fill("комнат", "2");
fill("этаж", "7/16");
// Состояние — группа кнопок с aria-pressed. Берём подпись у самой кнопки, а
// не из своих догадок о наборе вариантов.
const conditions = screen.getByRole("group", { name: /состояние/i });
const chosen = conditions.querySelectorAll("button")[0];
const label = chosen.textContent ?? "";
fireEvent.click(chosen);
fireEvent.click(screen.getByRole("button", { name: /мо[её]му дому/i }));
unmount();
return label;
}
describe("Черновик: обязательные поля переживают переход на /estimate", () => {
it("этаж и состояние доступны после монтирования целевой страницы", () => {
const condition = submitLandingCard();
expect(condition, "в группе «Состояние» не нашлось кнопок").not.toBe("");
// Тот самый шаг, который раньше уничтожал оба поля.
render(<EstimateFlow />);
const extras = readDraftExtras();
expect(extras, "после /estimate от черновика не осталось ничего").not.toBeNull();
expect(extras?.floor).toBe("7/16");
expect(extras?.condition).toBe(condition);
// Хвост без адреса нельзя предъявлять платному шагу: он не отличит этаж
// этой квартиры от этажа предыдущей в той же вкладке.
expect(extras?.address).toBe("Екатеринбург, Ленина 5");
});
});

View file

@ -201,7 +201,6 @@ export function FreeCheckCard() {
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
// Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL
// попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts).
// Этаж не передаём — его не использует ни проба покрытия, ни экран
// Всё, что человек ввёл обязательным полем, доезжает до следующего шага.
// Раньше этаж здесь терялся под доводом «повторно набрать дешевле, чем
// хранить лишнее» — довод был неверен: поля этажа на целевой странице нет,

View file

@ -19,6 +19,13 @@
*/
const KEY = "mera:estimate-draft";
/**
* Поля, которые целевая страница НЕ читает, но которые собраны обязательными и
* ждут платного шага (#2896). Отдельный ключ, потому что черновик забирается
* «на вынос»: без переноса `takeDraft` стирал бы вместе с адресом ровно то, что
* обещал донести (#3321).
*/
const EXTRAS_KEY = "mera:estimate-draft-extras";
export interface EstimateDraft {
address: string;
@ -45,6 +52,20 @@ export interface EstimateDraft {
floor?: string;
}
/**
* Хвост черновика: то, что собрали на лэндинге, но на `/estimate` не читают.
*
* Адрес лежит здесь ВМЕСТЕ с полями, а не подразумевается: иначе этаж «7/16»
* от квартиры на Ленина переживёт свой черновик и на платном шаге приклеится к
* другому дому в той же вкладке. Потребитель обязан сверить адрес, а не верить
* тому, что нашёл.
*/
export interface EstimateDraftExtras {
address: string;
condition?: string;
floor?: string;
}
/**
* Все обращения обёрнуты: в приватном режиме Safari и при выключенном
* хранилище `sessionStorage` бросает на записи, а на чтении может вернуть
@ -73,7 +94,7 @@ export function takeDraft(): EstimateDraft | null {
const value = parsed as Record<string, unknown>;
if (typeof value.address !== "string") return null;
return {
const draft: EstimateDraft = {
address: value.address,
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
area: typeof value.area === "string" ? value.area : undefined,
@ -82,6 +103,56 @@ export function takeDraft(): EstimateDraft | null {
typeof value.condition === "string" ? value.condition : undefined,
floor: typeof value.floor === "string" ? value.floor : undefined,
};
// Состояние и этаж форма `/estimate` не спрашивает и не показывает: удали
// их вместе с черновиком — и человек заполнил два обязательных поля, чей
// единственный эффект был потрачен на удаление. Перекладываем туда, где их
// заберёт платный шаг; черновик как таковой по-прежнему уходит «на вынос».
saveDraftExtras(draft);
return draft;
} catch {
return null;
}
}
function saveDraftExtras(draft: EstimateDraft): void {
try {
// Пусто — не оставляем прошлый хвост лежать: он относится к прошлому
// адресу, а не к этому.
if (!draft.condition && !draft.floor) {
window.sessionStorage.removeItem(EXTRAS_KEY);
return;
}
const extras: EstimateDraftExtras = {
address: draft.address,
condition: draft.condition,
floor: draft.floor,
};
window.sessionStorage.setItem(EXTRAS_KEY, JSON.stringify(extras));
} catch {
// Не сохранился — платный шаг переспросит.
}
}
/**
* Читает, но НЕ забирает: платный шаг может открыться не с первого раза, а
* второе чтение того же хвоста ничего не портит. Вкладка закроется исчезнет
* само.
*/
export function readDraftExtras(): EstimateDraftExtras | null {
try {
const raw = window.sessionStorage.getItem(EXTRAS_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
const value = parsed as Record<string, unknown>;
if (typeof value.address !== "string") return null;
return {
address: value.address,
condition:
typeof value.condition === "string" ? value.condition : undefined,
floor: typeof value.floor === "string" ? value.floor : undefined,
};
} catch {
return null;
}