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}.`, }; }