257 lines
7.8 KiB
TypeScript
257 lines
7.8 KiB
TypeScript
/**
|
|||
|
|
* Tests for energy/EnergyCharts.tsx
|
||
|
|
*
|
||
|
|
* Coverage:
|
||
|
|
* 1. Renders chart title with device name.
|
||
|
|
* 2. Shows loading state while readings/metrics are loading.
|
||
|
|
* 3. Shows error state when either query fails.
|
||
|
|
* 4. Shows empty state when no readings are in the time window.
|
||
|
|
* 5. Renders chart container when data is available.
|
||
|
|
* 6. Tolerates missing keys in payload (no crash when key absent).
|
||
|
|
* 7. Time-range segmented control is rendered.
|
||
|
|
*
|
||
|
|
* NOTE: Recharts itself is NOT mocked — we let it render (jsdom-compatible
|
||
|
|
* rendering), but we only assert on data-testid wrappers, not Recharts internals.
|
||
|
|
* Recharts SVG rendering may produce warnings in jsdom; these are expected and
|
||
|
|
* benign (jsdom lacks ResizeObserver / SVG layout).
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
|
|
import { screen, waitFor } from '@testing-library/react'
|
||
|
|
import { renderWithProviders } from '../test-utils'
|
||
|
|
import { EnergyCharts } from './EnergyCharts'
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Fixtures
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const UUID = 'device-uuid-test'
|
||
|
|
const DEVICE_NAME = 'SDM120 Main'
|
||
|
|
|
||
|
|
const METRICS_RESP = {
|
||
|
|
profile: 'sdm120',
|
||
|
|
metrics: [
|
||
|
|
{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' },
|
||
|
|
{ key: 'current', label: 'Current', unit: 'A', device_class: 'current' },
|
||
|
|
],
|
||
|
|
}
|
||
|
|
|
||
|
|
const READING = {
|
||
|
|
recorded_at: '2026-06-22T10:00:00Z',
|
||
|
|
payload: { voltage: 230.2, current: 1.3 },
|
||
|
|
}
|
||
|
|
|
||
|
|
const READINGS_RESP = {
|
||
|
|
items: [READING],
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Mock apiClient
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
const mockGet = vi.fn()
|
||
|
|
|
||
|
|
vi.mock('../api/client', () => ({
|
||
|
|
default: {
|
||
|
|
GET: (...args: unknown[]) => mockGet(...args),
|
||
|
|
POST: vi.fn(),
|
||
|
|
PATCH: vi.fn(),
|
||
|
|
DELETE: vi.fn(),
|
||
|
|
},
|
||
|
|
ApiError: class ApiError extends Error {
|
||
|
|
status: number
|
||
|
|
body: unknown
|
||
|
|
constructor(status: number, body: unknown) {
|
||
|
|
super(`API error ${status}`)
|
||
|
|
this.name = 'ApiError'
|
||
|
|
this.status = status
|
||
|
|
this.body = body
|
||
|
|
}
|
||
|
|
},
|
||
|
|
registerLoginRedirect: vi.fn(),
|
||
|
|
}))
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function setupDefaultMocks() {
|
||
|
|
mockGet.mockImplementation((path: string) => {
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||
|
|
return Promise.resolve({ data: METRICS_RESP })
|
||
|
|
}
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||
|
|
return Promise.resolve({ data: READINGS_RESP })
|
||
|
|
}
|
||
|
|
return Promise.resolve({ data: null })
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderChart() {
|
||
|
|
return renderWithProviders(
|
||
|
|
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
||
|
|
{ initialPath: '/energy' },
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('EnergyCharts — rendering', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
setupDefaultMocks()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('renders chart title with device name', async () => {
|
||
|
|
renderChart()
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId(`energy-charts-title-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(screen.getByTestId(`energy-charts-title-${UUID}`).textContent).toBe(DEVICE_NAME)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('renders time-range segmented control', async () => {
|
||
|
|
renderChart()
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId(`energy-charts-preset-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows loading state while data is being fetched', () => {
|
||
|
|
// Never resolve
|
||
|
|
mockGet.mockReturnValue(new Promise(() => {}))
|
||
|
|
renderChart()
|
||
|
|
|
||
|
|
expect(screen.getByTestId(`energy-charts-loading-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows error state when readings query fails', async () => {
|
||
|
|
mockGet.mockRejectedValue(new Error('Network error'))
|
||
|
|
renderChart()
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId(`energy-charts-error-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('shows empty state when no readings in time window', async () => {
|
||
|
|
mockGet.mockImplementation((path: string) => {
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||
|
|
return Promise.resolve({ data: METRICS_RESP })
|
||
|
|
}
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||
|
|
return Promise.resolve({ data: { items: [] } })
|
||
|
|
}
|
||
|
|
return Promise.resolve({ data: null })
|
||
|
|
})
|
||
|
|
|
||
|
|
renderChart()
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId(`energy-charts-empty-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('renders chart container when data is available', async () => {
|
||
|
|
renderChart()
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('EnergyCharts — truncation notice', () => {
|
||
|
|
beforeEach(() => vi.clearAllMocks())
|
||
|
|
|
||
|
|
it('shows truncation notice when readings count equals the limit cap (1000)', async () => {
|
||
|
|
// Build exactly 1000 synthetic readings to trigger the truncation hint.
|
||
|
|
const truncatedItems = Array.from({ length: 1000 }, (_, i) => ({
|
||
|
|
recorded_at: new Date(Date.now() - (999 - i) * 5000).toISOString(),
|
||
|
|
payload: { voltage: 230 + i * 0.01 },
|
||
|
|
}))
|
||
|
|
|
||
|
|
mockGet.mockImplementation((path: string) => {
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||
|
|
return Promise.resolve({ data: METRICS_RESP })
|
||
|
|
}
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||
|
|
return Promise.resolve({ data: { items: truncatedItems } })
|
||
|
|
}
|
||
|
|
return Promise.resolve({ data: null })
|
||
|
|
})
|
||
|
|
|
||
|
|
renderChart()
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(
|
||
|
|
screen.getByTestId(`energy-charts-truncated-${UUID}`),
|
||
|
|
).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does NOT show truncation notice when readings count is below limit', async () => {
|
||
|
|
// Default mock has exactly 1 item — well below 1000.
|
||
|
|
setupDefaultMocks()
|
||
|
|
renderChart()
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId(`energy-charts-chart-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(screen.queryByTestId(`energy-charts-truncated-${UUID}`)).not.toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('EnergyCharts — payload key tolerance', () => {
|
||
|
|
beforeEach(() => vi.clearAllMocks())
|
||
|
|
|
||
|
|
it('does not crash when payload is missing a metric key', async () => {
|
||
|
|
mockGet.mockImplementation((path: string) => {
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||
|
|
return Promise.resolve({ data: METRICS_RESP })
|
||
|
|
}
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||
|
|
// payload is missing 'current' key
|
||
|
|
return Promise.resolve({
|
||
|
|
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return Promise.resolve({ data: null })
|
||
|
|
})
|
||
|
|
|
||
|
|
// Should render without throwing
|
||
|
|
expect(() => renderChart()).not.toThrow()
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
it('does not crash when payload is entirely empty', async () => {
|
||
|
|
mockGet.mockImplementation((path: string) => {
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
||
|
|
return Promise.resolve({ data: METRICS_RESP })
|
||
|
|
}
|
||
|
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
||
|
|
return Promise.resolve({
|
||
|
|
data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: {} }] },
|
||
|
|
})
|
||
|
|
}
|
||
|
|
return Promise.resolve({ data: null })
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(() => renderChart()).not.toThrow()
|
||
|
|
|
||
|
|
// All values null → empty state (no chart) or chart with no data points shown.
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId(`energy-charts-${UUID}`)).toBeInTheDocument()
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|