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' })
+ );
+ });
+});