Replace react-vis with visx, add component testing infrastructure, and update Electron packages (#959)

This commit is contained in:
Copilot
2025-12-23 21:45:33 +01:00
committed by GitHub
parent d4dbc36a8a
commit 6e355decbf
13 changed files with 2306 additions and 512 deletions

309
app/TESTING.md Normal file
View 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'
```

View File

@@ -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",

View 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')
})
})

View 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')
})
})
})

View File

@@ -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>
)

View File

@@ -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])
}

View File

@@ -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
}
}

View File

@@ -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}>

View File

@@ -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,
}
}
}
}

View 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'

File diff suppressed because it is too large Load Diff