fix(mera-public): честный текст покрытия и разные квартиры в раундах игры
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m8s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m8s
Остаток «100 − уверенные» в подписи города был однородным только на словах: внутрь него входят пустые пробы, на которые describeCoverage отвечает «рядом не продаётся ничего похожего» и расчёт не предлагает. Текст обещал расчёт и на них — две наши же строки спорили друг с другом. Остаток разложен на долю «посчитаем, но разброс шире» и долю «считать не станем». Возраст объявления бэкенд считает percentile_cont(0.5) — это медиана, а «в среднем» под скошенным распределением называет другую величину. Раунды игры брались первыми тремя строками витрины, а она отсортирована по полноте и свежести: на проде это давало три студии с одной улицы. Выбор жадный по комнатности и улице, детерминированный — страница серверная.
This commit is contained in:
parent
d8b7de2cf6
commit
c093212eae
5 changed files with 168 additions and 14 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,30 @@ 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);
|
||||
expect(measure.confidentPct + thin + measure.emptyPct).toBe(100);
|
||||
// Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает
|
||||
// расчёт там, где следующий же экран в нём откажет.
|
||||
expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch(
|
||||
/считать не станем/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("неизвестный город не выдумывает величину", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Какие сделки попадают в игру «Угадай цену».
|
||||
*
|
||||
* Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому
|
||||
* первые три её строки на проде оказывались тремя студиями с одной улицы: игра
|
||||
* трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора
|
||||
* — какие именно строки взяты, — а не факт вызова функции.
|
||||
*/
|
||||
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("выбор детерминирован — страница рендерится на сервере", () => {
|
||||
// Разный набор на сервере и в браузере — это разъехавшаяся гидрация,
|
||||
// поэтому никакого случайного выбора здесь быть не может.
|
||||
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,40 @@ 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% строк — такая строка не «повторяет улицу», она
|
||||
// про неё молчит, и держать её из-за этого вне игры не за что.
|
||||
if (deal.street_name !== null && streets.has(deal.street_name)) continue;
|
||||
picked.push(deal);
|
||||
rooms.add(deal.rooms);
|
||||
if (deal.street_name !== null) 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,7 +17,8 @@
|
|||
* объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё
|
||||
* продаются, реальная экспозиция проданных всегда меньше. Написать под
|
||||
* цифрой «за столько продаются» — соврать; поэтому подпись здесь одна и
|
||||
* тестом запинена. И при `median === null` (бэкенд занулил её, когда
|
||||
* тестом запинена. Величина при этом МЕДИАННАЯ (`percentile_cont(0.5)` на
|
||||
* бэкенде), а не средняя — «в среднем» под ней было такой же неправдой. И при `median === null` (бэкенд занулил её, когда
|
||||
* возраст известен меньше чем по пяти объявлениям) плитки просто нет —
|
||||
* ноль вместо неизвестного значения был бы худшей из ошибок.
|
||||
*/
|
||||
|
|
@ -73,10 +74,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}`
|
||||
: "столько и дольше висит каждое второе объявление",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -103,13 +108,17 @@ export function describeCityExpectation(city: string): CityExpectation | null {
|
|||
const measure = CITY_COVERAGE.find((m) => m.city === city);
|
||||
if (!measure) return null;
|
||||
|
||||
const rest = 100 - measure.confidentPct;
|
||||
// Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где
|
||||
// считать нечего. Пока он назывался одним числом, текст обещал расчёт и на
|
||||
// них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» —
|
||||
// две наши же строки спорили друг с другом. Делим остаток надвое.
|
||||
const thin = 100 - measure.confidentPct - measure.emptyPct;
|
||||
return {
|
||||
text:
|
||||
`В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` +
|
||||
`из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` +
|
||||
`будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` +
|
||||
"не находится ни одной похожей квартиры.",
|
||||
`из 100. Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` +
|
||||
`ответе это будет написано. В оставшихся ${measure.emptyPct} рядом не находится ни одной ` +
|
||||
"похожей квартиры — так и скажем, а считать не станем.",
|
||||
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