Skip to content
Open
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
3 changes: 3 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
#!/usr/bin/env sh
export PATH="$PATH:/c/Program Files/Git/bin:/c/Program Files/Git/usr/bin:C:/Program Files/Git/bin:C:/Program Files/Git/usr/bin"

sh ./scripts/check-no-package-lock.sh
npx lint-staged

147 changes: 147 additions & 0 deletions src/components/common/BondingCurveChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ReferenceDot,
} from 'recharts';

Check failure on line 10 in src/components/common/BondingCurveChart.tsx

View workflow job for this annotation

GitHub Actions / verify

Cannot find module 'recharts' or its corresponding type declarations.
import { cn } from '@/lib/utils';

export interface BondingCurveDataPoint {
supply: number;
priceXLM: number;
isCurrent?: boolean;
}

export interface BondingCurveChartProps {
data?: BondingCurveDataPoint[];
currentSupply?: number;
className?: string;
width?: number | string;
height?: number | string;
}

export function BondingCurveChart({
data = [],
currentSupply,
className,
width = '100%',
height = 300,
}: BondingCurveChartProps) {
if (!data || data.length === 0) {
return (
<div
className={cn(
'flex items-center justify-center p-8 text-sm text-neutral-400 bg-neutral-900/50 rounded-lg border border-neutral-800',
className
)}
data-testid="no-data-message"
>
No data
</div>
);
}

const maxSupplyInData = Math.max(...data.map((d) => d.supply));
const xAxisMax = currentSupply ?? maxSupplyInData;

const currentPoint = data.find(
(d) => d.isCurrent || (currentSupply !== undefined && d.supply === currentSupply)
);

interface CustomDotProps {
cx?: number;
cy?: number;
payload?: BondingCurveDataPoint;
}

const CustomDot = (props: CustomDotProps) => {
const { cx, cy, payload } = props;
if (!cx || !cy || !payload) return null;

const isHighlighted =
payload.isCurrent || (currentSupply !== undefined && payload.supply === currentSupply);

return (
<circle
key={`dot-${payload.supply}`}
cx={cx}
cy={cy}
r={isHighlighted ? 6 : 3}
className={cn(
'transition-all duration-200',
isHighlighted
? 'current-price-highlight highlight fill-emerald-400 stroke-emerald-200 stroke-2'
: 'fill-emerald-600 stroke-emerald-800 opacity-60'
)}
data-testid={isHighlighted ? 'current-price-highlight' : `data-point-${payload.supply}`}
data-supply={payload.supply}
data-price={payload.priceXLM}
/>
);
};

return (
<div
className={cn('w-full relative bonding-curve-chart-container', className)}
data-testid="bonding-curve-chart"
data-datapoints-count={data.length}
data-xaxis-max={xAxisMax}
>
<ResponsiveContainer width={width} height={height}>
<LineChart
data={data}
margin={{ top: 10, right: 30, left: 10, bottom: 20 }}
data-testid="line-chart"
>
<CartesianGrid strokeDasharray="3 3" stroke="#262626" />
<XAxis
dataKey="supply"
domain={[0, xAxisMax]}
type="number"
stroke="#737373"
tickLine={false}
data-testid="x-axis"
/>
<YAxis
dataKey="priceXLM"
stroke="#737373"
tickLine={false}
unit=" XLM"
data-testid="y-axis"
/>
<Tooltip
contentStyle={{
backgroundColor: '#171717',
borderColor: '#404040',
borderRadius: '0.5rem',
color: '#f5f5f5',
}}
formatter={(value: number) => [`${value} XLM`, 'Price']}
labelFormatter={(label: number) => `Key Supply: ${label}`}
/>
<Line
type="monotone"
dataKey="priceXLM"
stroke="#10b981"
strokeWidth={2}
dot={<CustomDot />}
activeDot={{ r: 8, className: 'highlight-active-dot' }}
/>
{currentPoint && (
<ReferenceDot
x={currentPoint.supply}
y={currentPoint.priceXLM}
r={6}
className="current-price-highlight highlight"
data-testid="reference-current-dot"
/>
)}
</LineChart>
</ResponsiveContainer>
</div>
);
}
190 changes: 190 additions & 0 deletions src/components/common/__tests__/BondingCurveChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import type { ReactNode } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BondingCurveChart, type BondingCurveDataPoint } from '../BondingCurveChart';
import * as recharts from 'recharts';

