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:
parent
2c9b4ab547
commit
b17a2a310c
1 changed files with 111 additions and 20 deletions
|
|
@ -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 ₽</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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue