diff --git a/.husky/pre-commit b/.husky/pre-commit index e65f5e7b..60e30912 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 + diff --git a/src/components/common/BondingCurveChart.tsx b/src/components/common/BondingCurveChart.tsx new file mode 100644 index 00000000..4bd1e7b0 --- /dev/null +++ b/src/components/common/BondingCurveChart.tsx @@ -0,0 +1,147 @@ +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + ReferenceDot, +} from 'recharts'; +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 ( +
+ No data +
+ ); + } + + 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 ( + + ); + }; + + return ( +
+ + + + + + [`${value} XLM`, 'Price']} + labelFormatter={(label: number) => `Key Supply: ${label}`} + /> + } + activeDot={{ r: 8, className: 'highlight-active-dot' }} + /> + {currentPoint && ( + + )} + + +
+ ); +} diff --git a/src/components/common/__tests__/BondingCurveChart.test.tsx b/src/components/common/__tests__/BondingCurveChart.test.tsx new file mode 100644 index 00000000..7e052bbe --- /dev/null +++ b/src/components/common/__tests__/BondingCurveChart.test.tsx @@ -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(); + return { + ...original, + ResponsiveContainer: vi.fn( + ({ + children, + width, + height, + }: { + children?: ReactNode; + width?: string | number; + height?: string | number; + }) => ( +
+ {children} +
+ ) + ), + LineChart: vi.fn( + ({ + children, + data, + 'data-testid': testId, + }: { + children?: ReactNode; + data?: unknown; + 'data-testid'?: string; + }) => ( +
+ {children} +
+ ) + ), + XAxis: vi.fn( + ({ + dataKey, + domain, + 'data-testid': testId, + }: { + dataKey?: string; + domain?: unknown; + 'data-testid'?: string; + }) => ( +
+ ) + ), + YAxis: vi.fn( + ({ dataKey, 'data-testid': testId }: { dataKey?: string; 'data-testid'?: string }) => ( +
+ ) + ), + Tooltip: vi.fn(() =>
), + CartesianGrid: vi.fn(() =>
), + Line: vi.fn(({ dataKey }: { dataKey?: string }) => ( +
+ )), + ReferenceDot: vi.fn( + ({ + x, + y, + className, + 'data-testid': testId, + }: { + x?: number; + y?: number; + className?: string; + 'data-testid'?: string; + }) => ( +
+ ) + ), + }; +}); + +describe('BondingCurveChart', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders a message "No data" when given an empty data array', () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + // 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 }, + ]); + }); +});