fix(mera-public): честный текст покрытия и разные квартиры в раундах игры #3337
5 changed files with 216 additions and 17 deletions
|
|
@ -63,7 +63,18 @@ describe("describeCoverage", () => {
|
|||
it("когда дата известна у всех — доля не упоминается вовсе", () => {
|
||||
// «дата известна у 30 из 30» — шум: доля есть, но она ничего не сообщает.
|
||||
const [, ageTile] = describeCoverage(probe({ n_listings: 30, n_with_age: 30 })).tiles;
|
||||
expect(ageTile.label).toBe("столько в среднем висит объявление");
|
||||
expect(ageTile.label).toBe("столько и дольше висит каждое второе объявление");
|
||||
});
|
||||
|
||||
it("возраст подписан как медиана — «в среднем» под percentile_cont(0.5) неправда", () => {
|
||||
// Бэкенд считает `percentile_cont(0.5)` (trade_in.py::_coverage_probe).
|
||||
// На возрасте объявлений распределение скошено вправо: среднее заметно
|
||||
// больше медианы, и подпись «в среднем» называла бы другую величину.
|
||||
for (const over of [{ n_with_age: 6 }, { n_with_age: 34 }]) {
|
||||
const [, ageTile] = describeCoverage(probe(over)).tiles;
|
||||
expect(ageTile.label).not.toMatch(/в среднем/i);
|
||||
expect(ageTile.label).toMatch(/каждое второе|половин|медиан/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => {
|
||||
|
|
@ -142,9 +153,49 @@ describe("describeCityExpectation", () => {
|
|||
const said = describeCityExpectation("Ревда");
|
||||
expect(said?.text).toContain("проверках");
|
||||
expect(said?.text).not.toMatch(/точност/i);
|
||||
// Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст
|
||||
// утверждает больше, чем измерено.
|
||||
expect(said?.text).toContain("84");
|
||||
});
|
||||
|
||||
it("остаток разложен: где считаем с широким разбросом, а где не считаем вовсе", () => {
|
||||
// Прежний текст называл остаток одним числом (100 − уверенные) и обещал
|
||||
// расчёт на всём нём. Но пустые пробы сидят внутри этого остатка, а
|
||||
// `describeCoverage` отвечает на них «рядом не продаётся ничего похожего»
|
||||
// и платный шаг не предлагает. Числа берём из замера, а не из строки:
|
||||
// подстрокой «3» нашлось бы и внутри «83».
|
||||
for (const measure of CITY_COVERAGE) {
|
||||
const thin = 100 - measure.confidentPct - measure.emptyPct;
|
||||
const said = describeCityExpectation(measure.city);
|
||||
const nums = (said?.text.match(/\d+/g) ?? []).map(Number);
|
||||
|
||||
expect(measure.emptyPct, `${measure.city}: нечего разделять`).toBeGreaterThan(0);
|
||||
expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct);
|
||||
expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin);
|
||||
expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct);
|
||||
// Инвариант ЗАМЕРА, а не арифметики: уверенные и пустые обязаны
|
||||
// оставлять место третьей доле, иначе рендер печатает «Ещё в 0».
|
||||
expect(
|
||||
measure.confidentPct + measure.emptyPct,
|
||||
`${measure.city}: средней доле не осталось места`,
|
||||
).toBeLessThan(100);
|
||||
// Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает
|
||||
// расчёт там, где следующий же экран в нём откажет.
|
||||
expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch(
|
||||
/считать не станем/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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("неизвестный город не выдумывает величину", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Какие сделки попадают в игру «Угадай цену».
|
||||
*
|
||||
* Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому
|
||||
* первые три её строки на проде оказывались тремя студиями с одной улицы: игра
|
||||
* трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора
|
||||
* — какие именно строки взяты, — а не факт вызова функции.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pickVariedDeals } from "../_components/v3/deal-view";
|
||||
import type { ShowcaseDeal } from "../public-api";
|
||||
|
||||
const deal = (rooms: number, street: string | null, area_m2 = 40): ShowcaseDeal =>
|
||||
({
|
||||
district: "Кировский",
|
||||
rooms,
|
||||
area_m2,
|
||||
floor: 3,
|
||||
total_floors: 9,
|
||||
deal_quarter: "II квартал 2026",
|
||||
predicted_rub: 5_000_000,
|
||||
fact_rub: 5_000_000,
|
||||
err_pct: 0,
|
||||
n_analogs: 8,
|
||||
note: "",
|
||||
street_name: street,
|
||||
street_scheme: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
}) as ShowcaseDeal;
|
||||
|
||||
/** Порядок — как на витрине: студии одной улицы стоят первыми. */
|
||||
const showcase: readonly ShowcaseDeal[] = [
|
||||
deal(0, "улица Ленина", 25),
|
||||
deal(0, "улица Ленина", 27),
|
||||
deal(0, "улица Ленина", 29),
|
||||
deal(2, "улица Ленина", 62),
|
||||
deal(1, "улица Мира", 38),
|
||||
deal(2, "улица Щорса", 58),
|
||||
];
|
||||
|
||||
describe("pickVariedDeals", () => {
|
||||
it("три раунда — три разные комнатности и три разные улицы", () => {
|
||||
const picked = pickVariedDeals(showcase, 3);
|
||||
expect(picked).toHaveLength(3);
|
||||
expect(picked.map((d) => d.rooms)).toEqual([0, 1, 2]);
|
||||
expect(picked.map((d) => d.street_name)).toEqual([
|
||||
"улица Ленина",
|
||||
"улица Мира",
|
||||
"улица Щорса",
|
||||
]);
|
||||
// Прежний slice(0, 3) взял бы три студии с Ленина — вот они, и их нет.
|
||||
expect(picked.map((d) => d.area_m2)).toEqual([25, 38, 58]);
|
||||
});
|
||||
|
||||
it("когда разнообразия нет — берём сколько есть, а не показываем меньше раундов", () => {
|
||||
// Игра не рендерится, пока раундов меньше трёх. Отдать два «ради чистоты
|
||||
// выбора» значило бы погасить секцию из-за однородной витрины.
|
||||
const same = [deal(0, "улица Ленина", 25), deal(0, "улица Ленина", 27), deal(0, "улица Ленина", 29)];
|
||||
expect(pickVariedDeals(same, 3)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("строки без улицы не считаются повтором улицы", () => {
|
||||
// `street_name` пуст примерно у 8% строк — такая строка про улицу молчит.
|
||||
const noStreet = [deal(1, null, 38), deal(1, "улица Мира", 40), deal(2, null, 58)];
|
||||
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("выбор детерминирован — страница рендерится на сервере", () => {
|
||||
// Разный набор на сервере и в браузере — это разъехавшаяся гидрация,
|
||||
// поэтому никакого случайного выбора здесь быть не может.
|
||||
const first = pickVariedDeals(showcase, 3);
|
||||
const second = pickVariedDeals(showcase, 3);
|
||||
expect(first.map((d) => d.area_m2)).toEqual(second.map((d) => d.area_m2));
|
||||
});
|
||||
});
|
||||
|
|
@ -130,6 +130,42 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Строки витрины для игры — РАЗНЫЕ по типу квартиры и по улице.
|
||||
*
|
||||
* `slice(0, n)` брал первые в порядке витрины, а он отсортирован по полноте и
|
||||
* свежести (`_sort_key` в `landing_showcase_deals.py`), а не по разнообразию:
|
||||
* на проде это давало три студии с одной улицы, и игра выглядела как одна и та
|
||||
* же квартира трижды. Жадно берём первую строку, у которой И комнатность, И
|
||||
* улица ещё не встречались; когда таких не осталось — добираем первыми
|
||||
* попавшимися, потому что раундов должно быть столько, сколько просили.
|
||||
*
|
||||
* Порядок строго по входу: страница рендерится на сервере, и любой `random`
|
||||
* здесь означал бы разный набор на сервере и в браузере.
|
||||
*/
|
||||
export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): ShowcaseDeal[] {
|
||||
const picked: ShowcaseDeal[] = [];
|
||||
const rooms = new Set<number>();
|
||||
const streets = new Set<string>();
|
||||
for (const deal of deals) {
|
||||
if (picked.length >= n) break;
|
||||
if (rooms.has(deal.rooms)) continue;
|
||||
// Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она
|
||||
// про неё молчит, и держать её из-за этого вне игры не за что. Проверка
|
||||
// именно на пустоту, а не на `!== null`: пустая строка — то же самое
|
||||
// молчание, и второй такой строке нельзя вылетать как «повтору улицы».
|
||||
if (deal.street_name && streets.has(deal.street_name)) continue;
|
||||
picked.push(deal);
|
||||
rooms.add(deal.rooms);
|
||||
if (deal.street_name) streets.add(deal.street_name);
|
||||
}
|
||||
for (const deal of deals) {
|
||||
if (picked.length >= n) break;
|
||||
if (!picked.includes(deal)) picked.push(deal);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разброс расхождений ПОКАЗАННЫХ строк.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@
|
|||
* объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё
|
||||
* продаются, реальная экспозиция проданных всегда меньше. Написать под
|
||||
* цифрой «за столько продаются» — соврать; поэтому подпись здесь одна и
|
||||
* тестом запинена. И при `median === null` (бэкенд занулил её, когда
|
||||
* тестом запинена. Величина при этом МЕДИАННАЯ (`percentile_cont(0.5)` на
|
||||
* бэкенде), а не средняя — «в среднем» под ней было такой же неправдой. И при `median === null` (бэкенд занулил её, когда
|
||||
* возраст известен меньше чем по пяти объявлениям) плитки просто нет —
|
||||
* ноль вместо неизвестного значения был бы худшей из ошибок.
|
||||
*/
|
||||
|
||||
import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts";
|
||||
import type { CityCoverageMeasure } from "./landing-facts";
|
||||
import type { CoverageProbe } from "./public-api";
|
||||
|
||||
export interface CoverageTile {
|
||||
|
|
@ -73,10 +75,14 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] {
|
|||
const partial = probe.n_with_age < probe.n_listings;
|
||||
tiles.push({
|
||||
value: `${probe.median_listing_age_days} дн.`,
|
||||
// Формулировка запинена тестом — см. шапку модуля.
|
||||
// Формулировка запинена тестом — см. шапку модуля. «В среднем» отсюда
|
||||
// убрано 02.09: бэкенд считает `percentile_cont(0.5)`
|
||||
// (`trade_in.py::_coverage_probe`) — это МЕДИАНА, и на скошенном вправо
|
||||
// возрасте объявлений она заметно меньше среднего. «Каждое второе» —
|
||||
// то же самое утверждение словами, которые не надо расшифровывать.
|
||||
label: partial
|
||||
? `столько в среднем висит объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}`
|
||||
: "столько в среднем висит объявление",
|
||||
? `столько и дольше висит каждое второе объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}`
|
||||
: "столько и дольше висит каждое второе объявление",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -99,17 +105,36 @@ 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;
|
||||
|
||||
const rest = 100 - measure.confidentPct;
|
||||
// Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где
|
||||
// считать нечего. Пока он назывался одним числом, текст обещал расчёт и на
|
||||
// них тоже, а `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; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` +
|
||||
`будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` +
|
||||
"не находится ни одной похожей квартиры.",
|
||||
thin > 0
|
||||
? `${head}Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` +
|
||||
`ответе это будет написано. ${tail}`
|
||||
: head + tail,
|
||||
source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3";
|
|||
import { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
|
||||
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
|
||||
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3";
|
||||
import { toGameRound } from "./_components/v3/deal-view";
|
||||
import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view";
|
||||
import { fetchLandingStats, fetchShowcase } from "./public-api";
|
||||
import styles from "./landing.module.css";
|
||||
|
||||
|
|
@ -81,7 +81,9 @@ const MIN_ROUNDS = 3;
|
|||
export default async function MeraPublicPage() {
|
||||
const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]);
|
||||
const deals = showcase?.deals ?? [];
|
||||
const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound);
|
||||
// Не первые три, а три РАЗНЫЕ — иначе игра предлагает угадать одну и ту же
|
||||
// студию с одной улицы трижды (см. `pickVariedDeals`).
|
||||
const rounds = pickVariedDeals(deals, MIN_ROUNDS).map(toGameRound);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue