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
20 changes: 18 additions & 2 deletions src/components/dashboard/DiversificationChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,39 @@ interface DiversificationPieProps {
title: string;
}

/**
* Derives allocation percentages from raw values, normalized to sum to 100.
* Returns all zeros for an empty or zero-total input so charts never show
* invalid (NaN/Infinity) allocations.
*/
export const computeAllocationPercents = (values: number[]): number[] => {
const total = values.reduce((sum, value) => sum + value, 0);
if (total <= 0) {
return values.map(() => 0);
}
return values.map((value) => Math.round((value / total) * 100));
};

const DiversificationPie = ({ data, title }: DiversificationPieProps) => {
const percents = computeAllocationPercents(data.map((entry) => entry.value));
const chartData = data.map((entry, index) => ({ ...entry, value: percents[index] }));

return (
<div className="flex-1">
<h4 className="text-sm font-medium text-muted-foreground mb-4 text-center">{title}</h4>
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
data={chartData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={70}
paddingAngle={2}
dataKey="value"
>
{data.map((entry, index) => (
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
Expand Down
11 changes: 6 additions & 5 deletions src/components/dashboard/RiskAnalysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ const ConcentrationItem = ({ name, percentage, color }: { name: string; percenta
</div>
);

export const getRiskLevel = (score: number) => {
if (score < 30) return { level: "Low", color: "text-success", bgColor: "bg-success/10" };
if (score < 60) return { level: "Medium", color: "text-warning", bgColor: "bg-warning/10" };
return { level: "High", color: "text-destructive", bgColor: "bg-destructive/10" };
};

export const RiskAnalysis = () => {
const riskMetrics: RiskMetric[] = [
{
Expand Down Expand Up @@ -106,11 +112,6 @@ export const RiskAnalysis = () => {
];

const overallRiskScore = 32;
const getRiskLevel = (score: number) => {
if (score < 30) return { level: "Low", color: "text-success", bgColor: "bg-success/10" };
if (score < 60) return { level: "Medium", color: "text-warning", bgColor: "bg-warning/10" };
return { level: "High", color: "text-destructive", bgColor: "bg-destructive/10" };
};
const riskLevel = getRiskLevel(overallRiskScore);

return (
Expand Down
59 changes: 59 additions & 0 deletions src/components/dashboard/__tests__/DiversificationChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { DiversificationChart, computeAllocationPercents } from '../DiversificationChart';

describe('computeAllocationPercents', () => {
it('normalizes values to percentages that sum to 100', () => {
expect(computeAllocationPercents([45, 30, 15, 10])).toEqual([45, 30, 15, 10]);
expect(computeAllocationPercents([50, 25, 15, 10])).toEqual([50, 25, 15, 10]);
});

it('handles equal weights', () => {
expect(computeAllocationPercents([1, 1, 1, 1])).toEqual([25, 25, 25, 25]);
});

it('returns all zeros for a zero-total input', () => {
expect(computeAllocationPercents([0, 0, 0])).toEqual([0, 0, 0]);
});

it('returns an empty array for an empty input', () => {
expect(computeAllocationPercents([])).toEqual([]);
});
});

describe('DiversificationChart', () => {
it('renders the diversification card with both allocation breakdowns', () => {
render(<DiversificationChart />);

expect(screen.getByText('Portfolio Diversification')).toBeInTheDocument();
expect(screen.getByText('Asset allocation breakdown')).toBeInTheDocument();
expect(screen.getByText('By Property Type')).toBeInTheDocument();
expect(screen.getByText('By Geography')).toBeInTheDocument();
});

it('renders the property type allocation legend (fixture sums to 100)', () => {
render(<DiversificationChart />);

expect(screen.getByText('Residential')).toBeInTheDocument();
expect(screen.getByText('Commercial')).toBeInTheDocument();
expect(screen.getByText('Industrial')).toBeInTheDocument();
expect(screen.getByText('Mixed-Use')).toBeInTheDocument();
});

it('renders the geographic allocation legend (fixture sums to 100)', () => {
render(<DiversificationChart />);

expect(screen.getByText('North America')).toBeInTheDocument();
expect(screen.getByText('Europe')).toBeInTheDocument();
expect(screen.getByText('Asia Pacific')).toBeInTheDocument();
expect(screen.getByText('Other')).toBeInTheDocument();
});

it('derives allocation percentages that sum to 100 for both fixtures', () => {
const propertyTypeValues = [45, 30, 15, 10];
const geographicValues = [50, 25, 15, 10];

expect(computeAllocationPercents(propertyTypeValues).reduce((a, b) => a + b, 0)).toBe(100);
expect(computeAllocationPercents(geographicValues).reduce((a, b) => a + b, 0)).toBe(100);
});
});
74 changes: 74 additions & 0 deletions src/components/dashboard/__tests__/PortfolioReport.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { PortfolioReport } from '../PortfolioReport';

const mockSave = jest.fn();

jest.mock('jspdf', () => {
return jest.fn().mockImplementation(() => ({
setFillColor: jest.fn(),
rect: jest.fn(),
setTextColor: jest.fn(),
setFontSize: jest.fn(),
text: jest.fn(),
addPage: jest.fn(),
setPage: jest.fn(),
save: mockSave,
internal: { getNumberOfPages: jest.fn(() => 1) },
lastAutoTable: { finalY: 105 },
}));
});

jest.mock('jspdf-autotable', () => jest.fn());

describe('PortfolioReport', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the report card with controls', () => {
render(<PortfolioReport />);

expect(screen.getByText('Export Reports')).toBeInTheDocument();
expect(screen.getByText('Report Type')).toBeInTheDocument();
expect(screen.getByText('Year')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /generate pdf/i })).toBeInTheDocument();
});

it('renders all report type cards', () => {
render(<PortfolioReport />);

expect(screen.getByText('Full Report')).toBeInTheDocument();
expect(screen.getByText('Tax Summary')).toBeInTheDocument();
expect(screen.getByText('Performance')).toBeInTheDocument();
expect(screen.getByText('Transactions')).toBeInTheDocument();
});

it('selects a report type when its card is clicked', () => {
render(<PortfolioReport />);

expect(screen.getByText('Full Portfolio Report')).toBeInTheDocument();

fireEvent.click(screen.getByText('Tax Summary'));

expect(screen.getByText('Tax Summary Only')).toBeInTheDocument();
});

it('generates a PDF and shows the downloaded state', async () => {
jest.useFakeTimers();
render(<PortfolioReport />);

fireEvent.click(screen.getByRole('button', { name: /generate pdf/i }));

expect(screen.getByText('Generating...')).toBeInTheDocument();

await act(async () => {
jest.advanceTimersByTime(1500);
});

expect(mockSave).toHaveBeenCalledWith('mettachain-portfolio-report-2024.pdf');
expect(screen.getByText('Downloaded!')).toBeInTheDocument();

jest.useRealTimers();
});
});
59 changes: 59 additions & 0 deletions src/components/dashboard/__tests__/RiskAnalysis.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RiskAnalysis, getRiskLevel } from '../RiskAnalysis';

describe('getRiskLevel', () => {
it('derives Low for scores below 30', () => {
expect(getRiskLevel(0)).toEqual({ level: 'Low', color: 'text-success', bgColor: 'bg-success/10' });
expect(getRiskLevel(29)).toEqual({ level: 'Low', color: 'text-success', bgColor: 'bg-success/10' });
});

it('derives Medium for scores from 30 to 59', () => {
expect(getRiskLevel(30)).toEqual({ level: 'Medium', color: 'text-warning', bgColor: 'bg-warning/10' });
expect(getRiskLevel(59)).toEqual({ level: 'Medium', color: 'text-warning', bgColor: 'bg-warning/10' });
});

it('derives High for scores 60 and above', () => {
expect(getRiskLevel(60)).toEqual({ level: 'High', color: 'text-destructive', bgColor: 'bg-destructive/10' });
expect(getRiskLevel(100)).toEqual({ level: 'High', color: 'text-destructive', bgColor: 'bg-destructive/10' });
});
});

describe('RiskAnalysis', () => {
it('renders the risk analysis card', () => {
render(<RiskAnalysis />);

expect(screen.getByText('Risk Analysis')).toBeInTheDocument();
expect(screen.getByText('Portfolio risk metrics and concentration')).toBeInTheDocument();
});

it('renders the overall risk score with the derived level (32 -> Medium)', () => {
render(<RiskAnalysis />);

expect(screen.getByText('32/100')).toBeInTheDocument();
expect(screen.getByText('Medium Risk')).toBeInTheDocument();
});

it('renders each risk metric with its derived percentage', () => {
render(<RiskAnalysis />);

expect(screen.getByText('Portfolio Volatility')).toBeInTheDocument();
expect(screen.getByText('12.4%')).toBeInTheDocument();
expect(screen.getByText('Concentration Risk')).toBeInTheDocument();
expect(screen.getByText('34.2%')).toBeInTheDocument();
expect(screen.getByText('Liquidity Risk')).toBeInTheDocument();
expect(screen.getByText('18.5%')).toBeInTheDocument();
expect(screen.getByText('Market Correlation')).toBeInTheDocument();
expect(screen.getByText('45.8%')).toBeInTheDocument();
});

it('renders the concentration breakdown and alert', () => {
render(<RiskAnalysis />);

expect(screen.getByText('Top Holdings Concentration')).toBeInTheDocument();
expect(screen.getByText('Manhattan Luxury')).toBeInTheDocument();
expect(screen.getByText('28%')).toBeInTheDocument();
expect(screen.getByText('Concentration Alert')).toBeInTheDocument();
expect(screen.getByText(/Top 2 properties represent 50% of your portfolio/)).toBeInTheDocument();
});
});
123 changes: 123 additions & 0 deletions src/components/dashboard/__tests__/StakingModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { StakingModal } from '../StakingModal';
import { toast } from 'sonner';

jest.mock('sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));

const onClose = jest.fn();
const mockToken = {
id: '1',
name: 'Manhattan Tower Suite',
symbol: 'MTS',
amount: 500,
apy: 12.5,
};

describe('StakingModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('does not render content when closed', () => {
render(<StakingModal isOpen={false} onClose={onClose} type="stake" />);

expect(screen.queryByText('Stake Property Tokens')).not.toBeInTheDocument();
});

it('renders the stake input step when opened', () => {
render(<StakingModal isOpen onClose={onClose} type="stake" />);

expect(screen.getByText('Stake Property Tokens')).toBeInTheDocument();
expect(screen.getByLabelText(/amount to stake/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /review staking/i })).toBeInTheDocument();
});

it('blocks confirm when the amount is empty or invalid', () => {
render(<StakingModal isOpen onClose={onClose} type="stake" />);

const reviewButton = screen.getByRole('button', { name: /review staking/i });

// Empty amount
fireEvent.click(reviewButton);
expect(toast.error).toHaveBeenCalledWith('Please enter a valid amount');
expect(screen.getByText('Stake Property Tokens')).toBeInTheDocument();

// Non-positive amount
fireEvent.change(screen.getByLabelText(/amount to stake/i), { target: { value: '0' } });
fireEvent.click(reviewButton);
expect(toast.error).toHaveBeenCalledTimes(2);
expect(screen.queryByText('Confirm Stake')).not.toBeInTheDocument();
});

it('gates the confirm step until a valid amount is entered, then completes the stake', async () => {
jest.useFakeTimers();
render(<StakingModal isOpen onClose={onClose} type="stake" />);

fireEvent.change(screen.getByLabelText(/amount to stake/i), { target: { value: '100' } });
fireEvent.click(screen.getByRole('button', { name: /review staking/i }));

// Confirm step shows the entered amount
expect(screen.getByText('Confirm Stake')).toBeInTheDocument();
expect(screen.getByText('100 MTS')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: /confirm & stake/i }));
expect(screen.getByText('Confirming...')).toBeInTheDocument();

await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(screen.getByText('Transaction Successful!')).toBeInTheDocument();
expect(toast.success).toHaveBeenCalledWith('Staked 100 MTS successfully!');

jest.useRealTimers();
});

it('supports the unstake flow with a token', async () => {
jest.useFakeTimers();
render(<StakingModal isOpen onClose={onClose} type="unstake" token={mockToken} />);

expect(screen.getByText('Unstake Property Tokens')).toBeInTheDocument();

fireEvent.change(screen.getByLabelText(/amount to unstake/i), { target: { value: '50' } });
fireEvent.click(screen.getByRole('button', { name: /review withdrawal/i }));

expect(screen.getByText('Confirm Unstake')).toBeInTheDocument();
expect(screen.getByText('50 MTS')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: /confirm & withdraw/i }));

await act(async () => {
jest.advanceTimersByTime(2000);
});

expect(screen.getByText('Transaction Successful!')).toBeInTheDocument();
expect(toast.success).toHaveBeenCalledWith('Unstaked 50 MTS successfully!');

jest.useRealTimers();
});

it('goes back from the confirm step to edit the amount', () => {
render(<StakingModal isOpen onClose={onClose} type="stake" />);

fireEvent.change(screen.getByLabelText(/amount to stake/i), { target: { value: '100' } });
fireEvent.click(screen.getByRole('button', { name: /review staking/i }));
expect(screen.getByText('Confirm Stake')).toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: /back/i }));
expect(screen.getByText('Stake Property Tokens')).toBeInTheDocument();
});

it('calls onClose when cancelled', () => {
render(<StakingModal isOpen onClose={onClose} type="stake" />);

fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalled();
});
});
Loading
Loading