diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx index ec453a799b61..162b6fa42b4d 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.test.tsx @@ -3,11 +3,15 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import PredictDetailsChart, { ChartSeries } from './PredictDetailsChart'; jest.mock('react-native-svg-charts', () => ({ - LineChart: jest.fn(({ children, data, ...props }) => { + LineChart: jest.fn(({ children, data, svg, ...props }) => { const { View, Text } = jest.requireActual('react-native'); + // Only add testID if the chart is visible (not the transparent tooltip overlay) + const isVisible = svg?.stroke !== 'transparent'; return ( - - {JSON.stringify(data)} + + + {JSON.stringify(data)} + {children} ); @@ -43,6 +47,14 @@ jest.mock('react-native-svg', () => ({ const { View } = jest.requireActual('react-native'); return ; }), + Circle: jest.fn((props) => { + const { View } = jest.requireActual('react-native'); + return ; + }), + Rect: jest.fn((props) => { + const { View } = jest.requireActual('react-native'); + return ; + }), })); jest.mock('d3-shape', () => ({ @@ -142,11 +154,16 @@ describe('PredictDetailsChart', () => { }); it('renders chart with multiple series data', () => { - const { getAllByTestId } = setupTest({ data: mockMultipleSeries }); + const { getAllByTestId, getByText } = setupTest({ + data: mockMultipleSeries, + }); - // Multiple LineChart components are rendered for multiple series + // At least one chart is rendered const charts = getAllByTestId('line-chart'); expect(charts.length).toBeGreaterThanOrEqual(1); + // Legend with multiple series is displayed + expect(getByText(/Outcome A/)).toBeOnTheScreen(); + expect(getByText(/Outcome B/)).toBeOnTheScreen(); }); it('renders timeframe selector with all timeframes', () => { @@ -390,4 +407,290 @@ describe('PredictDetailsChart', () => { expect(getByTestId('line-chart')).toBeOnTheScreen(); }); }); + + describe('Interactive Features', () => { + describe('Touch Interactions', () => { + it('renders chart with pan handler setup', () => { + const { getByTestId } = setupTest(); + + const lineChart = getByTestId('line-chart'); + expect(lineChart).toBeOnTheScreen(); + }); + + it('handles chart layout calculation', () => { + const { getAllByTestId } = setupTest({ data: mockMultipleSeries }); + + const lineCharts = getAllByTestId('line-chart'); + // Verify chart is rendered and can receive layout events + expect(lineCharts.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('ChartTooltip', () => { + it('renders SVG tooltip elements for multiple series', () => { + const { getAllByTestId } = setupTest({ data: mockMultipleSeries }); + + // Verify chart is rendered with SVG components + const charts = getAllByTestId('line-chart'); + expect(charts.length).toBeGreaterThanOrEqual(1); + }); + + it('includes tooltip overlay chart layer', () => { + const { getAllByTestId } = setupTest({ data: mockMultipleSeries }); + + // With multiple series, we have primary + overlays + tooltip layer + const charts = getAllByTestId('line-chart'); + expect(charts.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Label Truncation', () => { + it('handles long series labels', () => { + const longLabelSeries: ChartSeries[] = [ + { + label: 'This is a very long outcome label that exceeds limit', + color: '#4459FF', + data: [ + { timestamp: 1640995200000, value: 0.5 }, + { timestamp: 1640998800000, value: 0.6 }, + ], + }, + { + label: 'Short Label', + color: '#FF6B6B', + data: [ + { timestamp: 1640995200000, value: 0.3 }, + { timestamp: 1640998800000, value: 0.4 }, + ], + }, + ]; + + const { getByText } = setupTest({ data: longLabelSeries }); + + // Component should render without crashing with long labels + // Legend shows labels for multiple series + expect( + getByText(/This is a very long outcome label/), + ).toBeOnTheScreen(); + }); + + it('handles special characters in labels', () => { + const specialCharSeries: ChartSeries[] = [ + { + label: 'Outcome #1 (Test) - Result', + color: '#4459FF', + data: [ + { timestamp: 1640995200000, value: 0.5 }, + { timestamp: 1640998800000, value: 0.6 }, + ], + }, + { + label: 'Normal Label', + color: '#FF6B6B', + data: [ + { timestamp: 1640995200000, value: 0.3 }, + { timestamp: 1640998800000, value: 0.4 }, + ], + }, + ]; + + const { getByText } = setupTest({ data: specialCharSeries }); + + expect(getByText(/Outcome #1 \(Test\)/)).toBeOnTheScreen(); + }); + }); + + describe('Active Index and Legend Updates', () => { + it('passes activeIndex to legend for multiple series', () => { + const { getByText } = setupTest({ data: mockMultipleSeries }); + + // Legend should display last values initially + expect(getByText(/Outcome A/)).toBeOnTheScreen(); + expect(getByText(/Outcome B/)).toBeOnTheScreen(); + }); + + it('legend displays values correctly for multiple series', () => { + const { getByText } = setupTest({ data: mockMultipleSeries }); + + // Both series labels should be visible in legend + expect(getByText(/Outcome A/)).toBeOnTheScreen(); + expect(getByText(/Outcome B/)).toBeOnTheScreen(); + }); + }); + + describe('Collision Detection', () => { + it('handles series with crossing values', () => { + const crossingSeries: ChartSeries[] = [ + { + label: 'Series A', + color: '#4459FF', + data: [ + { timestamp: 1, value: 0.3 }, + { timestamp: 2, value: 0.7 }, + ], + }, + { + label: 'Series B', + color: '#FF6B6B', + data: [ + { timestamp: 1, value: 0.7 }, + { timestamp: 2, value: 0.3 }, + ], + }, + ]; + + const { getByText } = setupTest({ data: crossingSeries }); + + // Component should handle crossing values without errors + expect(getByText(/Series A/)).toBeOnTheScreen(); + expect(getByText(/Series B/)).toBeOnTheScreen(); + }); + + it('handles series with very close values', () => { + const closeSeries: ChartSeries[] = [ + { + label: 'Close A', + color: '#4459FF', + data: [ + { timestamp: 1, value: 0.5 }, + { timestamp: 2, value: 0.501 }, + ], + }, + { + label: 'Close B', + color: '#FF6B6B', + data: [ + { timestamp: 1, value: 0.502 }, + { timestamp: 2, value: 0.503 }, + ], + }, + ]; + + const { getByText } = setupTest({ data: closeSeries }); + + expect(getByText(/Close A/)).toBeOnTheScreen(); + expect(getByText(/Close B/)).toBeOnTheScreen(); + }); + }); + + describe('Tooltip Positioning', () => { + it('renders chart with proper layout for tooltip positioning', () => { + const { getAllByTestId } = setupTest({ data: mockMultipleSeries }); + + const charts = getAllByTestId('line-chart'); + // Should have multiple chart layers including tooltip overlay + expect(charts.length).toBeGreaterThanOrEqual(1); + }); + + it('handles chart with full data range', () => { + const fullRangeSeries: ChartSeries[] = [ + { + label: 'Full Range', + color: '#4459FF', + data: Array.from({ length: 50 }, (_, i) => ({ + timestamp: 1640995200000 + i * 3600000, + value: 0.3 + (i / 50) * 0.4, // Values from 0.3 to 0.7 + })), + }, + ]; + + const { getByTestId } = setupTest({ data: fullRangeSeries }); + + expect(getByTestId('line-chart')).toBeOnTheScreen(); + }); + }); + + describe('Theme Support', () => { + it('renders chart with theme colors', () => { + const { getAllByTestId } = setupTest({ data: mockMultipleSeries }); + + const lineCharts = getAllByTestId('line-chart'); + // Theme colors should be applied via mocked useTheme + expect(lineCharts.length).toBeGreaterThanOrEqual(1); + }); + + it('applies correct colors to series', () => { + const coloredSeries: ChartSeries[] = [ + { + label: 'Blue', + color: '#0000FF', + data: [{ timestamp: 1, value: 0.5 }], + }, + { + label: 'Red', + color: '#FF0000', + data: [{ timestamp: 1, value: 0.5 }], + }, + ]; + + const { getByText } = setupTest({ data: coloredSeries }); + + expect(getByText(/Blue/)).toBeOnTheScreen(); + expect(getByText(/Red/)).toBeOnTheScreen(); + }); + }); + + describe('Multiple Series Rendering', () => { + it('renders primary and overlay charts for multiple series', () => { + const { getAllByTestId } = setupTest({ data: mockMultipleSeries }); + + const charts = getAllByTestId('line-chart'); + // Primary chart + overlay for second series + tooltip overlay + expect(charts.length).toBeGreaterThanOrEqual(2); + }); + + it('renders up to 3 series with overlays', () => { + const threeSeries: ChartSeries[] = [ + { + label: 'Series 1', + color: '#4459FF', + data: [ + { timestamp: 1, value: 0.5 }, + { timestamp: 2, value: 0.6 }, + ], + }, + { + label: 'Series 2', + color: '#FF6B6B', + data: [ + { timestamp: 1, value: 0.3 }, + { timestamp: 2, value: 0.4 }, + ], + }, + { + label: 'Series 3', + color: '#F0B034', + data: [ + { timestamp: 1, value: 0.2 }, + { timestamp: 2, value: 0.25 }, + ], + }, + ]; + + const { getByText } = setupTest({ data: threeSeries }); + + expect(getByText(/Series 1/)).toBeOnTheScreen(); + expect(getByText(/Series 2/)).toBeOnTheScreen(); + expect(getByText(/Series 3/)).toBeOnTheScreen(); + }); + }); + + describe('Data Point Formatting', () => { + it('formats timestamp labels correctly', () => { + const { getAllByText } = setupTest(); + + // Timestamps should be formatted as time labels + const timeLabels = getAllByText(/PM/); + expect(timeLabels.length).toBeGreaterThan(0); + }); + + it('displays correct number of axis labels', () => { + const { getAllByText } = setupTest(); + + // Should have time labels on x-axis + const timeLabels = getAllByText(/PM|AM/); + expect(timeLabels.length).toBeGreaterThan(0); + }); + }); + }); }); diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx index 35cbf1fc41d0..0aad87d77893 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx @@ -1,6 +1,12 @@ import React from 'react'; -import { StyleSheet, ActivityIndicator } from 'react-native'; +import { + StyleSheet, + ActivityIndicator, + PanResponder, + View, +} from 'react-native'; import { LineChart } from 'react-native-svg-charts'; +import { Circle, G, Line, Text as SvgText, Rect } from 'react-native-svg'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -12,6 +18,7 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useTheme } from '../../../../../util/theme'; +import type { Colors } from '../../../../../util/theme/models'; import TimeframeSelector from './components/TimeframeSelector'; import ChartGrid from './components/ChartGrid'; import ChartArea from './components/ChartArea'; @@ -28,7 +35,7 @@ import { export interface ChartSeries { label: string; color: string; - data: { timestamp: number; value: number }[]; + data: { timestamp: number; value: number; label?: string }[]; } interface PredictDetailsChartProps { @@ -40,6 +47,216 @@ interface PredictDetailsChartProps { emptyLabel?: string; } +interface TooltipManualProps { + activeIndex: number; + primaryData: { timestamp: number; value: number; label: string }[]; + nonEmptySeries: ChartSeries[]; + colors: Colors; +} + +interface TooltipInjectedProps { + x: (index: number) => number; + y: (value: number) => number; + ticks: number[]; +} + +type TooltipProps = TooltipManualProps & Partial; + +// Tooltip component for interactive chart - defined outside to avoid re-renders +const ChartTooltip: React.FC = ({ + x, + y, + ticks, + activeIndex, + primaryData, + nonEmptySeries, + colors, +}) => { + if (!x || !y || !ticks) return null; // Injected props not yet available + if (activeIndex < 0 || !primaryData[activeIndex]) return null; + + const activePoint = primaryData[activeIndex]; + const xPos = x(activeIndex); + + // Calculate label dimensions + const labelPadding = 6; + const fontSize = 12; + const labelHeight = fontSize + labelPadding * 2; + const labelOffset = 10; + + // Determine if we should show labels on the left or right + // If we're in the right half of the chart, show labels on the left + const maxDataIndex = primaryData.length - 1; + const isRightSide = activeIndex > maxDataIndex / 2; + + // Theme-aware colors for crosshair + const lineColor = colors.border.muted; + const textColor = colors.text.alternative; + + return ( + + {/* Top solid line connecting to timestamp */} + + + {/* Main vertical crosshair line through data area */} + + + {/* Bottom line extending to chart bottom */} + + + {/* Display timestamp at very top - adjust position based on side */} + + + {activePoint.label} + + + + {/* Render circles and labels for each series - positioned at their line points */} + {(() => { + const maxLabelChars = 25; // Maximum characters for label title + + // Calculate initial positions for all labels + const labelData = nonEmptySeries + .map((series, seriesIndex) => { + const seriesData = series.data[activeIndex]; + if (!seriesData) return null; + + const lineYPos = y(seriesData.value); + // Truncate label if too long + const truncatedLabel = + series.label.length > maxLabelChars + ? `${series.label.substring(0, maxLabelChars)}...` + : series.label; + const labelText = `${truncatedLabel}: ${seriesData.value.toFixed(2)}%`; + const textWidth = labelText.length * (fontSize * 0.55); + const labelWidth = textWidth + labelPadding * 2; + + return { + series, + seriesIndex, + seriesData, + lineYPos, + labelText, + labelWidth, + truncatedLabel, + adjustedY: lineYPos - labelHeight / 2, // Initial position + }; + }) + .filter(Boolean); + + // Sort by Y position to detect collisions + const sortedLabels = [...labelData] + .filter((item): item is NonNullable => item !== null) + .sort((a, b) => a.adjustedY - b.adjustedY); + + // Adjust positions to prevent overlap + const minSpacing = 4; // Minimum pixels between labels + for (let i = 1; i < sortedLabels.length; i++) { + const current = sortedLabels[i]; + const previous = sortedLabels[i - 1]; + + if (!current || !previous) continue; + + const overlap = + previous.adjustedY + labelHeight + minSpacing - current.adjustedY; + + if (overlap > 0) { + // Shift current label down + current.adjustedY += overlap; + } + } + + // Apply adjusted positions back to original array + sortedLabels.forEach((label) => { + if (!label) return; + const original = labelData.find( + (l) => l?.seriesIndex === label.seriesIndex, + ); + if (original) { + original.adjustedY = label.adjustedY; + } + }); + + // Render all labels + return labelData.map((data) => { + if (!data) return null; + + const { + series, + seriesIndex, + lineYPos, + labelText, + labelWidth, + adjustedY, + } = data; + + // Position label based on which side of the chart we're on + const labelX = isRightSide + ? xPos - labelWidth - labelOffset // Left side of crosshair + : xPos + labelOffset; // Right side of crosshair + + return ( + + {/* Circle on the line */} + + + {/* Background rectangle with series color */} + + + {/* White text */} + + {labelText} + + + ); + }); + })()} + + ); +}; + const PredictDetailsChart: React.FC = ({ data, timeframes, @@ -51,6 +268,11 @@ const PredictDetailsChart: React.FC = ({ const tw = useTailwind(); const { colors } = useTheme(); + // Interactive chart state + const [activeIndex, setActiveIndex] = React.useState(-1); + const chartWidthRef = React.useRef(0); + const isHorizontalGestureRef = React.useRef(false); + // Limit to MAX_SERIES const seriesToRender = React.useMemo(() => data.slice(0, MAX_SERIES), [data]); const isSingleSeries = seriesToRender.length === 1; @@ -95,12 +317,104 @@ const PredictDetailsChart: React.FC = ({ ? primaryData.map((point) => point.value) : [0, 0]; + // Handle chart layout to track width + const handleChartLayout = (event: { + nativeEvent: { layout: { width: number } }; + }) => { + const { width } = event.nativeEvent.layout; + chartWidthRef.current = width; + }; + + // Update active position based on touch X coordinate + const updatePosition = React.useCallback( + (x: number) => { + if (primaryData.length === 0) return; + + const adjustedX = x - CHART_CONTENT_INSET.left; + const chartDataWidth = + chartWidthRef.current - + CHART_CONTENT_INSET.left - + CHART_CONTENT_INSET.right; + + if (chartDataWidth <= 0) return; + + const index = Math.round( + (adjustedX / chartDataWidth) * (primaryData.length - 1), + ); + + const clampedIndex = Math.max(0, Math.min(primaryData.length - 1, index)); + setActiveIndex(clampedIndex); + }, + [primaryData.length], + ); + + // PanResponder for handling touch gestures + // Responds to touches but only blocks ScrollView for horizontal gestures + const panResponder = React.useMemo( + () => + PanResponder.create({ + // Claim gesture on initial touch + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => false, + + // Determine if we should keep the gesture based on direction + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: (_event, gestureState) => { + // Only capture (block ScrollView) if movement is more horizontal than vertical + const { dx, dy } = gestureState; + const isHorizontal = Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 5; + isHorizontalGestureRef.current = isHorizontal; + return isHorizontal; + }, + + // Allow ScrollView to take over if user scrolls vertically + onPanResponderTerminationRequest: (_event, gestureState) => { + const { dx, dy } = gestureState; + const isVertical = Math.abs(dy) > Math.abs(dx); + return isVertical; // Allow termination for vertical scrolls + }, + + onPanResponderGrant: (event) => { + isHorizontalGestureRef.current = true; // Assume horizontal initially + updatePosition(event.nativeEvent.locationX); + }, + + onPanResponderMove: (event, gestureState) => { + const { dx, dy } = gestureState; + // Check if gesture is still horizontal + if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 10) { + // User is scrolling vertically, stop updating + isHorizontalGestureRef.current = false; + setActiveIndex(-1); + } else { + // Continue with horizontal interaction + updatePosition(event.nativeEvent.locationX); + } + }, + + onPanResponderRelease: () => { + setActiveIndex(-1); + isHorizontalGestureRef.current = false; + }, + + onPanResponderTerminate: () => { + setActiveIndex(-1); + isHorizontalGestureRef.current = false; + }, + }), + [updatePosition], + ); + const renderGraph = () => { if (isLoading || !hasData) { return ( {isMultipleSeries && ( - + )} = ({ return ( {isMultipleSeries && ( - + )} - + = ({ curve={LINE_CURVE} /> ))} - + {/* Tooltip overlay - rendered last to appear on top */} + + + + ({ + formatTickValue: jest.fn((value: number, range: number) => { + if (!Number.isFinite(value)) { + return '0'; + } + if (range < 1) { + return value.toFixed(2); + } + if (range < 10) { + return value.toFixed(1); + } + return value.toFixed(0); + }), +})); + +describe('ChartLegend', () => { + const mockSingleSeries: ChartSeries[] = [ + { + label: 'Outcome A', + color: '#4459FF', + data: [ + { timestamp: 1640995200000, value: 0.5 }, + { timestamp: 1640998800000, value: 0.6 }, + { timestamp: 1641002400000, value: 0.75 }, + ], + }, + ]; + + const mockMultipleSeries: ChartSeries[] = [ + { + label: 'Outcome A', + color: '#4459FF', + data: [ + { timestamp: 1640995200000, value: 0.5 }, + { timestamp: 1640998800000, value: 0.6 }, + { timestamp: 1641002400000, value: 0.75 }, + ], + }, + { + label: 'Outcome B', + color: '#FF6B6B', + data: [ + { timestamp: 1640995200000, value: 0.3 }, + { timestamp: 1640998800000, value: 0.2 }, + { timestamp: 1641002400000, value: 0.15 }, + ], + }, + ]; + + const mockEmptySeries: ChartSeries[] = []; + + const setupTest = (props = {}) => { + const defaultProps = { + series: mockSingleSeries, + range: 1, + ...props, + }; + return renderWithProvider(); + }; + + describe('Component Rendering', () => { + it('renders with single series data', () => { + const { getByText } = setupTest(); + + expect(getByText(/Outcome A/)).toBeOnTheScreen(); + }); + + it('renders multiple series items', () => { + const { getByText } = setupTest({ series: mockMultipleSeries }); + + expect(getByText(/Outcome A/)).toBeOnTheScreen(); + expect(getByText(/Outcome B/)).toBeOnTheScreen(); + }); + + it('renders nothing when series is empty', () => { + const { queryByText } = setupTest({ series: mockEmptySeries }); + + // Should not render any text since series is empty + expect(queryByText(/Outcome/)).not.toBeOnTheScreen(); + }); + + it('renders series label with value', () => { + const { getByText } = setupTest(); + + expect(getByText(/Outcome A 0\.8%/)).toBeOnTheScreen(); + }); + + it('renders each series item', () => { + const { getByText } = setupTest({ series: mockMultipleSeries }); + + // Verify each series is rendered (color indicators are rendered as part of the layout) + expect(getByText(/Outcome A/)).toBeOnTheScreen(); + expect(getByText(/Outcome B/)).toBeOnTheScreen(); + }); + }); + + describe('Value Display', () => { + it('displays last value when activeIndex is not provided', () => { + const { getByText } = setupTest(); + + // Last value in mockSingleSeries is 0.75, which with range 1 formats to "0.8" + expect(getByText(/0\.8%/)).toBeOnTheScreen(); + }); + + it('displays last value when activeIndex is -1', () => { + const { getByText } = setupTest({ activeIndex: -1 }); + + // Last value in mockSingleSeries is 0.75, which with range 1 formats to "0.8" + expect(getByText(/0\.8%/)).toBeOnTheScreen(); + }); + + it('displays value at activeIndex when provided', () => { + const { getByText } = setupTest({ activeIndex: 0 }); + + // First value in mockSingleSeries is 0.5, which with range 1 formats to "0.5" + expect(getByText(/0\.5%/)).toBeOnTheScreen(); + }); + + it('displays middle value when activeIndex points to middle', () => { + const { getByText } = setupTest({ activeIndex: 1 }); + + // Middle value in mockSingleSeries is 0.6 + expect(getByText(/0\.6%/)).toBeOnTheScreen(); + }); + + it('displays em-dash when data is empty', () => { + const seriesWithEmptyData: ChartSeries[] = [ + { + label: 'Empty Series', + color: '#4459FF', + data: [], + }, + ]; + + const { getByText } = setupTest({ series: seriesWithEmptyData }); + + expect(getByText(/Empty Series —/)).toBeOnTheScreen(); + }); + + it('formats value with correct decimal places based on range', () => { + const { getByText, rerender } = setupTest({ range: 0.5 }); + + // Range < 1: 2 decimal places + expect(getByText(/0\.75%/)).toBeOnTheScreen(); + + // Re-render with different range + rerender(); + + // Range < 10: 1 decimal place + expect(getByText(/0\.8%/)).toBeOnTheScreen(); + + // Re-render with larger range + rerender(); + + // Range >= 10: 0 decimal places + expect(getByText(/1%/)).toBeOnTheScreen(); + }); + }); + + describe('Active Index Behavior', () => { + it('updates displayed value when activeIndex changes', () => { + const { getByText, rerender } = setupTest({ activeIndex: 0 }); + + expect(getByText(/0\.5%/)).toBeOnTheScreen(); + + rerender( + , + ); + + expect(getByText(/0\.8%/)).toBeOnTheScreen(); + }); + + it('reverts to last value when activeIndex becomes undefined', () => { + const { getByText, rerender } = setupTest({ activeIndex: 0 }); + + expect(getByText(/0\.5%/)).toBeOnTheScreen(); + + rerender( + , + ); + + expect(getByText(/0\.8%/)).toBeOnTheScreen(); + }); + + it('shows last value when activeIndex is negative', () => { + const { getByText } = setupTest({ activeIndex: -5 }); + + expect(getByText(/0\.8%/)).toBeOnTheScreen(); + }); + }); + + describe('Edge Cases', () => { + it('handles empty data array gracefully', () => { + const seriesWithEmptyData: ChartSeries[] = [ + { + label: 'Empty', + color: '#4459FF', + data: [], + }, + ]; + + const { getByText } = setupTest({ series: seriesWithEmptyData }); + + expect(getByText(/Empty —/)).toBeOnTheScreen(); + }); + + it('handles single data point', () => { + const seriesWithSinglePoint: ChartSeries[] = [ + { + label: 'Single Point', + color: '#4459FF', + data: [{ timestamp: 1640995200000, value: 0.42 }], + }, + ]; + + const { getByText } = setupTest({ series: seriesWithSinglePoint }); + + expect(getByText(/Single Point 0\.4%/)).toBeOnTheScreen(); + }); + + it('handles out-of-bounds activeIndex', () => { + const { getByText } = setupTest({ activeIndex: 999 }); + + // Out of bounds should return undefined, showing em-dash + expect(getByText(/—/)).toBeOnTheScreen(); + }); + + it('renders all series with different activeIndex values', () => { + const { getByText } = setupTest({ + series: mockMultipleSeries, + activeIndex: 1, + }); + + // Outcome A at index 1: 0.6 + expect(getByText(/Outcome A 0\.6%/)).toBeOnTheScreen(); + // Outcome B at index 1: 0.2 + expect(getByText(/Outcome B 0\.2%/)).toBeOnTheScreen(); + }); + + it('handles very small values', () => { + const seriesWithSmallValues: ChartSeries[] = [ + { + label: 'Small', + color: '#4459FF', + data: [{ timestamp: 1640995200000, value: 0.001 }], + }, + ]; + + const { getByText } = setupTest({ + series: seriesWithSmallValues, + range: 0.5, + }); + + expect(getByText(/Small 0\.00%/)).toBeOnTheScreen(); + }); + + it('handles very large values', () => { + const seriesWithLargeValues: ChartSeries[] = [ + { + label: 'Large', + color: '#4459FF', + data: [{ timestamp: 1640995200000, value: 999.99 }], + }, + ]; + + const { getByText } = setupTest({ + series: seriesWithLargeValues, + range: 100, + }); + + expect(getByText(/Large 1000%/)).toBeOnTheScreen(); + }); + }); + + describe('Multiple Series', () => { + it('renders correct values for all series at activeIndex', () => { + const { getByText } = setupTest({ + series: mockMultipleSeries, + activeIndex: 0, + }); + + expect(getByText(/Outcome A 0\.5%/)).toBeOnTheScreen(); + expect(getByText(/Outcome B 0\.3%/)).toBeOnTheScreen(); + }); + + it('renders correct last values for all series when not dragging', () => { + const { getByText } = setupTest({ series: mockMultipleSeries }); + + expect(getByText(/Outcome A 0\.8%/)).toBeOnTheScreen(); + expect(getByText(/Outcome B 0\.1%/)).toBeOnTheScreen(); + }); + + it('handles series with different data lengths', () => { + const seriesWithDifferentLengths: ChartSeries[] = [ + { + label: 'Long', + color: '#4459FF', + data: [ + { timestamp: 1, value: 0.1 }, + { timestamp: 2, value: 0.2 }, + { timestamp: 3, value: 0.3 }, + ], + }, + { + label: 'Short', + color: '#FF6B6B', + data: [{ timestamp: 1, value: 0.5 }], + }, + ]; + + const { getByText } = setupTest({ + series: seriesWithDifferentLengths, + activeIndex: 2, + }); + + expect(getByText(/Long 0\.3%/)).toBeOnTheScreen(); + // Short series doesn't have index 2, should show em-dash + expect(getByText(/Short —/)).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx index b91b5e6de3f4..f58bbe83db71 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx @@ -14,9 +14,14 @@ import { ChartSeries } from '../PredictDetailsChart'; interface ChartLegendProps { series: ChartSeries[]; range: number; + activeIndex?: number; // Index of the active dragged position } -const ChartLegend: React.FC = ({ series, range }) => { +const ChartLegend: React.FC = ({ + series, + range, + activeIndex, +}) => { if (!series.length) return null; return ( @@ -26,9 +31,14 @@ const ChartLegend: React.FC = ({ series, range }) => { twClassName="px-4 mb-2 flex-wrap" > {series.map((seriesItem, index) => { - const lastPoint = seriesItem.data[seriesItem.data.length - 1]; - const valueLabel = lastPoint - ? `${formatTickValue(lastPoint.value, range)}%` + // Show value at active index if dragging, otherwise show last value + const dataPoint = + activeIndex !== undefined && activeIndex >= 0 + ? seriesItem.data[activeIndex] + : seriesItem.data[seriesItem.data.length - 1]; + + const valueLabel = dataPoint + ? `${formatTickValue(dataPoint.value, range)}%` : '\u2014'; return (