Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -573,5 +573,15 @@
"successTitle": "KYC Submitted Successfully!",
"successDescription": "Your KYC verification has been submitted and is under review.",
"dash": "—"
},
"portfolioChartWidget": {
"title": "Portfolio Value",
"allocation": "Allocation",
"trend": "Trend",
"loading": "Loading portfolio chart...",
"historyLoading": "Loading performance history...",
"emptyAllocation": "No allocation data available yet.",
"emptyAssets": "No portfolio assets available yet.",
"emptyHistory": "No performance history available yet."
}
}
10 changes: 10 additions & 0 deletions frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -573,5 +573,15 @@
"successTitle": "KYC enviado exitosamente!",
"successDescription": "Tu verificacion KYC ha sido enviada y esta en revision.",
"dash": "—"
},
"portfolioChartWidget": {
"title": "Valor del portafolio",
"allocation": "Distribucion",
"trend": "Tendencia",
"loading": "Cargando grafico del portafolio...",
"historyLoading": "Cargando historial de rendimiento...",
"emptyAllocation": "Todavia no hay datos de asignacion.",
"emptyAssets": "Todavia no hay activos en el portafolio.",
"emptyHistory": "Todavia no hay historial de rendimiento."
}
}
10 changes: 10 additions & 0 deletions frontend/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -573,5 +573,15 @@
"successTitle": "KYC enviado com sucesso!",
"successDescription": "Sua verificacao KYC foi enviada e esta em revisao.",
"dash": "—"
},
"portfolioChartWidget": {
"title": "Valor do portafolio",
"allocation": "Distribuicao",
"trend": "Tendencia",
"loading": "Carregando grafico do portafolio...",
"historyLoading": "Carregando historico de desempenho...",
"emptyAllocation": "Ainda nao ha dados de alocacao.",
"emptyAssets": "Ainda nao ha ativos no portafolio.",
"emptyHistory": "Ainda nao ha historico de desempenho."
}
}
257 changes: 89 additions & 168 deletions frontend/src/components/PortfolioChartWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,48 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PortfolioChartWidget, PortfolioAsset } from './PortfolioChartWidget';

// Mock recharts to avoid canvas issues in tests
const translations = {
en: {
portfolioChartWidget: {
title: 'Portfolio Value',
allocation: 'Allocation',
trend: 'Trend',
loading: 'Loading portfolio chart...',
historyLoading: 'Loading performance history...',
emptyAllocation: 'No allocation data available yet.',
emptyAssets: 'No portfolio assets available yet.',
emptyHistory: 'No performance history available yet.',
},
},
es: {
portfolioChartWidget: {
title: 'Valor del portafolio',
allocation: 'Distribucion',
trend: 'Tendencia',
loading: 'Cargando grafico del portafolio...',
historyLoading: 'Cargando historial de rendimiento...',
emptyAllocation: 'Todavia no hay datos de asignacion.',
emptyAssets: 'Todavia no hay activos en el portafolio.',
emptyHistory: 'Todavia no hay historial de rendimiento.',
},
},
} as const;

let mockLocale: keyof typeof translations = 'en';

vi.mock('next-intl', () => ({
useLocale: () => mockLocale,
useTranslations: (namespace: keyof (typeof translations)['en']) => (key: string) =>
translations[mockLocale][namespace][key as keyof (typeof translations)['en'][typeof namespace]] ?? key,
}));

