feat: compact portfolio currency totals

This commit is contained in:
2026-09-09 10:31:04 +03:00
parent 8b29cdfd98
commit 47fbc21283
8 changed files with 335 additions and 52 deletions
+135 -33
View File
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -113,6 +113,67 @@ function emptyProps() {
}
describe("AccountPage states", () => {
it("keeps initial placeholders decorative without amounts or focus targets", () => {
const { container } = renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
expect(screen.queryByRole("definition")).not.toBeInTheDocument();
expect(container.querySelector("[tabindex], button, a, input")).toBeNull();
expect(container).not.toHaveTextContent(/₽|\$|€|0,00/);
});
it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])(
"does not offer retry without cached data for %s",
(code) => {
renderPage(<AccountPageError code={code} retry={vi.fn()} />);
expect(
screen.queryByRole("button", { name: "Повторить" }),
).not.toBeInTheDocument();
expect(screen.getByRole("link")).toHaveAttribute(
"href",
code === "NOT_FOUND" ? "/accounts" : "/settings",
);
},
);
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
"allows retry for %s",
async (code) => {
const retry = vi.fn();
renderPage(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
},
);
it("shows only supplied totals despite cash and positions with unknown value", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
expect(
screen.getAllByRole("definition").map((node) => node.textContent),
).toEqual(["1 234,5 ₽"]);
expect(
screen.queryByText(/полная оценка|общая стоимость/i),
).not.toBeInTheDocument();
});
it.each([true, false])(
"keeps a nonempty portfolio without totals (positions: %s)",
async (hasPositions) => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({
...portfolio,
totalsByCurrency: [],
positions: hasPositions ? portfolio.positions : [],
}),
),
);
renderAccountRoute(createTestQueryClient());
await screen.findByRole("heading", { name: "Портфель", level: 1 });
expect(screen.queryByRole("definition")).not.toBeInTheDocument();
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
},
);
it("renders loading and empty states", () => {
renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toBeInTheDocument();
@@ -512,40 +573,76 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
it("keeps the cached portfolio visible with distinct server and refresh warnings", async () => {
let requestCount = 0;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(portfolio);
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
it.each([503, 404])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(portfolio);
if (requestCount === 3)
return new Promise<Response>((resolve) => {
resolveRetry = resolve;
});
return HttpResponse.json(
{ code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" },
{ status },
);
}),
);
const queryClient = createTestQueryClient();
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey,
});
await screen.findByText("Вторая");
const snapshotText = screen.getByText(/Данные получены/).textContent;
expect(snapshotText).toContain("10:00");
void queryClient.invalidateQueries({
queryKey: portfolioQueryOptions("account-1").queryKey,
});
expect(
await screen.findByText(
"Не удалось обновить портфель. Показан последний полученный снимок.",
),
).toBeInTheDocument();
expect(
screen.getByText(
"T-Bank временно недоступен. Показан последний успешный снимок.",
),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click();
await waitFor(() => expect(requestCount).toBe(3));
});
expect(
await screen.findByText(
"Не удалось обновить портфель. Показан последний полученный снимок.",
),
).toBeInTheDocument();
expect(
screen.getByText(
"T-Bank временно недоступен. Показан последний успешный снимок.",
),
).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
expect(screen.getByText(/Данные получены/).textContent).toBe(
snapshotText,
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(requestCount).toBe(3));
expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
expect(screen.getByText(/Данные получены/).textContent).toBe(
snapshotText,
);
resolveRetry?.(
HttpResponse.json({
...portfolio,
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
}),
);
await waitFor(() =>
expect(screen.getByRole("definition")).toHaveTextContent("-0,5 $"),
);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
expect(
screen.queryByText(/Не удалось обновить портфель/),
).not.toBeInTheDocument();
expect(
screen.queryByText(/T-Bank временно недоступен/),
).not.toBeInTheDocument();
},
);
it("keeps the cached portfolio visible during a background refresh", async () => {
let requestCount = 0;
@@ -569,6 +666,11 @@ describe("AccountPage states", () => {
await waitFor(() => expect(requestCount).toBe(2));
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽");
expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00");
expect(
screen.queryByRole("status", { name: "Загрузка" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();