diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts
index b8e81e6b..6e34f1ab 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts
@@ -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: /(? {
"
×2,4 дольше продаётся
",
"
150 ₽
",
"",
+ // Четыре строки ревьюера. Он вписал их в DealsTickerV3, и гейт остался
+ // зелёным 17/17: каждая проходила МИМО прежних шести форм. Они здесь не
+ // ради полноты — без них правило «сканер проверен на образцах» повторяло
+ // то, что сканер и так знает, то есть покрытия не расширяло.
+ "
',
'/* в макете стояло 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);
});
});