fix: harden broker overview edge cases

This commit is contained in:
Sergey Krylov 2026-06-19 06:52:22 +03:00
parent a19ad67a89
commit b82771107d
6 changed files with 265 additions and 21 deletions

View File

@ -7,7 +7,7 @@ import { BrokerAllocationChart } from './BrokerAllocationChart';
import { BrokerOperationsTable } from './BrokerOperationsTable';
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
if (!value) return '';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
@ -16,7 +16,7 @@ function formatMoney(value: BrokerMoney | null | undefined) {
}
function formatPercent(value: number | null) {
if (value === null) return '-';
if (value === null) return '';
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
}
@ -47,8 +47,8 @@ function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
<span>Нет денежных остатков</span>
) : (
<ul className="broker-overview__cash">
{portfolio.cash.map((money) => (
<li key={money.currency}>
{portfolio.cash.map((money, index) => (
<li key={`${money.currency}-${index}`}>
<span>{money.currency}</span>
<strong>{formatMoney(money)}</strong>
</li>
@ -61,10 +61,14 @@ function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
}
function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) {
if (!value || !total || total.value <= 0) return 0;
if (!value || !total || total.value <= 0) return null;
return (value.value / total.value) * 100;
}
function formatAllocationPercent(value: number | null) {
return value === null ? '—' : `${value.toFixed(1)}%`;
}
function BrokerAssetCards({
accountId,
portfolio,
@ -103,7 +107,9 @@ function BrokerAssetCards({
{card.count} {card.countLabel}
</span>
<span>{formatMoney(card.value)}</span>
<span>{allocationPercent(card.value, portfolio.totals.portfolio).toFixed(1)}%</span>
<span>
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
</span>
</Link>
))}
</section>

View File

@ -4,21 +4,31 @@ import { buildBrokerAllocation } from './brokerAllocation';
const RADIUS = 44;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function formatMoneyValue(value: number) {
function formatMoneyValue(value: number, currency: string) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
currency,
maximumFractionDigits: 2,
}).format(value);
}
function allocationCurrency(portfolio: BrokerPortfolio) {
return (
portfolio.totals.portfolio?.currency ||
Object.values(portfolio.totals).find((total) => total?.currency)?.currency ||
'RUB'
);
}
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
const { sectors, negative } = buildBrokerAllocation(portfolio);
let consumedPercent = 0;
const currency = allocationCurrency(portfolio);
let remaining = CIRCUMFERENCE;
const arcs = sectors.map((sector) => {
const dashOffset = -((consumedPercent / 100) * CIRCUMFERENCE);
const dashLength = (sector.percent / 100) * CIRCUMFERENCE;
consumedPercent += sector.percent;
const dashOffset = -(CIRCUMFERENCE - remaining);
const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE;
const dashLength = Math.min(Math.max(rawDashLength, 0), remaining);
remaining = Math.max(0, remaining - dashLength);
return { ...sector, dashOffset, dashLength };
});
@ -54,7 +64,8 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
style={{ background: sector.color }}
/>
<span>
{sector.label}: {formatMoneyValue(sector.value)} · {sector.percent.toFixed(1)}%
{sector.label}: {formatMoneyValue(sector.value, currency)} ·{' '}
{sector.percent.toFixed(1)}%
</span>
</li>
))}
@ -67,7 +78,7 @@ export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfoli
>
{negative.map((item) => (
<li key={item.key}>
{item.label}: отрицательное значение {formatMoneyValue(item.value)}
{item.label}: отрицательное значение {formatMoneyValue(item.value, currency)}
</li>
))}
</ul>

View File

@ -111,7 +111,7 @@ export function BrokerOperationsTable({
const operations = page?.items ?? [];
return (
<section>
<section aria-busy={isFetching}>
<div
style={{
display: 'flex',
@ -127,6 +127,7 @@ export function BrokerOperationsTable({
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
aria-label="Предыдущая страница"
onClick={onPrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
@ -153,6 +154,7 @@ export function BrokerOperationsTable({
</span>
<button
type="button"
aria-label="Следующая страница"
onClick={onNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
@ -242,8 +244,8 @@ export function BrokerOperationsTable({
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<div className="table-loading-overlay" role="status">
<div className="loading-spinner" aria-hidden="true" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
{pagination ? `Загрузка страницы ${pageNumber}` : 'Обновление операций…'}
</span>

View File

@ -668,8 +668,8 @@ describe('Broker pages', () => {
const operationsSection = screen.getByRole('heading', { name: 'Операции' }).closest('section')!;
const withinOperations = within(operationsSection);
const nextButton = withinOperations.getByRole('button', { name: '' });
const prevButton = withinOperations.getByRole('button', { name: '' });
const nextButton = withinOperations.getByRole('button', { name: 'Следующая страница' });
const prevButton = withinOperations.getByRole('button', { name: 'Предыдущая страница' });
expect(prevButton).toBeDisabled();
expect(nextButton).not.toBeDisabled();
@ -899,4 +899,198 @@ describe('Broker pages', () => {
expect(screen.getByText('Нет данных для распределения')).toBeInTheDocument();
});
it('bounds visual allocation arcs when textual percentages exceed 100%', () => {
const portfolio = createOverviewPortfolio();
portfolio.totals.portfolio = { currency: 'RUB', units: '100', nano: 0, value: 100 };
portfolio.totals.shares = { currency: 'RUB', units: '120', nano: 0, value: 120 };
portfolio.totals.bonds = { currency: 'RUB', units: '-20', nano: 0, value: -20 };
portfolio.totals.etf = null;
portfolio.totals.currencies = null;
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const chart = screen.getByRole('img', { name: 'Структура брокерского портфеля' });
const circumference = 2 * Math.PI * 44;
const dashLengths = Array.from(chart.querySelectorAll('circle')).map((circle) => {
const parts = circle
.getAttribute('stroke-dasharray')!
.split(' ')
.map((part) => Number(part));
expect(parts.every((part) => Number.isFinite(part) && part >= 0)).toBe(true);
expect(Math.abs(Number(circle.getAttribute('stroke-dashoffset')))).toBeLessThanOrEqual(
circumference,
);
return parts[0];
});
expect(dashLengths.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(circumference);
expect(screen.getByText(/Акции:.*120.*120\.0%/)).toBeInTheDocument();
});
it('uses the portfolio currency in the allocation legend', () => {
const portfolio = createOverviewPortfolio();
portfolio.totals.portfolio!.currency = 'USD';
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const sharesLegend = screen.getByText(/Акции:.*60\.0%/);
expect(sharesLegend).toHaveTextContent('$');
expect(sharesLegend).not.toHaveTextContent('₽');
});
it('falls back to an available asset currency when the portfolio currency is absent', () => {
const portfolio = createOverviewPortfolio();
for (const total of Object.values(portfolio.totals)) {
if (total) total.currency = 'USD';
}
portfolio.totals.portfolio!.currency = '';
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const sharesLegend = screen.getByText(/Акции:.*60\.0%/);
expect(sharesLegend).toHaveTextContent('$');
expect(sharesLegend).not.toHaveTextContent('₽');
});
it('distinguishes unavailable allocation percentages from a genuine zero', () => {
const unavailable = createOverviewPortfolio();
unavailable.totals.shares = null;
unavailable.totals.bonds = { currency: 'RUB', units: '0', nano: 0, value: 0 };
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: unavailable,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ });
const bonds = screen.getByRole('link', { name: /Облигации.*8 выпусков/ });
expect(within(shares).getAllByText('—')).toHaveLength(2);
expect(within(bonds).getByText('0.0%')).toBeInTheDocument();
});
it.each([null, 0, -10])(
'shows an unavailable card percentage for portfolio total %s',
(portfolioTotal) => {
const portfolio = createOverviewPortfolio();
portfolio.totals.portfolio =
portfolioTotal === null
? null
: { currency: 'RUB', units: String(portfolioTotal), nano: 0, value: portfolioTotal };
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
renderOverview();
const shares = screen.getByRole('link', { name: /Акции.*14 позиций/ });
expect(within(shares).getByText('—')).toBeInTheDocument();
expect(within(shares).queryByText('0.0%')).not.toBeInTheDocument();
},
);
it('renders duplicate cash currencies without duplicate React keys', () => {
const portfolio = createOverviewPortfolio();
portfolio.cash = [
{ currency: 'RUB', units: '100', nano: 0, value: 100 },
{ currency: 'RUB', units: '200', nano: 0, value: 200 },
];
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: portfolio,
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations();
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
renderOverview();
expect(screen.getAllByText('RUB')).toHaveLength(2);
expect(consoleError.mock.calls.flat().join(' ')).not.toContain(
'Encountered two children with the same key',
);
consoleError.mockRestore();
});
it('marks the recent operations table busy while retaining its rows', () => {
vi.spyOn(portfolioHook, 'useBrokerPortfolio').mockReturnValue({
data: createOverviewPortfolio(),
isLoading: false,
isFetching: false,
error: null,
} as any);
mockOverviewOperations({
data: {
accountId: 'acc-1',
items: [
{
cursor: 'recent-busy',
accountId: 'acc-1',
id: 'recent-busy',
parentOperationId: null,
date: '2026-06-18T10:00:00.000Z',
category: 'income',
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
name: 'Купон ОФЗ',
state: 'OPERATION_STATE_EXECUTED',
instrumentUid: 'bond-uid',
figi: null,
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
instrumentType: 'bond',
payment: { currency: 'RUB', units: '120', nano: 0, value: 120 },
price: null,
commission: null,
yield: null,
accruedInt: null,
quantity: 2,
quantityDone: 2,
},
],
nextCursor: null,
hasNext: false,
asOf: '2026-06-19T00:00:00.000Z',
},
isFetching: true,
});
renderOverview();
const operations = screen
.getByRole('heading', { name: 'Последние операции' })
.closest('section')!;
expect(operations).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('status')).toHaveTextContent('Обновление операций…');
expect(screen.getByText('SU26238RMFS5')).toBeInTheDocument();
});
});

View File

@ -128,4 +128,17 @@ describe('buildBrokerAllocation', () => {
negative: [],
});
});
it('preserves named negative components when the portfolio total is nonpositive', () => {
expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({
total: 0,
sectors: [],
negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }],
});
expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({
total: -10,
sectors: [],
negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }],
});
});
});

View File

@ -26,12 +26,30 @@ export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
negative: BrokerNegativeAllocationItem[];
} {
const total = portfolio.totals.portfolio?.value ?? 0;
if (total <= 0) return { total, sectors: [], negative: [] };
const shares = portfolio.totals.shares?.value ?? 0;
const bonds = portfolio.totals.bonds?.value ?? 0;
const etf = portfolio.totals.etf?.value ?? 0;
const cash = portfolio.totals.currencies?.value ?? 0;
const namedValues: Record<Exclude<BrokerAllocationKey, 'other'>, number> = {
shares,
bonds,
etf,
cash,
};
if (total <= 0) {
const negative = ALLOCATION_CONFIG.filter(
(
item,
): item is (typeof ALLOCATION_CONFIG)[number] & {
key: Exclude<BrokerAllocationKey, 'other'>;
} => item.key !== 'other',
)
.filter((item) => namedValues[item.key] < 0)
.map((item) => ({ ...item, value: namedValues[item.key] }));
return { total, sectors: [], negative };
}
const mappedTotal = shares + bonds + etf + cash;
const residual = total - mappedTotal;
const residualTolerance =