348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
/**
|
|
* 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(<EnergyPage />, { 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')
|
|
})
|
|
})
|