vi.mock('recharts', () => ({
PieChart: ({ children }: any) => <div data-testid="pie-chart">{children}</div>,
Pie: ({ children, onClick, data }: any) => (
<div
data-testid="pie"
onClick={() => onClick && onClick(data[0])}
>
<div data-testid="pie" onClick={() => onClick && data?.[0] && onClick(data[0])}>
{children}
</div>
),
Expand Down Expand Up @@ -49,6 +81,11 @@ describe('PortfolioChartWidget', () => {
},
];

const historyData = [
{ timestamp: Date.UTC(2026, 0, 1), value: 3200 },
{ timestamp: Date.UTC(2026, 0, 2), value: 4000 },
];

const defaultProps = {
assets: mockAssets,
totalValue: 4000,
Expand All @@ -58,103 +95,53 @@ describe('PortfolioChartWidget', () => {

beforeEach(() => {
vi.clearAllMocks();
mockLocale = 'en';
});

it('renders the component with portfolio value', () => {
it('renders the localized title and portfolio value', () => {
render(<PortfolioChartWidget {...defaultProps} />);

expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
expect(screen.getByText('$4,000.00')).toBeInTheDocument();
});

it('displays the correct currency format', () => {
render(
<PortfolioChartWidget
{...defaultProps}
totalValue={5000}
currency="EUR"
/>
);

// The component should format currency, checking for the value in the DOM
const portfolioValue = screen.getByText(/Portfolio Value/i).parentElement;
expect(portfolioValue).toBeInTheDocument();
});

it('renders all assets in the list', () => {
render(<PortfolioChartWidget {...defaultProps} />);

expect(screen.getByText('XLM')).toBeInTheDocument();
expect(screen.getByText('USDC')).toBeInTheDocument();
});

it('displays asset percentages correctly', () => {
render(<PortfolioChartWidget {...defaultProps} />);

const percentageElements = screen.getAllByText(/50\.0%/);
expect(percentageElements.length).toBeGreaterThan(0);
});

it('displays asset amounts', () => {
render(<PortfolioChartWidget {...defaultProps} />);

expect(screen.getByText('1000.0000 XLM')).toBeInTheDocument();
expect(screen.getByText('500.0000 USDC')).toBeInTheDocument();
});

it('switches between chart types when buttons are clicked', async () => {
render(<PortfolioChartWidget {...defaultProps} />);
it('switches to the history view when history data is available', async () => {
render(<PortfolioChartWidget {...defaultProps} historyData={historyData} />);

const trendButton = screen.getByText('Trend');
fireEvent.click(trendButton);
fireEvent.click(screen.getByRole('button', { name: 'Trend' }));

await waitFor(() => {
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
});

const allocationButton = screen.getByText('Allocation');
fireEvent.click(allocationButton);

await waitFor(() => {
expect(screen.getByTestId('pie-chart')).toBeInTheDocument();
});
});

it('calls onAssetClick when an asset is clicked', () => {
const onAssetClick = vi.fn();
render(
<PortfolioChartWidget
{...defaultProps}
onAssetClick={onAssetClick}
/>
);
it('shows an accessible loading state and disables chart toggles', () => {
render(<PortfolioChartWidget {...defaultProps} loading />);

const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]');
if (assetElement) {
fireEvent.click(assetElement);
}

// Should be called (exact behavior depends on component implementation)
expect(screen.getByText('XLM')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('Loading portfolio chart...');
expect(screen.getByRole('button', { name: 'Allocation' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Trend' })).toBeDisabled();
});

it('toggles asset selection on click', () => {
render(<PortfolioChartWidget {...defaultProps} />);

const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]');
it('shows an empty history state when no trend data exists', async () => {
render(<PortfolioChartWidget {...defaultProps} historyData={[]} />);

if (assetElement) {
fireEvent.click(assetElement);
// Check that the element has selected styling (bg-blue-50)
expect(assetElement).toHaveClass('bg-blue-50');
fireEvent.click(screen.getByRole('button', { name: 'Trend' }));

fireEvent.click(assetElement);
// The selection might be toggled off
expect(assetElement).toBeInTheDocument();
}
await waitFor(() => {
expect(screen.getByText('No performance history available yet.')).toBeInTheDocument();
});
});

it('renders with empty assets array', () => {
it('shows the empty assets state when no assets are provided', () => {
render(
<PortfolioChartWidget
assets={[]}
Expand All @@ -164,127 +151,61 @@ describe('PortfolioChartWidget', () => {
/>
);

expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
expect(screen.getByText('$0.00')).toBeInTheDocument();
expect(screen.getByText('No allocation data available yet.')).toBeInTheDocument();
expect(screen.getByText('No portfolio assets available yet.')).toBeInTheDocument();
});

it('assigns colors from palette to assets without color', () => {
const assetsWithoutColor: PortfolioAsset[] = [
{
id: '1',
symbol: 'XLM',
name: 'Stellar',
amount: 100,
value: 1000,
percentage: 50,
},
{
id: '2',
symbol: 'USDC',
name: 'USD Coin',
amount: 100,
value: 1000,
percentage: 50,
},
];
it('renders a locale-aware translation and currency format', () => {
mockLocale = 'es';
const formattedValue = new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(5000);

render(
<PortfolioChartWidget
assets={assetsWithoutColor}
totalValue={2000}
showAnimation={false}
/>
);

expect(screen.getByText('XLM')).toBeInTheDocument();
expect(screen.getByText('USDC')).toBeInTheDocument();
});

it('handles custom className', () => {
const { container } = render(
<PortfolioChartWidget
{...defaultProps}
className="custom-class"
totalValue={5000}
currency="EUR"
/>
);

const mainDiv = container.querySelector('.custom-class');
expect(mainDiv).toBeInTheDocument();
expect(screen.getByText('Valor del portafolio')).toBeInTheDocument();
expect(screen.getByText(formattedValue)).toBeInTheDocument();
});

it('respects showAnimation prop', () => {
const { rerender } = render(
it('handles asset selection and onAssetClick callbacks', () => {
const onAssetClick = vi.fn();
render(
<PortfolioChartWidget
{...defaultProps}
showAnimation={true}
onAssetClick={onAssetClick}
/>
);

expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]');
expect(assetElement).toBeInTheDocument();

rerender(
<PortfolioChartWidget
{...defaultProps}
showAnimation={false}
/>
);
if (assetElement) {
fireEvent.click(assetElement);
expect(assetElement).toHaveClass('bg-blue-50');
}

expect(screen.getByText('Portfolio Value')).toBeInTheDocument();
expect(onAssetClick).toHaveBeenCalledWith(mockAssets[0]);
});

it('handles currency formatting for different currencies', () => {
const { rerender } = render(
<PortfolioChartWidget
{...defaultProps}
currency="USD"
totalValue={1000}
/>
);

expect(screen.getByText('$1,000.00')).toBeInTheDocument();

rerender(
it('renders an error message when provided', () => {
render(
<PortfolioChartWidget
{...defaultProps}
currency="GBP"
totalValue={1000}
error="Unable to load the latest portfolio snapshot."
/>
);

// Should render with different currency formatting
expect(screen.getByText(/Portfolio Value/)).toBeInTheDocument();
});

it('handles large portfolio values', () => {
const largeAssets: PortfolioAsset[] = [
{
id: '1',
symbol: 'BTC',
name: 'Bitcoin',
amount: 0.5,
value: 20000,
percentage: 100,
},
];

const { container } = render(
<PortfolioChartWidget
assets={largeAssets}
totalValue={20000}
showAnimation={false}
/>
expect(screen.getByRole('alert')).toHaveTextContent(
'Unable to load the latest portfolio snapshot.'
);

// Find the total portfolio value (first $20,000.00 in the portfolio value section)
const portfolioValueTexts = screen.getAllByText('$20,000.00');
expect(portfolioValueTexts.length).toBeGreaterThan(0);
});

it('displays asset color indicators', () => {
render(<PortfolioChartWidget {...defaultProps} />);

const colorDots = screen.getAllByTestId('cell').length;
// Should have color cells for each asset
expect(colorDots).toBeGreaterThanOrEqual(0);
});
});
Loading
Loading