chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
+200 -179
View File
@@ -1,16 +1,19 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { StockPage } from "./StockPage";
import type { StockPageVm } from "./StockPage.vm";
import { StockPage } from "./StockPage";
const LocationProbe = () => {
const location = useLocation();
return (
@@ -22,64 +25,66 @@ const LocationProbe = () => {
};
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
const money = (value: string) => ({ value, currency: "RUB" });
const stock = {
instrumentUid: uid,
name: "Тестовая акция",
ticker: "TEST",
shareType: "SHARE_TYPE_COMMON",
currency: "RUB",
sector: "financial",
lot: 10,
logoName: null,
historyFrom: null,
};
const money = (value: string) => ({ currency: "RUB", value });
const stock = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
currency: "RUB",
historyFrom: null,
instrumentUid: uid,
logoName: null,
lot: 10,
name: "Тестовая акция",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "TEST",
}
/* eslint-enable unicorn/no-null */);
const position = {
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
accounts: [
{
absoluteReturn: money("1000"),
account: { id: "main", name: "Основной" },
averagePurchasePrice: money("200"),
currentValue: money("3000"),
instrumentUid: uid,
quantity: "10",
currentValue: money("3000"),
averagePurchasePrice: money("200"),
absoluteReturn: money("1000"),
returnPercent: "50",
account: { id: "main", name: "Основной" },
},
],
averagePurchasePrice: money("200"),
currentValue: money("3000"),
isStale: false,
observedAt: "2026-09-10T10:00:00Z",
quantity: "10",
returnPercent: "50",
};
const quote = {
change: money("10"),
changePercent: "3.448276",
observedAt: "2026-09-10T10:00:00Z",
previousClose: money("290"),
previousCloseDate: "2026-09-09",
price: money("300"),
priceAt: "2026-09-10T10:00:00Z",
tradingStatus: "SECURITY_TRADING_STATUS_NORMAL_TRADING",
change: money("10"),
changePercent: "3.448276",
previousCloseDate: "2026-09-09",
previousClose: money("290"),
unavailable: [],
observedAt: "2026-09-10T10:00:00Z",
};
function setup(
options: {
noPosition?: boolean;
ambiguousTicker?: boolean;
cursorError?: string;
failCompany?: boolean;
failStock?: boolean;
cursorError?: string;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
noPosition?: boolean;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
} = {},
) {
const requests: URL[] = [];
const resolved = { ...stock, slug: options.slug ?? uid };
let failQuote = false;
let isFailQuote = false;
let cursorError = options.cursorError;
let operationPayment = "200";
server.use(
@@ -97,109 +102,117 @@ function setup(
http.get("*/api/v1/stocks/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "quote")
return failQuote
return isFailQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource(quote));
if (params.section === "position")
return HttpResponse.json(
resource(
options.noPosition
? { ...position, quantity: "0", accounts: [] }
? { ...position, accounts: [], quantity: "0" }
: position,
),
);
if (params.section === "candles")
return HttpResponse.json(
resource({
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
candles: [],
currency: "RUB",
historyFrom: null,
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
if (params.section === "company")
return options.failCompany
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource({
description: "Описание компании",
metrics: options.metrics ?? [
{
key: "peRatioTtm",
value: 5.6,
unit: "ratio",
currency: null,
period: "TTM",
},
],
unavailable: [],
observedAt: quote.observedAt,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
description: "Описание компании",
metrics: options.metrics ?? [
{
currency: null,
key: "peRatioTtm",
period: "TTM",
unit: "ratio",
value: 5.6,
},
],
observedAt: quote.observedAt,
unavailable: [],
}
/* eslint-enable unicorn/no-null */)),
);
return HttpResponse.json(
resource({
dividends: [
{
perShare: money("32.88"),
recordDate: "2026-10-13",
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
timing: "upcoming",
estimatedAmount: options.noPosition ? null : money("328.8"),
},
{
perShare: money("20"),
recordDate: "2025-10-13",
paymentDate: null,
lastBuyDate: null,
timing: "past",
estimatedAmount: null,
},
],
from: "2021-09-10",
to: "2027-09-10",
observedAt: quote.observedAt,
positionAt: null,
estimateUnavailable: false,
positionIsStale: false,
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
dividends: [
{
estimatedAmount: options.noPosition ? null : money("328.8"),
lastBuyDate: "2026-10-12",
paymentDate: "2026-10-27",
perShare: money("32.88"),
recordDate: "2026-10-13",
timing: "upcoming",
},
{
estimatedAmount: null,
lastBuyDate: null,
paymentDate: null,
perShare: money("20"),
recordDate: "2025-10-13",
timing: "past",
},
],
estimateUnavailable: false,
from: "2021-09-10",
observedAt: quote.observedAt,
positionAt: null,
positionIsStale: false,
to: "2027-09-10",
}
/* eslint-enable unicorn/no-null */)),
);
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
if (next && cursorError)
const isNext = url.searchParams.has("cursor");
if (isNext && cursorError)
return HttpResponse.json({ code: cursorError }, { status: 400 });
return HttpResponse.json(
resource({
accountId: "all",
observedAt: quote.observedAt,
hasNext: !next,
nextCursor: next ? null : "next",
items: [
{
id: next ? "sold" : "dividend",
occurredAt: next
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money(operationPayment),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
],
}),
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "all",
hasNext: !isNext,
items: [
{
account: { id: "main", name: "Основной" },
category: isNext ? "trade" : "income",
description: "",
id: isNext ? "sold" : "dividend",
occurredAt: isNext
? "2022-01-02T10:00:00Z"
: "2025-01-02T10:00:00Z",
payment: money(operationPayment),
price: isNext ? money("100") : null,
quantity: isNext ? "2" : null,
type: isNext ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_DIVIDEND",
},
],
nextCursor: isNext ? null : "next",
observedAt: quote.observedAt,
}
/* eslint-enable unicorn/no-null */)),
);
}),
);
configureAuthClient({
accessToken: () => "synthetic",
refresh: async () => false,
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
const client = createTestQueryClient();
render(
@@ -208,71 +221,73 @@ function setup(
<MemoryRouter initialEntries={[options.path ?? `/stocks/${uid}`]}>
<LocationProbe />
<Routes>
<Route path="/stocks/:stockId" element={<StockPage />} />
<Route element={<StockPage />} path="/stocks/:stockId" />
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
return {
requests,
failQuote: () => {
isFailQuote = true;
},
recoverOperations: () => {
cursorError = undefined;
operationPayment = "201";
},
failQuote: () => {
failQuote = true;
},
requests,
};
}
describe("stock page", () => {
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup({
metrics: [
{
key: "marketCapitalization",
value: 1234567.89,
unit: "money",
currency: null,
period: null,
},
{
key: "revenueTtm",
value: 1234567.89,
unit: "money",
currency: "RUB",
period: "TTM",
},
{
key: "ebitdaTtm",
value: 1234567.89,
unit: "money",
currency: "USD",
period: "TTM",
},
{
key: "netIncomeTtm",
value: 1234567.89,
unit: "money",
currency: "CAD",
period: "TTM",
},
{
key: "peRatioTtm",
value: 5.678,
unit: "ratio",
currency: null,
period: "TTM",
},
{
key: "roe",
value: 12.3456,
unit: "percent",
currency: null,
period: null,
},
],
});
setup((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
metrics: [
{
currency: null,
key: "marketCapitalization",
period: null,
unit: "money",
value: 1_234_567.89,
},
{
currency: "RUB",
key: "revenueTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: "USD",
key: "ebitdaTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: "CAD",
key: "netIncomeTtm",
period: "TTM",
unit: "money",
value: 1_234_567.89,
},
{
currency: null,
key: "peRatioTtm",
period: "TTM",
unit: "ratio",
value: 5.678,
},
{
currency: null,
key: "roe",
period: null,
unit: "percent",
value: 12.3456,
},
],
}
/* eslint-enable unicorn/no-null */));
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
@@ -288,12 +303,12 @@ describe("stock page", () => {
await screen.findByText("Зачисление дивидендов");
expect(screen.getByTestId("location")).toHaveTextContent("/stocks/SBERP");
expect(
requests.some((url) => url.pathname === `/api/v1/stocks/${uid}/quote`),
requests.some(url => url.pathname === `/api/v1/stocks/${uid}/quote`),
).toBe(true);
expect(
requests.some((url) => url.searchParams.get("instrumentUid") === uid),
requests.some(url => url.searchParams.get("instrumentUid") === uid),
).toBe(true);
expect(requests.some((url) => url.pathname.includes("/SBERP/quote"))).toBe(
expect(requests.some(url => url.pathname.includes("/SBERP/quote"))).toBe(
false,
);
});
@@ -307,15 +322,15 @@ describe("stock page", () => {
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
});
it("offers search for ambiguous tickers without requesting personal data", async () => {
const { requests } = setup({ path: "/stocks/SAME", ambiguousTicker: true });
const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" });
expect(
await screen.findByText(/Этот тикер относится к нескольким выпускам/),
).toBeVisible();
expect(
requests.some(
(url) =>
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/position"),
url =>
url.pathname.endsWith("/operations")
|| url.pathname.endsWith("/position"),
),
).toBe(false);
});
@@ -346,15 +361,17 @@ describe("stock page", () => {
await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible());
});
it("keeps sold-position history and market content when company data fails", async () => {
setup({ noPosition: true, failCompany: true });
setup({ failCompany: true, noPosition: true });
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible();
const message = await screen.findByText(/Не удалось загрузить этот раздел/);
expect(
(await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
'[role="status"]',
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
).toHaveAttribute("aria-live", "polite");
});
@@ -362,7 +379,7 @@ describe("stock page", () => {
const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
const modes: (string | null)[] = [];
const modes: (null | string)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
@@ -370,8 +387,8 @@ describe("stock page", () => {
resource({
description: "Восстановленное описание",
metrics: [],
unavailable: [],
observedAt: quote.observedAt,
unavailable: [],
}),
);
}),
@@ -393,18 +410,18 @@ describe("stock page", () => {
expect(screen.getByText(/02.01.2022, 10:00 UTC/)).toBeVisible();
expect(
requests.some(
(url) =>
url.searchParams.get("cursor") === "next" &&
url.searchParams.get("instrumentUid") === uid,
url =>
url.searchParams.get("cursor") === "next"
&& url.searchParams.get("instrumentUid") === uid,
),
).toBe(true);
fireEvent.click(screen.getByRole("radio", { name: "Сделки" }));
await waitFor(() =>
expect(
requests.some(
(url) =>
url.searchParams.get("categories") === "trade" &&
!url.searchParams.has("cursor"),
url =>
url.searchParams.get("categories") === "trade"
&& !url.searchParams.has("cursor"),
),
).toBe(true),
);
@@ -419,12 +436,12 @@ describe("stock page", () => {
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
await waitFor(() =>
expect(
requests.some((url) => url.searchParams.get("period") === "year"),
requests.some(url => url.searchParams.get("period") === "year"),
).toBe(true),
);
});
it.each([
["INVALID_OPERATIONS_CURSOR", null, null],
["INVALID_OPERATIONS_CURSOR", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, null],
["TBANK_UNAVAILABLE", "next", "observe"],
])(
"recovers history after %s with fresh data and the correct cursor",
@@ -447,7 +464,9 @@ describe("stock page", () => {
expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
if (cursor)
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
},
);
@@ -468,20 +487,22 @@ describe("stock page", () => {
await screen.findByText("300 ₽");
controls.failQuote();
fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
const message = await screen.findByText(/последние полученные/);
expect(
(await screen.findByText(/последние полученные/)).closest(
'[role="status"]',
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
message.closest(
"[role=\"status\"]",
),
).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("300 ₽")).toBeVisible();
expect(scopes).toEqual([
{
screen: "stock",
instrumentUid: uid,
accountId: "all",
candlePeriod: "year",
categories: ["trade"],
instrumentUid: uid,
limit: 20,
screen: "stock",
},
]);
});