fix(mera/b2c): гейт витринных чисел ловит происхождение, а не форму записи

Ревьюер нашёл три дыры и воспроизвёл каждую на живом коде.

1. Сканер проверял ФОРМУ. Четыре маркетинговых утверждения, вписанные в
   DealsTickerV3, оставляли гейт зелёным 17/17: «9 из 10» (доля словами),
   «42700 квартир» (разделитель тысяч никакой + единица вне списка),
   «в 2 раза точнее» (кратность словами), «42 городах». Последнее — строка
   из таблицы расхождений СТАРОГО гейта, то есть новый пропускал ровно тот
   класс вранья, ради которого заводился. Добавлены четыре формы; эти же
   четыре строки внесены в контроль на инструмент — прежние шесть образцов
   повторяли то, что сканер и так знал, и покрытия не расширяли.

2. noindex перестал охраняться. `toContain("robots")` остаётся зелёным при
   `index: true` — проверено переворотом флага. Вернулась проверка значения
   (не слабее прежней `noindexIsOn()`), и не только в layout, но и в
   метаданных самой страницы, которые layout перекрывают.

3. Список файлов отставал от страницы: корень рендерил 15 компонентов,
   сканировались 11 — «возражения», шапка, подвал и sticky-CTA были вне
   охраны. Список больше не пишется руками: он выводится из каталога
   компонентов, а отдельная проверка требует, чтобы всё, что импортирует
   корень, попало в этот список (ловит компонент, положенный мимо каталога).

Гейт на этой ветке КРАСНЫЙ и красный по делу: расширенный список нашёл
«Проверить за 30 секунд» в HeaderV3, StickyCtaV3, ObjectionsV3 и
FreeCheckCard. Замера времени заполнения формы нет и не было — это записано
в landing-facts.ts и в StepsV3, откуда ту же фразу уже сняли. Правка текста —
вне участка этой задачи (файлы вёрстки правит соседняя ветка).
This commit is contained in:
bot-backend 2026-08-29 20:17:11 +05:00
parent 2c9b4ab547
commit b17a2a310c

View file

