From ef166eaf2191d399e22dc19482a32d1f350af936 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 2 Sep 2026 17:43:56 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera-public):=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D0=B9=20=D0=B2?= =?UTF-8?q?=20coverage-=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B5=20=D1=80=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка «уверенные + средние + пустые = 100» была верна по определению средней доли и упасть не могла. Инвариант перенесён на сам замер: уверенные и пустые обязаны оставлять место третьей доле. Если её всё-таки не останется (перезамер 85 + 15), текст теперь двухчастный — «Ещё в 0 данных меньше» отрендериться не может. `street_name !== null` считал пустую строку названием улицы, и вторая такая строка вылетала из игры как «повтор». Проверка на пустоту. --- .../__tests__/coverage-copy.test.ts | 21 +++++++++++++- .../__tests__/varied-rounds.test.ts | 8 ++++++ .../mera-public/_components/v3/deal-view.ts | 8 ++++-- .../src/app/mera-public/coverage-copy.ts | 28 +++++++++++++++---- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts index 57cb0f5f..e46e5232 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -170,7 +170,12 @@ describe("describeCityExpectation", () => { expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct); expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin); expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct); - expect(measure.confidentPct + thin + measure.emptyPct).toBe(100); + // Инвариант ЗАМЕРА, а не арифметики: уверенные и пустые обязаны + // оставлять место третьей доле, иначе рендер печатает «Ещё в 0». + expect( + measure.confidentPct + measure.emptyPct, + `${measure.city}: средней доле не осталось места`, + ).toBeLessThan(100); // Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает // расчёт там, где следующий же экран в нём откажет. expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch( @@ -179,6 +184,20 @@ describe("describeCityExpectation", () => { } }); + it("когда средней доли не осталось — предложения про неё нет вовсе", () => { + // Перезамер вида 85 уверенных + 15 пустых законен, и «Ещё в 0 данных + // меньше» на нём было бы сообщением о доле, которой не существует. + const said = describeCityExpectation("Ревда", { + city: "Ревда", + confidentPct: 85, + emptyPct: 15, + sampleN: 100, + }); + expect(said?.text).not.toMatch(/Ещё в 0|в 0 /); + expect((said?.text.match(/\d+/g) ?? []).map(Number)).toEqual([85, 100, 15]); + expect(said?.text).toMatch(/считать не станем/); + }); + it("неизвестный город не выдумывает величину", () => { expect(describeCityExpectation("Москва")).toBeNull(); }); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts index 88aa458a..8143d250 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/varied-rounds.test.ts @@ -67,6 +67,14 @@ describe("pickVariedDeals", () => { expect(pickVariedDeals(noStreet, 2).map((d) => d.area_m2)).toEqual([38, 58]); }); + it("пустая строка в street_name — не ключ улицы, а её отсутствие", () => { + // `!== null` считал "" настоящим названием, и вторая такая строка + // вылетала как «повтор улицы» — повтора здесь нет, есть два молчания. + // Третья строка — та, которую при этом брали ВМЕСТО второй. + const blank = [deal(1, "", 38), deal(2, "", 58), deal(2, "улица Мира", 60)]; + expect(pickVariedDeals(blank, 2).map((d) => d.area_m2)).toEqual([38, 58]); + }); + it("выбор детерминирован — страница рендерится на сервере", () => { // Разный набор на сервере и в браузере — это разъехавшаяся гидрация, // поэтому никакого случайного выбора здесь быть не может. diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts index 0a5bdcda..5bbd2f0e 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts @@ -151,11 +151,13 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show if (picked.length >= n) break; if (rooms.has(deal.rooms)) continue; // Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она - // про неё молчит, и держать её из-за этого вне игры не за что. - if (deal.street_name !== null && streets.has(deal.street_name)) continue; + // про неё молчит, и держать её из-за этого вне игры не за что. Проверка + // именно на пустоту, а не на `!== null`: пустая строка — то же самое + // молчание, и второй такой строке нельзя вылетать как «повтору улицы». + if (deal.street_name && streets.has(deal.street_name)) continue; picked.push(deal); rooms.add(deal.rooms); - if (deal.street_name !== null) streets.add(deal.street_name); + if (deal.street_name) streets.add(deal.street_name); } for (const deal of deals) { if (picked.length >= n) break; diff --git a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts index ffa884a0..442c0edb 100644 --- a/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts +++ b/tradein-mvp/frontend/src/app/mera-public/coverage-copy.ts @@ -24,6 +24,7 @@ */ import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts"; +import type { CityCoverageMeasure } from "./landing-facts"; import type { CoverageProbe } from "./public-api"; export interface CoverageTile { @@ -104,8 +105,13 @@ export interface CityExpectation { * что выбрасывать города из списка не за что. Разница между ними * количественная — её и показываем числом, а не отсутствием опции. */ -export function describeCityExpectation(city: string): CityExpectation | null { - const measure = CITY_COVERAGE.find((m) => m.city === city); +export function describeCityExpectation( + city: string, + // Замер вторым аргументом — чтобы ветку «средней доли не осталось» можно + // было проверить, не подделывая CITY_COVERAGE: сегодня такого города в + // замере нет, а появиться он может при первом же перезамере. + measure: CityCoverageMeasure | undefined = CITY_COVERAGE.find((m) => m.city === city), +): CityExpectation | null { if (!measure) return null; // Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где @@ -113,12 +119,22 @@ export function describeCityExpectation(city: string): CityExpectation | null { // них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» — // две наши же строки спорили друг с другом. Делим остаток надвое. const thin = 100 - measure.confidentPct - measure.emptyPct; + const head = + `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} ` + + "проверках из 100. "; + // Средней доли может не остаться вовсе (перезамер вида 85 уверенных + 15 + // пустых) — тогда предложение про неё выпадает целиком. «Ещё в 0 данных + // меньше» было бы не округлением, а сообщением о доле, которой нет. + const empty = thin > 0 ? measure.emptyPct : 100 - measure.confidentPct; + const tail = + `В оставшихся ${empty} рядом не находится ни одной похожей квартиры — ` + + "так и скажем, а считать не станем."; return { text: - `В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` + - `из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + - `ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` + - "похожей квартиры — так и скажем, а считать не станем.", + thin > 0 + ? `${head}Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` + + `ответе это будет написано. ${tail}` + : head + tail, source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`, }; }