Compare commits

..
Author SHA1 Message Date
ksv741 7b4e6367e6 test: align upcoming events empty state with new label (WealthLedger/frontend#161)
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m27s
2026-11-07 10:34:15 +03:00
ksv741 6333426ba2 fix: unify history and upcoming events labels (WealthLedger/frontend#161) 2026-11-07 10:29:25 +03:00
11 changed files with 44 additions and 148 deletions
@@ -49,10 +49,6 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`;
}
function isCouponPayment(operation: BrokerageOperation): boolean {
return isOperationType(operation.type, OperationType.Coupon);
}
function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type),
@@ -135,7 +131,6 @@ function paymentPresentation(
export {
brokerageOperationGroupKey,
entryLabelKey,
isCouponPayment,
isIncomePayment,
isZeroDecimal,
operationLabelKey,
@@ -55,27 +55,23 @@ function renderGroup(
}
describe("OperationGroup", () => {
it("shows the received coupon and positive totals in flat account rows", () => {
it("shows the header, exact total and separate card rows per account", () => {
renderGroup([
{ ...operation("1", { id: "broker", name: "Брокерский" }, "0.1"), description: "Выплата купонов СФО ВТБ РКС Эталон 04" },
operation("1", { id: "broker", name: "Брокерский" }, "0.1"),
operation("2", { id: "iis", name: "ИИС" }, "0.2"),
]);
expect(screen.getByText("Выплата купонов СФО ВТБ РКС Эталон 04")).toBeInTheDocument();
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Выплата купонов СФО ВТБ РКС Эталон 04, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
name: /Купон · BOND, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
}));
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.getByText("+0,1 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.getByText("+0,2 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.not.toHaveClass("EntryGroup_accountCardRow");
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
@@ -87,10 +83,8 @@ describe("OperationGroup", () => {
]);
expect(screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено .*Итог недоступен/,
name: /Купон · BOND, Исполнено .*Итог недоступен/,
})).toBeInTheDocument();
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
expect(screen.getByText("—")).not.toHaveClass(/positive/);
});
it.each(["0", "0.000"])(
@@ -120,7 +114,6 @@ describe("OperationGroup", () => {
operation("2", { id: "iis", name: "ИИС" }, "-100"),
]);
expect(screen.getByText("0 ₽")).toBeInTheDocument();
expect(screen.getByText("−100 ₽")).toHaveClass(/negative/);
});
it("renders repeated ids inside one group as separate rows", () => {
@@ -130,7 +123,7 @@ describe("OperationGroup", () => {
]);
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { name: /Выплата купонов/ }));
const accounts = within(screen.getByRole("list", { name: /Купон/ }));
expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
expect(accounts.getAllByText("ИИС")).toHaveLength(1);
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
@@ -9,13 +9,12 @@ import {
sumMoney,
totalText,
} from "@/shared/lib";
import { AccountCardRow, AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import { AccountCardRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import {
entryLabelKey,
isCouponPayment,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
@@ -38,8 +37,6 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
? entryLabelKey(first)
: operationLabelKey(first.type));
const isCoupon = isCouponPayment(first);
const instrument
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
@@ -55,21 +52,9 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
: null;
const date = formatSnapshotDay(first.occurredAt);
const description = first.description.trim();
let title = `${typeLabel} · ${instrument}`;
if (isCoupon) {
title = description && description.toLocaleLowerCase() !== "купон"
? description
: t("ledger.couponPayment", { instrument });
}
const title = `${typeLabel} · ${instrument}`;
const totalsText = totalText(totals, true);
const totalTone = isCoupon && totals?.length && totals.every(total => !isZeroDecimal(total.value) && !total.value.startsWith("-"))
? "positive"
: "neutral";
const accessibleName = [
title,
t("payout.executedAt", { date }),
@@ -78,14 +63,13 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
return (
<EntryGroup
flat={isCoupon}
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })}
title={title}
tone={totalTone}
tone="neutral"
value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel}
wrap
@@ -96,15 +80,14 @@ const OperationGroup: FC<OperationGroupProps> = (props) => {
{props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation);
const accountRowProps = {
accountName: operation.account?.name ?? "—",
value: payment.amount,
valueLabel: t(payment.label),
};
return isCoupon
? <AccountRow key={key} {...accountRowProps} tone={payment.tone} />
: <AccountCardRow key={key} {...accountRowProps} />;
return (
<AccountCardRow
accountName={operation.account?.name ?? "—"}
key={key}
value={payment.amount}
valueLabel={t(payment.label)}
/>
);
})}
</EntryGroup>
);
@@ -10,9 +10,6 @@ import { server } from "@/test/server";
import { useScreenSync } from "../index";
const TRANSPORT_ATTEMPTS = 3;
const TRANSPORT_RECOVERY_MS = 3000;
function auth() {
configureAuthClient({
accessToken: () => "synthetic",
@@ -31,13 +28,11 @@ describe("read-only screen synchronization", () => {
gets += 1;
syncModes.push(new URL(request.url).searchParams.get("sync"));
// The transport retries a transient read twice (1 s, 2 s) on its own.
return gets <= TRANSPORT_ATTEMPTS
return gets === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
);
vi.useFakeTimers();
const { result } = renderHook(
() => {
@@ -51,26 +46,12 @@ describe("read-only screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper },
);
try {
// Exhaust the transport back-off so the query settles in error.
await vi.waitFor(async () => {
await act(() => vi.advanceTimersByTimeAsync(TRANSPORT_RECOVERY_MS));
expect(result.current.sync.transportError).toBe(true);
});
expect(gets).toBe(TRANSPORT_ATTEMPTS);
await waitFor(() => expect(gets).toBe(1));
await act(async () => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(async () => result.current.sync.retry());
await vi.waitFor(async () => {
await act(() => vi.advanceTimersByTimeAsync(0));
expect(result.current.accounts.data).toEqual([]);
});
}
finally {
vi.useRealTimers();
}
expect(gets).toBe(TRANSPORT_ATTEMPTS + 1);
expect(syncModes).toEqual(Array.from({ length: gets }, () => null));
expect(gets).toBe(2);
expect(syncModes).toEqual([null, null]);
});
it("refreshes the currently open resources together", async () => {
@@ -817,11 +817,11 @@ describe("income period and snapshot boundary", () => {
);
renderPage(undefined, "all");
expect(await screen.findByText("Выплата купонов BOND")).toBeInTheDocument();
expect(await screen.findByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено/,
name: /Купон · BOND, Исполнено/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
@@ -856,7 +856,7 @@ describe("income period and snapshot boundary", () => {
expect(await screen.findByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Выплата купонов BOND/,
name: /Купон · BOND/,
}));
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
@@ -889,7 +889,7 @@ describe("income period and snapshot boundary", () => {
);
const view = renderPage(undefined, "all");
await screen.findByText("Выплата купонов BOND");
await screen.findByText("Купон · BOND");
expect(screen.queryByText("+30 ₽")).not.toBeInTheDocument();
view.unmount();
@@ -787,10 +787,10 @@ describe("all-account operation groups", () => {
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const accounts = await screen.findByRole("list", {
name: /Выплата купонов BOND, Исполнено/,
name: /Купон · BOND, Исполнено/,
});
expect(screen.getByText("Выплата купонов BOND")).toBeInTheDocument();
expect(screen.getByText("Купон · BOND")).toBeInTheDocument();
expect(screen.getByText("+200 ₽")).toBeInTheDocument();
expect(within(accounts).getAllByRole("listitem")).toHaveLength(2);
@@ -1655,7 +1655,7 @@ describe("AccountPage states", () => {
await screen.findByRole("heading", { level: 1, name: "Все счета" });
const operationGroup = screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено/,
name: /Купон · BOND, Исполнено/,
});
expect(screen.getByText("+500 ₽")).toBeInTheDocument();
-1
View File
@@ -443,7 +443,6 @@ const ru = {
transfer: "Перевод",
},
clearPeriod: "Очистить период",
couponPayment: "Выплата купонов {{instrument}}",
emptyText: "Измените период или фильтры.",
emptyTitle: "Операций нет",
entryLabel: {
@@ -4,40 +4,6 @@
background: var(--wl-color-subtle);
}
.flat {
background: var(--wl-color-surface);
}
.flat > :first-child {
border: 0;
background: transparent;
}
.flat .accounts {
padding-bottom: var(--mantine-spacing-md);
}
.flat .accountRow {
padding-block: var(--mantine-spacing-md);
}
.flat .accountRow:last-child {
padding-bottom: 0;
}
.flat .accountRow > :first-child {
min-width: 0;
overflow-wrap: anywhere;
}
.positive {
color: var(--wl-color-positive);
}
.negative {
color: var(--wl-color-negative);
}
.accounts {
padding: 0 var(--mantine-spacing-md) var(--mantine-spacing-sm);
list-style: none;
@@ -72,12 +38,4 @@
.accountRow {
align-items: flex-start;
}
.flat .accountRow {
flex-wrap: wrap;
}
.flat .accountValue {
white-space: normal;
}
}
+14 -23
View File
@@ -9,15 +9,11 @@ import classes from "./EntryGroup.module.css";
type AccountRowProps = {
accountName: string;
tone?: "negative"
| "neutral"
| "positive";
value: string;
valueLabel: string;
};
type EntryGroupProps = PropsWithChildren<{
flat?: boolean;
header: ReactNode;
label: string;
}>;
@@ -29,14 +25,13 @@ type EntryGroupTotalProps = {
const AccountRow: FC<AccountRowProps> = (props) => {
const accessibleName = `${props.accountName}, ${props.value}`;
const valueClass = props.tone ? classes[props.tone] : undefined;
return (
<li aria-label={accessibleName} className={classes.accountRow}>
<Text>{props.accountName}</Text>
<Stack className={classes.accountValue} gap={2}>
<Text className={valueClass} fw={700}>{props.value}</Text>
<Text fw={700}>{props.value}</Text>
<Text c="dimmed" size="xs">{props.valueLabel}</Text>
</Stack>
@@ -58,24 +53,20 @@ const AccountCardRow: FC<AccountRowProps> = props => (
</li>
);
const EntryGroup: FC<EntryGroupProps> = (props) => {
const groupClass = props.flat ? `${classes.group} ${classes.flat}` : classes.group;
const EntryGroup: FC<EntryGroupProps> = props => (
<Paper className={classes.group} component="li" withBorder>
{props.header}
return (
<Paper className={groupClass} component="li" withBorder>
{props.header}
<Stack
aria-label={props.label}
className={classes.accounts}
component="ul"
gap={0}
>
{props.children}
</Stack>
</Paper>
);
};
<Stack
aria-label={props.label}
className={classes.accounts}
component="ul"
gap={0}
>
{props.children}
</Stack>
</Paper>
);
const EntryGroupTotal: FC<EntryGroupTotalProps> = (props) => {
const { t } = useTranslation();
-4
View File
@@ -1,6 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, configure } from "@testing-library/react";
import { MotionGlobalConfig } from "motion/react";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
@@ -11,9 +10,6 @@ import { server } from "./server";
// Allow async UI updates on the slower CI runner; local waits stay unchanged.
if (process.env.CI) configure({ asyncUtilTimeout: 5000 });
// Route fades start at opacity 0; under load toBeVisible can catch that frame.
MotionGlobalConfig.skipAnimations = true;
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();