/** * Tests for EnergyPage (M5-T06). * * Coverage: * 1. Renders device list from GET /api/modbus/devices. * 2. Empty state when no devices. * 3. "New Device" button opens DeviceForm modal. * 4. Edit button opens DeviceForm modal pre-filled with device data. * 5. Delete button opens confirmation modal; confirm calls DELETE. * 6. Delete 409 error shows friendly hint in confirmation modal. * 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result. * 8. Test-read failure shows error in modal. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { screen, waitFor, fireEvent } from '@testing-library/react' import { renderWithProviders } from '../test-utils' import { EnergyPage } from './EnergyPage' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const DEVICE = { uuid: 'abc-123', friendly_name: 'SDM120 Main', transport: 'tcp', host: '192.168.1.100', port: 502, unit_id: 1, profile: 'sdm120', poll_interval_s: 5, enabled: true, last_poll_at: '2026-06-22T10:00:00Z', last_poll_ok: true, created_at: '2026-06-01T00:00:00Z', updated_at: '2026-06-01T00:00:00Z', } const PROFILES_RESP = { profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }], } // --------------------------------------------------------------------------- // Mock apiClient // --------------------------------------------------------------------------- const mockGet = vi.fn() const mockPost = vi.fn() const mockDelete = vi.fn() const mockPatch = vi.fn() vi.mock('../api/client', () => ({ default: { GET: (...args: unknown[]) => mockGet(...args), POST: (...args: unknown[]) => mockPost(...args), PATCH: (...args: unknown[]) => mockPatch(...args), DELETE: (...args: unknown[]) => mockDelete(...args), }, 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(devices = [DEVICE]) { mockGet.mockImplementation((path: string) => { if (path === '/api/modbus/devices') { return Promise.resolve({ data: { items: devices, total: devices.length } }) } if (path === '/api/modbus/profiles') { return Promise.resolve({ data: PROFILES_RESP }) } return Promise.resolve({ data: null }) }) } function renderEnergy() { return renderWithProviders(, { initialPath: '/energy' }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('EnergyPage — device list', () => { beforeEach(() => { vi.clearAllMocks() setupDefaultMocks() }) it('renders device list from GET /api/modbus/devices', async () => { renderEnergy() await waitFor(() => { expect(screen.getByTestId('devices-table')).toBeInTheDocument() }) // Device name appears in multiple places now (table row + readings section + chart title). const nameElements = screen.getAllByText('SDM120 Main') expect(nameElements.length).toBeGreaterThan(0) expect(screen.getByText('192.168.1.100')).toBeInTheDocument() // 'sdm120' may also appear in multiple places (table badge + chart). const profileElements = screen.getAllByText('sdm120') expect(profileElements.length).toBeGreaterThan(0) }) it('shows empty state when no devices', async () => { setupDefaultMocks([]) renderEnergy() await waitFor(() => { expect(screen.getByTestId('devices-empty')).toBeInTheDocument() }) }) it('shows loading state initially', () => { // Delay resolution so we can catch the loading state mockGet.mockReturnValue(new Promise(() => {})) renderEnergy() expect(screen.getByTestId('energy-loading')).toBeInTheDocument() }) }) describe('EnergyPage — create device', () => { beforeEach(() => { vi.clearAllMocks() setupDefaultMocks() }) it('opens DeviceForm modal when New Device is clicked', async () => { renderEnergy() await waitFor(() => { expect(screen.getByTestId('device-new-button')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('device-new-button')) // Wait for both modal AND the form (which appears after profiles load) await waitFor(() => { expect(screen.getByTestId('device-form-modal')).toBeInTheDocument() }) await waitFor(() => { expect(screen.getByTestId('device-form')).toBeInTheDocument() }) expect(screen.getByTestId('device-friendly-name')).toBeInTheDocument() expect(screen.getByTestId('device-host')).toBeInTheDocument() }) it('submit on new device form calls POST /api/modbus/devices', async () => { mockPost.mockResolvedValue({ data: DEVICE }) renderEnergy() await waitFor(() => expect(screen.getByTestId('device-new-button')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('device-new-button')) // Wait for profiles to load so the form is visible (profiles auto-select sdm120) await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument()) // Fill required fields (profile is auto-selected to 'sdm120') fireEvent.change(screen.getByTestId('device-friendly-name'), { target: { value: 'Test Device' }, }) fireEvent.change(screen.getByTestId('device-host'), { target: { value: '10.0.0.1' }, }) fireEvent.submit(screen.getByTestId('device-form')) await waitFor(() => { expect(mockPost).toHaveBeenCalledWith( '/api/modbus/devices', expect.objectContaining({ body: expect.objectContaining({ friendly_name: 'Test Device', host: '10.0.0.1', profile: 'sdm120', }), }), ) }) }) }) describe('EnergyPage — edit device', () => { beforeEach(() => { vi.clearAllMocks() setupDefaultMocks() }) it('opens DeviceForm modal with device data when Edit is clicked', async () => { renderEnergy() await waitFor(() => { expect(screen.getByTestId(`device-edit-${DEVICE.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`device-edit-${DEVICE.uuid}`)) await waitFor(() => { expect(screen.getByTestId('device-form-modal')).toBeInTheDocument() }) // Wait for profiles to load so form body is visible await waitFor(() => { expect(screen.getByTestId('device-form')).toBeInTheDocument() }) // UUID shown in edit mode expect(screen.getByTestId('device-uuid-display').textContent).toContain(DEVICE.uuid) }) }) describe('EnergyPage — delete device', () => { beforeEach(() => { vi.clearAllMocks() setupDefaultMocks() }) it('shows confirmation modal on Delete click', async () => { renderEnergy() await waitFor(() => { expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`)) await waitFor(() => { expect(screen.getByTestId('device-delete-modal')).toBeInTheDocument() }) expect(screen.getByTestId('device-delete-message').textContent).toContain('SDM120 Main') }) it('calls DELETE on confirm and closes modal', async () => { mockDelete.mockResolvedValue({ data: null }) renderEnergy() await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument()) fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`)) await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('device-delete-confirm')) await waitFor(() => { expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', { params: { path: { uuid: DEVICE.uuid } }, }) }) }) it('cancel button closes confirmation modal', async () => { renderEnergy() await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument()) fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`)) await waitFor(() => expect(screen.getByTestId('device-delete-cancel')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('device-delete-cancel')) await waitFor(() => { expect(screen.queryByTestId('device-delete-modal')).not.toBeInTheDocument() }) }) it('shows friendly 409 hint when device has readings', async () => { const { ApiError } = await import('../api/client') mockDelete.mockRejectedValue(new ApiError(409, { detail: 'device has readings' })) renderEnergy() await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument()) fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`)) await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument()) fireEvent.click(screen.getByTestId('device-delete-confirm')) await waitFor(() => { expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument() }) }) }) describe('EnergyPage — test-read', () => { beforeEach(() => { vi.clearAllMocks() setupDefaultMocks() }) it('shows success payload modal after successful test-read', async () => { mockPost.mockResolvedValue({ data: { ok: true, payload: { voltage: 230.2, current: 1.3 } }, }) renderEnergy() await waitFor(() => { expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`)) await waitFor(() => { expect(screen.getByTestId('test-read-modal')).toBeInTheDocument() }) expect(screen.getByTestId('test-read-ok')).toBeInTheDocument() expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2') }) it('shows error modal after failed test-read', async () => { mockPost.mockResolvedValue({ data: { ok: false, error: 'Connection timed out' }, }) renderEnergy() await waitFor(() => { expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument() }) fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`)) await waitFor(() => { expect(screen.getByTestId('test-read-modal')).toBeInTheDocument() }) expect(screen.getByTestId('test-read-error')).toBeInTheDocument() expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out') }) })