Merge branch 'main' into fix/3194-hide-sql-parameters
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m10s
CI Trade-In / backend-tests (pull_request) Successful in 5m56s
CI / backend-tests (pull_request) Successful in 18m10s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 19s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m10s
CI Trade-In / backend-tests (pull_request) Successful in 5m56s
CI / backend-tests (pull_request) Successful in 18m10s
This commit is contained in:
commit
8fb5587888
10 changed files with 482 additions and 49 deletions
|
|
@ -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 используется
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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<readonly [string, string]> = [
|
||||
["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<readonly [string, string]> = [
|
||||
["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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(75.7)]} />);
|
||||
const note = screen.getByText(/Медиана расхождения показанных строк/u);
|
||||
expect(note.textContent).toContain("11,5 %");
|
||||
expect(note.textContent).toContain("75,7 %");
|
||||
});
|
||||
|
||||
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
|
||||
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
|
||||
const note = screen.getByText(/Медиана расхождения показанных строк/u);
|
||||
expect(note.textContent).toContain("7,8 %");
|
||||
expect(note.textContent).not.toContain("75,7 %");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className={styles.tickerRoot}>
|
||||
<div className={styles.tickerBadge}>
|
||||
<span className={styles.tickerDot} aria-hidden="true" />
|
||||
<span className={styles.tickerBadgeText}>ПРОГНОЗ → ФАКТ</span>
|
||||
</div>
|
||||
<div className={styles.tickerTrack} aria-hidden="true">
|
||||
{items.map((deal, i) => (
|
||||
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
|
||||
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
|
||||
<span className={styles.tickerPair}>
|
||||
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
|
||||
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
|
||||
</span>
|
||||
<span className={styles.tickerArrow}>→</span>
|
||||
<span className={styles.tickerPair}>
|
||||
<span className={styles.tickerLabelAccent}>ДКП</span>
|
||||
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
|
||||
</span>
|
||||
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
|
||||
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.tickerStrip}>
|
||||
<div className={styles.tickerBadge}>
|
||||
<span className={styles.tickerDot} aria-hidden="true" />
|
||||
<span className={styles.tickerBadgeText}>ПРОГНОЗ → ФАКТ</span>
|
||||
</div>
|
||||
<div className={styles.tickerTrack} aria-hidden="true">
|
||||
{items.map((deal, i) => (
|
||||
<div key={`${deal.deal_quarter}-${i}`} className={styles.tickerItem}>
|
||||
<span className={styles.tickerObj}>{dealTitle(deal)}</span>
|
||||
<span className={styles.tickerPair}>
|
||||
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
|
||||
<span className={styles.tickerSaid}>{rub(deal.predicted_rub)}</span>
|
||||
</span>
|
||||
<span className={styles.tickerArrow}>→</span>
|
||||
<span className={styles.tickerPair}>
|
||||
<span className={styles.tickerLabelAccent}>ДКП</span>
|
||||
<span className={styles.tickerFact}>{rub(deal.fact_rub)}</span>
|
||||
</span>
|
||||
<span className={styles.tickerErr}>{errPct(deal.err_pct)}</span>
|
||||
<span className={styles.tickerGain}>{deal.deal_quarter}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{spread && (
|
||||
<p className={styles.tickerNote}>
|
||||
{`Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
* не подтвердил ни одно:
|
||||
*
|
||||
* | макет | что показал замер |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 дней, дедуп по источнику и адресу), " +
|
||||
"собственный адрес из когорты исключён";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1397,9 +1399,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;
|
||||
|
|
@ -1438,7 +1453,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 +1466,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 +1497,7 @@
|
|||
.tickerErr {
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 11px;
|
||||
color: #9fb0b4;
|
||||
color: var(--b2c-muted-dark);
|
||||
}
|
||||
.tickerGain {
|
||||
display: flex;
|
||||
|
|
@ -1654,7 +1673,7 @@
|
|||
.gameMeta {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: #9fb0b4;
|
||||
color: var(--b2c-muted-dark);
|
||||
}
|
||||
.gameTags {
|
||||
display: flex;
|
||||
|
|
@ -1665,7 +1684,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 +1758,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 +1828,7 @@
|
|||
}
|
||||
.gameRowLabelMuted {
|
||||
composes: gameRowLabel;
|
||||
color: #9fb0b4;
|
||||
color: var(--b2c-muted-dark);
|
||||
}
|
||||
.gameRowValue {
|
||||
font-weight: 800;
|
||||
|
|
@ -1936,7 +1955,7 @@
|
|||
}
|
||||
.pathBodyDark {
|
||||
composes: pathBody;
|
||||
color: #9fb0b4;
|
||||
color: var(--b2c-muted-dark);
|
||||
}
|
||||
.pathBtnLight {
|
||||
display: block;
|
||||
|
|
@ -2368,7 +2387,7 @@
|
|||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #9fb0b4;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
/* --- Раздел статей и хаб документов (макет 29.08) ------------------------- */
|
||||
|
|
@ -2422,7 +2441,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 +2482,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 +2584,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 +2633,7 @@
|
|||
margin: 0 0 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #9fb0b4;
|
||||
color: var(--b2c-muted-dark);
|
||||
max-width: 520px;
|
||||
}
|
||||
.artCta .gameBtnAccent {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue