Replace react-vis with visx, add component testing infrastructure, and update Electron packages (#959)
This commit is contained in:
309
app/TESTING.md
Normal file
309
app/TESTING.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# React Component Testing Guide
|
||||
|
||||
This guide explains how to write tests for React components in the MQTT-Explorer project using the generic testing utilities.
|
||||
|
||||
## Overview
|
||||
|
||||
We use the following testing stack:
|
||||
- **Mocha** - Test framework
|
||||
- **Chai** - Assertion library
|
||||
- **React Testing Library** - React component testing utilities
|
||||
- **JSDOM** - DOM implementation for Node.js
|
||||
- **Custom Test Utilities** - Located in `src/utils/spec/testUtils.tsx`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a Test File
|
||||
|
||||
Test files should be placed next to the component they test with the `.spec.tsx` extension:
|
||||
|
||||
```
|
||||
src/components/MyComponent/
|
||||
├── MyComponent.tsx
|
||||
└── MyComponent.spec.tsx
|
||||
```
|
||||
|
||||
### 2. Basic Test Structure
|
||||
|
||||
```typescript
|
||||
import React from 'react'
|
||||
import { expect } from 'chai'
|
||||
import { describe, it } from 'mocha'
|
||||
import MyComponent from './MyComponent'
|
||||
import { renderWithProviders } from '../../utils/spec/testUtils'
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('should render correctly', () => {
|
||||
const { container } = renderWithProviders(<MyComponent />)
|
||||
expect(container).to.exist
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Using Test Utilities
|
||||
|
||||
### `renderWithProviders`
|
||||
|
||||
This function wraps your component with necessary providers (Theme, Redux).
|
||||
|
||||
```typescript
|
||||
import { renderWithProviders } from '../../utils/spec/testUtils'
|
||||
|
||||
// Render with theme only (default)
|
||||
const { container } = renderWithProviders(<MyComponent />, { withTheme: true })
|
||||
|
||||
// Render with both theme and Redux
|
||||
const { container } = renderWithProviders(<MyComponent />, {
|
||||
withTheme: true,
|
||||
withRedux: true
|
||||
})
|
||||
|
||||
// Render with custom theme
|
||||
import { createTheme } from '@mui/material/styles'
|
||||
const darkTheme = createTheme({ palette: { mode: 'dark' } })
|
||||
const { container } = renderWithProviders(<MyComponent />, {
|
||||
theme: darkTheme
|
||||
})
|
||||
|
||||
// Render with custom Redux store
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
const customStore = configureStore({ /* ... */ })
|
||||
const { container } = renderWithProviders(<MyComponent />, {
|
||||
store: customStore,
|
||||
withRedux: true
|
||||
})
|
||||
```
|
||||
|
||||
### `createMockChartData`
|
||||
|
||||
Helper function to generate mock chart data:
|
||||
|
||||
```typescript
|
||||
import { createMockChartData } from '../../utils/spec/testUtils'
|
||||
|
||||
// Create 10 data points (default)
|
||||
const data = createMockChartData()
|
||||
|
||||
// Create specific number of points
|
||||
const data = createMockChartData(50)
|
||||
```
|
||||
|
||||
### Global Mocks
|
||||
|
||||
The test utilities automatically set up global mocks:
|
||||
- **ResizeObserver** - Mocked for components using `react-resize-detector`
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### Testing Rendering
|
||||
|
||||
```typescript
|
||||
it('should render without crashing', () => {
|
||||
const { container } = renderWithProviders(<MyComponent />)
|
||||
expect(container).to.exist
|
||||
})
|
||||
|
||||
it('should render specific element', () => {
|
||||
const { container } = renderWithProviders(<MyComponent />)
|
||||
const element = container.querySelector('.my-class')
|
||||
expect(element).to.exist
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Props
|
||||
|
||||
```typescript
|
||||
it('should accept all valid props', () => {
|
||||
const props = {
|
||||
title: 'Test',
|
||||
value: 123,
|
||||
onchange: () => {},
|
||||
}
|
||||
const { container } = renderWithProviders(<MyComponent {...props} />)
|
||||
expect(container).to.exist
|
||||
})
|
||||
```
|
||||
|
||||
### Testing User Interactions
|
||||
|
||||
```typescript
|
||||
import { userEvent } from '../../utils/spec/testUtils'
|
||||
|
||||
it('should handle click events', async () => {
|
||||
const { container } = renderWithProviders(<MyComponent />)
|
||||
const button = container.querySelector('button')
|
||||
|
||||
if (button) {
|
||||
await userEvent.click(button)
|
||||
// Assert expected behavior
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Different States
|
||||
|
||||
```typescript
|
||||
it('should render empty state', () => {
|
||||
const { container } = renderWithProviders(<MyComponent data={[]} />)
|
||||
// Assert empty state
|
||||
})
|
||||
|
||||
it('should render with data', () => {
|
||||
const data = createMockChartData(5)
|
||||
const { container } = renderWithProviders(<MyComponent data={data} />)
|
||||
// Assert data is rendered
|
||||
})
|
||||
```
|
||||
|
||||
### Testing SVG Components
|
||||
|
||||
```typescript
|
||||
it('should render SVG elements', () => {
|
||||
const { container } = renderWithProviders(<ChartComponent />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).to.exist
|
||||
|
||||
const paths = container.querySelectorAll('path')
|
||||
expect(paths.length).to.be.greaterThan(0)
|
||||
|
||||
const circles = container.querySelectorAll('circle')
|
||||
expect(circles.length).to.equal(5)
|
||||
})
|
||||
```
|
||||
|
||||
### Testing Edge Cases
|
||||
|
||||
```typescript
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle negative values', () => {
|
||||
const data = [{ x: 1, y: -10 }, { x: 2, y: -20 }]
|
||||
const { container } = renderWithProviders(<MyComponent data={data} />)
|
||||
expect(container.querySelector('svg')).to.exist
|
||||
})
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const { container } = renderWithProviders(<MyComponent data={[]} />)
|
||||
expect(container).to.exist
|
||||
})
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
const data = [{ x: 1, y: 1000000 }]
|
||||
const { container } = renderWithProviders(<MyComponent data={data} />)
|
||||
expect(container).to.exist
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all tests
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
|
||||
### Run specific test file
|
||||
```bash
|
||||
npx mocha --require tsx --require source-map-support/register "src/components/MyComponent/MyComponent.spec.tsx"
|
||||
```
|
||||
|
||||
### Run tests in watch mode
|
||||
```bash
|
||||
npx mocha --require tsx --require source-map-support/register --watch "src/**/*.spec.{ts,tsx}"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test Behavior, Not Implementation**
|
||||
- Focus on what the component does, not how it does it
|
||||
- Test user-facing behavior and output
|
||||
|
||||
2. **Use Descriptive Test Names**
|
||||
```typescript
|
||||
// Good
|
||||
it('should render error message when validation fails')
|
||||
|
||||
// Bad
|
||||
it('test1')
|
||||
```
|
||||
|
||||
3. **Group Related Tests**
|
||||
```typescript
|
||||
describe('MyComponent', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render correctly')
|
||||
it('should render with props')
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should handle clicks')
|
||||
it('should handle keyboard input')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
4. **Keep Tests Independent**
|
||||
- Each test should be able to run in isolation
|
||||
- Don't rely on test execution order
|
||||
- Clean up after each test if needed
|
||||
|
||||
5. **Test Edge Cases**
|
||||
- Empty data
|
||||
- Null/undefined values
|
||||
- Very large/small numbers
|
||||
- Negative values
|
||||
- Single item arrays
|
||||
|
||||
6. **Use Chai Assertions**
|
||||
```typescript
|
||||
expect(value).to.exist
|
||||
expect(value).to.be.true
|
||||
expect(value).to.equal(expected)
|
||||
expect(array).to.have.length(5)
|
||||
expect(number).to.be.greaterThan(0)
|
||||
```
|
||||
|
||||
## Example: Complete Test Suite
|
||||
|
||||
See `src/components/Chart/Chart.spec.tsx` for a comprehensive example that demonstrates:
|
||||
- Multiple test groups (Rendering, Data Visualization, Edge Cases, etc.)
|
||||
- Testing with different props and configurations
|
||||
- Testing SVG elements
|
||||
- Testing theme integration
|
||||
- Performance testing
|
||||
- Edge case coverage
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "ResizeObserver is not defined"
|
||||
This is automatically mocked by the test utilities. Make sure you're importing from `testUtils.tsx`.
|
||||
|
||||
### "Cannot find module"
|
||||
Check your import paths. Remember to use relative paths from the test file.
|
||||
|
||||
### "Window is not defined"
|
||||
Make sure `jsdom-global/register` is imported in testUtils.tsx.
|
||||
|
||||
### Tests timing out
|
||||
Increase the timeout in your test:
|
||||
```typescript
|
||||
it('should complete async operation', function() {
|
||||
this.timeout(5000) // 5 seconds
|
||||
// test code
|
||||
})
|
||||
```
|
||||
|
||||
## Adding New Test Utilities
|
||||
|
||||
To add new helper functions, update `src/utils/spec/testUtils.tsx`:
|
||||
|
||||
```typescript
|
||||
export function myNewHelper() {
|
||||
// Helper implementation
|
||||
}
|
||||
```
|
||||
|
||||
Then use it in your tests:
|
||||
```typescript
|
||||
import { myNewHelper } from '../../utils/spec/testUtils'
|
||||
```
|
||||
@@ -6,8 +6,8 @@
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"dev": "node_modules/.bin/webpack-dev-server --mode development --progress",
|
||||
"test": "mocha --require tsx --require source-map-support/register --recursive src/*/**/*.spec.ts",
|
||||
"mochatest": "mocha --require tsx --require source-map-support/register --recursive src/*/**/*.spec.ts"
|
||||
"test": "mocha --require tsx --require source-map-support/register --recursive 'src/*/**/*.spec.{ts,tsx}'",
|
||||
"mochatest": "mocha --require tsx --require source-map-support/register --recursive 'src/*/**/*.spec.{ts,tsx}'"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -21,7 +21,12 @@
|
||||
"@mui/lab": "^7.0.1-beta.20",
|
||||
"@mui/material": "^7.3.6",
|
||||
"@mui/styles": "^6.4.8",
|
||||
"@react-spring/web": "^9.7.5",
|
||||
"@types/react-transition-group": "^4.4.11",
|
||||
"@visx/axis": "^3.10.1",
|
||||
"@visx/grid": "^3.5.0",
|
||||
"@visx/tooltip": "^3.3.0",
|
||||
"@visx/xychart": "^3.10.2",
|
||||
"ace-builds": "^1.4.11",
|
||||
"axios": "^1.13.2",
|
||||
"compare-versions": "^6.1.1",
|
||||
@@ -51,7 +56,6 @@
|
||||
"react-resize-detector": "^11.0.1",
|
||||
"react-split-pane": "^0.1.92",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-vis": "^1.12.1",
|
||||
"redux": "^5.0.1",
|
||||
"redux-batched-actions": "^0.5.0",
|
||||
"redux-thunk": "^3.1.0",
|
||||
@@ -62,6 +66,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@reduxjs/toolkit": "2.5.0",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/react": "16.1.0",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/diff": "^7.0.0",
|
||||
"@types/get-value": "^3.0.5",
|
||||
@@ -81,6 +89,8 @@
|
||||
"css-loader": "^7.1.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"jsdom": "25.0.1",
|
||||
"jsdom-global": "3.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"mocha": "^10.8.2",
|
||||
"moment": "^2.30.1",
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,15 @@ function mapWidth(width: 'big' | 'medium' | 'small' | undefined, calculatedSpaci
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper function to generate unique keys for charts
|
||||
const getChartKey = (chart: ChartParameters) => `${chart.topic}-${chart.dotPath || ''}`
|
||||
|
||||
function ChartPanel(props: Props) {
|
||||
const chartsInView = props.charts.count()
|
||||
|
||||
const [spacing, setSpacing] = React.useState(spacingForChartCount(chartsInView))
|
||||
const nodeRefsMap = React.useRef<Map<string, React.RefObject<HTMLDivElement>>>(new Map())
|
||||
|
||||
React.useEffect(() => {
|
||||
props.actions.chart.loadCharts()
|
||||
@@ -65,17 +70,42 @@ function ChartPanel(props: Props) {
|
||||
}
|
||||
}, [chartsInView])
|
||||
|
||||
const charts = props.charts.map(chartParameters => (
|
||||
<CSSTransition
|
||||
key={`${chartParameters.topic}-${chartParameters.dotPath || ''}`}
|
||||
timeout={{ enter: 500, exit: 500 }}
|
||||
classNames="example"
|
||||
>
|
||||
<Grid item xs={mapWidth(chartParameters.width, spacing)}>
|
||||
<ChartWithTreeNode tree={props.tree} parameters={chartParameters} />
|
||||
</Grid>
|
||||
</CSSTransition>
|
||||
))
|
||||
// Clean up refs for removed charts
|
||||
React.useEffect(() => {
|
||||
const currentKeys = new Set(props.charts.map(getChartKey).toArray())
|
||||
const refsToDelete: string[] = []
|
||||
|
||||
nodeRefsMap.current.forEach((_, key) => {
|
||||
if (!currentKeys.has(key)) {
|
||||
refsToDelete.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
refsToDelete.forEach(key => nodeRefsMap.current.delete(key))
|
||||
}, [props.charts])
|
||||
|
||||
const charts = props.charts.map(chartParameters => {
|
||||
const key = getChartKey(chartParameters)
|
||||
|
||||
// Get or create a ref for this specific chart
|
||||
if (!nodeRefsMap.current.has(key)) {
|
||||
nodeRefsMap.current.set(key, React.createRef<HTMLDivElement>())
|
||||
}
|
||||
const nodeRef = nodeRefsMap.current.get(key)!
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={key}
|
||||
timeout={{ enter: 500, exit: 500 }}
|
||||
classNames="example"
|
||||
nodeRef={nodeRef}
|
||||
>
|
||||
<Grid item xs={mapWidth(chartParameters.width, spacing)} ref={nodeRef}>
|
||||
<ChartWithTreeNode tree={props.tree} parameters={chartParameters} />
|
||||
</Grid>
|
||||
</CSSTransition>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={props.classes.container}>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* React 19 compatibility shim for react-vis
|
||||
*
|
||||
* react-vis uses React internals that were removed in React 19.
|
||||
* This shim adds back the missing internals to maintain compatibility.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
// Add missing React internals that react-vis expects
|
||||
if (typeof React !== 'undefined') {
|
||||
const internals = (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
|
||||
|
||||
if (internals) {
|
||||
// ReactCurrentOwner was removed in React 19 but react-vis expects it
|
||||
if (!internals.ReactCurrentOwner) {
|
||||
internals.ReactCurrentOwner = {
|
||||
current: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ReactCurrentDispatcher compatibility
|
||||
if (!internals.ReactCurrentDispatcher) {
|
||||
internals.ReactCurrentDispatcher = {
|
||||
current: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
166
app/src/utils/spec/testUtils.tsx
Normal file
166
app/src/utils/spec/testUtils.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Generic test utilities for React component testing
|
||||
*
|
||||
* This file provides a reusable testing setup that can be used across all component tests.
|
||||
* It includes:
|
||||
* - Custom render function with theme and Redux providers
|
||||
* - Common test utilities and matchers
|
||||
* - Mock setup helpers
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render, RenderOptions, RenderResult } from '@testing-library/react'
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles'
|
||||
import { Provider } from 'react-redux'
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
|
||||
// Setup JSDOM environment for tests
|
||||
import 'jsdom-global/register'
|
||||
|
||||
// Setup global mocks
|
||||
mockResizeObserver()
|
||||
|
||||
/**
|
||||
* Helper to create a ResizeObserver mock
|
||||
* This is set up globally for all tests
|
||||
*/
|
||||
export function mockResizeObserver() {
|
||||
const MockResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as any
|
||||
|
||||
if (typeof global.ResizeObserver === 'undefined') {
|
||||
global.ResizeObserver = MockResizeObserver
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.ResizeObserver === 'undefined') {
|
||||
(window as any).ResizeObserver = MockResizeObserver
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default theme for testing
|
||||
*/
|
||||
const defaultTheme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
light: '#42a5f5',
|
||||
dark: '#1565c0',
|
||||
},
|
||||
secondary: {
|
||||
main: '#9c27b0',
|
||||
light: '#ba68c8',
|
||||
dark: '#7b1fa2',
|
||||
},
|
||||
text: {
|
||||
primary: 'rgba(0, 0, 0, 0.87)',
|
||||
secondary: 'rgba(0, 0, 0, 0.6)',
|
||||
},
|
||||
divider: 'rgba(0, 0, 0, 0.12)',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Default Redux store for testing
|
||||
*/
|
||||
const createTestStore = (initialState = {}) => {
|
||||
return configureStore({
|
||||
reducer: {
|
||||
// Add minimal reducers as needed
|
||||
charts: (state = { charts: [] }) => state,
|
||||
connection: (state = {}) => state,
|
||||
},
|
||||
preloadedState: initialState,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom render options
|
||||
*/
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
theme?: typeof defaultTheme
|
||||
store?: ReturnType<typeof createTestStore>
|
||||
withTheme?: boolean
|
||||
withRedux?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom render function that wraps components with necessary providers
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { renderWithProviders } from '../utils/spec/testUtils'
|
||||
*
|
||||
* describe('MyComponent', () => {
|
||||
* it('renders correctly', () => {
|
||||
* const { getByText } = renderWithProviders(<MyComponent />)
|
||||
* expect(getByText('Hello')).toBeDefined()
|
||||
* })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
{
|
||||
theme = defaultTheme,
|
||||
store = createTestStore(),
|
||||
withTheme = true,
|
||||
withRedux = false,
|
||||
...renderOptions
|
||||
}: CustomRenderOptions = {}
|
||||
): RenderResult {
|
||||
let Wrapper: React.FC<{ children: React.ReactNode }>
|
||||
|
||||
if (withRedux && withTheme) {
|
||||
Wrapper = ({ children }) => (
|
||||
<Provider store={store}>
|
||||
<ThemeProvider theme={theme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
)
|
||||
} else if (withRedux) {
|
||||
Wrapper = ({ children }) => (
|
||||
<Provider store={store}>
|
||||
{children}
|
||||
</Provider>
|
||||
)
|
||||
} else if (withTheme) {
|
||||
Wrapper = ({ children }) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
} else {
|
||||
Wrapper = ({ children }) => <>{children}</>
|
||||
}
|
||||
|
||||
return render(ui, { wrapper: Wrapper, ...renderOptions })
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create mock chart data
|
||||
*/
|
||||
export function createMockChartData(count: number = 10): Array<{ x: number; y: number }> {
|
||||
const now = Date.now()
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
x: now - (count - i) * 1000,
|
||||
y: Math.random() * 100,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to wait for async operations
|
||||
*/
|
||||
export const waitFor = async (ms: number = 100) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-export everything from @testing-library/react for convenience
|
||||
*/
|
||||
export * from '@testing-library/react'
|
||||
export { default as userEvent } from '@testing-library/user-event'
|
||||
1054
app/yarn.lock
1054
app/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user