@ -29,8 +29,8 @@
* их регуляркой значило бы требовать переписать статью в константы; правило
* (2) там не поможет, а правило (1) дало бы шум вместо охраны.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { readdirSync, readFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "vitest";
@ -42,36 +42,62 @@ function read(rel: string): string {
return readFileSync(join(ROOT, rel), "utf8");
}
/** Компоненты, которые рисуют витринные величины. */
/**
* Корень витрины и каталог её компонентов.
*
* КОРЕНЬ `page.tsx`, а не `v3/page.tsx`: 29.08.2026 превью стало корнем,
* каталог `v3/` удалён. Путь тут один и используется и для скана, и для
* разбора импортов ниже; разъехаться половинки не могут, а `read()` на
* несуществующем файле падает громко, а не молча пропускает проверку.
*/
const LANDING_ROOT = "page.tsx";
const V3_DIR = "_components/v3";
/**
* Что сканируем. Список НЕ пишется руками: он выводится из каталога
* компонентов витрины плюс сам корень страницы. Захардкоженный перечень уже
* отстал однажды страница рендерила пятнадцать компонентов, в списке стояло
* одиннадцать, и «возражения» (естественное место для «в 9 из 10 случаев»)
* оказались вне охраны. Каталог отстать не может: файл, положенный рядом с
* остальными, попадает под гейт тем же коммитом, которым появился.
*
* Компонент, положенный ВНЕ каталога, каталогом не ловится за это отвечает
* отдельная проверка «всё, что импортирует корень, попало в список».
*/
const V3_SOURCES = [
"page.tsx",
"_components/v3/HeroV3.tsx",
"_components/v3/AccuracyV3.tsx",
"_components/v3/CostOfErrorV3.tsx",
"_components/v3/TwoPathsV3.tsx",
"_components/v3/StepsV3.tsx",
"_components/v3/DealsTickerV3.tsx",
"_components/v3/GuessGameV3.tsx",
"_components/v3/FreeCheckCard.tsx",
"_components/v3/FreeResultV3.tsx",
"_components/v3/SourcesV3.tsx",
"_components/v3/WhatYouGetV3.tsx",
"_components/v3/deal-view.ts",
LANDING_ROOT,
...readdirSync(join(ROOT, V3_DIR))
.filter((name) => /\.tsx?$/u.test(name))
.sort()
.map((name) => `${V3_DIR}/${name}`),
];
/**
* Формы, в которых величина попадает на экран. Ищутся ИМЕННО они, а не любые
* цифры: `useState(0)`, `slice(0, 3)` и `toFixed(2)` это код, а «14,5 %»,
* «42 700», «±6 дн.» и «×2,4» утверждения о рынке.
*
* ЧЕГО СКАНЕР НЕ ЛОВИЛ ДО 29.08.2026. Ревьюер вписал в DealsTickerV3 четыре
* маркетинговых утверждения, и гейт остался зелёным: он проверял ФОРМУ записи,
* а формы были соседние. «42700» разделитель тысяч не пробелом, а никак;
* «квартир»/«городах» единица вне короткого списка; «в 2 раза» кратность
* словами вместо «×2»; «9 из 10» доля словами. Последнее особенно дорого:
* «42 города РФ» стояло отдельной строкой в таблице расхождений СТАРОГО гейта,
* то есть новый пропускал ровно тот класс вранья, ради которого заводился.
*/
const MEASUREMENT_SHAPES: readonly { rx: RegExp; what: string }[] = [
{ rx: /\d[\s ]\d{3}/u, what: "число с разделителем тысяч (42 700)" },
{ rx: /(?<!\d)\d{4,}(?!\d)/u, what: "число от четырёх знаков без разделителя (42700)" },
{ rx: /\d,\d/u, what: "десятичная дробь через запятую (1,8)" },
{
rx: /\d\s*(?:%||дн\.|дней|мес\.|млн|тыс\.|м²|сделок|аналог|объявлен|оцен)/iu,
what: "число с единицей измерения (83%, 150 ₽, ±6 дн.)",
rx: /\d\s*(?:%||дн\.|дней|мес\.|млн|тыс\.|м²|сек|минут|сделок|аналог|объявлен|оцен|квартир|город|объект|дом[ао]|проверок|прогноз|район|клиент|человек|регион)/iu,
what: "число с единицей измерения (83%, 150 ₽, ±6 дн., 42 700 квартир, 42 города)",
},
{ rx: /×\s*\d/u, what: "кратность (×2,4)" },
// `\b` здесь не годится: границей слова JS считает край [A-Za-z0-9_], и
// перед кириллическим «в» она не срабатывает никогда. Якорим по цифре.
{ rx: /\d\s*раз/iu, what: "кратность словами (в 2 раза точнее)" },
{ rx: /\d\s+из\s+\d/iu, what: "доля словами (9 из 10)" },
];
/**
@ -125,6 +151,14 @@ describe("гейт витринных чисел (#2904)", () => {
"<div>×2,4 дольше продаётся</div>",
"<div>150 ₽</div>",
"<button>Открыть за 150&nbsp;₽</button>",
// Четыре строки ревьюера. Он вписал их в DealsTickerV3, и гейт остался
// зелёным 17/17: каждая проходила МИМО прежних шести форм. Они здесь не
// ради полноты — без них правило «сканер проверен на образцах» повторяло
// то, что сканер и так знает, то есть покрытия не расширяло.
"<div>Точность 9 из 10 прогнозов</div>",
"<div>Проверено более 42700 квартир по всей России</div>",
"<div>МЕРА в 2 раза точнее риелтора</div>",
"<div>Работаем в 42 городах</div>",
]) {
expect(handwrittenValues(bad), `сканер не увидел вписанное число: ${bad}`).not.toEqual([]);
}
@ -134,18 +168,75 @@ describe("гейт витринных чисел (#2904)", () => {
'const STEP_MLN = 0.05;\nMath.ceil(v * 2) / 2',
'<div id="hero-v3-title">{formatStat(stats, "analogs_median", { digits: 1 })?.text}</div>',
'/* в макете стояло 1,8% и 42 700 — снято */\nconst x = 1;',
// Живые строки из компонентов: счётчик берётся из длины массива, а не
// вписан. Расширяя список единиц, легко начать ругаться и на них.
'ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ: {names}',
'РАУНД {Math.min(idx + 1, rounds.length)} ИЗ {rounds.length}',
'`Показано ${count(s.written)} строк из ${count(s.eligible)} годных`',
]) {
expect(handwrittenValues(good), `сканер ругается на честный код: ${good}`).toEqual([]);
}
});
/**
* Список сканируемых файлов обязан покрывать то, что реально рендерит
* корень. Каталог `_components/v3` ловит соседей автоматически; эта проверка
* ловит компонент, положенный ВНЕ каталога и подключённый к странице,
* случай, при котором каталожный вывод отстал бы так же, как отставал
* захардкоженный список.
*/
it("каждый компонент, который импортирует корень страницы, попал в список сканируемых", () => {
// Корень лежит в самом `mera-public/`, поэтому его относительные импорты
// разрешаются от ROOT. Пока страница жила в `v3/`, здесь стоял `v3` —
// после переезда тот же путь дал бы `_components/` мимо каталога, и
// проверка молча перестала бы что-либо находить.
const pageDir = resolve(ROOT);
const rendered = [...read(LANDING_ROOT).matchAll(/from\s+"(\.[^"]+)"/gu)]
.map(([, spec]) => relative(ROOT, resolve(pageDir, spec)).split("\\").join("/"))
.filter((rel) => rel.startsWith("_components/"));
expect(rendered.length, "в корне не нашлось ни одного импорта — зонд сломан").toBeGreaterThan(0);
const missed = rendered.filter((rel) => !V3_SOURCES.some((src) => src === rel || src === `${rel}.tsx` || src === `${rel}.ts`));
expect(missed, [
`корень рендерит ${missed.join(", ")}, а гейт эти файлы не читает.`,
"Компоненты витрины живут в _components/v3 — оттуда список набирается сам.",
].join(" ")).toEqual([]);
});
it("плейсхолдерные экспорты не вернулись под новым именем", () => {
for (const rel of V3_SOURCES) {
expect(stripComments(read(rel)), `${rel} снова тянет *_PLACEHOLDER`).not.toMatch(/_PLACEHOLDER\b/);
}
});
it("блок robots в layout.tsx на месте", () => {
expect(read("layout.tsx"), "layout.tsx не содержит блока robots — зонд сломан").toContain("robots");
/**
* НАЛИЧИЯ СЛОВА `robots` НЕДОСТАТОЧНО. Проверка на подстроку остаётся
* зелёной при `index: true` то есть ровно тогда, когда страница уходит в
* индекс. Поэтому сверяется значение.
*
* ПРОВЕРЯЕТСЯ ПАРА, А НЕ ОДИН ФАЙЛ. Пока витрина жила на `v3/page.tsx`, у
* неё был собственный `export const metadata` и флаг стоял в двух местах
* обоих и требовали. С переездом в корень второй экземпляр снят намеренно
* (см. шапку `page.tsx`): метаданные держит только layout. Требовать
* `noindex` в `page.tsx` теперь значило бы требовать вернуть дубль, а просто
* убрать проверку оставить дыру: страничные `metadata` в Next
* ПЕРЕКРЫВАЮТ layout, и `index: true` на уровне корня погасил бы флаг, не
* тронув файла, за которым тут следят. Отсюда две половины: в layout стоит
* noindex, а корень своих метаданных не заводит вовсе.
*/
const NOINDEX = /robots:\s*\{\s*index:\s*false\s*,\s*follow:\s*false\s*\}/u;
it("в layout.tsx стоит noindex, а не просто слово robots", () => {
expect(
read("layout.tsx"),
"layout.tsx: витрина открыта поисковикам — маршрут наружу ещё не открыт",
).toMatch(NOINDEX);
});
it("корень не заводит своих metadata — иначе noindex из layout перекрыт незаметно", () => {
expect(
stripComments(read(LANDING_ROOT)),
`${LANDING_ROOT}: появился export const metadata — он перекрывает layout, и noindex надо проверять здесь же`,
).not.toMatch(/export\s+const\s+metadata\b/u);
});
});