M5-T07: add latest-reading cards and Recharts trend charts; readings return most-recent rows
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user