diff --git a/src/components/dashboard/DiversificationChart.tsx b/src/components/dashboard/DiversificationChart.tsx index 437db642..3e0a4fec 100644 --- a/src/components/dashboard/DiversificationChart.tsx +++ b/src/components/dashboard/DiversificationChart.tsx @@ -49,7 +49,23 @@ 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 (

{title}

@@ -57,7 +73,7 @@ const DiversificationPie = ({ data, title }: DiversificationPieProps) => { { paddingAngle={2} dataKey="value" > - {data.map((entry, index) => ( + {chartData.map((entry, index) => ( ))} diff --git a/src/components/dashboard/RiskAnalysis.tsx b/src/components/dashboard/RiskAnalysis.tsx index ba65a6fd..b057315f 100644 --- a/src/components/dashboard/RiskAnalysis.tsx +++ b/src/components/dashboard/RiskAnalysis.tsx @@ -65,6 +65,12 @@ const ConcentrationItem = ({ name, percentage, color }: { name: string; percenta
); +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[] = [ { @@ -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 ( diff --git a/src/components/dashboard/__tests__/DiversificationChart.test.tsx b/src/components/dashboard/__tests__/DiversificationChart.test.tsx new file mode 100644 index 00000000..c11e52c1 --- /dev/null +++ b/src/components/dashboard/__tests__/DiversificationChart.test.tsx @@ -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(); + + 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(); + + 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(); + + 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); + }); +}); diff --git a/src/components/dashboard/__tests__/PortfolioReport.test.tsx b/src/components/dashboard/__tests__/PortfolioReport.test.tsx new file mode 100644 index 00000000..773aaef2 --- /dev/null +++ b/src/components/dashboard/__tests__/PortfolioReport.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/components/dashboard/__tests__/RiskAnalysis.test.tsx b/src/components/dashboard/__tests__/RiskAnalysis.test.tsx new file mode 100644 index 00000000..48ab707d --- /dev/null +++ b/src/components/dashboard/__tests__/RiskAnalysis.test.tsx @@ -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(); + + 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(); + + expect(screen.getByText('32/100')).toBeInTheDocument(); + expect(screen.getByText('Medium Risk')).toBeInTheDocument(); + }); + + it('renders each risk metric with its derived percentage', () => { + render(); + + 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(); + + 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(); + }); +}); diff --git a/src/components/dashboard/__tests__/StakingModal.test.tsx b/src/components/dashboard/__tests__/StakingModal.test.tsx new file mode 100644 index 00000000..df72a1b6 --- /dev/null +++ b/src/components/dashboard/__tests__/StakingModal.test.tsx @@ -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(); + + expect(screen.queryByText('Stake Property Tokens')).not.toBeInTheDocument(); + }); + + it('renders the stake input step when opened', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/components/dashboard/__tests__/StakingPanel.test.tsx b/src/components/dashboard/__tests__/StakingPanel.test.tsx new file mode 100644 index 00000000..74d85b40 --- /dev/null +++ b/src/components/dashboard/__tests__/StakingPanel.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { StakingPanel } from '../StakingPanel'; +import { toast } from 'sonner'; + +jest.mock('sonner', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +// Recharts ResponsiveContainer warns about 0x0 dimensions in jsdom and +// renders nothing useful there; stub it out for panel behavior tests. +jest.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + AreaChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Area: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, +})); + +describe('StakingPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the staking stats', () => { + render(); + + expect(screen.getByText('Total Staked Value')).toBeInTheDocument(); + expect(screen.getByText('$350,000')).toBeInTheDocument(); + expect(screen.getByText('Pending Rewards')).toBeInTheDocument(); + expect(screen.getByText('$17.57')).toBeInTheDocument(); + expect(screen.getByText('Active Stakes')).toBeInTheDocument(); + }); + + it('renders the active stakes list', () => { + render(); + + expect(screen.getByText('Your Active Stakes')).toBeInTheDocument(); + expect(screen.getByText('Manhattan Tower Suite')).toBeInTheDocument(); + expect(screen.getByText('Sunset Beach Villa')).toBeInTheDocument(); + }); + + it('opens the stake modal when New Stake is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /new stake/i })); + + expect(screen.getByText('Stake Property Tokens')).toBeInTheDocument(); + }); + + it('opens the unstake modal with the selected token', () => { + render(); + + fireEvent.click(screen.getAllByRole('button', { name: /^unstake$/i })[0]); + + expect(screen.getByText('Unstake Property Tokens')).toBeInTheDocument(); + }); + + it('claims all rewards with a toast', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /claim all/i })); + + expect(toast.success).toHaveBeenCalledWith( + 'Claiming rewards for all tokens...', + expect.objectContaining({ description: 'Estimated gas cost: 0.0012 ETH' }) + ); + }); + + it('claims rewards for an individual stake', () => { + render(); + + fireEvent.click(screen.getAllByRole('button', { name: /^claim$/i })[0]); + + expect(toast.success).toHaveBeenCalledWith( + 'Claiming rewards for Manhattan Tower Suite...', + expect.objectContaining({ description: 'Estimated gas cost: 0.0012 ETH' }) + ); + }); +});