Files
home-automation/frontend/src/energy/EnergyCharts.test.tsx
T

257 lines
7.8 KiB
TypeScript
Raw Normal View History

/**
* 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()
})
})
})