fix(mera-public): защиты вместо тавтологий в coverage-тексте и выборе раундов
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m9s

Проверка «уверенные + средние + пустые = 100» была верна по определению
средней доли и упасть не могла. Инвариант перенесён на сам замер: уверенные и
пустые обязаны оставлять место третьей доле. Если её всё-таки не останется
(перезамер 85 + 15), текст теперь двухчастный — «Ещё в 0 данных меньше»
отрендериться не может.

`street_name !== null` считал пустую строку названием улицы, и вторая такая
строка вылетала из игры как «повтор». Проверка на пустоту.
This commit is contained in:
bot-backend 2026-09-02 17:43:56 +05:00
parent c093212eae
commit ef166eaf21
4 changed files with 55 additions and 10 deletions

View file

@ -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();
});

View file

@ -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("выбор детерминирован — страница рендерится на сервере", () => {
// Разный набор на сервере и в браузере — это разъехавшаяся гидрация,
// поэтому никакого случайного выбора здесь быть не может.

View file

@ -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;

View file

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