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