diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 9f053b5..647683e 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -1,6 +1,12 @@ import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -145,11 +151,39 @@ describe("AccountPage states", () => { }, ); - it("shows only supplied totals despite cash and positions with unknown value", () => { - renderPage(); + it("shows supplied cash separately without adding currency instruments or recalculating totals", () => { + renderPage( + , + ); + const balances = within( + screen.getByRole("region", { name: "Денежные остатки" }), + ); + expect( + balances.getAllByRole("definition").map((node) => node.textContent), + ).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); expect( screen.getAllByRole("definition").map((node) => node.textContent), - ).toEqual(["1 234,5 ₽"]); + ).toEqual(["1 234,5 ₽", "234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); + expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect( screen.queryByText(/полная оценка|общая стоимость/i), ).not.toBeInTheDocument(); @@ -169,11 +203,58 @@ describe("AccountPage states", () => { ); renderAccountRoute(createTestQueryClient()); await screen.findByRole("heading", { name: "Портфель", level: 1 }); - expect(screen.queryByRole("definition")).not.toBeInTheDocument(); + expect(screen.getByRole("definition")).toHaveTextContent("0,5 $"); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); }, ); + it.each([ + { totalsByCurrency: [] }, + { totalsByCurrency: [{ currency: "RUB", value: "100" }] }, + ])( + "hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)", + async ({ totalsByCurrency }) => { + const snapshot: Portfolio = { + ...portfolio, + totalsByCurrency, + positions: [ + { + ...portfolio.positions[0], + displayName: "Торгуемая валюта", + type: "CURRENCY", + currentValue: { currency: "RUB", value: "100" }, + }, + ], + cashBalances: [], + }; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(snapshot), + ), + ); + const queryClient = createTestQueryClient(); + renderAccountRoute(queryClient); + + await screen.findByRole("heading", { name: "Портфель", level: 1 }); + expect( + screen.queryByRole("button", { name: "Другие позиции" }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); + expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); + expect( + within( + screen.getByRole("region", { name: "Денежные остатки" }), + ).queryByRole("definition"), + ).not.toBeInTheDocument(); + expect( + screen.queryAllByRole("definition").map((node) => node.textContent), + ).toEqual(totalsByCurrency.length ? ["100 ₽"] : []); + expect( + queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), + ).toEqual(snapshot); + }, + ); + it("renders loading and empty states", () => { renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); @@ -613,13 +694,17 @@ describe("AccountPage states", () => { ), ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); - expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽"); + expect( + screen.getAllByRole("definition").map((node) => node.textContent), + ).toEqual(["1 234,5 ₽", "0,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.getAllByRole("definition").map((node) => node.textContent), + ).toEqual(["1 234,5 ₽", "0,5 $"]); expect(screen.getByText(/Данные получены/).textContent).toBe( snapshotText, ); @@ -629,10 +714,13 @@ describe("AccountPage states", () => { isStale: false, lastSuccessfulAt: "2026-08-14T11:30:00Z", totalsByCurrency: [{ currency: "USD", value: "-0.5" }], + cashBalances: [{ currency: "EUR", value: "2.50" }], }), ); await waitFor(() => - expect(screen.getByRole("definition")).toHaveTextContent("-0,5 $"), + expect( + screen.getAllByRole("definition").map((node) => node.textContent), + ).toEqual(["-0,5 $", "2,50 €"]), ); expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30"); expect( @@ -666,7 +754,9 @@ describe("AccountPage states", () => { await waitFor(() => expect(requestCount).toBe(2)); expect(screen.getByText("Вторая")).toBeInTheDocument(); - expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽"); + expect( + screen.getAllByRole("definition").map((node) => node.textContent), + ).toEqual(["1 234,5 ₽", "0,5 $"]); expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00"); expect( screen.queryByRole("status", { name: "Загрузка" }), diff --git a/src/pages/account/ui/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage.vm.test.tsx index 0949a85..8a9acf3 100644 --- a/src/pages/account/ui/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage.vm.test.tsx @@ -8,7 +8,10 @@ import type { Portfolio } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; -import { useAccountPageViewModel } from "./AccountPage.vm"; +import { + toAccountPagePositionSections, + useAccountPageViewModel, +} from "./AccountPage.vm"; const portfolio: Portfolio = { lastSuccessfulAt: "2026-08-13T10:00:00Z", @@ -130,6 +133,55 @@ function mockLoadedAccountPage() { } describe("useAccountPageViewModel", () => { + it("excludes currency instruments only from display, preserving order and share denominators", () => { + const otherTypes = [ + "OPTION", + "OTHER", + "FUTURE", + "STRUCTURED_NOTE", + "DIGITAL_FINANCIAL_ASSET", + "FUND", + ] as const; + const snapshot: Portfolio = { + ...portfolio, + totalsByCurrency: [{ currency: "RUB", value: "1250" }], + cashBalances: [{ currency: "RUB", value: "1000" }], + positions: [ + ...portfolio.positions, + { + ...portfolio.positions[0], + figi: "FIGI-CURRENCY", + type: "CURRENCY", + currentValue: { currency: "RUB", value: "50" }, + }, + ...otherTypes.map((type) => ({ + ...portfolio.positions[2], + figi: `FIGI-${type}-NEXT`, + type, + currentValue: + type === "OPTION" ? { currency: "RUB", value: "50" } : null, + })), + ], + }; + const original = structuredClone(snapshot); + + const sections = toAccountPagePositionSections(snapshot); + + expect(sections.map((section) => section.kind)).toEqual([ + "stocks", + "bonds", + "other", + ]); + expect(sections[2].positions.map((position) => position.figi)).toEqual([ + "FIGI-FUND", + ...otherTypes.map((type) => `FIGI-${type}-NEXT`), + ]); + expect(sections[0].positions[0].shareText).toBe("40 %"); + expect(sections[1].positions[0].shareText).toBe("20 %"); + expect(sections[2].positions[1].shareText).toBe("20 %"); + expect(snapshot).toEqual(original); + }); + it("prepares grouped position sections with derived display data", async () => { configureTestAuth(); mockLoadedAccountPage(); diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 95d3fa1..6fc389d 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -202,7 +202,11 @@ function toSection( .filter((position) => { if (kind === "stocks") return position.type === "STOCK"; if (kind === "bonds") return position.type === "BOND"; - return position.type !== "STOCK" && position.type !== "BOND"; + return ( + position.type !== "STOCK" && + position.type !== "BOND" && + position.type !== "CURRENCY" + ); }) .map((position) => toPosition(position, denominators, kind)), }; diff --git a/src/pages/account/ui/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/components/AccountPageLoaded.view.tsx index 41d8e36..76eb8d2 100644 --- a/src/pages/account/ui/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/components/AccountPageLoaded.view.tsx @@ -12,10 +12,9 @@ import type { AccountPageRecentOperations, } from "../AccountPage.vm"; import { AccountHeading } from "./AccountHeading.view"; -import { CashBalances } from "./CashBalances.view"; +import { CurrencyCards } from "./CurrencyCards.view"; import { IncomeSummary } from "./IncomeSummary.view"; import { PortfolioRefreshState } from "./PortfolioRefreshState.view"; -import { PortfolioTotals } from "./PortfolioTotals.view"; import { PositionSection } from "./PositionSection.view"; import { RecentEvents } from "./RecentEvents.view"; import { RecentOperations } from "./RecentOperations.view"; @@ -50,7 +49,7 @@ const AccountPageLoaded: FC = (props) => { date: formatSnapshotTime(props.portfolio.lastSuccessfulAt), })} - + = (props) => { {t("portfolio.cashBalances")} - + diff --git a/src/pages/account/ui/components/CashBalances.view.tsx b/src/pages/account/ui/components/CashBalances.view.tsx deleted file mode 100644 index 7b675c8..0000000 --- a/src/pages/account/ui/components/CashBalances.view.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Paper, Text } from "@mantine/core"; -import type { FC } from "react"; -import type { Portfolio } from "@/entities/portfolio"; -import { formatMoney } from "@/shared/lib"; -import classes from "../AccountPage.module.css"; - -type CashBalancesProps = { - balances: Portfolio["cashBalances"]; -}; - -const CashBalances: FC = (props) => { - const { balances } = props; - - return ( - <> - {balances.map((balance) => ( - - {balance.currency} - {formatMoney(balance)} - - ))} - - ); -}; - -export { CashBalances }; diff --git a/src/pages/account/ui/components/PortfolioTotals.test.tsx b/src/pages/account/ui/components/CurrencyCards.test.tsx similarity index 90% rename from src/pages/account/ui/components/PortfolioTotals.test.tsx rename to src/pages/account/ui/components/CurrencyCards.test.tsx index f66ca1a..1366e63 100644 --- a/src/pages/account/ui/components/PortfolioTotals.test.tsx +++ b/src/pages/account/ui/components/CurrencyCards.test.tsx @@ -1,9 +1,9 @@ import { MantineProvider } from "@mantine/core"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { PortfolioTotals } from "./PortfolioTotals.view"; +import { CurrencyCards } from "./CurrencyCards.view"; -describe("PortfolioTotals currency presentation", () => { +describe("CurrencyCards currency presentation", () => { it.each([0, 1, 3, 5])( "renders only the %i supplied totals in upstream order", (count) => { @@ -16,7 +16,7 @@ describe("PortfolioTotals currency presentation", () => { ].slice(0, count); const { container } = render( - + , ); expect( @@ -52,7 +52,7 @@ describe("PortfolioTotals currency presentation", () => { (currency, value, expected, name, symbol) => { render( - + , ); const term = screen.getByRole("term"); @@ -70,8 +70,8 @@ describe("PortfolioTotals currency presentation", () => { it("keeps USD and CAD as distinct supplied values", () => { render( - = (props) => { - const { totals } = props; +const CurrencyCards: FC = (props) => { + const { amounts } = props; const { t } = useTranslation(); const currencies: Record = { RUB: { symbol: "₽", name: t("portfolio.currencies.RUB") }, @@ -20,22 +20,22 @@ const PortfolioTotals: FC = (props) => { return ( - {totals.map((total) => { - const amount = formatDecimal(total.value); + {amounts.map((money) => { + const amount = formatDecimal(money.value); return ( - {currencies[total.currency]?.name ?? total.currency} + {currencies[money.currency]?.name ?? money.currency} @@ -49,7 +49,7 @@ const PortfolioTotals: FC = (props) => { ) : ( amount )}{" "} - {currencies[total.currency]?.symbol ?? total.currency} + {currencies[money.currency]?.symbol ?? money.currency} ); @@ -58,4 +58,4 @@ const PortfolioTotals: FC = (props) => { ); }; -export { PortfolioTotals }; +export { CurrencyCards }; diff --git a/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx b/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx index 7b259c2..5c7358b 100644 --- a/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx +++ b/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx @@ -1,8 +1,8 @@ import { Stack } from "@mantine/core"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { fn } from "storybook/test"; +import { CurrencyCards } from "./CurrencyCards.view"; import { PortfolioRefreshState } from "./PortfolioRefreshState.view"; -import { PortfolioTotals } from "./PortfolioTotals.view"; const meta = { title: "Product/PortfolioRefreshState", @@ -18,14 +18,14 @@ const meta = { docs: { description: { component: - "Обновление уже показанных данных портфеля. Реальные PortfolioTotals остаются видимыми вместе с PortfolioRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.", + "Обновление уже показанных данных портфеля. Реальные CurrencyCards остаются видимыми вместе с PortfolioRefreshState при refetch и его ошибке. Суммы синтетические; это presentation Client Cached Snapshot, без QueryClient и запроса API. Retry регистрирует локальное действие в Actions и не доказывает восстановление API.", }, }, }, render: (args) => ( -