refactor: simplify resource queries and layout tests
This commit was merged in pull request #40.
This commit is contained in:
@@ -660,26 +660,6 @@ describe("AccountPage states", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the bonds section as a single-column grid on any screen width", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [portfolio.positions[1]],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const singleColumnGrids = Array.from(document.querySelectorAll("style"))
|
||||
.map((style) => (style.textContent ?? "").replace(/\s/g, ""))
|
||||
.filter(
|
||||
(css) =>
|
||||
css.includes("--sg-cols:1") &&
|
||||
!css.includes("@media") &&
|
||||
!/--sg-cols:[23]/.test(css),
|
||||
);
|
||||
expect(singleColumnGrids).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@wealthledger/ui/styles.css";
|
||||
import "@/shared/i18n";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type {
|
||||
AccountPagePosition,
|
||||
AccountPagePositionSection,
|
||||
} from "./AccountPage.vm";
|
||||
import { PositionSection } from "./components/PositionSection.view";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("keeps expanded positions vertical while other positions adapt to the viewport", () => {
|
||||
const kinds: AccountPagePositionSection["kind"][] = [
|
||||
"stocks",
|
||||
"bonds",
|
||||
"other",
|
||||
];
|
||||
const width = window.innerWidth;
|
||||
expect([375, 1280]).toContain(width);
|
||||
for (const kind of kinds) {
|
||||
const positions: AccountPagePosition[] = [1, 2, 3].map((index) => ({
|
||||
figi: null,
|
||||
displayName: `Инструмент ${index}`,
|
||||
type: kind === "stocks" ? "STOCK" : kind === "bonds" ? "BOND" : "FUND",
|
||||
quantity: "10",
|
||||
currentValue: { currency: "RUB", value: "1000" },
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: null,
|
||||
shareText: null,
|
||||
returnText: null,
|
||||
logoSrc: null,
|
||||
}));
|
||||
const { unmount } = render(
|
||||
<MantineProvider
|
||||
theme={theme}
|
||||
cssVariablesResolver={cssVariablesResolver}
|
||||
forceColorScheme="light"
|
||||
>
|
||||
<PositionSection
|
||||
section={{
|
||||
kind,
|
||||
groups: positions.map((position) => ({
|
||||
key: position.displayName,
|
||||
positions: [position],
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
const bounds = positions.map((position) =>
|
||||
screen.getByText(position.displayName).getBoundingClientRect(),
|
||||
);
|
||||
const context = `${kind} at ${width}px`;
|
||||
for (const [index, current] of bounds.entries()) {
|
||||
expect(current.width, context).toBeGreaterThan(0);
|
||||
expect(current.height, context).toBeGreaterThan(0);
|
||||
if (index === 0) continue;
|
||||
const previous = bounds[index - 1];
|
||||
if (kind === "other" && width === 1280) {
|
||||
expect(current.top, context).toBeCloseTo(previous.top, 0);
|
||||
expect(current.left, context).toBeGreaterThan(previous.right);
|
||||
} else {
|
||||
expect(current.left, context).toBeCloseTo(previous.left, 0);
|
||||
expect(current.top, context).toBeGreaterThan(previous.bottom);
|
||||
}
|
||||
}
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import {
|
||||
type BondPeriod,
|
||||
@@ -10,12 +8,11 @@ import {
|
||||
useBondPosition,
|
||||
useBondQuote,
|
||||
} from "@/entities/bond";
|
||||
import type { OperationCategory } from "@/entities/broker-operation";
|
||||
import {
|
||||
brokerageOperationsQueryOptions,
|
||||
type OperationCategory,
|
||||
useBrokerageOperations,
|
||||
} from "@/entities/broker-operation";
|
||||
import { section } from "@/features/instrument-details";
|
||||
section,
|
||||
useInstrumentOperations,
|
||||
} from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||
@@ -38,27 +35,13 @@ function useBondPageVm() {
|
||||
periods.find((value) => value === params.get("period")) ?? "month";
|
||||
const filter =
|
||||
filters.find((value) => value === params.get("events")) ?? "all";
|
||||
const scope = `${uid}:${filter}`;
|
||||
const [pagination, setPagination] = useState<{
|
||||
scope: string;
|
||||
cursors: string[];
|
||||
}>({ scope, cursors: [] });
|
||||
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
||||
const client = useQueryClient();
|
||||
const quote = useBondQuote(uid);
|
||||
const metrics = useBondMetrics(uid);
|
||||
const candles = useBondCandles(uid, period);
|
||||
const position = useBondPosition(uid);
|
||||
const coupons = useBondCoupons(uid);
|
||||
const firstPage = {
|
||||
instrumentUid: uid,
|
||||
categories: categories[filter],
|
||||
limit: 20,
|
||||
};
|
||||
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
||||
...firstPage,
|
||||
cursor: cursors.at(-1),
|
||||
});
|
||||
const history = useInstrumentOperations(uid, filter, categories[filter]);
|
||||
const { operations } = history;
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
screen: "bond",
|
||||
@@ -75,15 +58,6 @@ function useBondPageVm() {
|
||||
next.set(key, value);
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
const retryOperations = () => {
|
||||
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
||||
setPagination({ scope, cursors: [] });
|
||||
void client.invalidateQueries({
|
||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||
exact: true,
|
||||
});
|
||||
} else void sync.retry();
|
||||
};
|
||||
const chartData =
|
||||
candles.data?.candles.map((candle) => ({
|
||||
...candle,
|
||||
@@ -138,7 +112,7 @@ function useBondPageVm() {
|
||||
operations: operations.data,
|
||||
operationsState: {
|
||||
...section(operations, sync.retry),
|
||||
retry: retryOperations,
|
||||
retry: () => history.retry(sync.retry),
|
||||
},
|
||||
eventRows:
|
||||
operations.data?.items.map((item) => ({
|
||||
@@ -148,15 +122,9 @@ function useBondPageVm() {
|
||||
})) ?? [],
|
||||
filter,
|
||||
setFilter: (value: string) => setParam("events", value),
|
||||
hasPrevious: cursors.length > 0,
|
||||
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
|
||||
onNext: () => {
|
||||
if (operations.data?.nextCursor)
|
||||
setPagination({
|
||||
scope,
|
||||
cursors: [...cursors, operations.data.nextCursor],
|
||||
});
|
||||
},
|
||||
hasPrevious: history.hasPrevious,
|
||||
onPrevious: history.onPrevious,
|
||||
onNext: history.onNext,
|
||||
};
|
||||
}
|
||||
type BondPageVm = ReturnType<typeof useBondPageVm>;
|
||||
|
||||
@@ -70,6 +70,7 @@ function setup(
|
||||
noPosition?: boolean;
|
||||
failCompany?: boolean;
|
||||
failStock?: boolean;
|
||||
cursorError?: string;
|
||||
path?: string;
|
||||
slug?: string;
|
||||
ambiguousTicker?: boolean;
|
||||
@@ -79,6 +80,8 @@ function setup(
|
||||
const requests: URL[] = [];
|
||||
const resolved = { ...stock, slug: options.slug ?? uid };
|
||||
let failQuote = false;
|
||||
let cursorError = options.cursorError;
|
||||
let operationPayment = "200";
|
||||
server.use(
|
||||
http.get("*/api/v1/stocks/:uid", () =>
|
||||
options.failStock
|
||||
@@ -166,6 +169,8 @@ function setup(
|
||||
const url = new URL(request.url);
|
||||
requests.push(url);
|
||||
const next = url.searchParams.has("cursor");
|
||||
if (next && cursorError)
|
||||
return HttpResponse.json({ code: cursorError }, { status: 400 });
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId: "all",
|
||||
@@ -182,7 +187,7 @@ function setup(
|
||||
category: next ? "trade" : "income",
|
||||
description: "",
|
||||
account: { id: "main", name: "Основной" },
|
||||
payment: money("200"),
|
||||
payment: money(operationPayment),
|
||||
quantity: next ? "2" : null,
|
||||
price: next ? money("100") : null,
|
||||
},
|
||||
@@ -211,6 +216,10 @@ function setup(
|
||||
);
|
||||
return {
|
||||
requests,
|
||||
recoverOperations: () => {
|
||||
cursorError = undefined;
|
||||
operationPayment = "201";
|
||||
},
|
||||
failQuote: () => {
|
||||
failQuote = true;
|
||||
},
|
||||
@@ -374,7 +383,7 @@ describe("stock page", () => {
|
||||
expect(requests).toHaveLength(previousRequests);
|
||||
expect(modes).toEqual(["observe"]);
|
||||
});
|
||||
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
|
||||
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
|
||||
const { requests } = setup();
|
||||
await screen.findByText("Зачисление дивидендов");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||
@@ -399,6 +408,14 @@ describe("stock page", () => {
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Все" }));
|
||||
expect(await screen.findByText("Продажа")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
|
||||
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -406,6 +423,34 @@ describe("stock page", () => {
|
||||
).toBe(true),
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
["INVALID_OPERATIONS_CURSOR", null, null],
|
||||
["TBANK_UNAVAILABLE", "next", "observe"],
|
||||
])(
|
||||
"recovers history after %s with fresh data and the correct cursor",
|
||||
async (code, cursor, sync) => {
|
||||
const controls = setup({ cursorError: code });
|
||||
await screen.findByText("Зачисление дивидендов");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
|
||||
const retry = await screen.findByRole("button", { name: "Повторить" });
|
||||
controls.recoverOperations();
|
||||
const previousRequests = controls.requests.length;
|
||||
fireEvent.click(retry);
|
||||
expect(await screen.findByText("+201 ₽")).toBeVisible();
|
||||
const recovered = controls.requests.slice(previousRequests);
|
||||
expect(recovered).toHaveLength(1);
|
||||
expect(recovered[0]?.pathname).toBe(
|
||||
"/api/v1/brokerage-accounts/all/operations",
|
||||
);
|
||||
expect(recovered[0]?.searchParams.get("instrumentUid")).toBe(uid);
|
||||
expect(recovered[0]?.searchParams.get("limit")).toBe("20");
|
||||
expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
|
||||
expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
|
||||
if (cursor)
|
||||
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
|
||||
else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
|
||||
},
|
||||
);
|
||||
it("keeps the last quote on a refresh failure", async () => {
|
||||
const scopes: unknown[] = [];
|
||||
server.use(
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import {
|
||||
brokerageOperationsQueryOptions,
|
||||
type OperationCategory,
|
||||
useBrokerageOperations,
|
||||
} from "@/entities/broker-operation";
|
||||
import type { OperationCategory } from "@/entities/broker-operation";
|
||||
import {
|
||||
type StockPeriod,
|
||||
useStock,
|
||||
@@ -15,7 +9,10 @@ import {
|
||||
useStockPosition,
|
||||
useStockQuote,
|
||||
} from "@/entities/stock";
|
||||
import { section } from "@/features/instrument-details";
|
||||
import {
|
||||
section,
|
||||
useInstrumentOperations,
|
||||
} from "@/features/instrument-details";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||
@@ -38,27 +35,13 @@ function useStockPageVm() {
|
||||
periods.find((value) => value === params.get("period")) ?? "month";
|
||||
const filter =
|
||||
filters.find((value) => value === params.get("events")) ?? "all";
|
||||
const scope = `${uid}:${filter}`;
|
||||
const [pagination, setPagination] = useState<{
|
||||
scope: string;
|
||||
cursors: string[];
|
||||
}>({ scope, cursors: [] });
|
||||
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
||||
const client = useQueryClient();
|
||||
const quote = useStockQuote(uid);
|
||||
const candles = useStockCandles(uid, period);
|
||||
const position = useStockPosition(uid);
|
||||
const company = useStockCompany(uid);
|
||||
const dividends = useStockDividends(uid);
|
||||
const firstPage = {
|
||||
instrumentUid: uid,
|
||||
categories: categories[filter],
|
||||
limit: 20,
|
||||
};
|
||||
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
||||
...firstPage,
|
||||
cursor: cursors.at(-1),
|
||||
});
|
||||
const history = useInstrumentOperations(uid, filter, categories[filter]);
|
||||
const { operations } = history;
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
screen: "stock",
|
||||
@@ -75,15 +58,6 @@ function useStockPageVm() {
|
||||
next.set(key, value);
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
const retryOperations = () => {
|
||||
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
||||
setPagination({ scope, cursors: [] });
|
||||
void client.invalidateQueries({
|
||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||
exact: true,
|
||||
});
|
||||
} else void sync.retry();
|
||||
};
|
||||
const chartData =
|
||||
candles.data?.candles.map((candle) => ({
|
||||
...candle,
|
||||
@@ -145,7 +119,7 @@ function useStockPageVm() {
|
||||
operations: operations.data,
|
||||
operationsState: {
|
||||
...section(operations, sync.retry),
|
||||
retry: retryOperations,
|
||||
retry: () => history.retry(sync.retry),
|
||||
},
|
||||
eventRows:
|
||||
operations.data?.items.map((item) => ({
|
||||
@@ -155,15 +129,9 @@ function useStockPageVm() {
|
||||
})) ?? [],
|
||||
filter,
|
||||
setFilter: (value: string) => setParam("events", value),
|
||||
hasPrevious: cursors.length > 0,
|
||||
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
|
||||
onNext: () => {
|
||||
if (operations.data?.nextCursor)
|
||||
setPagination({
|
||||
scope,
|
||||
cursors: [...cursors, operations.data.nextCursor],
|
||||
});
|
||||
},
|
||||
hasPrevious: history.hasPrevious,
|
||||
onPrevious: history.onPrevious,
|
||||
onNext: history.onNext,
|
||||
};
|
||||
}
|
||||
type StockPageVm = ReturnType<typeof useStockPageVm>;
|
||||
|
||||
Reference in New Issue
Block a user