feat: compact portfolio currency totals
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user