feat: show return, cash and structure on account cards (#114)
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
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
This commit was merged in pull request #116.
This commit is contained in:
+185
-111
@@ -5,128 +5,202 @@ 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";
|
||||
|
||||
describe("BrokerageAccountList", () => {
|
||||
it("renders account details and an account link", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<BrokerageAccountList
|
||||
accounts={brokerageAccountCards([
|
||||
{
|
||||
id: "account-1",
|
||||
name: "Основной",
|
||||
openedOn: "2024-08-13",
|
||||
portfolioSummary: {
|
||||
bondPositionCount: 12,
|
||||
stockPositionCount: 5,
|
||||
totalsByCurrency: [
|
||||
{
|
||||
currency: "RUB",
|
||||
value: "123456789012345678901.995",
|
||||
},
|
||||
{ currency: "USD", value: "20" },
|
||||
{ currency: "EUR", value: "5" },
|
||||
],
|
||||
},
|
||||
type: "BROKERAGE",
|
||||
type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
|
||||
|
||||
},
|
||||
{
|
||||
id: "account-2",
|
||||
name: "Брокерский счет",
|
||||
openedOn: null,
|
||||
portfolioSummary: null,
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
{
|
||||
id: "account-3",
|
||||
name: "Пустой",
|
||||
openedOn: null,
|
||||
portfolioSummary: {
|
||||
bondPositionCount: 0,
|
||||
stockPositionCount: 2,
|
||||
totalsByCurrency: [],
|
||||
},
|
||||
type: "BROKERAGE",
|
||||
},
|
||||
])}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
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;
|
||||
|
||||
expect(screen.getByRole("link", { name: /Основной/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/accounts/account-1",
|
||||
);
|
||||
expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
|
||||
expect(screen.getByText("123 456 789 012 345 678 902,00 ₽"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("20,00 USD")).toBeInTheDocument();
|
||||
expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument();
|
||||
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
|
||||
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
|
||||
type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][
|
||||
"accounts"
|
||||
];
|
||||
|
||||
const available = within(screen.getByRole("link", { name: /Основной/ }));
|
||||
expect(available.getAllByRole("definition")[0]).toHaveTextContent("5");
|
||||
expect(available.getAllByRole("definition")[1]).toHaveTextContent("12");
|
||||
function account(
|
||||
id: string,
|
||||
name: string,
|
||||
summary: null | PortfolioSummary,
|
||||
): BrokerageAccount {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
openedOn: null,
|
||||
portfolioSummary: summary,
|
||||
type: "BROKERAGE",
|
||||
};
|
||||
}
|
||||
|
||||
const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
|
||||
expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2");
|
||||
expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0");
|
||||
|
||||
const duplicatedName = within(screen.getByRole("link", {
|
||||
name: /Сводка временно недоступна/,
|
||||
}));
|
||||
|
||||
expect(duplicatedName.getAllByText("Брокерский счет")).toHaveLength(1);
|
||||
expect(duplicatedName.queryByText("Акции")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows portfolio summary freshness independently for each account", () => {
|
||||
const accounts = brokerageAccountCards(
|
||||
["fresh", "stale"].map(
|
||||
|
||||
id =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
openedOn: null,
|
||||
portfolioSummary: {
|
||||
bondPositionCount: 0,
|
||||
stockPositionCount: 1,
|
||||
totalsByCurrency: [{ currency: "RUB", value: "10" }],
|
||||
},
|
||||
type: "BROKERAGE" as const,
|
||||
}),
|
||||
),
|
||||
[
|
||||
resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
|
||||
.meta,
|
||||
resource(null, { freshness: "stale", resourceKey: "portfolio/stale" })
|
||||
.meta,
|
||||
],
|
||||
);
|
||||
|
||||
render(
|
||||
function renderList(accounts: BrokerageAccountCardInput) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<BrokerageAccountList accounts={accounts} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("link", { name: /stale/ })).getByText(
|
||||
"Данные требуют обновления",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole("link", { name: /fresh/ })).queryByText(
|
||||
"Данные требуют обновления",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user