376 lines
12 KiB
TypeScript
376 lines
12 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, afterEach } 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()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auto-refresh: rolling window (A2 — the core bug fix)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('EnergyCharts — auto-refresh rolling window', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
/**
|
|
* EnergyCharts now passes spanMs (not fixed start/end) to useReadings.
|
|
* This means each queryFn invocation computes end=now(), giving a rolling
|
|
* window. We verify here that:
|
|
* - The readings GET is called with an `end` param (window is computed).
|
|
* - Rendering twice (to simulate a second fetch via queryFn being called
|
|
* again) results in a second GET with a later `end` value.
|
|
*
|
|
* We test this by manually invoking the apiClient.GET mock twice with a
|
|
* small real-time delay between them, mimicking what happens when
|
|
* refetchInterval fires and the queryFn re-runs.
|
|
*/
|
|
it('uses spanMs-based rolling window so each queryFn call gets a fresh end=now()', async () => {
|
|
const endTimestamps: string[] = []
|
|
|
|
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
|
return Promise.resolve({ data: METRICS_RESP })
|
|
}
|
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
|
const end = opts?.params?.query?.end
|
|
if (end) endTimestamps.push(end)
|
|
return Promise.resolve({ data: READINGS_RESP })
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
})
|
|
|
|
// First render: triggers initial fetch (end=now at T0).
|
|
const t0 = Date.now()
|
|
renderWithProviders(
|
|
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
|
{ initialPath: '/energy' },
|
|
)
|
|
|
|
// Wait for the initial readings GET to fire.
|
|
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
|
|
|
// The recorded `end` must not be before the test started.
|
|
const firstEndMs = new Date(endTimestamps[0]).getTime()
|
|
expect(firstEndMs).toBeGreaterThanOrEqual(t0)
|
|
|
|
// The readings request must include an `end` query param (window is computed, not omitted).
|
|
expect(endTimestamps[0]).toBeTruthy()
|
|
})
|
|
|
|
it('does not freeze the window on mount — window end advances across separate renders', async () => {
|
|
// We simulate two independent hook invocations (as if refetch fired).
|
|
// Since each queryFn call uses new Date(), the end timestamps will advance
|
|
// naturally with real time. We just need to verify the concept holds.
|
|
const endTimestamps: string[] = []
|
|
|
|
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
|
return Promise.resolve({ data: METRICS_RESP })
|
|
}
|
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
|
const end = opts?.params?.query?.end
|
|
if (end) endTimestamps.push(end)
|
|
return Promise.resolve({ data: READINGS_RESP })
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
})
|
|
|
|
const { unmount } = renderWithProviders(
|
|
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
|
{ initialPath: '/energy' },
|
|
)
|
|
|
|
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
|
|
|
const firstEnd = new Date(endTimestamps[0]).getTime()
|
|
|
|
// Unmount and re-mount to simulate a new queryFn invocation after a tick.
|
|
unmount()
|
|
vi.clearAllMocks()
|
|
endTimestamps.length = 0
|
|
|
|
// Small real-time delay to ensure new Date() will be >= first.
|
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
|
|
mockGet.mockImplementation((path: string, opts: { params?: { query?: { end?: string } } } = {}) => {
|
|
if (path === '/api/modbus/devices/{uuid}/metrics') {
|
|
return Promise.resolve({ data: METRICS_RESP })
|
|
}
|
|
if (path === '/api/modbus/devices/{uuid}/readings') {
|
|
const end = opts?.params?.query?.end
|
|
if (end) endTimestamps.push(end)
|
|
return Promise.resolve({ data: READINGS_RESP })
|
|
}
|
|
return Promise.resolve({ data: null })
|
|
})
|
|
|
|
renderWithProviders(
|
|
<EnergyCharts uuid={UUID} deviceName={DEVICE_NAME} />,
|
|
{ initialPath: '/energy' },
|
|
)
|
|
|
|
await waitFor(() => expect(endTimestamps.length).toBeGreaterThanOrEqual(1))
|
|
|
|
const secondEnd = new Date(endTimestamps[0]).getTime()
|
|
|
|
// The second invocation's end must be >= the first — window is not frozen.
|
|
expect(secondEnd).toBeGreaterThanOrEqual(firstEnd)
|
|
})
|
|
})
|