test(tradein/frontend): первые проверки поведения — пустое поле, погашенное число, отказ 429 (#2766) (#2769)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / deploy (push) Successful in 57s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / deploy (push) Successful in 57s
This commit is contained in:
parent
0de22f4bc9
commit
a034d7a1c1
8 changed files with 416 additions and 1 deletions
|
|
@ -304,6 +304,20 @@ jobs:
|
||||||
# Blocking: любая TS-ошибка → job RED.
|
# Blocking: любая TS-ошибка → job RED.
|
||||||
run: npm run type-check
|
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)
|
- name: Lint (next lint)
|
||||||
# Blocking: любая ESLint-ошибка → job RED.
|
# Blocking: любая ESLint-ошибка → job RED.
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs"
|
"check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -18,12 +20,18 @@
|
||||||
"recharts": "^2.15.4"
|
"recharts": "^2.15.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@eslint/eslintrc": "^3.0.0",
|
"@eslint/eslintrc": "^3.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "^15.0.0",
|
"eslint-config-next": "^15.0.0",
|
||||||
"typescript": "^5.9.3"
|
"jsdom": "^25.0.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
Normal file
111
tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<LoginPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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> = {},
|
||||||
|
): 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(
|
||||||
|
<SaleShareList
|
||||||
|
buildings={buildings}
|
||||||
|
sort="share_desc"
|
||||||
|
onSort={vi.fn()}
|
||||||
|
selectedHouseId={null}
|
||||||
|
hoveredHouseId={null}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHover={vi.fn()}
|
||||||
|
isLoading={false}
|
||||||
|
isError={false}
|
||||||
|
minPct={5}
|
||||||
|
window="now"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст последней содержательной ячейки строки — колонка «Дом». */
|
||||||
|
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 эт.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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> = {}): 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> = {},
|
||||||
|
): 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<SalesVsListingsResponse>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StreetDealsCard — погашенный медианный торг", () => {
|
||||||
|
beforeEach(() => vi.mocked(useSalesVsListings).mockReset());
|
||||||
|
|
||||||
|
it("число прошло гейт — показываем его, объяснения нет", () => {
|
||||||
|
stubQuery(makeResponse());
|
||||||
|
render(<StreetDealsCard estimate={ESTIMATE} />);
|
||||||
|
// Именно строка-шапка: тот же «−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(<StreetDealsCard estimate={ESTIMATE} />);
|
||||||
|
|
||||||
|
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("не показываем");
|
||||||
|
});
|
||||||
|
});
|
||||||
2
tradein-mvp/frontend/vitest-env.d.ts
vendored
Normal file
2
tradein-mvp/frontend/vitest-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="vitest/globals" />
|
||||||
|
/// <reference types="@testing-library/jest-dom" />
|
||||||
21
tradein-mvp/frontend/vitest.config.ts
Normal file
21
tradein-mvp/frontend/vitest.config.ts
Normal file
|
|
@ -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}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
3
tradein-mvp/frontend/vitest.setup.ts
Normal file
3
tradein-mvp/frontend/vitest.setup.ts
Normal file
|
|
@ -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";
|
||||||
Loading…
Add table
Reference in a new issue