Replace react-vis with visx, add component testing infrastructure, and update Electron packages (#959)
This commit is contained in:
112
app/src/components/Chart/Chart.domain.spec.tsx
Normal file
112
app/src/components/Chart/Chart.domain.spec.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { expect } from 'chai'
|
||||
import { renderWithProviders } from '../../utils/spec/testUtils'
|
||||
import Chart from './Chart'
|
||||
|
||||
describe('Chart X-Axis Domain Investigation', () => {
|
||||
it('should spread points across X-axis with sequential timestamps', () => {
|
||||
// Create 5 data points, 1 second (1000ms) apart
|
||||
const now = Date.now()
|
||||
const data = [
|
||||
{ x: now - 4000, y: 20 },
|
||||
{ x: now - 3000, y: 21 },
|
||||
{ x: now - 2000, y: 22 },
|
||||
{ x: now - 1000, y: 23 },
|
||||
{ x: now, y: 24 }
|
||||
]
|
||||
|
||||
const { container } = renderWithProviders(<Chart data={data} />)
|
||||
|
||||
// Find all circle elements (data points)
|
||||
const circles = container.querySelectorAll('svg circle')
|
||||
expect(circles).to.have.length(5)
|
||||
|
||||
// Extract cx (X-position) values
|
||||
const cxValues: number[] = []
|
||||
circles.forEach(circle => {
|
||||
const cx = circle.getAttribute('cx')
|
||||
if (cx) {
|
||||
cxValues.push(parseFloat(cx))
|
||||
}
|
||||
})
|
||||
|
||||
// Log for debugging
|
||||
console.log('\n========== X-AXIS DOMAIN INVESTIGATION ==========')
|
||||
console.log('Data X values (timestamps):')
|
||||
data.forEach((d, i) => console.log(` Point ${i}: ${d.x} (${new Date(d.x).toISOString()})`))
|
||||
console.log(`\nData X range: ${data[data.length - 1].x - data[0].x}ms (${(data[data.length - 1].x - data[0].x) / 1000}s)`)
|
||||
|
||||
console.log('\nRendered circle CX positions:')
|
||||
cxValues.forEach((cx, i) => console.log(` Circle ${i}: cx=${cx.toFixed(2)}px`))
|
||||
|
||||
const minCx = Math.min(...cxValues)
|
||||
const maxCx = Math.max(...cxValues)
|
||||
const cxRange = maxCx - minCx
|
||||
|
||||
console.log(`\nCX position range: ${cxRange.toFixed(2)}px (from ${minCx.toFixed(2)} to ${maxCx.toFixed(2)})`)
|
||||
console.log(`Points per pixel: ${(cxValues.length / cxRange).toFixed(4)}`)
|
||||
|
||||
// Calculate spacing between consecutive points
|
||||
const spacings: number[] = []
|
||||
for (let i = 1; i < cxValues.length; i++) {
|
||||
spacings.push(cxValues[i] - cxValues[i - 1])
|
||||
}
|
||||
console.log('\nSpacing between consecutive points:')
|
||||
spacings.forEach((s, i) => console.log(` ${i} to ${i+1}: ${s.toFixed(2)}px`))
|
||||
|
||||
const avgSpacing = spacings.reduce((a, b) => a + b, 0) / spacings.length
|
||||
console.log(`Average spacing: ${avgSpacing.toFixed(2)}px`)
|
||||
console.log('=================================================\n')
|
||||
|
||||
// Assertions:
|
||||
// 1. Points should be spread out (CX range should be significant, not bunched)
|
||||
expect(cxRange).to.be.greaterThan(50, 'Points should be spread across at least 50px')
|
||||
|
||||
// 2. Points should be in ascending order (left to right)
|
||||
for (let i = 1; i < cxValues.length; i++) {
|
||||
expect(cxValues[i]).to.be.greaterThan(cxValues[i - 1],
|
||||
`Point ${i} (cx=${cxValues[i]}) should be to the right of point ${i-1} (cx=${cxValues[i-1]})`)
|
||||
}
|
||||
|
||||
// 3. Spacing should be relatively uniform (since data points are equally spaced in time)
|
||||
const spacingVariance = spacings.map(s => Math.abs(s - avgSpacing))
|
||||
const maxVariance = Math.max(...spacingVariance)
|
||||
expect(maxVariance).to.be.lessThan(avgSpacing * 0.5,
|
||||
'Spacing between points should be relatively uniform')
|
||||
})
|
||||
|
||||
it('should handle points bunched at far right correctly', () => {
|
||||
// Simulate the "bunched up" scenario with very large timestamps
|
||||
const largeTimestamp = 1703347200000 // Dec 23, 2023
|
||||
const data = [
|
||||
{ x: largeTimestamp, y: 20 },
|
||||
{ x: largeTimestamp + 1000, y: 21 },
|
||||
{ x: largeTimestamp + 2000, y: 22 },
|
||||
{ x: largeTimestamp + 3000, y: 23 },
|
||||
{ x: largeTimestamp + 4000, y: 24 }
|
||||
]
|
||||
|
||||
const { container } = renderWithProviders(<Chart data={data} />)
|
||||
|
||||
const circles = container.querySelectorAll('svg circle')
|
||||
const cxValues: number[] = []
|
||||
circles.forEach(circle => {
|
||||
const cx = circle.getAttribute('cx')
|
||||
if (cx) {
|
||||
cxValues.push(parseFloat(cx))
|
||||
}
|
||||
})
|
||||
|
||||
console.log('\n========== LARGE TIMESTAMP TEST ==========')
|
||||
console.log('Data X values:', data.map(d => d.x))
|
||||
console.log('Rendered CX values:', cxValues.map(v => v.toFixed(2)))
|
||||
|
||||
const minCx = Math.min(...cxValues)
|
||||
const maxCx = Math.max(...cxValues)
|
||||
console.log(`CX range: ${(maxCx - minCx).toFixed(2)}px`)
|
||||
console.log('==========================================\n')
|
||||
|
||||
// Points should still be spread out even with large timestamps
|
||||
expect(maxCx - minCx).to.be.greaterThan(50,
|
||||
'Points with large timestamps should still be spread across the chart')
|
||||
})
|
||||
})
|
||||
514
app/src/components/Chart/Chart.spec.tsx
Normal file
514
app/src/components/Chart/Chart.spec.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* Chart Component Tests
|
||||
*
|
||||
* These tests verify the Chart component functionality including:
|
||||
* - Rendering with various data configurations
|
||||
* - Theme integration
|
||||
* - Interactive features (tooltips, hover states)
|
||||
* - Different curve interpolation types
|
||||
* - Custom domains and ranges
|
||||
* - Responsive behavior
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { expect } from 'chai'
|
||||
import { describe, it } from 'mocha'
|
||||
import Chart, { Props as ChartProps } from './Chart'
|
||||
import { renderWithProviders, createMockChartData, screen } from '../../utils/spec/testUtils'
|
||||
import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
|
||||
describe('Chart Component', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render without crashing with valid data', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container).to.exist
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
it('should render NoData component when data is empty', () => {
|
||||
const { container } = renderWithProviders(<Chart data={[]} />, { withTheme: true })
|
||||
|
||||
expect(container).to.exist
|
||||
// NoData component should be rendered
|
||||
const noDataElement = container.querySelector('div')
|
||||
expect(noDataElement).to.exist
|
||||
})
|
||||
|
||||
it('should render chart with correct height', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
const chartContainer = container.querySelector('[style*="height"]') as HTMLElement
|
||||
expect(chartContainer).to.exist
|
||||
expect(chartContainer.style.height).to.equal('150px')
|
||||
})
|
||||
|
||||
it('should render SVG chart elements', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
// Check for SVG element
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).to.exist
|
||||
|
||||
// Check for chart elements (paths for line series)
|
||||
const paths = container.querySelectorAll('path')
|
||||
expect(paths.length).to.be.greaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Visualization', () => {
|
||||
it('should render data points as glyphs', () => {
|
||||
const data = createMockChartData(3)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
// Check for circles (glyphs representing data points)
|
||||
const circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
it('should render exact number of data points matching data length', () => {
|
||||
const dataLength = 5
|
||||
const data = createMockChartData(dataLength)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
// Each data point should render as a circle
|
||||
const circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).to.equal(dataLength, `Expected ${dataLength} circles for ${dataLength} data points`)
|
||||
|
||||
// Verify each circle has proper attributes
|
||||
circles.forEach((circle, index) => {
|
||||
expect(circle.getAttribute('cx')).to.exist
|
||||
expect(circle.getAttribute('cy')).to.exist
|
||||
expect(circle.getAttribute('r')).to.equal('3')
|
||||
expect(circle.getAttribute('fill')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
it('should position data points with valid coordinates', () => {
|
||||
const data = createMockChartData(3)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
const circles = container.querySelectorAll('circle')
|
||||
circles.forEach((circle) => {
|
||||
const cx = parseFloat(circle.getAttribute('cx') || '0')
|
||||
const cy = parseFloat(circle.getAttribute('cy') || '0')
|
||||
|
||||
// Coordinates should be valid numbers
|
||||
expect(cx).to.be.a('number')
|
||||
expect(cy).to.be.a('number')
|
||||
expect(isNaN(cx)).to.be.false
|
||||
expect(isNaN(cy)).to.be.false
|
||||
|
||||
// Coordinates should be within chart bounds (positive values)
|
||||
expect(cx).to.be.greaterThan(0)
|
||||
expect(cy).to.be.greaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render line connecting data points', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
// Line series should create a path element
|
||||
const paths = container.querySelectorAll('path')
|
||||
expect(paths.length).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle single data point', () => {
|
||||
const data = [{ x: Date.now(), y: 50 }]
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
const circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).to.equal(1, 'Single data point should render as one circle')
|
||||
})
|
||||
|
||||
it('should handle large datasets', () => {
|
||||
const data = createMockChartData(100)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
const circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).to.equal(100, '100 data points should render as 100 circles')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Curve Interpolation', () => {
|
||||
const curveTypes: PlotCurveTypes[] = ['curve', 'linear', 'cubic_basis_spline', 'step_after', 'step_before']
|
||||
|
||||
curveTypes.forEach((interpolation) => {
|
||||
it(`should render with ${interpolation} interpolation`, () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(
|
||||
<Chart data={data} interpolation={interpolation} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
const paths = container.querySelectorAll('path')
|
||||
expect(paths.length).to.be.greaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Styling', () => {
|
||||
it('should apply custom color', () => {
|
||||
const data = createMockChartData(5)
|
||||
const customColor = '#ff0000'
|
||||
const { container } = renderWithProviders(
|
||||
<Chart data={data} color={customColor} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Check if custom color is applied to line or glyphs
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).to.exist
|
||||
})
|
||||
|
||||
it('should use theme colors when no custom color provided', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Custom Domains and Ranges', () => {
|
||||
it('should render with custom Y range', () => {
|
||||
const data = createMockChartData(5)
|
||||
const range: [number, number] = [0, 100]
|
||||
const { container } = renderWithProviders(
|
||||
<Chart data={data} range={range} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
it('should render with custom time range', () => {
|
||||
const data = createMockChartData(5)
|
||||
const timeRangeStart = 60000 // 1 minute
|
||||
const { container } = renderWithProviders(
|
||||
<Chart data={data} timeRangeStart={timeRangeStart} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
it('should render with partial Y range (only min)', () => {
|
||||
const data = createMockChartData(5)
|
||||
const range: [number?, number?] = [0, undefined]
|
||||
const { container } = renderWithProviders(
|
||||
<Chart data={data} range={range} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
it('should render with partial Y range (only max)', () => {
|
||||
const data = createMockChartData(5)
|
||||
const range: [number?, number?] = [undefined, 100]
|
||||
const { container } = renderWithProviders(
|
||||
<Chart data={data} range={range} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chart Components', () => {
|
||||
it('should render Y-axis', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
// Y-axis should be present (look for axis group or tick marks)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).to.exist
|
||||
|
||||
// Axis typically contains text elements for labels
|
||||
const texts = container.querySelectorAll('text')
|
||||
expect(texts.length).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
it('should render X-axis with time labels', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
// X-axis should be present with text labels
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).to.exist
|
||||
|
||||
// X-axis has text labels for timestamps
|
||||
const texts = container.querySelectorAll('text')
|
||||
expect(texts.length).to.be.greaterThan(0, 'X-axis and Y-axis should have text labels')
|
||||
|
||||
// At least one text element should contain time format (e.g., contains ":")
|
||||
let hasTimeFormat = false
|
||||
texts.forEach((text) => {
|
||||
if (text.textContent && text.textContent.includes(':')) {
|
||||
hasTimeFormat = true
|
||||
}
|
||||
})
|
||||
expect(hasTimeFormat).to.be.true
|
||||
})
|
||||
|
||||
it('should render both X and Y axes', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).to.exist
|
||||
|
||||
// Both axes should render tick marks (lines)
|
||||
const lines = container.querySelectorAll('line')
|
||||
expect(lines.length).to.be.greaterThan(0, 'Axes should render tick marks')
|
||||
|
||||
// Both axes should have labels (text)
|
||||
const texts = container.querySelectorAll('text')
|
||||
expect(texts.length).to.be.greaterThan(2, 'Both axes should have multiple labels')
|
||||
})
|
||||
|
||||
it('should render grid lines', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
// Grid lines are rendered as line elements
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).to.exist
|
||||
})
|
||||
|
||||
it('should have proper chart margins', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).to.exist
|
||||
|
||||
// SVG should have proper dimensions
|
||||
expect(svg?.getAttribute('width')).to.exist
|
||||
expect(svg?.getAttribute('height')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle negative values', () => {
|
||||
const data = [
|
||||
{ x: Date.now() - 2000, y: -50 },
|
||||
{ x: Date.now() - 1000, y: -25 },
|
||||
{ x: Date.now(), y: -75 },
|
||||
]
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const data = [
|
||||
{ x: Date.now() - 2000, y: 0 },
|
||||
{ x: Date.now() - 1000, y: 0 },
|
||||
{ x: Date.now(), y: 0 },
|
||||
]
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
const data = [
|
||||
{ x: Date.now() - 2000, y: 1000000 },
|
||||
{ x: Date.now() - 1000, y: 2000000 },
|
||||
{ x: Date.now(), y: 3000000 },
|
||||
]
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
// Y-axis should abbreviate large numbers
|
||||
const texts = container.querySelectorAll('text')
|
||||
expect(texts.length).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle identical values', () => {
|
||||
const data = [
|
||||
{ x: Date.now() - 2000, y: 50 },
|
||||
{ x: Date.now() - 1000, y: 50 },
|
||||
{ x: Date.now(), y: 50 },
|
||||
]
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Props', () => {
|
||||
it('should accept all valid props without errors', () => {
|
||||
const data = createMockChartData(5)
|
||||
const props: ChartProps = {
|
||||
data,
|
||||
interpolation: 'curve',
|
||||
range: [0, 100],
|
||||
timeRangeStart: 60000,
|
||||
color: '#00ff00',
|
||||
}
|
||||
|
||||
const { container } = renderWithProviders(<Chart {...props} />, { withTheme: true })
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
it('should work with minimal props', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Theme Integration', () => {
|
||||
it('should render in light theme', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
// Note: Testing dark theme would require a custom theme provider
|
||||
// This demonstrates how the test structure supports theme variations
|
||||
})
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should memoize component with same props', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { rerender } = renderWithProviders(<Chart data={data} />, { withTheme: true })
|
||||
|
||||
// Component should not re-render with same props due to React.memo
|
||||
expect(() => {
|
||||
rerender(<Chart data={data} />)
|
||||
}).to.not.throw()
|
||||
})
|
||||
|
||||
it('should handle rapid data updates', () => {
|
||||
const { rerender, container } = renderWithProviders(
|
||||
<Chart data={createMockChartData(5)} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Simulate rapid updates
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rerender(<Chart data={createMockChartData(5)} />)
|
||||
}
|
||||
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactive Data Updates', () => {
|
||||
it('should dynamically update when data points are added', () => {
|
||||
// Start with 3 data points
|
||||
const initialData = createMockChartData(3)
|
||||
const { rerender, container } = renderWithProviders(
|
||||
<Chart data={initialData} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify initial state: should have 3 data points
|
||||
const initialCircles = container.querySelectorAll('circle')
|
||||
expect(initialCircles.length).to.equal(3, 'Should initially render 3 data points')
|
||||
|
||||
// Verify each initial circle has valid attributes
|
||||
initialCircles.forEach((circle, index) => {
|
||||
const cx = circle.getAttribute('cx')
|
||||
const cy = circle.getAttribute('cy')
|
||||
const r = circle.getAttribute('r')
|
||||
|
||||
expect(cx).to.exist
|
||||
expect(cy).to.exist
|
||||
expect(r).to.equal('3')
|
||||
expect(parseFloat(cx!)).to.be.a('number').and.not.NaN
|
||||
expect(parseFloat(cy!)).to.be.a('number').and.not.NaN
|
||||
})
|
||||
|
||||
// Update state: add 2 more data points (total 5)
|
||||
const updatedData = createMockChartData(5)
|
||||
rerender(<Chart data={updatedData} />)
|
||||
|
||||
// Verify updated state: should now have 5 data points
|
||||
const updatedCircles = container.querySelectorAll('circle')
|
||||
expect(updatedCircles.length).to.equal(5, 'Should render 5 data points after update')
|
||||
|
||||
// Verify each updated circle has valid attributes
|
||||
updatedCircles.forEach((circle, index) => {
|
||||
const cx = circle.getAttribute('cx')
|
||||
const cy = circle.getAttribute('cy')
|
||||
const r = circle.getAttribute('r')
|
||||
const fill = circle.getAttribute('fill')
|
||||
|
||||
expect(cx).to.exist
|
||||
expect(cy).to.exist
|
||||
expect(r).to.equal('3')
|
||||
expect(fill).to.exist
|
||||
expect(parseFloat(cx!)).to.be.a('number').and.not.NaN
|
||||
expect(parseFloat(cy!)).to.be.a('number').and.not.NaN
|
||||
expect(parseFloat(cy!)).to.be.greaterThan(0, 'Y coordinate should be positive')
|
||||
})
|
||||
|
||||
// Verify the line path is updated to connect all 5 points
|
||||
const linePath = container.querySelector('path[stroke]')
|
||||
expect(linePath).to.exist
|
||||
expect(linePath!.getAttribute('d')).to.exist
|
||||
|
||||
// The path should start with MoveTo (M) command and contain curve/line commands
|
||||
const pathData = linePath!.getAttribute('d')
|
||||
expect(pathData).to.include('M') // MoveTo command for first point
|
||||
// Path may contain 'L' (line) or 'C' (curve) commands depending on interpolation
|
||||
expect(pathData!.length).to.be.greaterThan(10, 'Path should have substantial data for 5 points')
|
||||
})
|
||||
|
||||
it('should handle data point removal', () => {
|
||||
// Start with 5 data points
|
||||
const initialData = createMockChartData(5)
|
||||
const { rerender, container } = renderWithProviders(
|
||||
<Chart data={initialData} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify initial state
|
||||
let circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).to.equal(5, 'Should initially render 5 data points')
|
||||
|
||||
// Remove 2 data points (now 3)
|
||||
const reducedData = createMockChartData(3)
|
||||
rerender(<Chart data={reducedData} />)
|
||||
|
||||
// Verify reduced state
|
||||
circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).to.equal(3, 'Should render 3 data points after removal')
|
||||
})
|
||||
|
||||
it('should maintain chart structure during data updates', () => {
|
||||
const initialData = createMockChartData(3)
|
||||
const { rerender, container } = renderWithProviders(
|
||||
<Chart data={initialData} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify chart structure exists initially
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
expect(container.querySelectorAll('line').length).to.be.greaterThan(0, 'Should have axis/grid lines')
|
||||
expect(container.querySelectorAll('text').length).to.be.greaterThan(0, 'Should have axis labels')
|
||||
|
||||
// Update data
|
||||
const updatedData = createMockChartData(5)
|
||||
rerender(<Chart data={updatedData} />)
|
||||
|
||||
// Verify chart structure is maintained after update
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
expect(container.querySelectorAll('line').length).to.be.greaterThan(0, 'Should still have axis/grid lines')
|
||||
expect(container.querySelectorAll('text').length).to.be.greaterThan(0, 'Should still have axis labels')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
import '../../react-vis-compat' // React 19 compatibility shim for react-vis
|
||||
import DateFormatter from '../helper/DateFormatter'
|
||||
import NoData from './NoData'
|
||||
import NumberFormatter from '../helper/NumberFormatter'
|
||||
import React, { memo, useCallback, useRef, useEffect } from 'react'
|
||||
import React, { memo, useCallback, useMemo } from 'react'
|
||||
import TooltipComponent from './TooltipComponent'
|
||||
import { useResizeDetector } from 'react-resize-detector'
|
||||
import { emphasize, useTheme } from '@mui/material/styles'
|
||||
@@ -11,8 +10,7 @@ import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
import { Point, Tooltip } from './Model'
|
||||
import { useCustomXDomain } from './effects/useCustomXDomain'
|
||||
import { useCustomYDomain } from './effects/useCustomYDomain'
|
||||
import 'react-vis/dist/style.css'
|
||||
const { XYPlot, LineMarkSeries, YAxis, HorizontalGridLines, Hint } = require('react-vis')
|
||||
import { XYChart, Axis, Grid, LineSeries, GlyphSeries } from '@visx/xychart'
|
||||
const abbreviate = require('number-abbreviate')
|
||||
|
||||
export interface Props {
|
||||
@@ -23,10 +21,14 @@ export interface Props {
|
||||
color?: string
|
||||
}
|
||||
|
||||
const CHART_HEIGHT = 150
|
||||
|
||||
export default memo((props: Props) => {
|
||||
const theme = useTheme()
|
||||
const [tooltip, setTooltip] = React.useState<Tooltip | undefined>()
|
||||
const [hoveredPoint, setHoveredPoint] = React.useState<Point | undefined>()
|
||||
const { width = 300, ref } = useResizeDetector()
|
||||
const chartContainerRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const hintFormatter = React.useCallback(
|
||||
(point: any) => [
|
||||
@@ -39,14 +41,19 @@ export default memo((props: Props) => {
|
||||
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
setTooltip(undefined)
|
||||
setHoveredPoint(undefined)
|
||||
}, [])
|
||||
|
||||
const showTooltip = React.useCallback((point: Point, something: { event: MouseEvent }) => {
|
||||
if (!something) {
|
||||
return
|
||||
}
|
||||
setTooltip({ point, value: hintFormatter(point), element: something.event.target as any })
|
||||
}, [])
|
||||
const showTooltip = React.useCallback(
|
||||
(point: Point) => {
|
||||
if (!chartContainerRef.current) {
|
||||
return
|
||||
}
|
||||
setHoveredPoint(point)
|
||||
setTooltip({ point, value: hintFormatter(point), element: chartContainerRef.current })
|
||||
},
|
||||
[hintFormatter]
|
||||
)
|
||||
|
||||
const paletteColor =
|
||||
theme.palette.mode === 'light' ? theme.palette.secondary.dark : theme.palette.primary.light
|
||||
@@ -54,47 +61,105 @@ export default memo((props: Props) => {
|
||||
|
||||
const highlightSelectedPoint = useCallback(
|
||||
(point: Point) => {
|
||||
const highlight = tooltip && tooltip.point.x === point.x && tooltip.point.y === point.y
|
||||
const highlight = hoveredPoint && hoveredPoint.x === point.x && hoveredPoint.y === point.y
|
||||
return highlight ? emphasize(color, 0.8) : color
|
||||
},
|
||||
[tooltip, color]
|
||||
[hoveredPoint, color]
|
||||
)
|
||||
|
||||
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
|
||||
|
||||
const formatXAxis = useCallback((timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}:${seconds}`
|
||||
}, [])
|
||||
|
||||
const xDomain = useCustomXDomain(props)
|
||||
const yDomain = useCustomYDomain(props)
|
||||
|
||||
const data = props.data
|
||||
const hasData = data.length > 0
|
||||
const dummyDomain = [-1, 1]
|
||||
const dummyDomain: [number, number] = [-1, 1]
|
||||
const dummyData = [{ x: -2, y: -2 }]
|
||||
|
||||
const accessors = useMemo(
|
||||
() => ({
|
||||
xAccessor: (d: Point) => d.x,
|
||||
yAccessor: (d: Point) => d.y,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={ref} style={{ height: '150px', width: '100%', position: 'relative' }}>
|
||||
<div ref={ref} style={{ height: `${CHART_HEIGHT}px`, width: '100%', position: 'relative' }}>
|
||||
{data.length === 0 ? <NoData /> : null}
|
||||
<XYPlot
|
||||
width={width || 300}
|
||||
height={180}
|
||||
yDomain={hasData ? yDomain : dummyDomain}
|
||||
xDomain={hasData ? xDomain : dummyDomain}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<HorizontalGridLines />
|
||||
<YAxis width={45} tickFormat={formatYAxis} />
|
||||
<LineMarkSeries
|
||||
color={color}
|
||||
colorType="literal"
|
||||
getColor={highlightSelectedPoint}
|
||||
onValueMouseOver={showTooltip}
|
||||
size={3}
|
||||
data={hasData ? data : dummyData}
|
||||
curve={mapCurveType(props.interpolation)}
|
||||
/>
|
||||
<Hint value={{ x: 0, y: 0 }} style={{ pointerEvents: 'none' }}>
|
||||
<TooltipComponent tooltip={tooltip} />
|
||||
</Hint>
|
||||
</XYPlot>
|
||||
<div ref={chartContainerRef}>
|
||||
<XYChart
|
||||
width={width || 300}
|
||||
height={CHART_HEIGHT}
|
||||
margin={{ top: 10, right: 10, bottom: 30, left: 50 }}
|
||||
xScale={{ type: 'time', domain: xDomain || dummyDomain }}
|
||||
yScale={{ type: 'linear', domain: hasData ? yDomain : dummyDomain }}
|
||||
onPointerOut={onMouseLeave}
|
||||
>
|
||||
<Grid rows={true} columns={false} stroke={theme.palette.divider} strokeOpacity={0.3} />
|
||||
<Axis
|
||||
orientation="left"
|
||||
numTicks={5}
|
||||
tickFormat={formatYAxis}
|
||||
stroke={theme.palette.text.secondary}
|
||||
tickStroke={theme.palette.text.secondary}
|
||||
tickLabelProps={() => ({ fontSize: 11, fill: theme.palette.text.secondary })}
|
||||
/>
|
||||
<Axis
|
||||
orientation="bottom"
|
||||
numTicks={4}
|
||||
tickFormat={formatXAxis}
|
||||
stroke={theme.palette.text.secondary}
|
||||
tickStroke={theme.palette.text.secondary}
|
||||
tickLabelProps={() => ({ fontSize: 10, fill: theme.palette.text.secondary, textAnchor: 'middle' })}
|
||||
/>
|
||||
<LineSeries
|
||||
dataKey="line"
|
||||
data={hasData ? data : dummyData}
|
||||
xAccessor={accessors.xAccessor}
|
||||
yAccessor={accessors.yAccessor}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
curve={mapCurveType(props.interpolation)}
|
||||
onPointerMove={(datum) => {
|
||||
if (datum && datum.datum) {
|
||||
const point = datum.datum as Point
|
||||
showTooltip(point)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<GlyphSeries
|
||||
dataKey="points"
|
||||
data={hasData ? data : dummyData}
|
||||
xAccessor={accessors.xAccessor}
|
||||
yAccessor={accessors.yAccessor}
|
||||
renderGlyph={(glyphProps) => {
|
||||
const point = glyphProps.datum as Point
|
||||
const pointColor = highlightSelectedPoint(point)
|
||||
return (
|
||||
<circle
|
||||
cx={glyphProps.x}
|
||||
cy={glyphProps.y}
|
||||
r={3}
|
||||
fill={pointColor}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</XYChart>
|
||||
</div>
|
||||
{/* Custom tooltip outside of visx to maintain exact same appearance */}
|
||||
<TooltipComponent tooltip={tooltip} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,22 @@ import { Props } from '../Chart'
|
||||
|
||||
export function useCustomXDomain(props: Props): [number, number] | undefined {
|
||||
return useMemo(() => {
|
||||
if (props.data.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lastDataPoint = [...props.data].sort((a, b) => b.x - a.x)[0]
|
||||
const lastDataDate = lastDataPoint ? lastDataPoint.x : Date.now()
|
||||
return props.timeRangeStart ? [Date.now() - props.timeRangeStart, lastDataDate] : undefined
|
||||
|
||||
if (props.timeRangeStart) {
|
||||
// Custom time range mode
|
||||
return [Date.now() - props.timeRangeStart, lastDataDate]
|
||||
} else {
|
||||
// Auto-calculate from data (like react-vis did)
|
||||
const xValues = props.data.map(d => d.x)
|
||||
const minX = Math.min(...xValues)
|
||||
const maxX = Math.max(...xValues)
|
||||
return [minX, maxX]
|
||||
}
|
||||
}, [props.data, props.timeRangeStart])
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
import * as d3Shape from 'd3-shape'
|
||||
|
||||
export function mapCurveType(type: PlotCurveTypes | undefined) {
|
||||
switch (type) {
|
||||
case 'curve':
|
||||
return 'curveMonotoneX'
|
||||
return d3Shape.curveMonotoneX
|
||||
case 'linear':
|
||||
return 'curveLinear'
|
||||
return d3Shape.curveLinear
|
||||
case 'cubic_basis_spline':
|
||||
return 'curveBasis'
|
||||
return d3Shape.curveBasis
|
||||
case 'step_after':
|
||||
return 'curveStepAfter'
|
||||
return d3Shape.curveStepAfter
|
||||
case 'step_before':
|
||||
return 'curveStepBefore'
|
||||
return d3Shape.curveStepBefore
|
||||
default:
|
||||
return 'curveMonotoneX'
|
||||
return d3Shape.curveMonotoneX
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user