From 00f177df036350ead5259b8e24a4053a4ff3bf93 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 14:53:05 +0500 Subject: [PATCH 1/4] =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3:?= =?UTF-8?q?=20=D0=B8=D0=BC=D1=8F=20=D0=B1=D0=BE=D0=B5=D0=B2=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BD=D0=BE=20=D0=B8=D0=B7=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026)» доезжала до экрана: CITY_COVERAGE_SOURCE собирается в coverage-copy.ts и рендерится в EstimateFlow.tsx:323. На первом экране лендинга строки не видно — она появляется в форме проверки, после выбора города, поэтому grep по отрендеренной главной давал ноль и дефект выглядел несуществующим. Хостнейм посетителю не сообщает ничего: «на боевой базе» несёт весь смысл. Убран из обеих строк-источников (сверка бэктеста и проба покрытия) и из комментария в статьях. Гейт смотрит в ИСХОДНИК, а не в вывод: какой путь рендера показывает строку сегодня, знать не нужно — завтра он будет другим. Список запретов явный, без эвристики «похоже на хостнейм»: она ловила бы domclick.ru и avito.ru в текстах про источники данных. gendsgn.ru в список НЕ входит — это публичный домен B2B, на который лендинг ссылается кнопкой «Для бизнеса». Гейт проверен фальсификацией: возврат хостнейма даёт красное с указанием landing-facts.ts:207, а не пустой список. --- .../__tests__/public-copy-no-infra.test.ts | 90 +++++++++++++++++++ .../mera-public/articles/articles-content.ts | 2 +- .../src/app/mera-public/landing-facts.ts | 4 +- 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-infra.test.ts diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-infra.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-infra.test.ts new file mode 100644 index 00000000..6b7d71e7 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-infra.test.ts @@ -0,0 +1,90 @@ +/** + * Инфраструктурные имена не должны попадать в файлы пользовательской копии. + * + * 30.08.2026 «Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026)» + * доехала до экрана: `CITY_COVERAGE_SOURCE` собирается в `coverage-copy.ts` и + * рендерится в `EstimateFlow.tsx:323`. На первом экране лендинга её не видно — + * она появляется в форме проверки, после выбора города. Поэтому grep по + * отрендеренной главной давал ноль, и дефект выглядел несуществующим. + * + * Отсюда форма гейта: он смотрит в ИСХОДНИК, а не в вывод. Какой именно путь + * рендера сегодня показывает строку, знать не нужно — завтра он будет другим, + * а хостнейм прода посетителю не сообщает ничего ни на одном из них. + * + * Скан идёт по всему тексту файла, включая комментарии: комментарий рядом с + * копией — заготовка следующей строки копии, и «на боевой базе» в нём ничем + * не хуже имени машины. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, ".."); + +/** + * Имена машин, контейнеров и внутренних хостов. Список ЯВНЫЙ, а не эвристика: + * эвристика на «что-то похожее на хостнейм» ловила бы доменные имена в текстах + * про источники данных (domclick.ru, avito.ru), которые тут законны. + * + * `gendsgn.ru` в список НЕ входит намеренно: это публичный домен B2B, на + * который лендинг ссылается кнопкой «Для бизнеса» (`B2B_URL` в content.ts). + * Запрет на него сломал бы рабочую ссылку — граница проходит по «внутреннее + * имя машины» против «адрес, который посетитель и так увидит в браузере». + */ +const BANNED = [ + "poincare", + "beget", + "tradein-postgres", + "tradein-backend", + "tradein-scraper", + "gendesign-postgres", + "gendesign-backend", + "glitchtip", + "forgejo", +]; + +/** Файлы копии: то, из чего собирается видимый посетителю текст. */ +function copySources(dir: string, acc: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (name === "__tests__" || name === "node_modules") continue; + const full = join(dir, name); + if (statSync(full).isDirectory()) { + copySources(full, acc); + } else if (name.endsWith(".ts") || name.endsWith(".tsx")) { + acc.push(full); + } + } + return acc; +} + +describe("публичная копия не содержит инфраструктурных имён", () => { + const files = copySources(ROOT); + + it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => { + // Без этой проверки сломанный обход каталога дал бы пустой список и + // «нарушений нет» на любом содержимом. + expect(files.length).toBeGreaterThan(20); + expect(files.some((f) => f.endsWith("landing-facts.ts"))).toBe(true); + expect(files.some((f) => f.endsWith("coverage-copy.ts"))).toBe(true); + }); + + it.each(BANNED)("нигде не встречается «%s»", (needle) => { + const hits: string[] = []; + for (const file of files) { + const text = readFileSync(file, "utf8"); + text.split("\n").forEach((line, i) => { + if (line.toLowerCase().includes(needle)) { + hits.push(`${file.slice(ROOT.length + 1)}:${i + 1}`); + } + }); + } + expect( + hits, + `«${needle}» — инфраструктурное имя в файле пользовательской копии. ` + + `Посетителю оно не сообщает ничего: «на боевой базе» несёт весь смысл. ` + + `Строка может не рендериться сегодня и начать рендериться завтра — ` + + `так и вышло с CITY_COVERAGE_SOURCE в EstimateFlow.tsx.`, + ).toEqual([]); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts b/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts index 7f0bfba8..e9b44953 100644 --- a/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts @@ -4,7 +4,7 @@ * ЧИСЛА ИЗ МАКЕТА СНЯТЫ (29.08.2026). Макет утверждал «по нашим данным, * средний разрыв между первой ценой в объявлении и ценой сделки — 4,1%, а у * провисевших дольше трёх месяцев доходит до 9%», «расхождение с кадастровой - * до 40%» и плитки −4,3% / ×2,4 / 83%. Прод-замер (poincare, 29.08.2026) + * до 40%» и плитки −4,3% / ×2,4 / 83%. Прод-замер (29.08.2026) * не подтвердил ни одно: * * | макет | что показал замер | diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index 176dbe4c..e1ee7300 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -44,7 +44,7 @@ export interface MeasuredValue { /** Общий источник трёх величин ниже — один и тот же прогон сверки. */ const BACKTEST_SOURCE = - "Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " + + "Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " + "Росреестра по Екатеринбургу, сделки II квартала 2026 года"; /** @@ -204,7 +204,7 @@ export interface CityCoverageMeasure { } export const CITY_COVERAGE_SOURCE = - "Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026): случайные адреса " + + "Симуляция пробы покрытия на боевой базе (30.08.2026): случайные адреса " + "активных объявлений, для каждого — когорта самой ручки /coverage (радиус 1 км, то же " + "число комнат, площадь ±15 %, свежесть 14 дней, дедуп по источнику и адресу), " + "собственный адрес из когорты исключён"; From 7d6caed5fb9c19b9345b64d8258f19f99c660dae Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 14:54:52 +0500 Subject: [PATCH 2/4] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=20=D0=BD=D0=B0=D0=B4=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D1=8B=D0=BC=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=BC?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0=D0=B5=D1=82=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Над лентой стояло «медианное расхождение с ценой ДКП — 14,5 % по 327 сделкам», а в самой ленте 5 строк из 20 расходились больше чем на 30 % (75,7 · 63,6 · 47,4 · 41,4 · 38,5). Первое, что видел посетитель страницы про точность, — крупный промах без единой цифры контекста. Промахи не прячутся: строки витрины отобраны по ПОЛНОТЕ и СВЕЖЕСТИ (_sort_key в app/tasks/landing_showcase_deals.py), отбор по величине ошибки был дефектом и снят. Лечится контекстом — под лентой печатается тот же разброс ПОКАЗАННЫХ строк, что уже стоит под таблицей сверок: медиана модуля и худшая, обе из shownSpread() (deal-view.ts), второго расчёта не заводится. Вывода вида «зато обычно точно» в подписи нет: он протух бы на первом пересчёте витрины, а два числа рядом — нет. Разметка: бегущая часть выделена в .tickerStrip — position/overflow нужны только ей, иначе абсолютный бейдж растянулся бы и на подпись. Проверка (landing-v3-render.test.tsx): те же строки с худшей и без неё дают РАЗНЫЕ числа в подписи — она сосчитана по показанному, а не вписана. Co-Authored-By: Claude Opus 5 --- .../__tests__/landing-v3-render.test.tsx | 29 +++++++++ .../_components/v3/DealsTickerV3.tsx | 64 ++++++++++++------- .../src/app/mera-public/landing-v3.module.css | 15 ++++- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx index 2fdbbac7..4a3c7aca 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx @@ -10,6 +10,7 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { AccuracyV3 } from "../_components/v3/AccuracyV3"; +import { DealsTickerV3 } from "../_components/v3/DealsTickerV3"; import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3"; import { HeroV3 } from "../_components/v3/HeroV3"; import type { LandingStat, ShowcaseResponse } from "../public-api"; @@ -136,3 +137,31 @@ describe("витрина лэндинга v3 без данных", () => { expect(screen.queryByText(/По всем расчётам МЕРЫ/)).toBeNull(); }); }); + +/** + * Лента висит НАД первым экраном, и её строки отобраны по полноте и свежести, + * а не по величине ошибки, — крупный промах в ней штатен. Проверяется, что + * рядом с промахом стоит его контекст и что оба числа сосчитаны ПО ПОКАЗАННЫМ + * строкам: подпись с вписанными руками величинами тут же разошлась бы с лентой. + */ +describe("лента сделок: контекст разброса", () => { + const tickerDeal = (err_pct: number) => ({ + ...SHOWCASE.deals[0], + err_pct, + predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)), + }); + + it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => { + render(); + const note = screen.getByText(/Медиана расхождения показанных строк/u); + expect(note.textContent).toContain("11,5 %"); + expect(note.textContent).toContain("75,7 %"); + }); + + it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => { + render(); + const note = screen.getByText(/Медиана расхождения показанных строк/u); + expect(note.textContent).toContain("7,8 %"); + expect(note.textContent).not.toContain("75,7 %"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx index 7f56ef50..4e0a957b 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx @@ -12,6 +12,15 @@ * не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому * объект описан тем, что есть — комнаты, площадь, район, этаж. * + * ПОД лентой — подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же + * функция, что и под таблицей сверок): медиана модуля и худшая. Без неё первое, + * что видит посетитель страницы про точность, — промах в семьдесят процентов + * без единой цифры контекста, опровергающий её же заголовок. Прятать промахи + * нельзя: строки отобраны по ПОЛНОТЕ и СВЕЖЕСТИ (`_sort_key` в + * app/tasks/landing_showcase_deals.py), поэтому лечится контекстом, а не + * отбором. Вывода («зато обычно точно») в подписи нет намеренно: он протух бы + * на первом же пересчёте витрины, а два числа рядом не протухают. + * * Список дублируется дважды подряд — стандартный приём бесшовного CSS-marquee * (анимация уводит ровно на −50%). * @@ -24,34 +33,45 @@ import type { ShowcaseDeal } from "../../public-api"; import styles from "../../landing-v3.module.css"; -import { dealTitle, errPct, rub } from "./deal-view"; +import { absPct, dealTitle, errPct, rub, shownSpread } from "./deal-view"; export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) { const items = [...deals, ...deals]; + // Разброс считается по `deals`, а не по `items`: `items` — та же двадцатка, + // склеенная сама с собой ради бесшовного marquee. Медиана и худшая на ней + // совпали бы, а `n` — нет, и подпись начала бы врать при первой же правке. + const spread = shownSpread(deals); return (
-
-
- ); } diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index c3705e01..5baa8c3f 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -1397,9 +1397,22 @@ /* --- Лента «ПРОГНОЗ → ФАКТ» ---------------------------------------------- */ .tickerRoot { + background: var(--b2c-surface-dark); +} +/* Бегущая часть отделена от подписи: `overflow: hidden` и точка отсчёта для + абсолютного бейджа нужны ТОЛЬКО ей, иначе бейдж растянулся бы на всю высоту + вместе с подписью. */ +.tickerStrip { position: relative; overflow: hidden; - background: var(--b2c-surface-dark); +} +.tickerNote { + margin: 0; + padding: 0 clamp(20px, 5vw, 56px) 10px; + font-family: var(--b2c-font-mono); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--b2c-muted); } .tickerBadge { position: absolute; From 8aac93ee9b928186d7b05f40162655742683873d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 14:57:21 +0500 Subject: [PATCH 3/4] =?UTF-8?q?fix(mera-public):=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=81=D1=82=20WCAG=20AA=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=BC=20=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axe 30.08 нашёл 32 узла color-contrast (все serious). Причина одна на все: пары «цвет × фон» подбирались глазами, а числа в комментариях писались по памяти — шапка b2c-tokens.ts утверждала «onAccent на accentDeep 4.6:1», тогда как реальный замер 4.41, то есть главная кнопка призыва не проходила норму, а документация уверяла, что проходит. Затемнены четыре токена до запаса на худшем светлом фоне (surfaceSoft, он темнее pageBg): accentDeep 4.41→4.97, muted 4.45→5.36, success 4.04→4.96, danger 4.60→5.23. Оттенок сохранён, фирменный вид не меняется. Отдельный класс — светлый токен на тёмной плашке surfaceDark, где шкала перевёрнута: метки тикера красились muted (2.7) и accentText (2.85), стрелка между прогнозом и фактом — линейным #2c3a3f (1.50). Переведены на mutedDark (6.2) и accent (6.24). Заодно убраны сырые hex #9fb0b4 (2.24 на светлом — .artSoon, .gameScale, .repPaidFine, .artFootnote; 7.84 на тёмном) — палитра запрещает hex в CSS именно потому, что мимо токена величина не считается никаким аудитом. Регресс ловится без браузера: contrast-tokens.test.ts считает яркость по формуле WCAG прямо на значениях токенов и проверяет ОБА фона страницы, плюс сторож на новые сырые hex в CSS. Проверен фальсификацией: возврат muted к #667579 красит тест. --- .../__tests__/contrast-tokens.test.ts | 152 ++++++++++++++++++ .../src/app/mera-public/b2c-tokens.ts | 30 +++- .../src/app/mera-public/landing-v3.module.css | 42 ++--- 3 files changed, 201 insertions(+), 23 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/contrast-tokens.test.ts diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/contrast-tokens.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/contrast-tokens.test.ts new file mode 100644 index 00000000..674d2e71 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/contrast-tokens.test.ts @@ -0,0 +1,152 @@ +/** + * contrast-tokens — контраст WCAG 2.1 AA прямо на ЗНАЧЕНИЯХ токенов `b2c`, + * без браузера и без axe. + * + * Зачем. 30.08.2026 axe нашёл на публичном лэндинге 32 узла `color-contrast` + * (все severity=serious), и все они — следствие одного и того же: пара + * «цвет × фон» подбиралась глазами, а число в комментарии писалось по памяти. + * Шапка `b2c-tokens.ts` утверждала «onAccent на accentDeep — 4.6:1», реальный + * замер — 4.41, то есть главная кнопка призыва не проходила AA, а + * документация уверяла, что проходит. Браузерный аудит ловит это раз в + * квартал; здесь то же самое считается на каждом прогоне тестов. + * + * Проверяются ОБА фона страницы: светлый (`pageBg`/`surface`/`surfaceSoft`) и + * тёмная плашка (`surfaceDark`). Половина из 32 нарушений была именно светлым + * токеном, положенным на тёмную плашку, — на ней шкала перевёрнута. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { b2c } from "../b2c-tokens"; + +/** Относительная яркость по WCAG 2.1 (§ relative luminance). */ +function luminance(hex: string): number { + const h = hex.replace("#", ""); + const channels = [0, 2, 4] + .map((i) => parseInt(h.slice(i, i + 2), 16) / 255) + .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + return ( + 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2] + ); +} + +/** Контраст пары цветов по WCAG 2.1 (§ contrast ratio). */ +export function contrast(fg: string, bg: string): number { + const a = luminance(fg); + const b = luminance(bg); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +/** Норма AA для обычного текста. Крупный (≥24px / ≥18.66px bold) — 3.0. */ +const AA_NORMAL = 4.5; + +/** Светлые фоны лэндинга. Худший из них — `surfaceSoft`, он темнее `pageBg`. */ +const LIGHT_BACKGROUNDS: ReadonlyArray = [ + ["surface", b2c.surface], + ["pageBg", b2c.pageBg], + ["surfaceSoft", b2c.surfaceSoft], +]; + +describe("контраст токенов b2c", () => { + it("формула считает эталонные пары верно", () => { + // Реперы: чёрное на белом — 21:1, цвет сам на себе — 1:1. + expect(contrast("#000000", "#FFFFFF")).toBeCloseTo(21, 1); + expect(contrast("#0E8496", "#0E8496")).toBeCloseTo(1, 5); + // Прежнее значение accentDeep — то самое 4.41, из-за которого кнопка + // призыва попала в отчёт axe. Если формула сломается, репер покажет это. + expect(contrast("#FFFFFF", "#0E8496")).toBeCloseTo(4.41, 2); + }); + + it("текстовые токены проходят AA на всех светлых фонах", () => { + const textTokens: ReadonlyArray = [ + ["ink", b2c.ink], + ["muted", b2c.muted], + ["accentText", b2c.accentText], + ["success", b2c.success], + ["danger", b2c.danger], + ]; + + for (const [fgName, fg] of textTokens) { + for (const [bgName, bg] of LIGHT_BACKGROUNDS) { + expect( + contrast(fg, bg), + `${fgName} на ${bgName}`, + ).toBeGreaterThanOrEqual(AA_NORMAL); + } + } + }); + + it("белый текст на заливке кнопки проходит AA", () => { + // .heroSubmitBtn, .hdrCta, .gameBtnAccent — сплошная заливка accentDeep. + expect(contrast(b2c.onAccent, b2c.accentDeep)).toBeGreaterThanOrEqual( + AA_NORMAL, + ); + }); + + it("на тёмной плашке текст красится mutedDark/accent, а не muted/accentText", () => { + // Что ГОДИТСЯ на surfaceDark. + expect(contrast(b2c.mutedDark, b2c.surfaceDark)).toBeGreaterThanOrEqual( + AA_NORMAL, + ); + expect(contrast(b2c.accent, b2c.surfaceDark)).toBeGreaterThanOrEqual( + AA_NORMAL, + ); + expect(contrast(b2c.onAccent, b2c.surfaceDark)).toBeGreaterThanOrEqual( + AA_NORMAL, + ); + + // Что НЕ годится — это и были метки тикера (2.85 и 2.7). Ассерт держит + // правило «на тёмном берём светлый конец шкалы»: если кто-то осветлит + // muted до проходного на плашке, он сломает его же на светлом фоне, и + // предыдущий тест покраснеет. + expect(contrast(b2c.muted, b2c.surfaceDark)).toBeLessThan(AA_NORMAL); + expect(contrast(b2c.accentText, b2c.surfaceDark)).toBeLessThan(AA_NORMAL); + }); + + it("accent не используется как текст на светлом: он и не может", () => { + // Ради этого в палитре и живёт отдельный accentText. Ассерт фиксирует + // причину, чтобы «упростить до одного акцента» нельзя было молча. + expect(contrast(b2c.accent, b2c.surface)).toBeLessThan(AA_NORMAL); + }); + + /** + * Тест выше защищает ЗНАЧЕНИЯ токенов — но не поймал бы `color: #9fb0b4`, + * вписанный в CSS мимо палитры. Именно так и появились .artSoon (2.24), + * .gameScale, .repPaidFine, .artFootnote: сырой hex не проходит ни через + * какой аудит, потому что его никто не считает токеном. + * + * ponytail: список разрешённых — снимок, а не расчёт: по CSS нельзя узнать, + * на какой подложке лежит правило (#ffffff годится на плашке и провален на + * карточке). Когда фон станет выводим (напр. все тёмные секции получат + * общий класс), эту тройку можно заменить настоящим расчётом. + */ + it("в CSS лэндинга нет новых сырых hex-цветов текста", () => { + const css = readFileSync( + join(__dirname, "..", "landing-v3.module.css"), + "utf8", + ); + const found = [ + ...css.matchAll(/(?:color|fill):\s*(#[0-9a-fA-F]{3,8})\b/g), + ].map((m) => m[1].toLowerCase()); + + const allowed = new Set([ + "#ffffff", // текст на surfaceDark-плашках, 17:1 + "#3fd1a6", // .tickerGain на плашке, 9.1:1 + "#3a4a4f", // .gameVerdict на светлой карточке, 9.2:1 + ]); + + expect([...new Set(found)].filter((h) => !allowed.has(h))).toEqual([]); + // Сам сканер проверен: он обязан находить hex вообще. + expect(found.length).toBeGreaterThan(0); + }); + + it("декоративные линии различимы как границы (норма 3.0 не для них)", () => { + // Границы — не текст, AA к ним не применяется; проверяем лишь, что они + // вообще отличаются от своей подложки, иначе рамка невидима. + expect(contrast(b2c.line, b2c.surface)).toBeGreaterThan(1.2); + expect(contrast(b2c.lineDark, b2c.surfaceDark)).toBeGreaterThan(1.2); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts b/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts index cb905db5..ccc2b690 100644 --- a/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts +++ b/tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts @@ -22,10 +22,30 @@ * старом theme.ts было для #2e8bff); * - `accentText` (#0B6B79) — тот же оттенок, затемнённый до **5.8:1** на * фоне страницы; им красится текст ссылок и акцентные подписи; - * - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0E8496) — 4.6:1, годится + * - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0C7B8B) — 4.97:1, годится * для текста кнопок. * Визуально разница между #16A9BC и #0B6B79 в тонкой ссылке незаметна, а * читаемость меняется принципиально. + * + * ПРАВКА 30.08.2026 (axe: 32 узла `color-contrast`, все serious). Прежние + * значения считались «по ощущению», и четыре из них не добирали до 4.5 — а + * подписанное в этой шапке «4.6:1» для `accentDeep` #0E8496 было просто + * неверным замером: реально **4.41**. Затемнены до запаса ≥4.8 на худшем + * светлом фоне (`surfaceSoft` #EAF5F7 — он темнее `pageBg`): + * accentDeep #0E8496 → #0C7B8B (белый текст 4.41 → 4.97) + * muted #667579 → #5B686C (на pageBg 4.45 → 5.36) + * success #1B8A5A → #177A50 (на pageBg 4.04 → 4.96) + * danger #C0442E → #B33D28 (на surfaceSoft 4.60 → 5.23) + * + * ТЁМНАЯ ПЛАШКА (`surfaceDark` #121A1D) — второй «фон-тема» этой страницы, и + * правила там ПЕРЕВЁРНУТЫ: `muted` там 2.7:1, `accentText` — 2.85:1. На тёмном + * текст красится `mutedDark` (6.2:1) и `accent` (6.24:1), т.е. светлым концом + * шкалы. Отсюда: перед тем как взять токен для текста, смотри, на ЧЁМ он + * лежит — половина из 32 нарушений была именно светлым токеном на тёмной + * плашке. + * + * Числа проверяются тестом `__tests__/contrast-tokens.test.ts` — он считает + * яркость по формуле WCAG прямо на значениях отсюда, без браузера. */ export const b2c = { @@ -41,7 +61,7 @@ export const b2c = { /** Основной текст. */ ink: "#121A1D", /** Вторичный текст, подписи. */ - muted: "#667579", + muted: "#5B686C", /** Границы, разделители. */ line: "#CBD7DA", @@ -73,16 +93,16 @@ export const b2c = { /** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */ accent: "#16A9BC", /** Акцент для заливки кнопок (под белым текстом). */ - accentDeep: "#0E8496", + accentDeep: "#0C7B8B", /** Акцент для ТЕКСТА — затемнён до AA. */ accentText: "#0B6B79", /** Текст на акцентной заливке. */ onAccent: "#FFFFFF", /** Положительная дельта («выше средней цены»). */ - success: "#1B8A5A", + success: "#177A50", /** Отрицательная дельта, предупреждения. */ - danger: "#C0442E", + danger: "#B33D28", font: { sans: "var(--font-manrope), 'Manrope', system-ui, sans-serif", diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index c3705e01..b97c2038 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -635,8 +635,10 @@ } .heroSubmitBtn { - /* accentDeep, не accent: белый текст на #16A9BC даёт ~2.8:1 (провал AA), - на accentDeep — см. обоснование в b2c-tokens.ts. Затемнение на hover — + /* accentDeep, не accent: белый текст на #16A9BC даёт 2.82:1 (провал AA), на + accentDeep — 4.97:1 после затемнения токена 30.08 (до него было 4.41, и + axe считал главную кнопку призыва нарушением). См. b2c-tokens.ts. + Затемнение на hover — через filter, чтобы не заводить ещё один цветовой литерал (тот же приём, что в .hdrCta выше). */ background: var(--b2c-accent-deep); @@ -1438,7 +1440,7 @@ align-items: center; gap: 20px; padding: 0 30px; - border-right: 1px solid #2c3a3f; + border-right: 1px solid var(--b2c-line-dark); white-space: nowrap; } .tickerObj { @@ -1451,23 +1453,27 @@ align-items: center; gap: 8px; } +/* Лента лежит на `surface-dark`, поэтому светлые токены здесь МЕНЯЮТСЯ + МЕСТАМИ со светлой темой: `muted` даёт на плашке 2.7:1, `accent-text` — + 2.85:1 (замер axe 30.08: обе метки в списке из 32 нарушений). На тёмном + читаются `muted-dark` (6.2:1) и сам `accent` (6.24:1). */ .tickerLabelMuted { font-family: var(--b2c-font-mono); font-size: 10px; letter-spacing: 0.12em; - color: var(--b2c-muted); + color: var(--b2c-muted-dark); } .tickerLabelAccent { composes: tickerLabelMuted; - color: var(--b2c-accent-text); + color: var(--b2c-accent); } .tickerSaid { font-size: 14px; font-weight: 600; - color: #9fb0b4; + color: var(--b2c-muted-dark); } .tickerArrow { - color: #2c3a3f; + color: var(--b2c-muted-dark); font-size: 15px; } .tickerFact { @@ -1478,7 +1484,7 @@ .tickerErr { font-family: var(--b2c-font-mono); font-size: 11px; - color: #9fb0b4; + color: var(--b2c-muted-dark); } .tickerGain { display: flex; @@ -1654,7 +1660,7 @@ .gameMeta { margin-top: 8px; font-size: 14px; - color: #9fb0b4; + color: var(--b2c-muted-dark); } .gameTags { display: flex; @@ -1665,7 +1671,7 @@ .gameTag { font-family: var(--b2c-font-mono); font-size: 10.5px; - color: #9fb0b4; + color: var(--b2c-muted-dark); border: 1px solid var(--b2c-line-dark); border-radius: 4px; padding: 5px 10px; @@ -1739,7 +1745,7 @@ margin-bottom: 24px; font-family: var(--b2c-font-mono); font-size: 10.5px; - color: #9fb0b4; + color: var(--b2c-muted); } .gameBtnAccent { display: block; @@ -1809,7 +1815,7 @@ } .gameRowLabelMuted { composes: gameRowLabel; - color: #9fb0b4; + color: var(--b2c-muted-dark); } .gameRowValue { font-weight: 800; @@ -1936,7 +1942,7 @@ } .pathBodyDark { composes: pathBody; - color: #9fb0b4; + color: var(--b2c-muted-dark); } .pathBtnLight { display: block; @@ -2368,7 +2374,7 @@ margin: 0; font-size: 13px; line-height: 1.55; - color: #9fb0b4; + color: var(--b2c-muted); } /* --- Раздел статей и хаб документов (макет 29.08) ------------------------- */ @@ -2422,7 +2428,7 @@ font-family: var(--b2c-font-mono); font-size: 9.5px; letter-spacing: 0.16em; - color: #9fb0b4; + color: var(--b2c-muted); } .artToc { margin-bottom: clamp(24px, 3vw, 32px); @@ -2463,7 +2469,7 @@ margin: 0 0 16px; font-size: 16px; line-height: 1.7; - color: #2c3a3f; + color: var(--b2c-line-dark); text-wrap: pretty; } .artH2 { @@ -2565,7 +2571,7 @@ font-family: var(--b2c-font-mono); font-size: 10px; letter-spacing: 0.08em; - color: #9fb0b4; + color: var(--b2c-muted); } .artTiles { display: grid; @@ -2614,7 +2620,7 @@ margin: 0 0 20px; font-size: 15px; line-height: 1.6; - color: #9fb0b4; + color: var(--b2c-muted-dark); max-width: 520px; } .artCta .gameBtnAccent { From 663d63aea4142b5ab31115086f8a86abcb0b64f5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 30 Aug 2026 14:58:03 +0500 Subject: [PATCH 4/4] =?UTF-8?q?fix(tradein):=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=B8=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20=D0=B2=D1=8B=D0=BF=D0=B0=D0=B4=D0=B0=D1=8E=D1=82,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прод отдавал «г Екатеринбург, ул Фролова , д. 29, корп.»: реестровый readable_address (ЕГРН) приходит УЖЕ склеенным вместе с пустыми частями — маркер без значения печатается, лишний пробел перед запятой остаётся, и все тиры /suggest (cadastral / geoportal / houses / DaData / Nominatim) пропускали строку насквозь. Чиним склейку, а не конкретный случай: `tidy_address()` — один проход по частям (схлопнуть пробелы, выбросить пустые и маркер-без-номера: корп./стр./лит./кв./оф./пом. и пр.), склейка обратно. Применяется в `GeocodeSuggestion.__post_init__` — единственной точке, через которую проходят все тиры, включая локальные, которые питают и `geocode()`. Тест на функцию склейки, фальсифицирован снятием фильтра пустых: краснеет по значению строки (10 из 14), а не исключением. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/services/geocoder.py | 33 +++++++++ .../test_geocode_suggest_address_join.py | 70 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_geocode_suggest_address_join.py diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 04dff251..f7760ce8 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -810,6 +810,33 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco # ── Suggest (автокомплит) ─────────────────────────────────────────────────── +# Части адреса приходят из реестров УЖЕ склеенными — и склеенными грязно: +# ЕГРН `readable_address` печатает маркер без значения и оставляет лишний +# пробел («г Екатеринбург, ул Фролова , д. 29, корп.»). Чиним склейку, а не +# конкретный случай: один проход по частям — схлопнуть пробелы, выбросить +# пустые и маркер-без-номера (корп./стр./лит./кв. и пр.), склеить обратно. +_RE_ADDR_WS = re.compile(r"\s+") +_RE_ADDR_BARE_MARKER = re.compile( + r"^(?:д|дом|корп|корпус|к|стр|строение|соор|сооружение|лит|литера|литер" + r"|кв|квартира|оф|офис|пом|помещение|вл|владение|уч|участок)\.?$", + re.IGNORECASE, +) + + +def tidy_address(s: str) -> str: + """«г Екатеринбург, ул Фролова , д. 29, корп.» → «г Екатеринбург, ул Фролова, д. 29». + + Часть без значения ВЫПАДАЕТ, а не печатается пустым маркером. + """ + parts = [] + for raw in s.split(","): + part = _RE_ADDR_WS.sub(" ", raw).strip() + if not part or _RE_ADDR_BARE_MARKER.match(part): + continue + parts.append(part) + return ", ".join(parts) + + @dataclass(frozen=True, slots=True) class GeocodeSuggestion: label: str # формат "Малышева 30, Октябрьский район" @@ -822,6 +849,12 @@ class GeocodeSuggestion: # street/locality-кандидатов остаётся None (нет стабильного house-fias). fias_id: str | None = None + def __post_init__(self) -> None: + # Единственная точка, через которую проходят ВСЕ тиры (cadastral/ + # geoportal/houses/DaData/Nominatim) — чистим здесь, а не у каждого. + object.__setattr__(self, "label", tidy_address(self.label)) + object.__setattr__(self, "full_address", tidy_address(self.full_address)) + # ── DaData suggest wrapper ────────────────────────────────────────────────── # Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется diff --git a/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py b/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py new file mode 100644 index 00000000..33484230 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py @@ -0,0 +1,70 @@ +"""Склейка адреса в подсказках: пустые части ВЫПАДАЮТ, а не печатаются. + +Прод-дефект: «г Екатеринбург, ул Фролова , д. 29, корп.» — двойной пробел +перед запятой и висячее «корп.» без номера (ЕГРН `readable_address` отдаёт +маркер даже когда значения нет, и все тиры /suggest пропускали строку насквозь). +""" + +import pytest + +from app.services.geocoder import GeocodeSuggestion, tidy_address + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + # Прод-случай целиком. + ( + "г Екатеринбург, ул Фролова , д. 29, корп.", + "г Екатеринбург, ул Фролова, д. 29", + ), + # Непустой корпус ОБЯЗАН остаться. + ( + "г Екатеринбург, ул Фролова, д. 29, корп. 2", + "г Екатеринбург, ул Фролова, д. 29, корп. 2", + ), + # Соседние типы частей — та же болезнь, тот же механизм. + ("г Екатеринбург, ул Мира, д. 1, стр.", "г Екатеринбург, ул Мира, д. 1"), + ("г Екатеринбург, ул Мира, д. 1, лит.", "г Екатеринбург, ул Мира, д. 1"), + ("г Екатеринбург, ул Мира, д. 1, кв.", "г Екатеринбург, ул Мира, д. 1"), + ("г Екатеринбург, ул Мира, д. 1, стр. 5", "г Екатеринбург, ул Мира, д. 1, стр. 5"), + ("г Екатеринбург, ул Мира, д. 1, лит. А", "г Екатеринбург, ул Мира, д. 1, лит. А"), + # Пустая часть посередине и подряд идущие запятые. + ("г Екатеринбург, , ул Мира, корп. , д. 1", "г Екатеринбург, ул Мира, д. 1"), + # Маркер без точки и без номера. + ("г Екатеринбург, ул Мира, д 1, корпус", "г Екатеринбург, ул Мира, д 1"), + # Чистая строка не меняется. + ("г Екатеринбург, ул Малышева, д 30", "г Екатеринбург, ул Малышева, д 30"), + ], +) +def test_tidy_address(raw: str, expected: str) -> None: + assert tidy_address(raw) == expected + + +@pytest.mark.parametrize( + "raw", + [ + "г Екатеринбург, ул Фролова , д. 29, корп.", + "г Екатеринбург, , ул Мира, корп. , д. 1", + "г Екатеринбург, ул Мира, д. 1, стр. , лит.", + ], +) +def test_no_double_spaces_or_dangling_parts(raw: str) -> None: + out = tidy_address(raw) + assert " " not in out + assert " ," not in out + assert not out.endswith(",") + assert out.split(", ")[-1] not in {"корп.", "стр.", "лит.", "кв.", "д."} + + +def test_suggestion_cleans_both_label_and_full_address() -> None: + """Чистка стоит в единой точке — её проходят ВСЕ тиры /suggest.""" + s = GeocodeSuggestion( + label="ул Фролова , д. 29, корп.", + full_address="г Екатеринбург, ул Фролова , д. 29, корп.", + lat=56.8, + lon=60.6, + kind="house", + ) + assert s.label == "ул Фролова, д. 29" + assert s.full_address == "г Екатеринбург, ул Фролова, д. 29"