fix(mera-public): этаж и состояние переживают переход на /estimate #3327
4 changed files with 173 additions and 3 deletions
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
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();
|
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("этаж не строка — поле выпадает, остальной черновик цел", () => {
|
it("этаж не строка — поле выпадает, остальной черновик цел", () => {
|
||||||
window.sessionStorage.setItem(
|
window.sessionStorage.setItem(
|
||||||
"mera:estimate-draft",
|
"mera:estimate-draft",
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,6 @@ export function FreeCheckCard() {
|
||||||
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
|
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
|
||||||
// Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL
|
// Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL
|
||||||
// попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts).
|
// попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts).
|
||||||
// Этаж не передаём — его не использует ни проба покрытия, ни экран
|
|
||||||
// Всё, что человек ввёл обязательным полем, доезжает до следующего шага.
|
// Всё, что человек ввёл обязательным полем, доезжает до следующего шага.
|
||||||
// Раньше этаж здесь терялся под доводом «повторно набрать дешевле, чем
|
// Раньше этаж здесь терялся под доводом «повторно набрать дешевле, чем
|
||||||
// хранить лишнее» — довод был неверен: поля этажа на целевой странице нет,
|
// хранить лишнее» — довод был неверен: поля этажа на целевой странице нет,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const KEY = "mera:estimate-draft";
|
const KEY = "mera:estimate-draft";
|
||||||
|
/**
|
||||||
|
* Поля, которые целевая страница НЕ читает, но которые собраны обязательными и
|
||||||
|
* ждут платного шага (#2896). Отдельный ключ, потому что черновик забирается
|
||||||
|
* «на вынос»: без переноса `takeDraft` стирал бы вместе с адресом ровно то, что
|
||||||
|
* обещал донести (#3321).
|
||||||
|
*/
|
||||||
|
const EXTRAS_KEY = "mera:estimate-draft-extras";
|
||||||
|
|
||||||
export interface EstimateDraft {
|
export interface EstimateDraft {
|
||||||
address: string;
|
address: string;
|
||||||
|
|
@ -45,6 +52,16 @@ export interface EstimateDraft {
|
||||||
floor?: string;
|
floor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хвост черновика: то, что собрали на лэндинге, но на `/estimate` не читают.
|
||||||
|
* Адрес лежит здесь вместе с полями — сверку делает `readDraftExtras`.
|
||||||
|
*/
|
||||||
|
export interface EstimateDraftExtras {
|
||||||
|
address: string;
|
||||||
|
condition?: string;
|
||||||
|
floor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Все обращения обёрнуты: в приватном режиме Safari и при выключенном
|
* Все обращения обёрнуты: в приватном режиме Safari и при выключенном
|
||||||
* хранилище `sessionStorage` бросает на записи, а на чтении может вернуть
|
* хранилище `sessionStorage` бросает на записи, а на чтении может вернуть
|
||||||
|
|
@ -73,7 +90,7 @@ export function takeDraft(): EstimateDraft | null {
|
||||||
const value = parsed as Record<string, unknown>;
|
const value = parsed as Record<string, unknown>;
|
||||||
if (typeof value.address !== "string") return null;
|
if (typeof value.address !== "string") return null;
|
||||||
|
|
||||||
return {
|
const draft: EstimateDraft = {
|
||||||
address: value.address,
|
address: value.address,
|
||||||
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
|
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
|
||||||
area: typeof value.area === "string" ? value.area : 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,
|
typeof value.condition === "string" ? value.condition : undefined,
|
||||||
floor: typeof value.floor === "string" ? value.floor : 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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue