refactor!: support RUB only (WealthLedger/main#55) (#168)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 9m0s

This commit was merged in pull request #168.
This commit is contained in:
2026-11-11 09:36:29 +03:00
parent e2062ce837
commit 2b94a16ae5
55 changed files with 540 additions and 1153 deletions
@@ -96,7 +96,7 @@ function renderPage(ui: React.ReactNode) {
const portfolio
= {
cashBalances: [{ currency: "USD", value: "0.5" }],
cash: { currency: "RUB", value: "0.5" },
isStale: true,
lastSuccessfulAt: "2026-08-13T10:00:00Z",
positions: [
@@ -129,8 +129,8 @@ const portfolio
type: "BOND",
},
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
totals: { currency: "RUB", value: "1234.5" },
} satisfies Portfolio;
function position(
@@ -201,8 +201,6 @@ const LoadedHarness = (
function loadedProps(nextPortfolio: Portfolio = portfolio) {
return {
concentration: null,
currency: null,
onCurrencyChange: vi.fn(),
onSliceChange: vi.fn(),
portfolio: nextPortfolio,
positionSections: toAccountPagePositionSections(nextPortfolio),
@@ -242,10 +240,10 @@ describe("AccountPage states", () => {
HttpResponse.json(resource(
{
...portfolio,
cashBalances: [],
cash: { currency: "RUB", value: "0" },
isStale: false,
positions: [],
totalsByCurrency: [],
totals: { currency: "RUB", value: "0" },
},
{
lastAttempt: {
@@ -278,7 +276,7 @@ describe("AccountPage states", () => {
const data = { ...portfolio, positions };
const model = toConcentration(
data, "stocks", "RUB", concentrationLabels(),
data, "stocks", concentrationLabels(),
);
const props = loadedProps(data);
@@ -287,7 +285,6 @@ describe("AccountPage states", () => {
{...props}
accountId="a"
concentration={model}
currency="RUB"
tab="concentration"
/>,
);
@@ -326,10 +323,8 @@ describe("AccountPage states", () => {
concentration={toConcentration(
{ ...portfolio, positions },
"stocks",
"RUB",
concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
@@ -354,9 +349,8 @@ describe("AccountPage states", () => {
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
data, "stocks", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
@@ -370,9 +364,8 @@ describe("AccountPage states", () => {
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "issuers", "RUB", concentrationLabels(),
data, "issuers", concentrationLabels(),
)}
currency="RUB"
slice="issuers"
tab="concentration"
/>,
@@ -392,9 +385,8 @@ describe("AccountPage states", () => {
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
data, "stocks", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
@@ -424,9 +416,8 @@ describe("AccountPage states", () => {
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "sectors", "RUB", concentrationLabels(),
data, "sectors", concentrationLabels(),
)}
currency="RUB"
slice="sectors"
tab="concentration"
/>,
@@ -457,9 +448,8 @@ describe("AccountPage states", () => {
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
data, "stocks", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
@@ -484,9 +474,8 @@ describe("AccountPage states", () => {
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
data, "stocks", concentrationLabels(),
)}
currency="RUB"
partial
tab="concentration"
/>,
@@ -496,16 +485,14 @@ describe("AccountPage states", () => {
.toHaveLength(1);
});
it("keeps slice and currency while switching page tabs and resets on account change", async () => {
it("keeps the slice while switching page tabs and resets on account change", async () => {
const multiCurrency = { ...portfolio, positions:
["RUB", "USD"].flatMap(currency =>
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
position({
currentValue: { currency, value: String(value) },
displayName: `${currency} акция ${index + 1}`,
sector: "energy", type: "STOCK",
}),
),
[400, 250, 150, 100, 50, 30, 20].map((value, index) =>
position({
currentValue: { currency: "RUB", value: String(value) },
displayName: `Акция ${index + 1}`,
sector: "energy", type: "STOCK",
}),
) };
server.use(
@@ -520,37 +507,27 @@ describe("AccountPage states", () => {
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
expect(screen.getByRole("button", { name: /Прочее/ }))
.toHaveAttribute("aria-expanded", "true");
fireEvent.click(screen.getByRole("combobox", { name: "Валюта концентрации" }));
fireEvent.click(screen.getByRole("option", { name: "USD" }));
expect(screen.getByRole("button", { name: /Прочее/ }))
.toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("button", { name: /Прочее/ }));
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
fireEvent.click(screen.getByRole("radio", { name: "Акции" }));
expect(screen.getByRole("button", { name: /Прочее/ }))
.toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("radio", { name: "Секторы" }));
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
.toHaveValue("USD");
fireEvent.click(screen.getByRole("tab", { name: "Позиции" }));
fireEvent.click(screen.getByRole("tab", { name: "Концентрация" }));
expect(screen.getByRole("radio", { name: "Секторы" }))
.toBeChecked();
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
.toHaveValue("USD");
fireEvent.click(screen.getByRole("button", { name: "Другой счёт" }));
fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" }));
expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked();
expect(screen.getByRole("combobox", { name: "Валюта концентрации" }))
.toHaveValue("RUB");
});
it("falls back to the first currency when the selected one disappears", () => {
it("includes all ruble positions in concentration", () => {
const data = { ...portfolio, positions: [
position({
currentValue: { currency: "USD", value: "200" },
currentValue: { currency: "RUB", value: "200" },
}),
position({
currentValue: { currency: "RUB", value: "100" },
@@ -558,12 +535,11 @@ describe("AccountPage states", () => {
] };
const model = toConcentration(
data, "stocks", "EUR", concentrationLabels(),
data, "stocks", concentrationLabels(),
);
expect(model.currencies).toEqual(["RUB", "USD"]);
expect(model.total).toBe("100");
expect(model.items.map(item => item.amount)).toEqual(["100"]);
expect(model.total).toBe("300");
expect(model.items.map(item => item.amount)).toEqual(["200", "100"]);
});
it("shows the loaded portfolio source sync status", async () => {
server.use(
@@ -679,7 +655,7 @@ describe("AccountPage states", () => {
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽"]);
).toEqual(["1 234,50 ₽"]);
});
it("keeps initial placeholders decorative without amounts or focus targets", () => {
const { container } = renderPage(<AccountPageLoading />);
@@ -722,11 +698,7 @@ describe("AccountPage states", () => {
<LoadedHarness
{...loadedProps({
...portfolio,
cashBalances: [
{ currency: "RUB", value: "234.500000000000000001" },
{ currency: "USD", value: "-0.5" },
{ currency: "XYZ", value: "3.00" },
],
cash: { currency: "RUB", value: "234.500000000000000001" },
positions: [
...portfolio.positions,
{
@@ -737,7 +709,7 @@ describe("AccountPage states", () => {
type: "CURRENCY",
},
],
structure: { currencies: [], unvaluedCategories: [] },
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
})}
/>,
);
@@ -750,14 +722,12 @@ describe("AccountPage states", () => {
expect(
balances.getAllByRole("definition").map(node => node.textContent),
).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]);
).toEqual(["234,50 ₽"]);
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual([
"1 234,5 ₽",
"234,500000000000000001 ₽",
"-0,5 $",
"3,00 XYZ",
"1 234,50 ₽",
"234,50 ₽",
]);
expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument();
expect(
@@ -774,7 +744,7 @@ describe("AccountPage states", () => {
resource({
...portfolio,
positions: hasPositions ? portfolio.positions : [],
totalsByCurrency: [],
totals: { currency: "RUB", value: "0" },
}),
),
),
@@ -782,20 +752,20 @@ describe("AccountPage states", () => {
renderAccountRoute(createTestQueryClient());
await screen.findByRole("heading", { level: 1, name: "Портфель" });
fireEvent.click(screen.getByRole("tab", { name: "Деньги" }));
expect(screen.getByRole("definition")).toHaveTextContent("0,5 $");
expect(screen.getByText("0,50 ₽")).toBeInTheDocument();
expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument();
},
);
it.each([
{ totalsByCurrency: [] },
{ totalsByCurrency: [{ currency: "RUB", value: "100" }] },
{ empty: true, totals: { currency: "RUB", value: "0" } },
{ empty: false, totals: { currency: "RUB", value: "100" } },
])(
"hides the empty other section without emptying a currency-only portfolio (totals: $totalsByCurrency)",
async ({ totalsByCurrency }) => {
"hides the empty other section without emptying a currency-only portfolio (totals: $totals.value)",
async ({ empty, totals }) => {
const snapshot: Portfolio = {
...portfolio,
cashBalances: [],
cash: { currency: "RUB", value: "0" },
positions: [
{
...portfolio.positions[0],
@@ -804,8 +774,8 @@ describe("AccountPage states", () => {
type: "CURRENCY",
},
],
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency,
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
totals,
};
server.use(
@@ -826,11 +796,11 @@ describe("AccountPage states", () => {
expect(
within(
screen.getByRole("tabpanel", { name: "Деньги" }),
).queryByRole("definition"),
).not.toBeInTheDocument();
).getByRole("definition"),
).toHaveTextContent("0,00 ₽");
expect(
screen.queryAllByRole("definition").map(node => node.textContent),
).toEqual(totalsByCurrency.length > 0 ? ["100 ₽"] : []);
).toEqual([empty ? "0,00 ₽" : "100,00 ₽", "0,00 ₽"]);
expect(
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
).toEqual(resource(snapshot));
@@ -1147,7 +1117,7 @@ describe("AccountPage states", () => {
date: "2026-09-11",
dateKind: "registry",
displayName: "Акция",
projectedAmount: { currency: "USD", value: "0" },
projectedAmount: { currency: "RUB", value: "0" },
type: "dividend",
},
{
@@ -1191,7 +1161,7 @@ describe("AccountPage states", () => {
expect(
content.getByText("11.09.2026 · Дата реестра · RU000A0JX0J2"),
).toBeVisible();
for (const amount of ["850,50 ₽", "0 $", "1 000 ₽", "Сумма неизвестна"])
for (const amount of ["850,50 ₽", "0 ₽", "1 000 ₽", "Сумма неизвестна"])
expect(content.getByText(amount)).toBeVisible();
expect(content.getAllByText("Прогноз")).toHaveLength(4);
expect(content.queryByText("Без движения денег")).not.toBeInTheDocument();
@@ -1277,7 +1247,7 @@ describe("AccountPage states", () => {
return HttpResponse.json(resource(
requests === 1
? portfolio
: { ...portfolio, totalsByCurrency: [{ currency: "RUB", value: "999" }] },
: { ...portfolio, totals: { currency: "RUB", value: "999" } },
{ resourceKey: "portfolio" },
));
}),
@@ -1301,14 +1271,14 @@ describe("AccountPage states", () => {
expect(screen.getByText("Не удалось обновить данные")).toBeInTheDocument();
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(screen.getByText("1 234,5 ₽")).toBeInTheDocument();
expect(screen.getByText("1 234,50 ₽")).toBeInTheDocument();
// One cached read plus the three automatic attempts.
expect(requests).toBe(4);
isFailing = false;
fireEvent.click(retry);
expect(await screen.findByText("999 ₽")).toBeInTheDocument();
expect(await screen.findByText("999,00 ₽")).toBeInTheDocument();
expect(requests).toBe(5);
expect(screen.queryByText("Не удалось обновить данные"))
.not.toBeInTheDocument();
@@ -1370,14 +1340,9 @@ describe("AccountPage states", () => {
= {
...portfolio,
structure: {
currencies: [
{
categories: [{ sharePercent: null, type: "BOND", value: "10" }],
currency: "RUB",
hasNegativeValues: false,
totalValue: "10",
},
],
categories: [{ sharePercent: null, type: "BOND", value: "10" }],
hasNegativeValues: false,
totalValue: "10",
unvaluedCategories: [{ count: 1, type: "STOCK" }],
},
};
@@ -1428,7 +1393,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽"]);
).toEqual(["1 234,50 ₽"]);
expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument();
void queryClient.invalidateQueries({
@@ -1438,17 +1403,17 @@ describe("AccountPage states", () => {
expect(structure).toHaveTextContent("10 ₽");
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽"]);
).toEqual(["1 234,50 ₽"]);
resolveRetry?.(
HttpResponse.json(
resource(
{
...portfolio,
cashBalances: [{ currency: "EUR", value: "2.50" }],
cash: { currency: "RUB", value: "2.50" },
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
totals: { currency: "RUB", value: "-0.5" },
},
{ dataRevision: "2", resourceKey: "portfolio" },
),
@@ -1457,7 +1422,7 @@ describe("AccountPage states", () => {
await waitFor(() =>
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["-0,5 $"]),
).toEqual(["-0,50 ₽"]),
);
expect(screen.getByText(/^Данные на /)).toHaveTextContent("Данные на");
expect(structure).not.toHaveTextContent("10 ₽");
@@ -1499,7 +1464,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
expect(
screen.getAllByRole("definition").map(node => node.textContent),
).toEqual(["1 234,5 ₽"]);
).toEqual(["1 234,50 ₽"]);
expect(screen.getByText(/^Данные на /)).toBeInTheDocument();
expect(
screen.queryByRole("status", { name: "Загрузка" }),