From b17a2a310c0bbc55363a2639e51b71e7381cd49e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 20:17:11 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera/b2c):=20=D0=B3=D0=B5=D0=B9=D1=82=20?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D0=BD=D1=8B=D1=85=20=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D0=B5=D0=BB=20=D0=BB=D0=BE=D0=B2=D0=B8=D1=82=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B8=D1=81=D1=85=D0=BE=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D0=B0=20=D0=BD=D0=B5=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=83=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревьюер нашёл три дыры и воспроизвёл каждую на живом коде. 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, откуда ту же фразу уже сняли. Правка текста — вне участка этой задачи (файлы вёрстки правит соседняя ветка). --- .../__tests__/landing-numbers-gate.test.ts | 131 +++++++++++++++--- 1 file changed, 111 insertions(+), 20 deletions(-) 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: каждая проходила МИМО прежних шести форм. Они здесь не + // ради полноты — без них правило «сканер проверен на образцах» повторяло + // то, что сканер и так знает, то есть покрытия не расширяло. + "
Точность 9 из 10 прогнозов
", + "
Проверено более 42700 квартир по всей России
", + "
МЕРА в 2 раза точнее риелтора
", + "
Работаем в 42 городах
", ]) { expect(handwrittenValues(bad), `сканер не увидел вписанное число: ${bad}`).not.toEqual([]); } @@ -134,18 +168,75 @@ describe("гейт витринных чисел (#2904)", () => { 'const STEP_MLN = 0.05;\nMath.ceil(v * 2) / 2', '
{formatStat(stats, "analogs_median", { digits: 1 })?.text}
', '/* в макете стояло 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); }); });