From 7b61ecb122d3080a0108371333048afe44086a10 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:40:36 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(mera-public):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=B6=20=D0=B8=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20/estimate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка лэндинга требовала этаж и состояние обязательными и клала их в черновик. Целевая страница на монтировании звала takeDraft, а та стирала черновик целиком, прочитав только адрес, комнаты, площадь и город: оба обязательных поля уничтожались непрочитанными. Человек заполнял два поля, единственным эффектом которых было их же удаление. takeDraft по-прежнему забирает черновик «на вынос» (адрес не должен подставляться на следующей неделе в той же вкладке), но перекладывает непрочитанные floor/condition в отдельный ключ вместе с адресом, к которому они относятся, — чтобы платный шаг (#2896) не приклеил этаж одной квартиры к другой. Потребителя у полей пока нет: платного шага нет. Closes #3321 --- .../estimate-draft-survives-estimate.test.tsx | 72 ++++++++++++++++++ .../_components/v3/FreeCheckCard.tsx | 1 - .../src/app/mera-public/estimate-draft.ts | 73 ++++++++++++++++++- 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx new file mode 100644 index 00000000..0539e722 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx @@ -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(); + + 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(); + + const extras = readDraftExtras(); + expect(extras, "после /estimate от черновика не осталось ничего").not.toBeNull(); + expect(extras?.floor).toBe("7/16"); + expect(extras?.condition).toBe(condition); + // Хвост без адреса нельзя предъявлять платному шагу: он не отличит этаж + // этой квартиры от этажа предыдущей в той же вкладке. + expect(extras?.address).toBe("Екатеринбург, Ленина 5"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx index 3afc400c..11d5a423 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -201,7 +201,6 @@ export function FreeCheckCard() { // Уходим на отдельный экран проверки — там живёт автокомплит и результат. // Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL // попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts). - // Этаж не передаём — его не использует ни проба покрытия, ни экран // Всё, что человек ввёл обязательным полем, доезжает до следующего шага. // Раньше этаж здесь терялся под доводом «повторно набрать дешевле, чем // хранить лишнее» — довод был неверен: поля этажа на целевой странице нет, diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts index b24e8567..d36314ef 100644 --- a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts +++ b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts @@ -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; 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; + 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; } -- 2.45.3 From 390a6f05b187a4dcf9afcd6ac02092e10b8df121 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 14:46:10 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(mera-public):=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0=20?= =?UTF-8?q?=D1=83=20=D1=85=D0=B2=D0=BE=D1=81=D1=82=D0=B0=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B0=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=D0=B5,=20=D0=BD=D0=B5=20=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readDraftExtras требует адрес, под который спрашивают, и на несовпадении отдаёт null. Гарантия «потребитель обязан сверить» жила только в комментарии, а takeDraft выходит раньше записи хвоста на пустом и на битом черновике — то есть хвост от предыдущей квартиры доживал до следующего захода в той же вкладке и дождался бы потребителя, который сверить забыл. Плюс два непокрытых кейса: черновик без floor/condition убирает прошлый хвост; хвост не отдаётся чужому адресу. --- .../estimate-draft-survives-estimate.test.tsx | 12 +++++---- .../__tests__/estimate-draft.test.ts | 25 ++++++++++++++++++- .../src/app/mera-public/estimate-draft.ts | 19 ++++++++------ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx index 0539e722..146f70ca 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft-survives-estimate.test.tsx @@ -45,6 +45,9 @@ function submitLandingCard(): string { // не из своих догадок о наборе вариантов. 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); @@ -56,17 +59,16 @@ function submitLandingCard(): string { describe("Черновик: обязательные поля переживают переход на /estimate", () => { it("этаж и состояние доступны после монтирования целевой страницы", () => { const condition = submitLandingCard(); - expect(condition, "в группе «Состояние» не нашлось кнопок").not.toBe(""); // Тот самый шаг, который раньше уничтожал оба поля. render(); - const extras = readDraftExtras(); + // Читаем так же, как будет читать платный шаг: под тот адрес, о котором + // спрашивают. Чужому адресу хвост не отдаётся — проверено отдельно в + // estimate-draft.test.ts. + const extras = readDraftExtras("Екатеринбург, Ленина 5"); expect(extras, "после /estimate от черновика не осталось ничего").not.toBeNull(); expect(extras?.floor).toBe("7/16"); expect(extras?.condition).toBe(condition); - // Хвост без адреса нельзя предъявлять платному шагу: он не отличит этаж - // этой квартиры от этажа предыдущей в той же вкладке. - expect(extras?.address).toBe("Екатеринбург, Ленина 5"); }); }); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts index 8449c935..5e5044a2 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts @@ -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", diff --git a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts index d36314ef..8063187d 100644 --- a/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts +++ b/tradein-mvp/frontend/src/app/mera-public/estimate-draft.ts @@ -54,11 +54,7 @@ export interface EstimateDraft { /** * Хвост черновика: то, что собрали на лэндинге, но на `/estimate` не читают. - * - * Адрес лежит здесь ВМЕСТЕ с полями, а не подразумевается: иначе этаж «7/16» - * от квартиры на Ленина переживёт свой черновик и на платном шаге приклеится к - * другому дому в той же вкладке. Потребитель обязан сверить адрес, а не верить - * тому, что нашёл. + * Адрес лежит здесь вместе с полями — сверку делает `readDraftExtras`. */ export interface EstimateDraftExtras { address: string; @@ -137,18 +133,25 @@ function saveDraftExtras(draft: EstimateDraft): void { * Читает, но НЕ забирает: платный шаг может открыться не с первого раза, а * второе чтение того же хвоста ничего не портит. Вкладка закроется — исчезнет * само. + * + * `expectedAddress` обязателен, а не «желателен»: `takeDraft` выходит раньше + * записи хвоста на пустом/битом черновике, поэтому хвост от ПРЕДЫДУЩЕЙ + * квартиры доживает до следующего захода в той же вкладке. Сверку нельзя + * оставлять на совесть потребителя — не тот адрес, значит ничего нет. */ -export function readDraftExtras(): EstimateDraftExtras | null { +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; - if (typeof value.address !== "string") return null; + if (value.address !== expectedAddress) return null; return { - address: value.address, + address: expectedAddress, condition: typeof value.condition === "string" ? value.condition : undefined, floor: typeof value.floor === "string" ? value.floor : undefined, -- 2.45.3