// Mock Recharts library to inspect parameters and test data shapes
vi.mock('recharts', async (importOriginal) => {
const original = await importOriginal<typeof import('recharts')>();
return {
...original,
ResponsiveContainer: vi.fn(
({
children,
width,
height,
}: {
children?: ReactNode;
width?: string | number;
height?: string | number;
}) => (
<div data-testid="responsive-container" data-width={width} data-height={height}>
{children}
</div>
)
),
LineChart: vi.fn(
({
children,
data,
'data-testid': testId,
}: {
children?: ReactNode;
data?: unknown;
'data-testid'?: string;
}) => (
<div data-testid={testId || 'line-chart'} data-data-shape={JSON.stringify(data)}>
{children}
</div>
)
),
XAxis: vi.fn(
({
dataKey,
domain,
'data-testid': testId,
}: {
dataKey?: string;
domain?: unknown;
'data-testid'?: string;
}) => (
<div
data-testid={testId || 'x-axis'}
data-datakey={dataKey}
data-domain={JSON.stringify(domain)}
/>
)
),
YAxis: vi.fn(
({ dataKey, 'data-testid': testId }: { dataKey?: string; 'data-testid'?: string }) => (
<div data-testid={testId || 'y-axis'} data-datakey={dataKey} />
)
),
Tooltip: vi.fn(() => <div data-testid="tooltip" />),
CartesianGrid: vi.fn(() => <div data-testid="cartesian-grid" />),
Line: vi.fn(({ dataKey }: { dataKey?: string }) => (
<div data-testid="line" data-datakey={dataKey} />
)),
ReferenceDot: vi.fn(
({
x,
y,
className,
'data-testid': testId,
}: {
x?: number;
y?: number;
className?: string;
'data-testid'?: string;
}) => (
<div
data-testid={testId || 'reference-dot'}
className={className}
data-x={x}
data-y={y}
/>
)
),
};
});

describe('BondingCurveChart', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders a message "No data" when given an empty data array', () => {
render(<BondingCurveChart data={[]} />);

expect(screen.getByText('No data')).toBeInTheDocument();
expect(screen.getByTestId('no-data-message')).toBeInTheDocument();
});

it('renders a message "No data" when data prop is undefined', () => {
render(<BondingCurveChart data={undefined} />);

expect(screen.getByText('No data')).toBeInTheDocument();
});

it('renders the chart container with exactly N data points when given N data points for supply N', () => {
const sampleData: BondingCurveDataPoint[] = [
{ supply: 1, priceXLM: 1.01 },
{ supply: 2, priceXLM: 1.02 },
{ supply: 3, priceXLM: 1.03 },
{ supply: 4, priceXLM: 1.04 },
{ supply: 5, priceXLM: 1.05 },
];

render(<BondingCurveChart data={sampleData} currentSupply={5} />);

const chartElement = screen.getByTestId('bonding-curve-chart');
expect(chartElement).toBeInTheDocument();
expect(chartElement).toHaveAttribute('data-datapoints-count', '5');

// Assert LineChart mock received all 5 data points
const lineChartMock = vi.mocked(recharts.LineChart);
expect(lineChartMock).toHaveBeenCalled();
const lineChartProps = lineChartMock.mock.calls[0][0];
expect(lineChartProps.data).toHaveLength(5);
expect(lineChartProps.data).toEqual(sampleData);
});

it('sets the x-axis maximum to equal the current supply value', () => {
const sampleData: BondingCurveDataPoint[] = [
{ supply: 1, priceXLM: 1.1 },
{ supply: 2, priceXLM: 1.2 },
{ supply: 3, priceXLM: 1.3 },
];
const currentSupply = 25;

render(<BondingCurveChart data={sampleData} currentSupply={currentSupply} />);

const chartElement = screen.getByTestId('bonding-curve-chart');
expect(chartElement).toHaveAttribute('data-xaxis-max', '25');

const xAxisMock = vi.mocked(recharts.XAxis);
expect(xAxisMock).toHaveBeenCalled();
const xAxisProps = xAxisMock.mock.calls[0][0];
expect(xAxisProps.domain).toEqual([0, 25]);
});

it('marks the current price point with a distinct highlight class', () => {
const sampleData: BondingCurveDataPoint[] = [
{ supply: 1, priceXLM: 1.0 },
{ supply: 2, priceXLM: 1.1, isCurrent: true },
{ supply: 3, priceXLM: 1.2 },
];

render(<BondingCurveChart data={sampleData} currentSupply={2} />);

// Check reference dot rendered for current point has highlight class
const referenceDot = screen.getByTestId('reference-current-dot');
expect(referenceDot).toBeInTheDocument();
expect(referenceDot).toHaveClass('current-price-highlight');
expect(referenceDot).toHaveClass('highlight');
expect(referenceDot).toHaveAttribute('data-x', '2');
expect(referenceDot).toHaveAttribute('data-y', '1.1');
});

it('mocks the chart library and asserts it is called with the correct data shape', () => {
const sampleData: BondingCurveDataPoint[] = [
{ supply: 10, priceXLM: 1.5 },
{ supply: 20, priceXLM: 2.0, isCurrent: true },
];

render(<BondingCurveChart data={sampleData} currentSupply={20} />);

// Verify ResponsiveContainer was invoked
expect(recharts.ResponsiveContainer).toHaveBeenCalled();

// Verify LineChart was invoked with correct data shape { supply: number, priceXLM: number }
const lineChartMock = vi.mocked(recharts.LineChart);
expect(lineChartMock).toHaveBeenCalled();
const firstCallProps = lineChartMock.mock.calls[0][0];
expect(firstCallProps.data).toEqual([
{ supply: 10, priceXLM: 1.5 },
{ supply: 20, priceXLM: 2.0, isCurrent: true },
]);
});
});
Loading