fix(mera-public): этаж и состояние переживают переход на /estimate #3327

Merged
bot-backend merged 2 commits from fix/3321-draft-fields-survive into main 2026-09-02 09:48:52 +00:00
4 changed files with 173 additions and 3 deletions

View file

@ -0,0 +1,74 @@
/**
* Этаж и состояние переживают ПЕРЕХОД, а не только запись в хранилище.
*
* Дефект #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];
// Без этой проверки пустая группа роняет тест TypeError'ом на textContent —
// отказом, по которому не видно, что именно разъехалось с формой.
expect(chosen, "в группе «Состояние» не нашлось кнопок").toBeTruthy();
const label = chosen.textContent ?? "";
fireEvent.click(chosen);
fireEvent.click(screen.getByRole("button", { name: /мо[её]му дому/i }));
unmount();
return label;
}
describe("Черновик: обязательные поля переживают переход на /estimate", () => {
it("этаж и состояние доступны после монтирования целевой страницы", () => {
const condition = submitLandingCard();
// Тот самый шаг, который раньше уничтожал оба поля.
render(<EstimateFlow />);
// Читаем так же, как будет читать платный шаг: под тот адрес, о котором
// спрашивают. Чужому адресу хвост не отдаётся — проверено отдельно в
// estimate-draft.test.ts.
const extras = readDraftExtras("Екатеринбург, Ленина 5");
expect(extras, "после /estimate от черновика не осталось ничего").not.toBeNull();
expect(extras?.floor).toBe("7/16");
expect(extras?.condition).toBe(condition);
});
});

View file

@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
import { normalizeDraftRooms, saveDraft, takeDraft } from "../estimate-draft";
import {
normalizeDraftRooms,
readDraftExtras,
saveDraft,
takeDraft,
} from "../estimate-draft";
/**
* Черновик, который лэндинг передаёт на экран проверки.
@ -107,6 +112,24 @@ describe("этаж переживает переход на целевую ст
expect(d?.floor).toBeUndefined();
});
it("черновик без этажа и состояния убирает прошлый хвост", () => {
// Иначе этаж от предыдущей квартиры пережил бы свой черновик и лежал бы,
// дожидаясь потребителя.
saveDraft({ address: "Ленина 5", floor: "7/16", condition: "Хорошее" });
takeDraft();
saveDraft({ address: "Малышева 51", rooms: "2" });
takeDraft();
expect(readDraftExtras("Ленина 5")).toBeNull();
expect(readDraftExtras("Малышева 51")).toBeNull();
});
it("хвост не отдаётся другому адресу", () => {
saveDraft({ address: "Ленина 5", floor: "7/16" });
takeDraft();
expect(readDraftExtras("Малышева 51")).toBeNull();
expect(readDraftExtras("Ленина 5")?.floor).toBe("7/16");
});
it("этаж не строка — поле выпадает, остальной черновик цел", () => {
window.sessionStorage.setItem(
"mera:estimate-draft",

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,16 @@ export interface EstimateDraft {
floor?: string;
}
/**
* Хвост черновика: то, что собрали на лэндинге, но на `/estimate` не читают.
* Адрес лежит здесь вместе с полями сверку делает `readDraftExtras`.
*/
export interface EstimateDraftExtras {
address: string;
condition?: string;
floor?: string;
}
/**
* Все обращения обёрнуты: в приватном режиме Safari и при выключенном
* хранилище `sessionStorage` бросает на записи, а на чтении может вернуть
@ -73,7 +90,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 +99,63 @@ 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 {
// Не сохранился — платный шаг переспросит.
}
}
/**
* Читает, но НЕ забирает: платный шаг может открыться не с первого раза, а
* второе чтение того же хвоста ничего не портит. Вкладка закроется исчезнет
* само.
*
* `expectedAddress` обязателен, а не «желателен»: `takeDraft` выходит раньше
* записи хвоста на пустом/битом черновике, поэтому хвост от ПРЕДЫДУЩЕЙ
* квартиры доживает до следующего захода в той же вкладке. Сверку нельзя
* оставлять на совесть потребителя не тот адрес, значит ничего нет.
*/
export function readDraftExtras(
expectedAddress: string,
): 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 (value.address !== expectedAddress) return null;
return {
address: expectedAddress,
condition:
typeof value.condition === "string" ? value.condition : undefined,
floor: typeof value.floor === "string" ? value.floor : undefined,
};
} catch {
return null;
}