diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml
index 5b228267..9315e1a6 100644
--- a/.forgejo/workflows/ci-tradein.yml
+++ b/.forgejo/workflows/ci-tradein.yml
@@ -304,6 +304,20 @@ jobs:
# Blocking: любая TS-ошибка → job RED.
run: npm run type-check
+ - name: Run tests (vitest)
+ # Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ
+ # проверки поведения: лэйн гейтил только типы и статический анализ, а оба
+ # молчат про то, что видит пользователь — пустое поле, погашенное число,
+ # отказ по частоте. Инфраструктура не изобретена, а взята у соседнего
+ # frontend/ (vitest + jsdom + testing-library), где сьют живёт давно.
+ #
+ # Пропусков в сьюте нет и быть не должно: сторож пропусков
+ # (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому
+ # пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722
+ # запретил на бэкенде. Тест либо чинится, либо помечается `.fails`
+ # с причиной В КОДЕ.
+ run: npm test
+
- name: Lint (next lint)
# Blocking: любая ESLint-ошибка → job RED.
run: npm run lint
diff --git a/tradein-mvp/frontend/package.json b/tradein-mvp/frontend/package.json
index f71653ba..ef448501 100644
--- a/tradein-mvp/frontend/package.json
+++ b/tradein-mvp/frontend/package.json
@@ -8,6 +8,8 @@
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
"check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs"
},
"dependencies": {
@@ -18,12 +20,18 @@
"recharts": "^2.15.4"
},
"devDependencies": {
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@eslint/eslintrc": "^3.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
- "typescript": "^5.9.3"
+ "jsdom": "^25.0.1",
+ "typescript": "^5.9.3",
+ "vitest": "^2.1.9"
}
}
diff --git a/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
new file mode 100644
index 00000000..d82503dc
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
@@ -0,0 +1,111 @@
+/**
+ * #2766 — отказ по частоте на форме входа должен читаться как отказ по частоте.
+ *
+ * PR #2712/#2717 ввели 429 при насыщении проверок пароля. До этого любой не-401
+ * падал в общую фразу «Не удалось войти…», а человек, которого притормозил
+ * лимит, читал её как проблему сети и долбил форму дальше. Здесь закреплено,
+ * что 429 даёт СВОЁ сообщение, и что оно не путается ни с «неверный пароль»,
+ * ни с общей ошибкой.
+ *
+ * Подменяется `fetch`, а не `loginErrorMessage`: так в тесте работает настоящая
+ * `apiFetch` и настоящий `HTTPError` — то есть проверяется весь путь от кода
+ * ответа до текста на экране, а не одна функция в вакууме.
+ */
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import LoginPage from "../page";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }),
+}));
+
+/**
+ * Ответ на POST /auth/login задаётся тестом; всё остальное (поллинг непрочитанных
+ * у виджета поддержки на этой же странице) отвечает пустым 200, чтобы фон не
+ * подмешивал свои ошибки в проверяемое состояние формы.
+ */
+function stubFetch(loginStatus: number, loginBody: unknown) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = typeof input === "string" ? input : String(input);
+ if (url.includes("/auth/login")) {
+ return new Response(JSON.stringify(loginBody), {
+ status: loginStatus,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response("{}", { status: 200 });
+ }),
+ );
+}
+
+function renderLogin() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+function submitCredentials() {
+ fireEvent.change(screen.getByLabelText("Логин"), {
+ target: { value: "praktika" },
+ });
+ fireEvent.change(screen.getByLabelText("Пароль"), {
+ target: { value: "hunter2" },
+ });
+ fireEvent.submit(screen.getByRole("form", { name: "Вход в Меру" }));
+}
+
+describe("LoginPage — сообщение об ошибке входа", () => {
+ beforeEach(() => vi.unstubAllGlobals());
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("429 — сообщение про частоту попыток, а не про пароль и не общее", async () => {
+ stubFetch(429, { detail: "Too Many Requests" });
+ renderLogin();
+ submitCredentials();
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Слишком много попыток. Попробуйте через несколько минут",
+ );
+ expect(alert).not.toHaveTextContent("Неверный логин или пароль");
+ expect(alert).not.toHaveTextContent("Не удалось войти");
+ });
+
+ it("401 — сообщение про пароль (429-ветка не поглощает остальные коды)", async () => {
+ stubFetch(401, { detail: "Invalid credentials" });
+ renderLogin();
+ submitCredentials();
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("Неверный логин или пароль");
+ expect(alert).not.toHaveTextContent("Слишком много попыток");
+ });
+
+ it("500 — общая фраза (её текст не должен подменять собой 429)", async () => {
+ stubFetch(500, { detail: "boom" });
+ renderLogin();
+ submitCredentials();
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Не удалось войти. Проверьте подключение и попробуйте ещё раз",
+ );
+ });
+
+ it("до отправки формы сообщения об ошибке нет", async () => {
+ stubFetch(429, { detail: "Too Many Requests" });
+ renderLogin();
+ await waitFor(() =>
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
+ );
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx
new file mode 100644
index 00000000..c3a0aa49
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/SaleShareList.test.tsx
@@ -0,0 +1,106 @@
+/**
+ * #2766 — что показывает карточка дома, когда поле пришло пустым.
+ *
+ * Повод: `series_name` пуст у всех 9 457 домов на проде (#2700). Строка «Дом»
+ * склеивается из четырёх полей через « · », и вопрос не в том, есть ли серия,
+ * а в том, ЧТО видит пользователь на её месте: висящий разделитель, слово
+ * «null» — или ничего. Здесь закреплён последний вариант.
+ *
+ * Проверка целится в текст ячейки ЦЕЛИКОМ (`toBe`, не `toContain`) — именно
+ * хвостовой разделитель отличает «пришло пусто, нарисовали пусто» от
+ * «пришло пусто, нарисовали дыру», и подстрочное совпадение его не заметит.
+ */
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { SaleShareList } from "../SaleShareList";
+import type { BuildingSaleShare } from "@/types/sale-share";
+
+function makeBuilding(
+ overrides: Partial = {},
+): BuildingSaleShare {
+ return {
+ house_id: 1,
+ address: "Ленина, 1",
+ lat: 56.83,
+ lon: 60.6,
+ sale_share_pct: 12.5,
+ sale_share_pct_45d: null,
+ listings_45d: null,
+ over_100: false,
+ active_secondary: 8,
+ flat_count_effective: 64,
+ gar_match_method: "exact",
+ median_price_rub: 6_500_000,
+ median_price_per_m2: 145_000,
+ avg_days_on_market: 42,
+ year_built: 1979,
+ house_type: "panel",
+ total_floors: 9,
+ series_name: "1-464Д",
+ is_emergency: false,
+ ...overrides,
+ };
+}
+
+function renderList(buildings: BuildingSaleShare[]) {
+ return render(
+ ,
+ );
+}
+
+/** Текст последней содержательной ячейки строки — колонка «Дом». */
+function houseMetaText(address: string): string {
+ const row = screen.getByText(address).closest("tr");
+ if (row === null) throw new Error(`строка с адресом ${address} не отрисована`);
+ // Колонки: Адрес · Доля · В продаже · Медиана · Экспозиция · Дом · Действия.
+ return row.cells[5].textContent ?? "";
+}
+
+describe("SaleShareList — колонка «Дом» при пустом поле", () => {
+ it("серия есть — попадает в строку последним элементом", () => {
+ renderList([makeBuilding({ series_name: "1-464Д" })]);
+ expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт. · 1-464Д");
+ });
+
+ it("series_name = null — строка без хвостового разделителя (прод-состояние #2700)", () => {
+ renderList([makeBuilding({ series_name: null })]);
+ expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт.");
+ });
+
+ it("series_name = пустая строка — тоже без хвостового разделителя", () => {
+ renderList([makeBuilding({ series_name: "" })]);
+ expect(houseMetaText("Ленина, 1")).toBe("1979 · Панель · 9 эт.");
+ });
+
+ it("пусты все поля дома — прочерк, а не пустая ячейка и не «null»", () => {
+ renderList([
+ makeBuilding({
+ year_built: null,
+ house_type: null,
+ total_floors: null,
+ series_name: null,
+ }),
+ ]);
+ expect(houseMetaText("Ленина, 1")).toBe("—");
+ });
+
+ it("пустая серия не съедает остальные поля дома", () => {
+ renderList([
+ makeBuilding({ series_name: null, house_type: null, year_built: null }),
+ ]);
+ expect(houseMetaText("Ленина, 1")).toBe("9 эт.");
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx
new file mode 100644
index 00000000..c6e8a77d
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/StreetDealsCard.test.tsx
@@ -0,0 +1,150 @@
+/**
+ * #2766 — погашенное число медианного торга обязано быть ОБЪЯСНЕНО.
+ *
+ * Гейт правдоподобия (#2666 + #2672, PR #2706) сократил показываемые числа
+ * вдвое: 64 группы из 128 → 32 после порога по различным объявлениям. Бэкенд в
+ * этом случае обнуляет `median_discount_pct` и кладёт в
+ * `median_discount_explanation` текст, называющий оба числа (сколько сделок и
+ * сколько разных объявлений). Пользователь должен увидеть этот текст — не
+ * пустое место (читается как поломка виджета) и не «0%» (читается как «торга
+ * нет»).
+ *
+ * Оба живых потребителя проверяются здесь, потому что гейт один, а мест показа
+ * два: карточка `/` (StreetDealsCard) и HUD `/v2` (mapHistory → dkpHeader.note).
+ */
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { UseQueryResult } from "@tanstack/react-query";
+
+import { StreetDealsCard } from "../StreetDealsCard";
+import { mapHistory } from "../v2/mappers";
+import { useSalesVsListings } from "@/lib/trade-in-api";
+import type {
+ AggregatedEstimate,
+ SalesListingPair,
+ SalesVsListingsResponse,
+} from "@/types/trade-in";
+
+vi.mock("@/lib/trade-in-api", () => ({
+ useSalesVsListings: vi.fn(),
+}));
+
+// Текст ровно в той форме, в какой его строит бэкенд для случая «мало разных
+// объявлений» (app/api/v1/trade_in.py, ветка n_distinct_listings < порога).
+const GATED_EXPLANATION =
+ "Медианный торг не показываем: сделок 50, а разных объявлений для сравнения " +
+ "всего 1 — такой процент говорит о цене одной конкретной квартиры, а не о " +
+ "торге на улице.";
+
+function makePair(overrides: Partial = {}): SalesListingPair {
+ return {
+ deal_id: 1,
+ deal_date: "2026-03-15",
+ deal_price_rub: 6_200_000,
+ deal_price_per_m2: 138_000,
+ deal_area_m2: 45,
+ deal_rooms: 2,
+ deal_floor: 4,
+ deal_address: "Белинского, 32",
+ listing_id: 777,
+ listing_source: "avito",
+ listing_source_url: "https://avito.ru/777",
+ listing_date: "2026-01-30",
+ listing_price_rub: 6_900_000,
+ listing_price_per_m2: 153_000,
+ listing_area_m2: 45,
+ days_listing_to_deal: 44,
+ discount_pct: -10.1,
+ ...overrides,
+ };
+}
+
+function makeResponse(
+ overrides: Partial = {},
+): SalesVsListingsResponse {
+ return {
+ street: "Белинского",
+ period_months: 24,
+ window_days: 180,
+ area_tolerance: 0.15,
+ total_deals: 50,
+ deals_with_listings: 50,
+ linkage_rate_pct: 100,
+ median_discount_pct: -10.1,
+ median_discount_explanation: null,
+ data_quality: "house_linked",
+ pairs: [makePair(), makePair({ deal_id: 2 })],
+ ...overrides,
+ };
+}
+
+/** Минимальный estimate — карточка читает из него только адрес/площадь/комнаты. */
+const ESTIMATE = {
+ target_address: "Белинского, 32",
+ area_m2: 45,
+ rooms: 2,
+} as AggregatedEstimate;
+
+function stubQuery(data: SalesVsListingsResponse) {
+ vi.mocked(useSalesVsListings).mockReturnValue({
+ data,
+ isLoading: false,
+ isError: false,
+ } as UseQueryResult);
+}
+
+describe("StreetDealsCard — погашенный медианный торг", () => {
+ beforeEach(() => vi.mocked(useSalesVsListings).mockReset());
+
+ it("число прошло гейт — показываем его, объяснения нет", () => {
+ stubQuery(makeResponse());
+ render();
+ // Именно строка-шапка: тот же «−10.1%» есть и в бейджах отдельных пар,
+ // поэтому целимся в блок linkage-hint, а не в первое совпадение на странице.
+ expect(
+ screen.getByText(/сделок имеют исторический ASK/),
+ ).toHaveTextContent("медианный торг −10.1%");
+ expect(screen.queryByText(/не показываем/)).not.toBeInTheDocument();
+ });
+
+ it("число погашено — на его месте объяснение с обоими числами", () => {
+ stubQuery(
+ makeResponse({
+ median_discount_pct: null,
+ median_discount_explanation: GATED_EXPLANATION,
+ }),
+ );
+ render();
+
+ expect(screen.getByText(GATED_EXPLANATION)).toBeInTheDocument();
+ // Ни строки «· медианный торг», ни подставленного вместо неё нуля.
+ expect(screen.queryByText(/· медианный торг/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/[−+]?0\.0%/)).not.toBeInTheDocument();
+ // Остальная карточка на месте: гаснет одно число, а не виджет целиком.
+ expect(screen.getByText("Сделок за 24 мес")).toBeInTheDocument();
+ expect(screen.getByText("100%")).toBeInTheDocument();
+ });
+});
+
+describe("mapHistory (/v2) — погашенный медианный торг", () => {
+ it("объяснение попадает в note, числа торга там нет", () => {
+ const { dkpHeader } = mapHistory(
+ null,
+ null,
+ makeResponse({
+ median_discount_pct: null,
+ median_discount_explanation: GATED_EXPLANATION,
+ }),
+ );
+ expect(dkpHeader.note).toContain(GATED_EXPLANATION);
+ expect(dkpHeader.note).not.toContain("медианный торг ");
+ });
+
+ it("прошедшее гейт число попадает в note вместо объяснения", () => {
+ const { dkpHeader } = mapHistory(null, null, makeResponse());
+ // Разделитель дробной части задаёт локаль ru-RU — знак и цифры пиним, а
+ // запятую/точку нет, иначе тест ловил бы сборку ICU, а не поведение гейта.
+ expect(dkpHeader.note).toMatch(/медианный торг −10[.,]1%/);
+ expect(dkpHeader.note).not.toContain("не показываем");
+ });
+});
diff --git a/tradein-mvp/frontend/vitest-env.d.ts b/tradein-mvp/frontend/vitest-env.d.ts
new file mode 100644
index 00000000..edfa8b53
--- /dev/null
+++ b/tradein-mvp/frontend/vitest-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/tradein-mvp/frontend/vitest.config.ts b/tradein-mvp/frontend/vitest.config.ts
new file mode 100644
index 00000000..0a1dea5b
--- /dev/null
+++ b/tradein-mvp/frontend/vitest.config.ts
@@ -0,0 +1,21 @@
+import { fileURLToPath } from "node:url";
+
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ // Mirror tsconfig "paths": { "@/*": ["./src/*"] } so test imports like
+ // `@/lib/wkt` resolve the same way they do in the Next.js build.
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ },
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./vitest.setup.ts"],
+ include: ["src/**/*.{test,spec}.{ts,tsx}"],
+ },
+});
diff --git a/tradein-mvp/frontend/vitest.setup.ts b/tradein-mvp/frontend/vitest.setup.ts
new file mode 100644
index 00000000..8272da97
--- /dev/null
+++ b/tradein-mvp/frontend/vitest.setup.ts
@@ -0,0 +1,3 @@
+// Registers jest-dom matchers (toBeInTheDocument, toHaveStyle, …) on Vitest's
+// `expect`. Imported via vitest.config.ts `setupFiles`.
+import "@testing-library/jest-dom/vitest";