Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 8s
Frontend verify / verify (pull_request) Successful in 5m25s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m20s
207 lines
6.7 KiB
TypeScript
207 lines
6.7 KiB
TypeScript
import { MantineProvider } from "@mantine/core";
|
||
import { render, screen, within } from "@testing-library/react";
|
||
import { MemoryRouter } from "react-router";
|
||
import { describe, expect, it } from "vitest";
|
||
|
||
import { resource } from "@/test/resource";
|
||
|
||
import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
|
||
|
||
import { brokerageAccountCards } from "../../model/display";
|
||
import { BrokerageAccountList } from "./BrokerageAccountList";
|
||
|
||
type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
|
||
|
||
const portfolioSummary = {
|
||
bondPositionCount: 12,
|
||
cashBalances: [
|
||
{ currency: "RUB", value: "82500.5" },
|
||
{ currency: "USD", value: "20" },
|
||
],
|
||
fundPositionCount: 0,
|
||
stockPositionCount: 5,
|
||
structure: {
|
||
currencies: [{
|
||
categories: [
|
||
{ sharePercent: "50.000000", type: "STOCK", value: "100" },
|
||
{ sharePercent: "30.000000", type: "BOND", value: "60" },
|
||
{ sharePercent: "0.000000", type: "OTHER", value: "0" },
|
||
{ sharePercent: "20.000000", type: "CASH", value: "40" },
|
||
],
|
||
currency: "RUB",
|
||
hasNegativeValues: false,
|
||
totalValue: "200",
|
||
}],
|
||
unvaluedCategories: [],
|
||
},
|
||
totalPositionCount: 17,
|
||
totalsByCurrency: [
|
||
{ currency: "RUB", value: "123456789012345678901.995" },
|
||
{ currency: "USD", value: "20" },
|
||
{ currency: "EUR", value: "5" },
|
||
],
|
||
unrealizedReturnByCurrency: [
|
||
{
|
||
absolute: "136900",
|
||
costBasis: "1000000",
|
||
currency: "RUB",
|
||
partial: true,
|
||
percent: "12.40",
|
||
},
|
||
{
|
||
absolute: "-4.2",
|
||
costBasis: "24.2",
|
||
currency: "USD",
|
||
partial: false,
|
||
percent: "-3.05",
|
||
},
|
||
{
|
||
absolute: "0",
|
||
costBasis: "5",
|
||
currency: "EUR",
|
||
partial: false,
|
||
percent: null,
|
||
},
|
||
],
|
||
} satisfies PortfolioSummary;
|
||
|
||
type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][
|
||
"accounts"
|
||
];
|
||
|
||
function account(
|
||
id: string,
|
||
name: string,
|
||
summary: null | PortfolioSummary,
|
||
): BrokerageAccount {
|
||
return {
|
||
id,
|
||
name,
|
||
openedOn: null,
|
||
portfolioSummary: summary,
|
||
type: "BROKERAGE",
|
||
};
|
||
}
|
||
|
||
function renderList(accounts: BrokerageAccountCardInput) {
|
||
return render(
|
||
<MantineProvider>
|
||
<MemoryRouter>
|
||
<BrokerageAccountList accounts={accounts} />
|
||
</MemoryRouter>
|
||
</MantineProvider>,
|
||
);
|
||
}
|
||
|
||
describe("BrokerageAccountList", () => {
|
||
it("renders returns, cash and structure with precise totals", () => {
|
||
const nullReturnSummary: PortfolioSummary = {
|
||
...portfolioSummary,
|
||
cashBalances: [],
|
||
structure: { currencies: [], unvaluedCategories: [] },
|
||
totalsByCurrency: [{ currency: "EUR", value: "5" }],
|
||
unrealizedReturnByCurrency: [{
|
||
absolute: "0",
|
||
costBasis: "5",
|
||
currency: "EUR",
|
||
partial: false,
|
||
percent: null,
|
||
}],
|
||
};
|
||
|
||
renderList(brokerageAccountCards([
|
||
account("account-1", "Основной", portfolioSummary),
|
||
account("account-2", "Без доходности", nullReturnSummary),
|
||
]));
|
||
|
||
const link = screen.getByRole("link", { name: /Основной/ });
|
||
expect(link).toHaveAttribute("href", "/accounts/account-1");
|
||
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
|
||
.toBeInTheDocument();
|
||
expect(within(link).getByText("Еще 1 валюта")).toBeInTheDocument();
|
||
expect(within(link).getByText("+12,40 %")).toBeInTheDocument();
|
||
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
|
||
expect(within(link).getByText("−3,05 %")).toBeInTheDocument();
|
||
expect(within(link).getByText("−4,2 USD")).toBeInTheDocument();
|
||
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
|
||
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
|
||
expect(within(link).getByText("частично")).toBeInTheDocument();
|
||
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
|
||
expect(within(link).getAllByText("20,00 USD")).not.toHaveLength(0);
|
||
expect(within(link).getByText("17 позиций")).toBeInTheDocument();
|
||
expect(screen.getByRole("img", {
|
||
name: "Структура в RUB: Акции 50 %; Облигации 30 %; "
|
||
+ "Другие активы 0 %; Свободные деньги 20 %",
|
||
})).toBeInTheDocument();
|
||
const stockLegend = within(link).getByText(/Акции 50 %/);
|
||
expect(stockLegend).toHaveTextContent("● Акции 50 %");
|
||
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
|
||
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
|
||
expect(within(link).queryByText(/Другие активы 0 %/)).not.toBeInTheDocument();
|
||
expect(link).toHaveTextContent("По данным брокера");
|
||
expect(link).toHaveTextContent("Структура RUB: Акции 100");
|
||
});
|
||
|
||
it("preserves empty, unavailable and stale states", () => {
|
||
const emptySummary: PortfolioSummary = {
|
||
...portfolioSummary,
|
||
cashBalances: [],
|
||
structure: { currencies: [], unvaluedCategories: [] },
|
||
totalPositionCount: 0,
|
||
totalsByCurrency: [],
|
||
unrealizedReturnByCurrency: [],
|
||
};
|
||
|
||
const cards = brokerageAccountCards([
|
||
account("empty", "Пустой", emptySummary),
|
||
account("unavailable", "Недоступный", null),
|
||
account("stale", "Устаревший", portfolioSummary),
|
||
account("failed", "Ошибка обновления", null),
|
||
], resource([], {
|
||
dependencies: [{
|
||
availability: "available",
|
||
completeness: "complete",
|
||
dataRevision: "stale-revision",
|
||
freshness: "stale",
|
||
lastAttempt: {
|
||
finishedAt: null,
|
||
problems: [],
|
||
startedAt: null,
|
||
state: "none",
|
||
},
|
||
lastSuccessfulAt: "2026-08-13T00:00:00Z",
|
||
resourceKey: "portfolio/stale",
|
||
}, {
|
||
availability: "available",
|
||
completeness: "complete",
|
||
dataRevision: "failed-revision",
|
||
freshness: "stale",
|
||
lastAttempt: {
|
||
finishedAt: "2026-08-14T00:01:00Z",
|
||
problems: [],
|
||
startedAt: "2026-08-14T00:00:00Z",
|
||
state: "failed",
|
||
},
|
||
lastSuccessfulAt: "2026-08-13T00:00:00Z",
|
||
resourceKey: "portfolio/failed",
|
||
}],
|
||
freshness: "stale",
|
||
lastSuccessfulAt: "2026-08-14T00:00:00Z",
|
||
}).meta.dependencies);
|
||
|
||
renderList(cards);
|
||
|
||
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
|
||
expect(screen.getAllByText("Сводка временно недоступна"))
|
||
.toHaveLength(2);
|
||
const staleLink = screen.getByRole("link", { name: /Устаревший/ });
|
||
expect(within(staleLink).getByText("Данные требуют обновления"))
|
||
.toBeInTheDocument();
|
||
expect(within(staleLink).getByText(/Данные на/)).toBeInTheDocument();
|
||
const failedLink = screen.getByRole("link", { name: /Ошибка обновления/ });
|
||
expect(within(failedLink).getByText(
|
||
"Не удалось обновить данные. Показаны последние полученные.",
|
||
)).toBeInTheDocument();
|
||
});
|
||
});
|